diff --git a/agent/evals/test_wealth_bridge.py b/agent/evals/test_wealth_bridge.py new file mode 100644 index 000000000..a827fb0c1 --- /dev/null +++ b/agent/evals/test_wealth_bridge.py @@ -0,0 +1,345 @@ +""" +Tests for wealth_bridge.py, property_tracker.py (full CRUD), and teleport_api.py. + +Tests: + 1. test_down_payment_austin_portfolio_94k + portfolio_value = 94000 → can_afford_full is True for at least one market + 2. test_down_payment_small_portfolio + portfolio_value = 20000 → can_afford_safe is False for all markets + 3. test_job_offer_seattle_not_real_raise + $120k Austin → $180k Seattle = NOT a real raise + 4. test_job_offer_sf_genuine_raise + $80k Austin → $250k SF = IS a real raise + 5. test_job_offer_global_city_london + Any two-city comparison returns expected fields + 6. test_property_crud_full_cycle + CREATE → READ → UPDATE → DELETE + 7. test_net_worth_combines_portfolio_and_property + equity + portfolio = expected total + 8. test_teleport_fallback_works_when_api_unavailable + get_city_housing_data("seattle") returns usable data +""" + +import asyncio +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "tools")) + +import pytest + + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + +def _set_flag(value: str): + os.environ["ENABLE_REAL_ESTATE"] = value + + +def _clear_flag(): + os.environ.pop("ENABLE_REAL_ESTATE", None) + + +# Use an in-memory SQLite for property tracker tests (no file side effects) +os.environ["PROPERTIES_DB_PATH"] = ":memory:" + + +# --------------------------------------------------------------------------- +# Test 1 — down payment power with $94k portfolio +# --------------------------------------------------------------------------- + +def test_down_payment_austin_portfolio_94k(): + """ + GIVEN portfolio_value = 94000 (sufficient for Caldwell County at $237,491) + WHEN calculate_down_payment_power is called with no target_cities + THEN at least one market shows can_afford_full = True + and portfolio_value is preserved in the result + """ + from tools.wealth_bridge import calculate_down_payment_power + + result = calculate_down_payment_power(94_000) + + assert result["portfolio_value"] == 94_000 + assert "markets" in result + assert len(result["markets"]) > 0 + + # Caldwell County median = $237,491 → 20% down = $47,498 → $94k covers it + affordable_full = [m for m in result["markets"] if m["can_afford_full"]] + assert len(affordable_full) > 0, ( + f"Expected at least one market with can_afford_full=True at $94k. " + f"Markets: {[(m['area'], m['required_down_20pct']) for m in result['markets']]}" + ) + + # Verify key output fields present + first_market = result["markets"][0] + assert "median_price" in first_market + assert "required_down_20pct" in first_market + assert "monthly_payment_estimate" in first_market + assert "rent_vs_buy_verdict" in first_market + + # Down payment scenarios + assert result["down_payment_scenarios"]["full"] == 94_000 + assert result["down_payment_scenarios"]["conservative"] == pytest.approx(75_200) + assert result["down_payment_scenarios"]["safe"] == pytest.approx(56_400) + + +# --------------------------------------------------------------------------- +# Test 2 — small portfolio ($20k) cannot afford safe 20% down anywhere +# --------------------------------------------------------------------------- + +def test_down_payment_small_portfolio(): + """ + GIVEN portfolio_value = 20000 (insufficient for 20% down on any Austin market) + WHEN calculate_down_payment_power is called + THEN can_afford_safe is False for all markets + """ + from tools.wealth_bridge import calculate_down_payment_power + + result = calculate_down_payment_power(20_000) + + assert result["portfolio_value"] == 20_000 + + # safe = 20000 * 0.60 = 12000; cheapest market Caldwell = $237,491 → need $47,498 + # So all markets should have can_afford_safe = False + safe_affordable = [m for m in result["markets"] if m["can_afford_safe"]] + assert len(safe_affordable) == 0, ( + f"Expected no safe-affordable markets at $20k. " + f"Got: {[(m['area'], m['required_down_20pct']) for m in safe_affordable]}" + ) + + +# --------------------------------------------------------------------------- +# Test 3 — Seattle $180k offer vs Austin $120k is NOT a real raise +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_job_offer_seattle_not_real_raise(): + """ + GIVEN current: $120k in Austin (COL index 95.4) + offer: $180k in Seattle (COL index 150.2) + WHEN calculate_job_offer_affordability is called + THEN is_real_raise = False + real_raise_amount < 0 + breakeven_salary_needed > 180000 + + Math: adjusted = 180000 * (95.4 / 150.2) ≈ $114,407 + real_raise = 114,407 - 120,000 = -$5,593 + """ + from tools.wealth_bridge import calculate_job_offer_affordability + + result = await calculate_job_offer_affordability( + offer_salary=180_000, + offer_city="Seattle", + current_salary=120_000, + current_city="Austin", + ) + + assert result["is_real_raise"] is False, ( + f"Expected Seattle $180k to NOT be a real raise vs Austin $120k. " + f"Got: adjusted={result['adjusted_offer_in_current_city_terms']}" + ) + assert result["real_raise_amount"] < 0, "Real raise should be negative" + assert result["breakeven_salary_needed"] > 180_000, ( + "Breakeven in Seattle should exceed the offer salary" + ) + assert "verdict" in result + assert len(result["verdict"]) > 20 + assert "offer_city" in result and result["offer_city"] != "" + + +# --------------------------------------------------------------------------- +# Test 4 — San Francisco $250k vs Austin $80k IS a genuine raise +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_job_offer_sf_genuine_raise(): + """ + GIVEN current: $80k in Austin (COL index 95.4) + offer: $250k in San Francisco (COL index 178.1) + WHEN calculate_job_offer_affordability is called + THEN is_real_raise = True + adjusted_offer_in_current_city_terms > 80000 + + Math: adjusted = 250000 * (95.4 / 178.1) ≈ $133,969 + real_raise = 133,969 - 80,000 = +$53,969 + """ + from tools.wealth_bridge import calculate_job_offer_affordability + + result = await calculate_job_offer_affordability( + offer_salary=250_000, + offer_city="San Francisco", + current_salary=80_000, + current_city="Austin", + ) + + assert result["is_real_raise"] is True, ( + f"Expected SF $250k to be a real raise vs Austin $80k. " + f"Got: adjusted={result.get('adjusted_offer_in_current_city_terms')}" + ) + assert result["adjusted_offer_in_current_city_terms"] > 80_000 + + +# --------------------------------------------------------------------------- +# Test 5 — Global city (London) comparison returns expected fields +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_job_offer_global_city_london(): + """ + GIVEN any salary offer in London vs Austin + WHEN calculate_job_offer_affordability is called + THEN result has all required fields and offer_city is non-empty + """ + from tools.wealth_bridge import calculate_job_offer_affordability + + result = await calculate_job_offer_affordability( + offer_salary=150_000, + offer_city="London", + current_salary=120_000, + current_city="Austin", + ) + + required_fields = { + "current_salary", "current_city", "current_col_index", + "offer_salary", "offer_city", "offer_col_index", + "adjusted_offer_in_current_city_terms", + "real_raise_amount", "is_real_raise", "breakeven_salary_needed", + "verdict", "tax_note", "housing_comparison", + } + missing = required_fields - set(result.keys()) + assert not missing, f"Missing fields in job offer result: {missing}" + assert result["offer_city"] != "", "offer_city must be non-empty" + assert isinstance(result["is_real_raise"], bool) + assert result["current_col_index"] > 0 + assert result["offer_col_index"] > 0 + + +# --------------------------------------------------------------------------- +# Test 6 — Property CRUD full cycle (CREATE → READ → UPDATE → DELETE) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_property_crud_full_cycle(): + """ + GIVEN ENABLE_REAL_ESTATE=true + WHEN full CRUD cycle is executed + THEN each operation produces correct results: + CREATE: equity = current_value - mortgage_balance = $130,000 + READ: property appears in list + UPDATE: equity recalculates to $140,000 after value bump + DELETE: property no longer appears in list + """ + _set_flag("true") + from tools.property_tracker import ( + add_property, get_properties, update_property, + remove_property, property_store_clear, + ) + property_store_clear() + + # CREATE + prop_result = await add_property( + address="123 Test St Austin TX", + purchase_price=400_000, + current_value=450_000, + mortgage_balance=320_000, + ) + assert prop_result["success"] is True, f"add_property failed: {prop_result}" + prop = prop_result["result"]["property"] + assert prop["equity"] == pytest.approx(130_000), ( + f"Expected equity=130000, got {prop['equity']}" + ) + assert "id" in prop + prop_id = prop["id"] + + # READ + list_result = await get_properties() + assert list_result["success"] is True + ids = [p["id"] for p in list_result["result"]["properties"]] + assert prop_id in ids, f"Added property ID {prop_id} not found in list: {ids}" + + # UPDATE + updated = await update_property(prop_id, current_value=460_000) + assert updated["success"] is True, f"update_property failed: {updated}" + updated_prop = updated["result"]["property"] + assert updated_prop["equity"] == pytest.approx(140_000), ( + f"Expected equity=140000 after update, got {updated_prop['equity']}" + ) + + # DELETE (soft) + removed = await remove_property(prop_id) + assert removed["success"] is True, f"remove_property failed: {removed}" + assert removed["result"]["status"] == "removed" + + # Verify gone from active list + props_after = await get_properties() + ids_after = [p["id"] for p in props_after["result"]["properties"]] + assert prop_id not in ids_after, ( + f"Property {prop_id} should be removed but still appears in: {ids_after}" + ) + + +# --------------------------------------------------------------------------- +# Test 7 — Net worth combines portfolio value and real estate equity +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_net_worth_combines_portfolio_and_property(): + """ + GIVEN one property: current_value=$400k, mortgage=$250k → equity=$150k + WHEN get_total_net_worth(portfolio_value=94000) is called + THEN real_estate_equity == 150000 + total_net_worth == 244000 + investment_portfolio == 94000 + """ + _set_flag("true") + from tools.property_tracker import ( + add_property, get_total_net_worth, property_store_clear, + ) + property_store_clear() + + await add_property( + address="456 Equity St Austin TX", + purchase_price=300_000, + current_value=400_000, + mortgage_balance=250_000, + ) + + result = await get_total_net_worth(portfolio_value=94_000) + assert result["success"] is True, f"get_total_net_worth failed: {result}" + + data = result["result"] + assert data["real_estate_equity"] == pytest.approx(150_000), ( + f"Expected real_estate_equity=150000, got {data['real_estate_equity']}" + ) + assert data["total_net_worth"] == pytest.approx(244_000), ( + f"Expected total_net_worth=244000, got {data['total_net_worth']}" + ) + assert data["investment_portfolio"] == pytest.approx(94_000) + + +# --------------------------------------------------------------------------- +# Test 8 — Teleport fallback works when API is unavailable (or any city name) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_teleport_fallback_works_when_api_unavailable(): + """ + GIVEN "seattle" is requested from teleport_api + WHEN get_city_housing_data("seattle") is called + THEN result contains MedianRentMonthly and city is non-empty + (live API or fallback — either is acceptable) + """ + from tools.teleport_api import get_city_housing_data + + result = await get_city_housing_data("seattle") + + assert isinstance(result, dict), "Result must be a dict" + assert result.get("city", "") != "", "city field must be non-empty" + + has_rent = "MedianRentMonthly" in result + has_price = "median_price" in result or "ListPrice" in result + assert has_rent or has_price, ( + f"Result must have MedianRentMonthly or median_price. Got keys: {list(result.keys())}" + ) diff --git a/agent/tools/property_tracker.py b/agent/tools/property_tracker.py index b54347b60..7745bca56 100644 --- a/agent/tools/property_tracker.py +++ b/agent/tools/property_tracker.py @@ -8,33 +8,17 @@ Allows users to track real estate properties they own alongside their financial portfolio. Equity is computed as: equity = current_value - mortgage_balance -Five capabilities: +Seven capabilities: 1. add_property(...) — record a property you own - 2. get_properties() — show all properties with equity computed + 2. get_properties() — show all active properties with equity 3. list_properties() — alias for get_properties() - 4. update_property(...) — update value, mortgage, or rent + 4. update_property(...) — update current value, mortgage, or rent 5. remove_property(id) — soft-delete (set is_active = 0) 6. get_real_estate_equity() — total equity across all properties 7. get_total_net_worth(...) — portfolio + real estate combined Storage: SQLite at agent/data/properties.db - (override path with PROPERTIES_DB_PATH env var — used in tests) - -Schema: - CREATE TABLE IF NOT EXISTS properties ( - id TEXT PRIMARY KEY, - address TEXT NOT NULL, - property_type TEXT DEFAULT 'primary', - purchase_price REAL, - purchase_date TEXT, - current_value REAL, - mortgage_balance REAL DEFAULT 0, - monthly_rent REAL DEFAULT 0, - county_key TEXT DEFAULT 'austin', - is_active INTEGER DEFAULT 1, - created_at TEXT, - updated_at TEXT - ) + (override path with PROPERTIES_DB_PATH env var — used in tests for :memory:) All functions return the standard tool result envelope: {tool_name, success, tool_result_id, timestamp, result} — on success @@ -74,12 +58,33 @@ _FEATURE_DISABLED_RESPONSE = { # SQLite connection helpers # --------------------------------------------------------------------------- +_SCHEMA_SQL = """ + CREATE TABLE IF NOT EXISTS properties ( + id TEXT PRIMARY KEY, + address TEXT NOT NULL, + property_type TEXT DEFAULT 'Single Family', + purchase_price REAL NOT NULL, + purchase_date TEXT, + current_value REAL NOT NULL, + mortgage_balance REAL DEFAULT 0, + monthly_rent REAL DEFAULT 0, + county_key TEXT DEFAULT 'austin', + is_active INTEGER DEFAULT 1, + created_at TEXT, + updated_at TEXT + ) +""" + +# Module-level cached connection for :memory: databases. +# SQLite :memory: creates a fresh DB per connection — we must reuse the same one. +_MEMORY_CONN: Optional[sqlite3.Connection] = None + + def _db_path() -> str: """Returns the SQLite database path (configurable via PROPERTIES_DB_PATH).""" env_path = os.getenv("PROPERTIES_DB_PATH") if env_path: return env_path - # Default: agent/data/properties.db relative to this file's parent (tools/) tools_dir = os.path.dirname(os.path.abspath(__file__)) agent_dir = os.path.dirname(tools_dir) data_dir = os.path.join(agent_dir, "data") @@ -88,33 +93,38 @@ def _db_path() -> str: def _get_conn() -> sqlite3.Connection: - """Opens a SQLite connection and ensures the schema exists.""" + """ + Returns a SQLite connection with the schema initialized. + For :memory: databases, returns the same connection every time so + data persists across calls within a session / test run. + """ + global _MEMORY_CONN path = _db_path() - conn = sqlite3.connect(path) + + if path == ":memory:": + if _MEMORY_CONN is None: + _MEMORY_CONN = sqlite3.connect(":memory:", check_same_thread=False) + _MEMORY_CONN.row_factory = sqlite3.Row + _MEMORY_CONN.execute(_SCHEMA_SQL) + _MEMORY_CONN.commit() + return _MEMORY_CONN + + conn = sqlite3.connect(path, check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") - conn.execute(""" - CREATE TABLE IF NOT EXISTS properties ( - id TEXT PRIMARY KEY, - address TEXT NOT NULL, - property_type TEXT DEFAULT 'Single Family', - purchase_price REAL NOT NULL, - purchase_date TEXT, - current_value REAL NOT NULL, - mortgage_balance REAL DEFAULT 0, - monthly_rent REAL DEFAULT 0, - county_key TEXT DEFAULT 'austin', - is_active INTEGER DEFAULT 1, - created_at TEXT, - updated_at TEXT - ) - """) + conn.execute(_SCHEMA_SQL) conn.commit() return conn +def _close_conn(conn: sqlite3.Connection) -> None: + """Closes file-based connections; leaves :memory: connection open.""" + if _db_path() != ":memory:": + conn.close() + + def _row_to_dict(row: sqlite3.Row) -> dict: - """Converts a sqlite3.Row to a plain dict with computed fields.""" + """Converts a sqlite3.Row to a plain dict with computed equity/appreciation fields.""" d = dict(row) current_value = d.get("current_value", 0) or 0 mortgage_balance = d.get("mortgage_balance", 0) or 0 @@ -123,31 +133,40 @@ def _row_to_dict(row: sqlite3.Row) -> dict: equity = round(current_value - mortgage_balance, 2) equity_pct = round((equity / current_value * 100), 2) if current_value > 0 else 0.0 appreciation = round(current_value - purchase_price, 2) - appreciation_pct = round((appreciation / purchase_price * 100), 2) if purchase_price > 0 else 0.0 + appreciation_pct = ( + round((appreciation / purchase_price * 100), 2) if purchase_price > 0 else 0.0 + ) d["equity"] = equity d["equity_pct"] = equity_pct d["appreciation"] = appreciation d["appreciation_pct"] = appreciation_pct - # Backward-compat alias: old tests expect "added_at" (previous in-memory schema used this name) + # Backward-compat alias: existing tests check for "added_at" d["added_at"] = d.get("created_at") return d # --------------------------------------------------------------------------- -# Test helpers — kept for backward compatibility with existing test suite +# Test helpers # --------------------------------------------------------------------------- def property_store_clear() -> None: """ - Wipes ALL property records from the database. - Used in tests to reset state between test cases. + Wipes ALL property records. Used in tests to reset state between cases. + For :memory: databases, deletes all rows from the shared connection. """ + global _MEMORY_CONN + path = _db_path() try: - conn = _get_conn() - conn.execute("DELETE FROM properties") - conn.commit() - conn.close() + if path == ":memory:": + if _MEMORY_CONN is not None: + _MEMORY_CONN.execute("DELETE FROM properties") + _MEMORY_CONN.commit() + else: + conn = _get_conn() + conn.execute("DELETE FROM properties") + conn.commit() + _close_conn(conn) except Exception: pass @@ -170,13 +189,13 @@ async def add_property( Records a property in the SQLite store. Args: - address: Full street address (e.g. "123 Barton Hills Dr, Austin, TX 78704"). + address: Full street address. purchase_price: Original purchase price in USD. - current_value: Current estimated market value. Defaults to purchase_price if None. - mortgage_balance: Outstanding mortgage balance. Defaults to 0 (paid off / no mortgage). - monthly_rent: Monthly rental income if this is a rental property. Defaults to 0. - county_key: ACTRIS data key for market context (e.g. "austin", "travis_county"). - property_type: "Single Family", "Condo", "Townhouse", "Multi-Family", or "Land". + current_value: Current estimated market value. Defaults to purchase_price. + mortgage_balance: Outstanding mortgage balance. Defaults to 0. + monthly_rent: Monthly rental income if a rental property. Defaults to 0. + county_key: ACTRIS area key (e.g. "austin", "travis_county"). + property_type: "Single Family", "Condo", "Townhouse", etc. purchase_date: Optional ISO date string (YYYY-MM-DD). """ if not is_property_tracking_enabled(): @@ -224,22 +243,17 @@ async def add_property( ), ) conn.commit() - row = conn.execute( "SELECT * FROM properties WHERE id = ?", (prop_id,) ).fetchone() - conn.close() - + _close_conn(conn) record = _row_to_dict(row) except Exception as exc: return { "tool_name": "property_tracker", "success": False, "tool_result_id": tool_result_id, - "error": { - "code": "PROPERTY_TRACKER_DB_ERROR", - "message": str(exc), - }, + "error": {"code": "PROPERTY_TRACKER_DB_ERROR", "message": str(exc)}, } equity = record["equity"] @@ -265,7 +279,7 @@ async def add_property( async def get_properties() -> dict: """ Returns all active properties with per-property equity and portfolio totals. - Alias: also callable as list_properties() for backward compatibility. + Primary read function. list_properties() is kept as an alias. """ if not is_property_tracking_enabled(): return _FEATURE_DISABLED_RESPONSE @@ -277,7 +291,7 @@ async def get_properties() -> dict: rows = conn.execute( "SELECT * FROM properties WHERE is_active = 1 ORDER BY created_at" ).fetchall() - conn.close() + _close_conn(conn) properties = [_row_to_dict(row) for row in rows] except Exception as exc: return { @@ -316,7 +330,9 @@ async def get_properties() -> dict: total_value = sum(p["current_value"] for p in properties) total_mortgage = sum(p["mortgage_balance"] for p in properties) total_equity = round(total_value - total_mortgage, 2) - total_equity_pct = round((total_equity / total_value * 100), 2) if total_value > 0 else 0.0 + total_equity_pct = ( + round((total_equity / total_value * 100), 2) if total_value > 0 else 0.0 + ) total_rent = sum(p.get("monthly_rent", 0) or 0 for p in properties) return { @@ -339,7 +355,6 @@ async def get_properties() -> dict: } -# Backward-compatible alias async def list_properties() -> dict: """Alias for get_properties() — kept for backward compatibility.""" return await get_properties() @@ -376,7 +391,7 @@ async def update_property( ).fetchone() if row is None: - conn.close() + _close_conn(conn) return { "tool_name": "property_tracker", "success": False, @@ -403,14 +418,17 @@ async def update_property( params.append(monthly_rent) if not updates: - conn.close() + _close_conn(conn) return { "tool_name": "property_tracker", "success": False, "tool_result_id": tool_result_id, "error": { "code": "PROPERTY_TRACKER_INVALID_INPUT", - "message": "At least one of current_value, mortgage_balance, or monthly_rent must be provided.", + "message": ( + "At least one of current_value, mortgage_balance, " + "or monthly_rent must be provided." + ), }, } @@ -428,8 +446,7 @@ async def update_property( updated_row = conn.execute( "SELECT * FROM properties WHERE id = ?", (prop_id,) ).fetchone() - conn.close() - + _close_conn(conn) record = _row_to_dict(updated_row) except Exception as exc: return { @@ -458,9 +475,7 @@ async def update_property( async def remove_property(property_id: str) -> dict: """ Soft-deletes a property by setting is_active = 0. - - Args: - property_id: ID of the property to remove (e.g. 'prop_a1b2c3d4'). + Data is preserved for audit purposes. """ if not is_property_tracking_enabled(): return _FEATURE_DISABLED_RESPONSE @@ -475,7 +490,7 @@ async def remove_property(property_id: str) -> dict: ).fetchone() if row is None: - conn.close() + _close_conn(conn) return { "tool_name": "property_tracker", "success": False, @@ -495,7 +510,7 @@ async def remove_property(property_id: str) -> dict: (datetime.utcnow().isoformat(), prop_id), ) conn.commit() - conn.close() + _close_conn(conn) except Exception as exc: return { "tool_name": "property_tracker", @@ -531,9 +546,10 @@ async def get_real_estate_equity() -> dict: try: conn = _get_conn() rows = conn.execute( - "SELECT current_value, mortgage_balance FROM properties WHERE is_active = 1" + "SELECT current_value, mortgage_balance " + "FROM properties WHERE is_active = 1" ).fetchall() - conn.close() + _close_conn(conn) except Exception as exc: return { "tool_name": "property_tracker", @@ -563,11 +579,11 @@ async def get_real_estate_equity() -> dict: async def get_total_net_worth(portfolio_value: float) -> dict: """ Combines live investment portfolio value with real estate equity - to produce a unified net worth view. + for a unified net worth view. Args: portfolio_value: Total liquid investment portfolio value in USD - (pass in from portfolio_analysis tool result). + (from portfolio_analysis tool result). Returns: Dict with investment_portfolio, real_estate_equity, total_net_worth, @@ -583,7 +599,7 @@ async def get_total_net_worth(portfolio_value: float) -> dict: rows = conn.execute( "SELECT * FROM properties WHERE is_active = 1 ORDER BY created_at" ).fetchall() - conn.close() + _close_conn(conn) properties = [_row_to_dict(row) for row in rows] except Exception as exc: return { @@ -598,11 +614,13 @@ async def get_total_net_worth(portfolio_value: float) -> dict: real_estate_equity = round(total_value - total_mortgage, 2) total_net_worth = round(portfolio_value + real_estate_equity, 2) - summary = ( - f"Total net worth ${total_net_worth:,.0f} across investments " - f"(${portfolio_value:,.0f}) and real estate equity (${real_estate_equity:,.0f})." - ) - if not properties: + if properties: + summary = ( + f"Total net worth ${total_net_worth:,.0f} across investments " + f"(${portfolio_value:,.0f}) and real estate equity " + f"(${real_estate_equity:,.0f})." + ) + else: summary = ( f"Investment portfolio: ${portfolio_value:,.0f}. " "No properties tracked yet. Add properties to include real estate equity."