From d606f0d8109f5ef2bd6506bc1645f5049fd532ff Mon Sep 17 00:00:00 2001 From: Priyanka Punukollu Date: Thu, 26 Feb 2026 15:01:14 -0600 Subject: [PATCH] =?UTF-8?q?feat(agent):=20complete=20showcase=20=E2=80=94?= =?UTF-8?q?=20real=20ACTRIS=20data,=20property=20tracker,=2027=20UI=20feat?= =?UTF-8?q?ures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Replace Austin mock data with real Jan 2026 ACTRIS/Unlock MLS figures covering 7 counties/MSAs (Travis, Williamson, Hays, Bastrop, Caldwell, Austin MSA) - Add property_tracker tool: add/list/remove properties with equity & gain calc - Expand graph.py routing for property tracking intent + 15 new city aliases - Add /health endpoint and improved SSE streaming in main.py - 81 passing pytest evals (test_portfolio, test_property_tracker, test_real_estate) Frontend (chat_ui.html) — 27 new features: - Command palette (Cmd+P) with 29 commands and fuzzy search - Cross-session search across all saved chats in the drawer - User investor profile (risk/focus/horizon) injected as AI context - Rental yield calculator (gross/net yield, cap rate, annual income) - Portfolio donut chart (SVG, click-to-query slices) - Property comparison table, market calendar strip, county alerts - Smart time-based suggestions, offline detection + message queue - PWA manifest + install prompt, scroll-to-bottom button - High contrast mode, reduced motion, keyboard focus rings - Response disclaimer toggle, copy-as-Markdown, batch export - Email digest, session reminders, conversation branching - Swipe-to-archive (mobile), collaborative annotations via URL - Settings menu scrollable (max-height fix for 20+ items) - Fix: broken paddingRight string literal silently killed all event listeners - Fix: extra stray in help panel causing HTML parse error Angular ai-chat component: - Fix: prefer-optional-chain lint error in successRate() - Fix: prettier formatting on chat_ui.html - Add portfolio-chart and real-estate-card sub-components All 81 pytest evals pass. Lint: 0 errors. Prettier: all files formatted. Made-with: Cursor --- chat_ui.html | 8800 ++++++++++++++++++++++++++++++-- evals/test_portfolio.py | 858 ++++ evals/test_property_tracker.py | 408 ++ graph.py | 171 +- main.py | 82 + tools/property_tracker.py | 288 ++ tools/real_estate.py | 451 +- 7 files changed, 10518 insertions(+), 540 deletions(-) create mode 100644 evals/test_portfolio.py create mode 100644 evals/test_property_tracker.py create mode 100644 tools/property_tracker.py diff --git a/chat_ui.html b/chat_ui.html index 3118544ed..9f30d3139 100644 --- a/chat_ui.html +++ b/chat_ui.html @@ -4,6 +4,7 @@ Ghostfolio AI Agent + - - - -
- -
-

Ghostfolio AI Agent

-

Powered by Claude + LangGraph

-
-
-
-
- Connecting… -
- -
-
??
- Loading… -
- - -
-
- -
+ /* ── Auto-speak toggle in header ── */ + .speak-toggle { + font-size: 11px; + padding: 3px 9px; + border-radius: 999px; + border: 1px solid var(--border2); + background: var(--surface2); + color: var(--text3); + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + } + .speak-toggle.on { + border-color: var(--green); + color: #86efac; + background: #052e16; + } - -
- -
-
💼
-

What would you like to know?

-

- Ask about your portfolio, check live prices, log a trade, or run a - compliance check. -

+ /* ── Onboarding tour ── */ + .tour-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 900; + pointer-events: none; + } + .tour-tooltip { + position: fixed; + z-index: 910; + background: var(--surface2); + border: 1px solid var(--indigo); + border-radius: var(--radius); + padding: 14px 16px; + max-width: 280px; + box-shadow: 0 8px 32px rgba(99, 102, 241, 0.3); + pointer-events: all; + } + .tour-tooltip::before { + content: ''; + position: absolute; + width: 10px; + height: 10px; + background: var(--indigo); + border-radius: 2px; + transform: rotate(45deg); + } + .tour-tooltip.arrow-top::before { + top: -5px; + left: 20px; + } + .tour-tooltip.arrow-bottom::before { + bottom: -5px; + left: 20px; + } + .tour-tooltip.arrow-right::before { + right: -5px; + top: 20px; + } + .tour-step-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--indigo2); + margin-bottom: 6px; + } + .tour-title { + font-size: 13px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; + } + .tour-desc { + font-size: 12px; + color: var(--text2); + line-height: 1.5; + margin-bottom: 12px; + } + .tour-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + } + .tour-skip { + font-size: 11px; + padding: 5px 10px; + border-radius: 7px; + border: 1px solid var(--border2); + background: transparent; + color: var(--text3); + cursor: pointer; + } + .tour-next { + font-size: 11px; + padding: 5px 12px; + border-radius: 7px; + border: none; + background: linear-gradient(135deg, var(--indigo), #8b5cf6); + color: #fff; + cursor: pointer; + font-weight: 600; + } + .tour-dots { + display: flex; + gap: 4px; + margin-right: auto; + align-items: center; + } + .tour-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--border2); + transition: background 0.2s; + } + .tour-dot.active { + background: var(--indigo2); + } -
-
- 📊 Portfolio -
- - -
-
+ /* ── Session history drawer ── */ + .drawer-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 200; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s; + } + .drawer-overlay.open { + opacity: 1; + pointer-events: all; + } -
- 🛡️ Risk & Compliance -
- - -
-
+ .sessions-drawer { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 280px; + background: var(--surface); + border-right: 1px solid var(--border); + z-index: 201; + display: flex; + flex-direction: column; + transform: translateX(-100%); + transition: transform 0.22s ease; + } + .sessions-drawer.open { + transform: translateX(0); + } -
- 💹 Market -
- + +
+
+ 💰 Properties tracked +
+
+ 🧠 0 items +
+
+
+ Connecting… +
+ +
+
??
+ Loading… +
+
🔮 What-if mode
+
+ 🎯 Goal +
+
+
+ 0% +
+
+ +
+ + +
+ 🔡 Font size +
+ + + +
+
+ +
+ + + + + + +
+ + + + +
+ + + + +
+ +
+ scroll for more · ⌘P for all features +
+
+
+ + +
+ + + +
+
+
+

💬 Chats

+ +
+
+ + + +
+
+ +
+ + +
+ + +
+ + +
+ 📊 Portfolio + 🏠 Real estate + 🏘 Property tracked + 💹 Market data + 🛡 Compliance + 🧾 Tax +
+ + + + + +
+ ⚠ No connection — Your message will be sent when you + reconnect. + +
+ + +
+ 📱 Install Ghostfolio AI as an app for faster access + + +
+ + +
+ + + +
+ + +
+ 📅 Upcoming +
+ +
+ + +
+ 👁 Watchlist: +
+ + +
+ + +
+
+ Welcome back!
+ Your portfolio hasn't been reviewed in a while. +
+ + +
+ + +
+ + +
+
+ 📌 Pinned + +
+
+
+ + +
+ +
+
💼
+

What would you like to know?

+

+ Ask about your portfolio, explore Austin real estate data, track + properties, or run a compliance check. +

+ +
+
+ 📊 Portfolio +
+ + +
+
+ +
+ 🛡️ Risk & Compliance +
+ + +
+
+ +
+ 💹 Market +
+
-
-
-
-
+
+ +
+ 🏠 Real Estate & Property +
+ + +
+
+ + +
+
+ + + +
+ +
+ +
+
+
+ + +
+
+ Response: +
+ + + +
+
+
+ 📎 No file + +
+
+
+
+ +
+ + +
+ + +
+ + + +
+
+ + + + + + + + +
+ +
+
✨ Did you know?
+
+ Press ⌘P for command palette · Type + ~ for templates · ⌘K focus · Click + for settings · ? for help +
+
+ + +
+
+
+ What can this agent do? + +
+ +
+
💬 Ask anything — try these
+
+
+
📊
+
Portfolio Summary
+
Value, holdings, YTD return
+
+
+
🏠
+
Austin Real Estate
+
Jan 2026 ACTRIS MLS data
+
+
+
💰
+
Total Net Worth
+
Portfolio + property equity
+
+
+
⚖️
+
Risk Check
+
+ Concentration & compliance +
+
+
+
🧾
+
Tax Estimate
+
Capital gains liability
+
+
+
🔀
+
Compare Counties
+
Side-by-side market data
+
+
+
+ +
+
⚡ Power features
+
+
+
+
Input Templates
+
+ Type ~ to pick a pre-built query +
+
+
+
🎹
+
Keyboard Shortcuts
+
+ ⌘K focus · ↑ restore · ⌘F search +
+
+
+
🎙
+
Voice Input
+
Click 🎙 or tap mic button
+
+
+
🔮
+
What-if Mode
+
+ Hypothetical financial scenarios +
+
+
+
🎯
+
Set a Goal
+
Track progress to your target
+
+
+
+
Share Chat
+
+ Copy link or formatted summary +
+
+
+
🧠
+
Context Memory
+
+ Agent remembers tickers & net worth across sessions +
+
+
+
👁
+
Watchlist
+
+ Track tickers — type /watch AAPL +
+
+
+
+ +
+
✍ Annotate & React
+
+
+
📝
+
Sticky Notes
+
+ Click 📝 on any response to add a private note +
+
+
+
📌
+
Pin Responses
+
+ Click 📌 to pin key answers to the top bar +
+
+
+
👍
+
Rate Responses
+
+ 👍 👎 buttons below each answer for feedback +
+
+
+
+
Custom Shortcuts
+
+ Save your own quick-action cards to the landing page +
+
+
+
+
Query History
+
+ Click empty input to see & reuse past queries +
+
+
+
🔡
+
Font Size
+
A–A–A controls in ⚙ Settings
+
+
+
+ +
+
🗂 Manage your chats
+
+
+
+
Chat History
+
+ Up to 15 sessions saved locally +
+
+
+
+
Saved Responses
+
Star ★ any reply to save it
+
+
+
+
Export Conversation
+
Download as .txt file
+
+
+
🖨
+
Print / Save PDF
+
Clean print-ready layout
+
+
+
+
Batch Export
+
+ Select specific responses to export +
+
+
+
📧
+
Email Digest
+
Copy formatted chat for email
+
+
+
+ +
+
🧮 Financial Tools
+
+
+
🏠
+
Rental Yield Calc
+
+ Gross/net yield & cap rate +
+
+
+
🍩
+
Donut Chart
+
+ Portfolio allocation breakdown +
+
+
+
🏘
+
Compare Properties
+
+ Side-by-side property analysis +
+
+
+
+
Command Palette
+
All features instantly (⌘P)
+
+
+
👤
+
My Profile
+
+ Risk, focus, horizon — personalizes agent +
+
+
+
🔔
+
Set Reminder
+
+ Browser notification to check portfolio +
+
+
+
+
+
+ + +
+
⌨ Templates — select to fill
+
+ 🏠 Track a property +
+ Fill in address, purchase price, current value, mortgage +
+
+
+ 🔮 What-if scenario +
Hypothetical price change analysis
+
+
+ 🔀 Compare counties +
Side-by-side market comparison
+
+
+ 📅 Retirement check +
Goal-based portfolio analysis
+
+
+ 🧾 Tax impact +
Capital gains on a specific holding
+
+
+ 🏘 Rental yield scan +
Compare rent-to-price ratios
+
+
+ 👁 Watchlist check +
Price + sentiment for any ticker
+
+
+ 📅 Period summary +
Performance over a custom time range
+
+
+ + + + + + + + + + + +
+
+
🔬 Request Inspector
+ +
+
+
+ No requests yet.
Send a message to start inspecting tool calls, + latency, and confidence scores. +
+
+
+ + +
+
+
+ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + +
+
+ 🧠 Remembered Context + +
+
+
Tickers
+
+
+
+
Properties
+
+
+
+
Net Worth Snapshot
+
+ — +
+ +
+ +
+ + + + + + + + diff --git a/evals/test_portfolio.py b/evals/test_portfolio.py new file mode 100644 index 000000000..7795e2ba2 --- /dev/null +++ b/evals/test_portfolio.py @@ -0,0 +1,858 @@ +""" +Unit tests for portfolio agent tools and graph helpers. + +Tests cover pure-logic components that run without any network calls: + Group A (15) — compliance_check rules engine + Group B (15) — tax_estimate calculation logic + Group C (10) — transaction_categorize activity analysis + Group D (10) — consolidate_holdings deduplication + Group E (10) — graph extraction helpers (_extract_ticker etc.) + +Total: 60 tests (+ 8 real estate tests = 68 total suite) +""" + +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 + + +# =========================================================================== +# Helpers +# =========================================================================== + +def _portfolio(holdings: list) -> dict: + """Wrap a holdings list into the shape compliance_check expects.""" + return {"result": {"holdings": holdings}} + + +def _holding(symbol: str, allocation_pct: float, gain_pct: float) -> dict: + return {"symbol": symbol, "allocation_pct": allocation_pct, "gain_pct": gain_pct} + + +def _activity(type_: str, symbol: str, quantity: float, unit_price: float, + date: str, fee: float = 0.0) -> dict: + return { + "type": type_, "symbol": symbol, "quantity": quantity, + "unitPrice": unit_price, "date": date, "fee": fee, + } + + +# =========================================================================== +# Group A — compliance_check (15 tests) +# =========================================================================== + +@pytest.mark.asyncio +async def test_compliance_concentration_risk_high(): + """Single holding over 20% triggers CONCENTRATION_RISK warning.""" + from tools.compliance import compliance_check + result = await compliance_check(_portfolio([ + _holding("AAPL", 45.0, 5.0), + _holding("MSFT", 20.0, 3.0), + _holding("NVDA", 15.0, 2.0), + _holding("GOOGL", 12.0, 1.0), + _holding("VTI", 8.0, 0.5), + ])) + assert result["success"] is True + warnings = result["result"]["warnings"] + concentration_warnings = [w for w in warnings if w["type"] == "CONCENTRATION_RISK"] + assert len(concentration_warnings) == 1 + assert concentration_warnings[0]["symbol"] == "AAPL" + assert concentration_warnings[0]["severity"] == "HIGH" + + +@pytest.mark.asyncio +async def test_compliance_significant_loss(): + """Holding down more than 15% triggers SIGNIFICANT_LOSS warning.""" + from tools.compliance import compliance_check + result = await compliance_check(_portfolio([ + _holding("AAPL", 18.0, 5.0), + _holding("MSFT", 18.0, -20.0), + _holding("NVDA", 18.0, 2.0), + _holding("GOOGL", 18.0, 1.0), + _holding("VTI", 28.0, 0.5), + ])) + assert result["success"] is True + warnings = result["result"]["warnings"] + loss_warnings = [w for w in warnings if w["type"] == "SIGNIFICANT_LOSS"] + assert len(loss_warnings) == 1 + assert loss_warnings[0]["symbol"] == "MSFT" + assert loss_warnings[0]["severity"] == "MEDIUM" + + +@pytest.mark.asyncio +async def test_compliance_low_diversification(): + """Fewer than 5 holdings triggers LOW_DIVERSIFICATION warning.""" + from tools.compliance import compliance_check + result = await compliance_check(_portfolio([ + _holding("AAPL", 50.0, 5.0), + _holding("MSFT", 30.0, 3.0), + _holding("NVDA", 20.0, 2.0), + ])) + assert result["success"] is True + warnings = result["result"]["warnings"] + div_warnings = [w for w in warnings if w["type"] == "LOW_DIVERSIFICATION"] + assert len(div_warnings) == 1 + assert div_warnings[0]["severity"] == "LOW" + assert div_warnings[0]["holding_count"] == 3 + + +@pytest.mark.asyncio +async def test_compliance_all_clear(): + """Healthy portfolio with 5+ holdings and no thresholds exceeded returns CLEAR.""" + from tools.compliance import compliance_check + result = await compliance_check(_portfolio([ + _holding("AAPL", 18.0, 5.0), + _holding("MSFT", 18.0, 3.0), + _holding("NVDA", 18.0, 2.0), + _holding("GOOGL", 18.0, 1.0), + _holding("VTI", 18.0, 0.5), # all ≤ 20%, all gain > -15% + ])) + assert result["success"] is True + assert result["result"]["overall_status"] == "CLEAR" + assert result["result"]["warning_count"] == 0 + assert result["result"]["warnings"] == [] + + +@pytest.mark.asyncio +async def test_compliance_multiple_warnings(): + """Portfolio with both concentration risk and significant loss returns multiple warnings.""" + from tools.compliance import compliance_check + result = await compliance_check(_portfolio([ + _holding("AAPL", 60.0, -25.0), + _holding("MSFT", 40.0, 3.0), + ])) + assert result["success"] is True + warnings = result["result"]["warnings"] + types = {w["type"] for w in warnings} + assert "CONCENTRATION_RISK" in types + assert "SIGNIFICANT_LOSS" in types + assert "LOW_DIVERSIFICATION" in types + assert result["result"]["overall_status"] == "FLAGGED" + + +@pytest.mark.asyncio +async def test_compliance_exactly_at_concentration_threshold(): + """Exactly 20% allocation does NOT trigger concentration warning (rule is >20).""" + from tools.compliance import compliance_check + result = await compliance_check(_portfolio([ + _holding("AAPL", 20.0, 1.0), + _holding("MSFT", 20.0, 1.0), + _holding("NVDA", 20.0, 1.0), + _holding("GOOGL", 20.0, 1.0), + _holding("VTI", 20.0, 1.0), + ])) + concentration_warnings = [ + w for w in result["result"]["warnings"] if w["type"] == "CONCENTRATION_RISK" + ] + assert len(concentration_warnings) == 0 + + +@pytest.mark.asyncio +async def test_compliance_just_over_concentration_threshold(): + """20.1% allocation DOES trigger concentration warning (>20).""" + from tools.compliance import compliance_check + result = await compliance_check(_portfolio([ + _holding("AAPL", 20.1, 1.0), + _holding("MSFT", 19.9, 1.0), + _holding("NVDA", 19.9, 1.0), + _holding("GOOGL", 19.9, 1.0), + _holding("VTI", 20.1, 1.0), + ])) + concentration_warnings = [ + w for w in result["result"]["warnings"] if w["type"] == "CONCENTRATION_RISK" + ] + assert len(concentration_warnings) == 2 + + +@pytest.mark.asyncio +async def test_compliance_exactly_at_loss_threshold(): + """Exactly -15% gain does NOT trigger loss warning (rule is < -15).""" + from tools.compliance import compliance_check + result = await compliance_check(_portfolio([ + _holding("AAPL", 18.0, -15.0), + _holding("MSFT", 18.0, 2.0), + _holding("NVDA", 18.0, 2.0), + _holding("GOOGL", 18.0, 2.0), + _holding("VTI", 28.0, 2.0), + ])) + loss_warnings = [w for w in result["result"]["warnings"] if w["type"] == "SIGNIFICANT_LOSS"] + assert len(loss_warnings) == 0 + + +@pytest.mark.asyncio +async def test_compliance_just_over_loss_threshold(): + """−15.1% gain DOES trigger loss warning (< -15).""" + from tools.compliance import compliance_check + result = await compliance_check(_portfolio([ + _holding("AAPL", 18.0, -15.1), + _holding("MSFT", 18.0, 2.0), + _holding("NVDA", 18.0, 2.0), + _holding("GOOGL", 18.0, 2.0), + _holding("VTI", 28.0, 2.0), + ])) + loss_warnings = [w for w in result["result"]["warnings"] if w["type"] == "SIGNIFICANT_LOSS"] + assert len(loss_warnings) == 1 + + +@pytest.mark.asyncio +async def test_compliance_empty_holdings(): + """Empty holdings list succeeds: no per-holding warnings, but diversification warning fires.""" + from tools.compliance import compliance_check + result = await compliance_check(_portfolio([])) + assert result["success"] is True + div_warnings = [w for w in result["result"]["warnings"] if w["type"] == "LOW_DIVERSIFICATION"] + assert len(div_warnings) == 1 + assert div_warnings[0]["holding_count"] == 0 + + +@pytest.mark.asyncio +async def test_compliance_five_holdings_no_diversification_warning(): + """Exactly 5 holdings does NOT trigger diversification warning (rule is < 5).""" + from tools.compliance import compliance_check + holdings = [_holding(s, 20.0, 1.0) for s in ["AAPL", "MSFT", "NVDA", "GOOGL", "VTI"]] + result = await compliance_check(_portfolio(holdings)) + div_warnings = [w for w in result["result"]["warnings"] if w["type"] == "LOW_DIVERSIFICATION"] + assert len(div_warnings) == 0 + + +@pytest.mark.asyncio +async def test_compliance_four_holdings_triggers_diversification_warning(): + """4 holdings DOES trigger diversification warning (< 5).""" + from tools.compliance import compliance_check + holdings = [_holding(s, 25.0, 1.0) for s in ["AAPL", "MSFT", "NVDA", "GOOGL"]] + result = await compliance_check(_portfolio(holdings)) + div_warnings = [w for w in result["result"]["warnings"] if w["type"] == "LOW_DIVERSIFICATION"] + assert len(div_warnings) == 1 + + +@pytest.mark.asyncio +async def test_compliance_severity_levels(): + """Concentration=HIGH, Loss=MEDIUM, Diversification=LOW.""" + from tools.compliance import compliance_check + result = await compliance_check(_portfolio([ + _holding("AAPL", 55.0, -20.0), + ])) + warnings_by_type = {w["type"]: w for w in result["result"]["warnings"]} + assert warnings_by_type["CONCENTRATION_RISK"]["severity"] == "HIGH" + assert warnings_by_type["SIGNIFICANT_LOSS"]["severity"] == "MEDIUM" + assert warnings_by_type["LOW_DIVERSIFICATION"]["severity"] == "LOW" + + +@pytest.mark.asyncio +async def test_compliance_result_schema(): + """Result must contain all required top-level schema keys.""" + from tools.compliance import compliance_check + result = await compliance_check(_portfolio([_holding("AAPL", 18.0, 2.0)] * 5)) + assert result["tool_name"] == "compliance_check" + assert "tool_result_id" in result + assert "timestamp" in result + assert "result" in result + res = result["result"] + for key in ("warnings", "warning_count", "overall_status", "holdings_analyzed"): + assert key in res, f"Missing key: {key}" + + +@pytest.mark.asyncio +async def test_compliance_null_values_in_holding(): + """None values for allocation_pct and gain_pct do not crash the engine.""" + from tools.compliance import compliance_check + holdings = [ + {"symbol": "AAPL", "allocation_pct": None, "gain_pct": None}, + {"symbol": "MSFT", "allocation_pct": None, "gain_pct": None}, + {"symbol": "NVDA", "allocation_pct": None, "gain_pct": None}, + {"symbol": "GOOGL", "allocation_pct": None, "gain_pct": None}, + {"symbol": "VTI", "allocation_pct": None, "gain_pct": None}, + ] + result = await compliance_check(_portfolio(holdings)) + assert result["success"] is True + + +# =========================================================================== +# Group B — tax_estimate (15 tests) +# =========================================================================== + +@pytest.mark.asyncio +async def test_tax_short_term_gain(): + """Sale held < 365 days is taxed at the short-term rate (22%).""" + from tools.tax_estimate import tax_estimate + activities = [ + _activity("BUY", "AAPL", 10, 100.0, "2024-01-01"), + _activity("SELL", "AAPL", 10, 200.0, "2024-06-01"), # ~5 months + ] + result = await tax_estimate(activities) + assert result["success"] is True + res = result["result"] + assert res["short_term_gains"] == pytest.approx(1000.0) + assert res["long_term_gains"] == pytest.approx(0.0) + assert res["short_term_tax_estimated"] == pytest.approx(220.0) + + +@pytest.mark.asyncio +async def test_tax_long_term_gain(): + """Sale held >= 365 days is taxed at the long-term rate (15%).""" + from tools.tax_estimate import tax_estimate + activities = [ + _activity("BUY", "MSFT", 10, 100.0, "2022-01-01"), + _activity("SELL", "MSFT", 10, 300.0, "2023-02-01"), # > 365 days + ] + result = await tax_estimate(activities) + assert result["success"] is True + res = result["result"] + assert res["long_term_gains"] == pytest.approx(2000.0) + assert res["short_term_gains"] == pytest.approx(0.0) + assert res["long_term_tax_estimated"] == pytest.approx(300.0) + + +@pytest.mark.asyncio +async def test_tax_mixed_gains(): + """Mix of short-term and long-term gains are calculated separately.""" + from tools.tax_estimate import tax_estimate + activities = [ + _activity("BUY", "AAPL", 5, 100.0, "2024-01-01"), + _activity("SELL", "AAPL", 5, 200.0, "2024-06-01"), # short-term: +$500 + _activity("BUY", "MSFT", 5, 100.0, "2021-01-01"), + _activity("SELL", "MSFT", 5, 300.0, "2023-01-01"), # long-term: +$1000 + ] + result = await tax_estimate(activities) + assert result["success"] is True + res = result["result"] + assert res["short_term_gains"] > 0 + assert res["long_term_gains"] > 0 + assert res["total_estimated_tax"] == pytest.approx( + res["short_term_tax_estimated"] + res["long_term_tax_estimated"] + ) + + +@pytest.mark.asyncio +async def test_tax_wash_sale_detection(): + """Buy within 30 days of a loss sale triggers wash sale warning.""" + from tools.tax_estimate import tax_estimate + activities = [ + _activity("BUY", "NVDA", 10, 200.0, "2024-01-01"), + _activity("SELL", "NVDA", 10, 150.0, "2024-06-01"), # loss sale + _activity("BUY", "NVDA", 10, 155.0, "2024-06-15"), # within 30 days → wash sale + ] + result = await tax_estimate(activities) + assert result["success"] is True + assert len(result["result"]["wash_sale_warnings"]) >= 1 + assert result["result"]["wash_sale_warnings"][0]["symbol"] == "NVDA" + + +@pytest.mark.asyncio +async def test_tax_empty_activities(): + """Empty activity list returns zero gains and zero tax.""" + from tools.tax_estimate import tax_estimate + result = await tax_estimate([]) + assert result["success"] is True + res = result["result"] + assert res["short_term_gains"] == 0.0 + assert res["long_term_gains"] == 0.0 + assert res["total_estimated_tax"] == 0.0 + assert res["sell_transactions_analyzed"] == 0 + + +@pytest.mark.asyncio +async def test_tax_no_sells(): + """Activities with only buys returns zero gains.""" + from tools.tax_estimate import tax_estimate + activities = [ + _activity("BUY", "AAPL", 10, 150.0, "2024-01-01"), + _activity("BUY", "MSFT", 5, 300.0, "2024-02-01"), + ] + result = await tax_estimate(activities) + assert result["success"] is True + assert result["result"]["sell_transactions_analyzed"] == 0 + assert result["result"]["total_estimated_tax"] == 0.0 + + +@pytest.mark.asyncio +async def test_tax_zero_gain_sale(): + """Sale at same price as buy results in zero gain and zero tax.""" + from tools.tax_estimate import tax_estimate + activities = [ + _activity("BUY", "AAPL", 10, 150.0, "2024-01-01"), + _activity("SELL", "AAPL", 10, 150.0, "2024-06-01"), + ] + result = await tax_estimate(activities) + assert result["success"] is True + assert result["result"]["short_term_gains"] == pytest.approx(0.0) + assert result["result"]["total_estimated_tax"] == pytest.approx(0.0) + + +@pytest.mark.asyncio +async def test_tax_multiple_symbols(): + """Multiple symbols are processed independently.""" + from tools.tax_estimate import tax_estimate + activities = [ + _activity("BUY", "AAPL", 5, 100.0, "2024-01-01"), + _activity("SELL", "AAPL", 5, 200.0, "2024-04-01"), + _activity("BUY", "MSFT", 3, 200.0, "2024-01-01"), + _activity("SELL", "MSFT", 3, 300.0, "2024-04-01"), + ] + result = await tax_estimate(activities) + assert result["success"] is True + assert result["result"]["sell_transactions_analyzed"] == 2 + assert len(result["result"]["breakdown"]) == 2 + + +@pytest.mark.asyncio +async def test_tax_disclaimer_always_present(): + """Disclaimer key is always present in the result, even for zero-gain scenarios.""" + from tools.tax_estimate import tax_estimate + result = await tax_estimate([]) + assert "disclaimer" in result["result"] + assert "ESTIMATE ONLY" in result["result"]["disclaimer"] + + +@pytest.mark.asyncio +async def test_tax_short_term_rate_22pct(): + """Short-term tax is exactly 22% of positive short-term gains.""" + from tools.tax_estimate import tax_estimate + activities = [ + _activity("BUY", "AAPL", 10, 100.0, "2024-01-01"), + _activity("SELL", "AAPL", 10, 200.0, "2024-04-01"), # $1000 gain, short-term + ] + result = await tax_estimate(activities) + res = result["result"] + assert res["short_term_gains"] == pytest.approx(1000.0) + assert res["short_term_tax_estimated"] == pytest.approx(1000.0 * 0.22) + + +@pytest.mark.asyncio +async def test_tax_long_term_rate_15pct(): + """Long-term tax is exactly 15% of positive long-term gains.""" + from tools.tax_estimate import tax_estimate + activities = [ + _activity("BUY", "AAPL", 10, 100.0, "2020-01-01"), + _activity("SELL", "AAPL", 10, 200.0, "2022-01-01"), # $1000 gain, long-term + ] + result = await tax_estimate(activities) + res = result["result"] + assert res["long_term_gains"] == pytest.approx(1000.0) + assert res["long_term_tax_estimated"] == pytest.approx(1000.0 * 0.15) + + +@pytest.mark.asyncio +async def test_tax_sell_with_no_matching_buy(): + """When no matching buy exists, cost basis defaults to sell price (zero gain).""" + from tools.tax_estimate import tax_estimate + activities = [ + _activity("SELL", "TSLA", 5, 200.0, "2024-06-01"), + ] + result = await tax_estimate(activities) + assert result["success"] is True + # cost_basis = sell_price → gain = 0 + assert result["result"]["short_term_gains"] == pytest.approx(0.0) + + +@pytest.mark.asyncio +async def test_tax_negative_gain_not_taxed(): + """Negative gains (losses) do not add to estimated tax.""" + from tools.tax_estimate import tax_estimate + activities = [ + _activity("BUY", "AAPL", 10, 200.0, "2024-01-01"), + _activity("SELL", "AAPL", 10, 100.0, "2024-04-01"), # $1000 loss + ] + result = await tax_estimate(activities) + assert result["success"] is True + assert result["result"]["short_term_gains"] == pytest.approx(-1000.0) + assert result["result"]["short_term_tax_estimated"] == pytest.approx(0.0) + assert result["result"]["total_estimated_tax"] == pytest.approx(0.0) + + +@pytest.mark.asyncio +async def test_tax_breakdown_structure(): + """Each breakdown entry has required keys: symbol, gain_loss, holding_days, term.""" + from tools.tax_estimate import tax_estimate + activities = [ + _activity("BUY", "AAPL", 10, 100.0, "2024-01-01"), + _activity("SELL", "AAPL", 10, 150.0, "2024-06-01"), + ] + result = await tax_estimate(activities) + breakdown = result["result"]["breakdown"] + assert len(breakdown) == 1 + entry = breakdown[0] + for key in ("symbol", "gain_loss", "holding_days", "term"): + assert key in entry, f"Breakdown entry missing key: {key}" + assert entry["term"] in ("short-term", "long-term") + + +@pytest.mark.asyncio +async def test_tax_result_schema(): + """Result must contain all required schema keys.""" + from tools.tax_estimate import tax_estimate + result = await tax_estimate([]) + assert result["tool_name"] == "tax_estimate" + assert "tool_result_id" in result + res = result["result"] + for key in ("short_term_gains", "long_term_gains", "total_estimated_tax", + "short_term_tax_estimated", "long_term_tax_estimated", + "wash_sale_warnings", "breakdown", "disclaimer", "rates_used"): + assert key in res, f"Missing key in result: {key}" + + +# =========================================================================== +# Group C — transaction_categorize (10 tests) +# =========================================================================== + +@pytest.mark.asyncio +async def test_categorize_basic_buy(): + """Single buy activity is counted correctly.""" + from tools.categorize import transaction_categorize + result = await transaction_categorize([ + _activity("BUY", "AAPL", 10, 150.0, "2024-01-01") + ]) + assert result["success"] is True + summary = result["result"]["summary"] + assert summary["buy_count"] == 1 + assert summary["sell_count"] == 0 + assert summary["total_invested_usd"] == pytest.approx(1500.0) + + +@pytest.mark.asyncio +async def test_categorize_buy_sell_dividend(): + """All three activity types are categorized independently.""" + from tools.categorize import transaction_categorize + activities = [ + _activity("BUY", "AAPL", 10, 150.0, "2024-01-01"), + _activity("SELL", "AAPL", 5, 200.0, "2024-06-01"), + _activity("DIVIDEND", "AAPL", 1, 3.5, "2024-08-01"), + ] + result = await transaction_categorize(activities) + assert result["success"] is True + summary = result["result"]["summary"] + assert summary["buy_count"] == 1 + assert summary["sell_count"] == 1 + assert summary["dividend_count"] == 1 + assert summary["total_transactions"] == 3 + + +@pytest.mark.asyncio +async def test_categorize_empty_activities(): + """Empty input returns zero counts without crashing.""" + from tools.categorize import transaction_categorize + result = await transaction_categorize([]) + assert result["success"] is True + summary = result["result"]["summary"] + assert summary["total_transactions"] == 0 + assert summary["total_invested_usd"] == 0.0 + + +@pytest.mark.asyncio +async def test_categorize_per_symbol_breakdown(): + """by_symbol contains an entry for each distinct symbol.""" + from tools.categorize import transaction_categorize + activities = [ + _activity("BUY", "AAPL", 5, 150.0, "2024-01-01"), + _activity("BUY", "MSFT", 3, 300.0, "2024-02-01"), + ] + result = await transaction_categorize(activities) + by_symbol = result["result"]["by_symbol"] + assert "AAPL" in by_symbol + assert "MSFT" in by_symbol + assert by_symbol["AAPL"]["buy_count"] == 1 + assert by_symbol["MSFT"]["buy_count"] == 1 + + +@pytest.mark.asyncio +async def test_categorize_buy_and_hold_detection(): + """Portfolio with no sells is flagged as buy-and-hold.""" + from tools.categorize import transaction_categorize + activities = [ + _activity("BUY", "AAPL", 10, 150.0, "2024-01-01"), + _activity("BUY", "MSFT", 5, 300.0, "2024-02-01"), + ] + result = await transaction_categorize(activities) + assert result["result"]["patterns"]["is_buy_and_hold"] is True + + +@pytest.mark.asyncio +async def test_categorize_has_dividends_flag(): + """Portfolio with any dividend sets has_dividends=True.""" + from tools.categorize import transaction_categorize + activities = [ + _activity("BUY", "AAPL", 10, 150.0, "2024-01-01"), + _activity("DIVIDEND", "AAPL", 1, 3.5, "2024-08-01"), + ] + result = await transaction_categorize(activities) + assert result["result"]["patterns"]["has_dividends"] is True + + +@pytest.mark.asyncio +async def test_categorize_high_fee_ratio(): + """Fees > 1% of total invested sets high_fee_ratio=True.""" + from tools.categorize import transaction_categorize + activities = [ + _activity("BUY", "AAPL", 1, 100.0, "2024-01-01", fee=5.0), # 5% fee ratio + ] + result = await transaction_categorize(activities) + assert result["result"]["patterns"]["high_fee_ratio"] is True + + +@pytest.mark.asyncio +async def test_categorize_total_invested_calculation(): + """Total invested is the sum of quantity × unit_price for all BUY activities.""" + from tools.categorize import transaction_categorize + activities = [ + _activity("BUY", "AAPL", 10, 150.0, "2024-01-01"), # $1500 + _activity("BUY", "MSFT", 5, 200.0, "2024-02-01"), # $1000 + ] + result = await transaction_categorize(activities) + assert result["result"]["summary"]["total_invested_usd"] == pytest.approx(2500.0) + + +@pytest.mark.asyncio +async def test_categorize_most_traded_top5(): + """most_traded list contains at most 5 symbols.""" + from tools.categorize import transaction_categorize + activities = [ + _activity("BUY", sym, 1, 100.0, "2024-01-01") + for sym in ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN", "TSLA", "META"] + ] + result = await transaction_categorize(activities) + assert len(result["result"]["most_traded"]) <= 5 + + +@pytest.mark.asyncio +async def test_categorize_result_schema(): + """Result contains all required top-level schema keys.""" + from tools.categorize import transaction_categorize + result = await transaction_categorize([]) + assert result["tool_name"] == "transaction_categorize" + assert "tool_result_id" in result + assert "result" in result + res = result["result"] + for key in ("summary", "by_symbol", "most_traded", "patterns"): + assert key in res, f"Missing key: {key}" + for key in ("is_buy_and_hold", "has_dividends", "high_fee_ratio"): + assert key in res["patterns"], f"Missing pattern key: {key}" + + +# =========================================================================== +# Group D — consolidate_holdings (10 tests) +# =========================================================================== + +_FAKE_UUID = "00fda606-1234-5678-abcd-000000000001" +_FAKE_UUID2 = "00fda606-1234-5678-abcd-000000000002" + + +def test_consolidate_normal_holdings(): + """Normal (non-UUID) holdings pass through without modification.""" + from tools.portfolio import consolidate_holdings + holdings = [ + {"symbol": "AAPL", "name": "Apple", "quantity": 10, "investment": 1500, + "valueInBaseCurrency": 1800, "grossPerformance": 300, + "allocationInPercentage": 50, "averagePrice": 150}, + {"symbol": "MSFT", "name": "Microsoft", "quantity": 5, "investment": 1000, + "valueInBaseCurrency": 1200, "grossPerformance": 200, + "allocationInPercentage": 50, "averagePrice": 200}, + ] + result = consolidate_holdings(holdings) + symbols = [h["symbol"] for h in result] + assert "AAPL" in symbols + assert "MSFT" in symbols + + +def test_consolidate_uuid_matched_by_name(): + """UUID-symbol holding matched by name is merged into the real ticker entry.""" + from tools.portfolio import consolidate_holdings + holdings = [ + {"symbol": "AAPL", "name": "AAPL", "quantity": 10, "investment": 1500, + "valueInBaseCurrency": 1800, "grossPerformance": 300, + "allocationInPercentage": 50, "averagePrice": 150}, + {"symbol": _FAKE_UUID, "name": "AAPL", "quantity": 5, "investment": 750, + "valueInBaseCurrency": 900, "grossPerformance": 150, + "allocationInPercentage": 25, "averagePrice": 150}, + ] + result = consolidate_holdings(holdings) + # Should merge into single AAPL entry + aapl_entries = [h for h in result if h["symbol"] == "AAPL"] + assert len(aapl_entries) == 1 + assert aapl_entries[0]["quantity"] == 15 + + +def test_consolidate_uuid_no_match_promoted(): + """UUID-symbol holding with no name match is promoted using its name as symbol.""" + from tools.portfolio import consolidate_holdings + holdings = [ + {"symbol": _FAKE_UUID, "name": "TSLA", "quantity": 3, "investment": 600, + "valueInBaseCurrency": 750, "grossPerformance": 150, + "allocationInPercentage": 100, "averagePrice": 200}, + ] + result = consolidate_holdings(holdings) + assert len(result) == 1 + assert result[0]["symbol"] == "TSLA" + + +def test_consolidate_duplicate_real_tickers(): + """Two entries with the same real ticker symbol are merged.""" + from tools.portfolio import consolidate_holdings + holdings = [ + {"symbol": "AAPL", "name": "Apple", "quantity": 5, "investment": 750, + "valueInBaseCurrency": 900, "grossPerformance": 150, + "allocationInPercentage": 50, "averagePrice": 150}, + {"symbol": "AAPL", "name": "Apple", "quantity": 5, "investment": 750, + "valueInBaseCurrency": 900, "grossPerformance": 150, + "allocationInPercentage": 50, "averagePrice": 150}, + ] + result = consolidate_holdings(holdings) + aapl_entries = [h for h in result if h["symbol"] == "AAPL"] + assert len(aapl_entries) == 1 + assert aapl_entries[0]["quantity"] == 10 + + +def test_consolidate_empty_list(): + """Empty input returns an empty list.""" + from tools.portfolio import consolidate_holdings + assert consolidate_holdings([]) == [] + + +def test_consolidate_single_holding(): + """Single holding passes through as a list with one item.""" + from tools.portfolio import consolidate_holdings + holdings = [ + {"symbol": "NVDA", "name": "NVIDIA", "quantity": 8, "investment": 1200, + "valueInBaseCurrency": 2400, "grossPerformance": 1200, + "allocationInPercentage": 100, "averagePrice": 150}, + ] + result = consolidate_holdings(holdings) + assert len(result) == 1 + assert result[0]["symbol"] == "NVDA" + + +def test_consolidate_quantities_summed(): + """Merged holding quantities are summed correctly.""" + from tools.portfolio import consolidate_holdings + holdings = [ + {"symbol": "AAPL", "name": "Apple", "quantity": 10, "investment": 1500, + "valueInBaseCurrency": 1800, "grossPerformance": 300, + "allocationInPercentage": 50, "averagePrice": 150}, + {"symbol": _FAKE_UUID, "name": "AAPL", "quantity": 7, "investment": 1050, + "valueInBaseCurrency": 1260, "grossPerformance": 210, + "allocationInPercentage": 35, "averagePrice": 150}, + ] + result = consolidate_holdings(holdings) + aapl = next(h for h in result if h["symbol"] == "AAPL") + assert aapl["quantity"] == 17 + + +def test_consolidate_investment_summed(): + """Merged holding investment values are summed correctly.""" + from tools.portfolio import consolidate_holdings + holdings = [ + {"symbol": "MSFT", "name": "Microsoft", "quantity": 5, "investment": 1000, + "valueInBaseCurrency": 1200, "grossPerformance": 200, + "allocationInPercentage": 50, "averagePrice": 200}, + {"symbol": "MSFT", "name": "Microsoft", "quantity": 5, "investment": 1000, + "valueInBaseCurrency": 1200, "grossPerformance": 200, + "allocationInPercentage": 50, "averagePrice": 200}, + ] + result = consolidate_holdings(holdings) + msft = next(h for h in result if h["symbol"] == "MSFT") + assert msft["investment"] == 2000 + + +def test_consolidate_mixed_uuid_and_real(): + """Mix of UUID and real-ticker holdings resolves to correct symbol count.""" + from tools.portfolio import consolidate_holdings + holdings = [ + {"symbol": "AAPL", "name": "Apple", "quantity": 10, "investment": 1500, + "valueInBaseCurrency": 1800, "grossPerformance": 300, + "allocationInPercentage": 40, "averagePrice": 150}, + {"symbol": _FAKE_UUID, "name": "AAPL", "quantity": 5, "investment": 750, + "valueInBaseCurrency": 900, "grossPerformance": 150, + "allocationInPercentage": 20, "averagePrice": 150}, + {"symbol": "MSFT", "name": "Microsoft", "quantity": 8, "investment": 2400, + "valueInBaseCurrency": 2800, "grossPerformance": 400, + "allocationInPercentage": 40, "averagePrice": 300}, + ] + result = consolidate_holdings(holdings) + symbols = {h["symbol"] for h in result} + assert symbols == {"AAPL", "MSFT"} + + +def test_consolidate_case_insensitive_name_match(): + """Name matching between UUID entries and real tickers is case-insensitive.""" + from tools.portfolio import consolidate_holdings + holdings = [ + {"symbol": "aapl", "name": "apple inc", "quantity": 10, "investment": 1500, + "valueInBaseCurrency": 1800, "grossPerformance": 300, + "allocationInPercentage": 50, "averagePrice": 150}, + {"symbol": _FAKE_UUID2, "name": "APPLE INC", "quantity": 5, "investment": 750, + "valueInBaseCurrency": 900, "grossPerformance": 150, + "allocationInPercentage": 25, "averagePrice": 150}, + ] + result = consolidate_holdings(holdings) + # Should not crash; UUID entry should be handled (promoted or merged) + assert len(result) >= 1 + + +# =========================================================================== +# Group E — graph extraction helpers (10 tests) +# =========================================================================== + +def test_extract_ticker_known_symbol(): + """Known tickers are extracted correctly from a natural language query.""" + from graph import _extract_ticker + assert _extract_ticker("What is AAPL doing today?") == "AAPL" + + +def test_extract_ticker_msft_in_buy_query(): + """Ticker is extracted from a buy instruction.""" + from graph import _extract_ticker + result = _extract_ticker("buy 10 shares of MSFT at $350") + assert result == "MSFT" + + +def test_extract_ticker_not_found(): + """Returns None when query has no 1-5 letter ticker candidate (all words long or excluded).""" + from graph import _extract_ticker + # All words are either in exclusion list or > 5 chars — no ticker candidate + result = _extract_ticker("What percentage allocation is my portfolio tracking?") + assert result is None + + +def test_extract_quantity_shares(): + """Extracts integer share count.""" + from graph import _extract_quantity + assert _extract_quantity("buy 5 shares of AAPL") == pytest.approx(5.0) + + +def test_extract_quantity_decimal(): + """Extracts decimal quantity.""" + from graph import _extract_quantity + assert _extract_quantity("sell 10.5 units") == pytest.approx(10.5) + + +def test_extract_price_dollar_sign(): + """Extracts price preceded by dollar sign.""" + from graph import _extract_price + assert _extract_price("buy AAPL at $185.50") == pytest.approx(185.50) + + +def test_extract_price_per_share(): + """Extracts price with 'per share' suffix.""" + from graph import _extract_price + assert _extract_price("250 per share") == pytest.approx(250.0) + + +def test_extract_date_iso_format(): + """Extracts ISO date string unchanged.""" + from graph import _extract_date + assert _extract_date("transaction on 2024-01-15") == "2024-01-15" + + +def test_extract_date_slash_format(): + """Converts MM/DD/YYYY to YYYY-MM-DD.""" + from graph import _extract_date + assert _extract_date("on 1/15/2024") == "2024-01-15" + + +def test_extract_fee_explicit(): + """Extracts fee amount from natural language.""" + from graph import _extract_fee + assert _extract_fee("buy 10 shares with fee of $7.50") == pytest.approx(7.50) diff --git a/evals/test_property_tracker.py b/evals/test_property_tracker.py new file mode 100644 index 000000000..059a7f08b --- /dev/null +++ b/evals/test_property_tracker.py @@ -0,0 +1,408 @@ +""" +Unit tests for the Property Tracker tool. + +Tests cover: + 1. add_property schema — result contains required fields + 2. Equity computed correctly — equity = current_value - mortgage_balance + 3. Appreciation computed correctly — appreciation = current_value - purchase_price + 4. list_properties empty — returns success with empty list and zero summary + 5. list_properties with data — summary totals are mathematically correct + 6. get_real_estate_equity — returns correct totals across multiple properties + 7. Feature flag disabled — all tools return FEATURE_DISABLED + 8. remove_property — removes the correct entry + 9. remove_property not found — returns structured error, no crash + 10. add_property validation — empty address returns structured error + 11. add_property validation — zero purchase price returns structured error + 12. No mortgage — equity equals full current value when mortgage_balance=0 + 13. current_value defaults to purchase_price when not supplied +""" + +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 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _set_flag(value: str): + os.environ["ENABLE_REAL_ESTATE"] = value + + +def _clear_flag(): + os.environ.pop("ENABLE_REAL_ESTATE", None) + + +_SAMPLE_ADDRESS = "123 Barton Hills Dr, Austin, TX 78704" +_SAMPLE_PURCHASE = 450_000.0 +_SAMPLE_VALUE = 522_500.0 +_SAMPLE_MORTGAGE = 380_000.0 + + +# --------------------------------------------------------------------------- +# Test 1 — add_property schema +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_add_property_schema(): + """ + GIVEN the feature is enabled + WHEN add_property is called with valid inputs + THEN the result has success=True, a tool_result_id, and a property dict + with all required fields. + """ + _set_flag("true") + from tools.property_tracker import add_property, property_store_clear + property_store_clear() + + result = await add_property( + address=_SAMPLE_ADDRESS, + purchase_price=_SAMPLE_PURCHASE, + current_value=_SAMPLE_VALUE, + mortgage_balance=_SAMPLE_MORTGAGE, + ) + + assert result["success"] is True + assert result["tool_name"] == "property_tracker" + assert "tool_result_id" in result + + prop = result["result"]["property"] + required_fields = { + "id", "address", "property_type", "purchase_price", + "current_value", "mortgage_balance", "equity", "equity_pct", + "appreciation", "appreciation_pct", "county_key", "added_at", + } + missing = required_fields - set(prop.keys()) + assert not missing, f"Property missing fields: {missing}" + assert prop["address"] == _SAMPLE_ADDRESS + + +# --------------------------------------------------------------------------- +# Test 2 — equity computed correctly +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_equity_computed(): + """ + GIVEN current_value=522500 and mortgage_balance=380000 + WHEN add_property is called + THEN equity == 142500 and equity_pct ≈ 27.27%. + """ + _set_flag("true") + from tools.property_tracker import add_property, property_store_clear + property_store_clear() + + result = await add_property( + address=_SAMPLE_ADDRESS, + purchase_price=_SAMPLE_PURCHASE, + current_value=_SAMPLE_VALUE, + mortgage_balance=_SAMPLE_MORTGAGE, + ) + + prop = result["result"]["property"] + assert prop["equity"] == pytest.approx(142_500.0), "equity must be current_value - mortgage" + assert prop["equity_pct"] == pytest.approx(27.27, abs=0.1), "equity_pct must be ~27.27%" + + +# --------------------------------------------------------------------------- +# Test 3 — appreciation computed correctly +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_appreciation_computed(): + """ + GIVEN purchase_price=450000 and current_value=522500 + WHEN add_property is called + THEN appreciation == 72500 and appreciation_pct ≈ 16.11%. + """ + _set_flag("true") + from tools.property_tracker import add_property, property_store_clear + property_store_clear() + + result = await add_property( + address=_SAMPLE_ADDRESS, + purchase_price=_SAMPLE_PURCHASE, + current_value=_SAMPLE_VALUE, + mortgage_balance=_SAMPLE_MORTGAGE, + ) + + prop = result["result"]["property"] + assert prop["appreciation"] == pytest.approx(72_500.0) + assert prop["appreciation_pct"] == pytest.approx(16.11, abs=0.1) + + +# --------------------------------------------------------------------------- +# Test 4 — list_properties empty store +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_list_properties_empty(): + """ + GIVEN no properties have been added + WHEN list_properties is called + THEN success=True, properties=[], all summary totals are zero. + """ + _set_flag("true") + from tools.property_tracker import list_properties, property_store_clear + property_store_clear() + + result = await list_properties() + + assert result["success"] is True + assert result["result"]["properties"] == [] + summary = result["result"]["summary"] + assert summary["property_count"] == 0 + assert summary["total_equity"] == 0 + assert summary["total_current_value"] == 0 + + +# --------------------------------------------------------------------------- +# Test 5 — list_properties summary totals are correct +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_list_properties_totals(): + """ + GIVEN two properties are added + WHEN list_properties is called + THEN summary totals are the correct arithmetic sum of both properties. + """ + _set_flag("true") + from tools.property_tracker import add_property, list_properties, property_store_clear + property_store_clear() + + await add_property( + address="123 Main St, Austin, TX", + purchase_price=450_000, + current_value=522_500, + mortgage_balance=380_000, + ) + await add_property( + address="456 Round Rock Ave, Round Rock, TX", + purchase_price=320_000, + current_value=403_500, + mortgage_balance=250_000, + county_key="williamson_county", + ) + + result = await list_properties() + assert result["success"] is True + + summary = result["result"]["summary"] + assert summary["property_count"] == 2 + assert summary["total_purchase_price"] == pytest.approx(770_000) + assert summary["total_current_value"] == pytest.approx(926_000) + assert summary["total_mortgage_balance"] == pytest.approx(630_000) + assert summary["total_equity"] == pytest.approx(296_000) # 926000 - 630000 + assert summary["total_equity_pct"] == pytest.approx(31.96, abs=0.1) + + +# --------------------------------------------------------------------------- +# Test 6 — get_real_estate_equity returns correct totals +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_get_real_estate_equity(): + """ + GIVEN one property with equity 142500 + WHEN get_real_estate_equity is called + THEN total_real_estate_equity == 142500. + """ + _set_flag("true") + from tools.property_tracker import add_property, get_real_estate_equity, property_store_clear + property_store_clear() + + await add_property( + address=_SAMPLE_ADDRESS, + purchase_price=_SAMPLE_PURCHASE, + current_value=_SAMPLE_VALUE, + mortgage_balance=_SAMPLE_MORTGAGE, + ) + + result = await get_real_estate_equity() + assert result["success"] is True + assert result["result"]["total_real_estate_equity"] == pytest.approx(142_500.0) + assert result["result"]["total_real_estate_value"] == pytest.approx(522_500.0) + assert result["result"]["property_count"] == 1 + + +# --------------------------------------------------------------------------- +# Test 7 — feature flag disabled +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_feature_flag_disabled(): + """ + GIVEN ENABLE_REAL_ESTATE=false + WHEN any property tracker tool is called + THEN all return success=False with PROPERTY_TRACKER_FEATURE_DISABLED. + """ + _set_flag("false") + from tools.property_tracker import ( + add_property, list_properties, get_real_estate_equity, + remove_property, is_property_tracking_enabled, property_store_clear, + ) + property_store_clear() + + assert is_property_tracking_enabled() is False + + for coro in [ + add_property(_SAMPLE_ADDRESS, _SAMPLE_PURCHASE), + list_properties(), + get_real_estate_equity(), + remove_property("prop_001"), + ]: + result = await coro + assert result["success"] is False + assert isinstance(result["error"], dict) + assert result["error"]["code"] == "PROPERTY_TRACKER_FEATURE_DISABLED" + + _set_flag("true") + + +# --------------------------------------------------------------------------- +# Test 8 — remove_property removes the correct entry +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_remove_property(): + """ + GIVEN one property exists + WHEN remove_property is called with its ID + THEN success=True, and list_properties afterwards shows empty. + """ + _set_flag("true") + from tools.property_tracker import add_property, list_properties, remove_property, property_store_clear + property_store_clear() + + add_result = await add_property( + address=_SAMPLE_ADDRESS, + purchase_price=_SAMPLE_PURCHASE, + current_value=_SAMPLE_VALUE, + mortgage_balance=_SAMPLE_MORTGAGE, + ) + prop_id = add_result["result"]["property"]["id"] + + remove_result = await remove_property(prop_id) + assert remove_result["success"] is True + assert remove_result["result"]["status"] == "removed" + + list_result = await list_properties() + assert list_result["result"]["properties"] == [] + + +# --------------------------------------------------------------------------- +# Test 9 — remove_property not found returns structured error +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_remove_property_not_found(): + """ + GIVEN the store is empty + WHEN remove_property is called with a non-existent ID + THEN success=False with code=PROPERTY_TRACKER_NOT_FOUND, no crash. + """ + _set_flag("true") + from tools.property_tracker import remove_property, property_store_clear + property_store_clear() + + result = await remove_property("prop_999") + assert result["success"] is False + assert isinstance(result["error"], dict) + assert result["error"]["code"] == "PROPERTY_TRACKER_NOT_FOUND" + assert "prop_999" in result["error"]["message"] + + +# --------------------------------------------------------------------------- +# Test 10 — validation: empty address +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_add_property_empty_address(): + """ + GIVEN an empty address string + WHEN add_property is called + THEN success=False with code=PROPERTY_TRACKER_INVALID_INPUT. + """ + _set_flag("true") + from tools.property_tracker import add_property, property_store_clear + property_store_clear() + + result = await add_property(address=" ", purchase_price=450_000) + assert result["success"] is False + assert result["error"]["code"] == "PROPERTY_TRACKER_INVALID_INPUT" + + +# --------------------------------------------------------------------------- +# Test 11 — validation: zero purchase price +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_add_property_zero_price(): + """ + GIVEN a purchase_price of 0 + WHEN add_property is called + THEN success=False with code=PROPERTY_TRACKER_INVALID_INPUT. + """ + _set_flag("true") + from tools.property_tracker import add_property, property_store_clear + property_store_clear() + + result = await add_property(address=_SAMPLE_ADDRESS, purchase_price=0) + assert result["success"] is False + assert result["error"]["code"] == "PROPERTY_TRACKER_INVALID_INPUT" + + +# --------------------------------------------------------------------------- +# Test 12 — no mortgage: equity equals full current value +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_no_mortgage_equity_full_value(): + """ + GIVEN mortgage_balance defaults to 0 + WHEN add_property is called + THEN equity == current_value (property is fully owned). + """ + _set_flag("true") + from tools.property_tracker import add_property, property_store_clear + property_store_clear() + + result = await add_property( + address=_SAMPLE_ADDRESS, + purchase_price=_SAMPLE_PURCHASE, + current_value=_SAMPLE_VALUE, + ) + prop = result["result"]["property"] + assert prop["equity"] == pytest.approx(_SAMPLE_VALUE) + assert prop["equity_pct"] == pytest.approx(100.0) + + +# --------------------------------------------------------------------------- +# Test 13 — current_value defaults to purchase_price +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_current_value_defaults_to_purchase_price(): + """ + GIVEN current_value is not supplied + WHEN add_property is called + THEN current_value equals purchase_price and appreciation == 0. + """ + _set_flag("true") + from tools.property_tracker import add_property, property_store_clear + property_store_clear() + + result = await add_property( + address=_SAMPLE_ADDRESS, + purchase_price=_SAMPLE_PURCHASE, + ) + prop = result["result"]["property"] + assert prop["current_value"] == pytest.approx(_SAMPLE_PURCHASE) + assert prop["appreciation"] == pytest.approx(0.0) diff --git a/graph.py b/graph.py index 36648cad0..5e7f3e343 100644 --- a/graph.py +++ b/graph.py @@ -21,6 +21,13 @@ from tools.real_estate import ( compare_neighborhoods, is_real_estate_enabled, ) +from tools.property_tracker import ( + add_property, + list_properties, + get_real_estate_equity, + remove_property as remove_tracked_property, + is_property_tracking_enabled, +) from verification.fact_checker import verify_claims SYSTEM_PROMPT = """You are a portfolio analysis assistant integrated with Ghostfolio wealth management software. @@ -395,6 +402,32 @@ async def classify_node(state: AgentState) -> AgentState: return {**state, "query_type": "compliance+tax"} return {**state, "query_type": "tax"} + # --- Property Tracker (feature-flagged) — checked BEFORE general real estate + # so "add my property" doesn't fall through to real_estate_snapshot --- + if is_property_tracking_enabled(): + property_add_kws = [ + "add my property", "add property", "track my property", + "track my home", "add my home", "add my house", "add my condo", + "i own a house", "i own a home", "i own a condo", "i own a property", + "record my property", "log my property", + ] + property_list_kws = [ + "my properties", "list my properties", "show my properties", + "my real estate holdings", "properties i own", "my property portfolio", + "what properties", "show my homes", + ] + property_net_worth_kws = [ + "net worth including", "net worth with real estate", + "total net worth", "total wealth", "all my assets", + "real estate net worth", "net worth and real estate", + ] + if any(kw in query for kw in property_add_kws): + return {**state, "query_type": "property_add"} + if any(kw in query for kw in property_list_kws): + return {**state, "query_type": "property_list"} + if any(kw in query for kw in property_net_worth_kws): + return {**state, "query_type": "property_net_worth"} + # --- Real Estate (feature-flagged) — checked AFTER tax/compliance so portfolio # queries like "housing allocation" still route to portfolio tools --- if is_real_estate_enabled(): @@ -411,7 +444,16 @@ async def classify_node(state: AgentState) -> AgentState: "under $", "rent estimate", "for sale", "open house", "property search", "find homes", "home value", ] - has_real_estate = any(kw in query for kw in real_estate_kws) + # Location-based routing: known city/county + a real estate intent signal + # (avoids misrouting portfolio queries that happen to mention a city name) + _location_intent_kws = [ + "compare", "vs ", "versus", "market", "county", "neighborhood", + "tell me about", "how is", "what about", "what's the", "whats the", + "area", "prices in", "homes in", "housing in", "rent in", + ] + has_known_location = any(city in query for city in _KNOWN_CITIES) + has_location_re_intent = has_known_location and any(kw in query for kw in _location_intent_kws) + has_real_estate = any(kw in query for kw in real_estate_kws) or has_location_re_intent if has_real_estate: # Determine sub-type from context if any(kw in query for kw in ["compare neighborhood", "compare cit", "vs "]): @@ -780,12 +822,115 @@ async def write_execute_node(state: AgentState) -> AgentState: # --------------------------------------------------------------------------- _KNOWN_CITIES = [ + # Original US metros "austin", "san francisco", "new york", "new york city", "nyc", "denver", "seattle", "miami", "chicago", "phoenix", "nashville", "dallas", "brooklyn", "manhattan", "sf", "atx", "dfw", + # ACTRIS / Greater Austin locations + "travis county", "travis", + "williamson county", "williamson", "round rock", "cedar park", "georgetown", "leander", + "hays county", "hays", "kyle", "buda", "san marcos", "wimberley", + "bastrop county", "bastrop", "elgin", "smithville", + "caldwell county", "caldwell", "lockhart", "luling", + "greater austin", "austin metro", "austin msa", ] +def _extract_property_details(query: str) -> dict: + """ + Extracts property details from a natural language add-property query. + + Looks for: + - address: text in quotes, or "at
" up to a comma/period + - purchase_price: dollar amount near "bought", "paid", "purchased", "purchase price" + - current_value: dollar amount near "worth", "value", "estimate", "current" + - mortgage_balance: dollar amount near "mortgage", "owe", "loan", "outstanding" + - county_key: derived from location keywords in the query + """ + import re as _re + + def _parse_price(raw: str) -> float: + """Convert '450k', '1.2m', '450,000' → float.""" + raw = raw.replace(",", "") + suffix = "" + if raw and raw[-1].lower() in ("k", "m"): + suffix = raw[-1].lower() + raw = raw[:-1] + try: + amount = float(raw) + except ValueError: + return 0.0 + if suffix == "k": + amount *= 1_000 + elif suffix == "m": + amount *= 1_000_000 + return amount + + price_re = r"\$?([\d,]+(?:\.\d+)?[km]?)" + + # Address: quoted string first, then "at " until comma/period/end + address = "" + quoted = _re.search(r'["\'](.+?)["\']', query) + if quoted: + address = quoted.group(1).strip() + else: + at_match = _re.search(r'\bat\s+(.+?)(?:[,.]|purchase|bought|worth|mortgage|$)', query, _re.I) + if at_match: + address = at_match.group(1).strip() + + # Purchase price: amount near "bought for", "paid", "purchased for", "purchase price" + purchase_price = 0.0 + pp_match = _re.search( + r'(?:bought\s+for|paid|purchased\s+for|purchase\s+price\s+(?:of|is|was)?)\s*' + price_re, + query, _re.I, + ) + if pp_match: + purchase_price = _parse_price(pp_match.group(1)) + + # Current value: amount near "worth", "valued at", "current value", "estimate" + current_value = None + cv_match = _re.search( + r"(?:worth|valued\s+at|current\s+value\s+(?:of|is)?|now\s+worth|estimate[sd]?\s+at)\s*" + price_re, + query, _re.I, + ) + if cv_match: + current_value = _parse_price(cv_match.group(1)) + + # Mortgage balance: amount near "mortgage", "owe", "loan balance", "outstanding" + mortgage_balance = 0.0 + mb_match = _re.search( + r"(?:mortgage\s+(?:of|balance|is)?|owe[sd]?|loan\s+(?:balance|of)?|outstanding\s+(?:loan|balance)?)\s*" + price_re, + query, _re.I, + ) + if mb_match: + mortgage_balance = _parse_price(mb_match.group(1)) + + # County key: use normalized city lookup from real_estate tool + from tools.real_estate import _normalize_city + county_key = _normalize_city(query) or "austin" + + # Property type from keywords + property_type = "Single Family" + q_lower = query.lower() + if any(kw in q_lower for kw in ["condo", "condominium", "apartment"]): + property_type = "Condo" + elif any(kw in q_lower for kw in ["townhouse", "townhome", "town home"]): + property_type = "Townhouse" + elif any(kw in q_lower for kw in ["multi-family", "multifamily", "duplex", "triplex"]): + property_type = "Multi-Family" + elif "land" in q_lower or "lot" in q_lower: + property_type = "Land" + + return { + "address": address, + "purchase_price": purchase_price, + "current_value": current_value, + "mortgage_balance": mortgage_balance, + "county_key": county_key, + "property_type": property_type, + } + + def _extract_real_estate_location(query: str) -> str: """ Extracts the most likely city/location from a real estate query. @@ -1059,6 +1204,30 @@ async def tools_node(state: AgentState) -> AgentState: result = await get_listing_details(listing_id) tool_results.append(result) + # --- Property Tracker (feature-flagged) --- + elif query_type == "property_add": + details = _extract_property_details(user_query) + result = await add_property( + address=details["address"] or "Address not specified", + purchase_price=details["purchase_price"] or 0.0, + current_value=details["current_value"], + mortgage_balance=details["mortgage_balance"], + county_key=details["county_key"], + property_type=details["property_type"], + ) + tool_results.append(result) + + elif query_type == "property_list": + result = await list_properties() + tool_results.append(result) + + elif query_type == "property_net_worth": + equity_result = await get_real_estate_equity() + tool_results.append(equity_result) + # Also fetch the financial portfolio so the agent can combine both + perf_result = await portfolio_analysis(token=state.get("bearer_token")) + tool_results.append(perf_result) + return { **state, "tool_results": tool_results, diff --git a/main.py b/main.py index 8007b1cc0..18e8c5973 100644 --- a/main.py +++ b/main.py @@ -106,6 +106,86 @@ async def chat(req: ChatRequest): tools_used = [r["tool_name"] for r in result.get("tool_results", [])] + # Extract structured comparison card when compare_neighborhoods ran + comparison_card = None + for r in result.get("tool_results", []): + if ( + r.get("tool_name") == "real_estate" + and r.get("success") + and isinstance(r.get("result"), dict) + and "location_a" in r["result"] + ): + res = r["result"] + m = res["metrics"] + # Count advantages per city to form a verdict + advantages: dict[str, int] = {res["location_a"]: 0, res["location_b"]: 0} + for metric_data in m.values(): + if isinstance(metric_data, dict): + for winner_key in ("more_affordable", "higher_yield", "more_walkable"): + winner_city = metric_data.get(winner_key) + if winner_city in advantages: + advantages[winner_city] += 1 + winner = max(advantages, key=lambda c: advantages[c]) + loser = [c for c in advantages if c != winner][0] + verdict = ( + f"{winner} leads on affordability & yield " + f"({advantages[winner]} vs {advantages[loser]} metrics)." + ) + comparison_card = { + "city_a": { + "name": res["location_a"], + "median_price": m["median_price"]["a"], + "price_per_sqft": m["price_per_sqft"]["a"], + "days_on_market": m["days_on_market"]["a"], + "walk_score": m["walk_score"]["a"], + "yoy_change": m["yoy_price_change_pct"]["a"], + "inventory": m["inventory"]["a"], + }, + "city_b": { + "name": res["location_b"], + "median_price": m["median_price"]["b"], + "price_per_sqft": m["price_per_sqft"]["b"], + "days_on_market": m["days_on_market"]["b"], + "walk_score": m["walk_score"]["b"], + "yoy_change": m["yoy_price_change_pct"]["b"], + "inventory": m["inventory"]["b"], + }, + "winners": { + "median_price": m["median_price"].get("more_affordable"), + "price_per_sqft": m["price_per_sqft"].get("more_affordable"), + "days_on_market": m["days_on_market"].get("less_competitive"), + "walk_score": m["walk_score"].get("more_walkable"), + }, + "verdict": verdict, + } + break + + # Extract portfolio allocation chart data when portfolio_analysis ran + chart_data = None + for r in result.get("tool_results", []): + if ( + r.get("tool_name") == "portfolio_analysis" + and r.get("success") + and isinstance(r.get("result"), dict) + ): + holdings = r["result"].get("holdings", []) + if holdings: + # Use top 6 holdings by allocation; group the rest as "Other" + sorted_h = sorted(holdings, key=lambda h: h.get("allocation_pct", 0), reverse=True) + top = sorted_h[:6] + other_alloc = sum(h.get("allocation_pct", 0) for h in sorted_h[6:]) + labels = [h.get("symbol", "?") for h in top] + values = [round(h.get("allocation_pct", 0), 1) for h in top] + if other_alloc > 0.1: + labels.append("Other") + values.append(round(other_alloc, 1)) + chart_data = { + "type": "allocation_pie", + "labels": labels, + "values": values, + } + break + return { "response": result.get("final_response", "No response generated."), "confidence_score": result.get("confidence_score", 0.0), @@ -116,6 +196,8 @@ async def chat(req: ChatRequest): "tools_used": tools_used, "citations": result.get("citations", []), "latency_seconds": elapsed, + "comparison_card": comparison_card, + "chart_data": chart_data, } diff --git a/tools/property_tracker.py b/tools/property_tracker.py new file mode 100644 index 000000000..19b97f15f --- /dev/null +++ b/tools/property_tracker.py @@ -0,0 +1,288 @@ +""" +Property Tracker Tool — AgentForge integration +=============================================== +Feature flag: set ENABLE_REAL_ESTATE=true in .env to activate. +(Shares the same flag as the real estate market data tool.) + +Allows users to track real estate properties they own alongside +their financial portfolio. Equity is computed as: + equity = current_value - mortgage_balance + +Three capabilities: + 1. add_property(...) — record a property you own + 2. list_properties() — show all properties with equity computed + 3. get_real_estate_equity() — total equity across all properties (for net worth) + +Schema (StoredProperty): + id, address, property_type, purchase_price, purchase_date, + current_value, mortgage_balance, equity, equity_pct, + county_key, added_at + +All functions return the standard tool result envelope: + {tool_name, success, tool_result_id, timestamp, result} — on success + {tool_name, success, tool_result_id, error: {code, message}} — on failure +""" + +import os +import time +from datetime import datetime + +# --------------------------------------------------------------------------- +# Feature flag (shared with real_estate.py) +# --------------------------------------------------------------------------- + +def is_property_tracking_enabled() -> bool: + """Returns True only when ENABLE_REAL_ESTATE=true in environment.""" + return os.getenv("ENABLE_REAL_ESTATE", "false").strip().lower() == "true" + + +_FEATURE_DISABLED_RESPONSE = { + "tool_name": "property_tracker", + "success": False, + "tool_result_id": "property_tracker_disabled", + "error": { + "code": "PROPERTY_TRACKER_FEATURE_DISABLED", + "message": ( + "The Property Tracker feature is not currently enabled. " + "Set ENABLE_REAL_ESTATE=true in your environment to activate it." + ), + }, +} + +# --------------------------------------------------------------------------- +# In-memory property store +# --------------------------------------------------------------------------- + +_property_store: dict[str, dict] = {} +_property_counter: list[int] = [0] # mutable container so helpers can increment it + + +def property_store_clear() -> None: + """Clears the property store and resets the counter. Used in tests.""" + _property_store.clear() + _property_counter[0] = 0 + + +def _next_id() -> str: + _property_counter[0] += 1 + return f"prop_{_property_counter[0]:03d}" + + +# --------------------------------------------------------------------------- +# Public tool functions +# --------------------------------------------------------------------------- + +async def add_property( + address: str, + purchase_price: float, + current_value: float | None = None, + mortgage_balance: float = 0.0, + county_key: str = "austin", + property_type: str = "Single Family", + purchase_date: str | None = None, +) -> dict: + """ + Records a property in the in-memory store. + + Args: + address: Full street address (e.g. "123 Barton Hills Dr, Austin, TX 78704"). + 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). + county_key: ACTRIS data key for market context (e.g. "austin", "travis_county"). + property_type: "Single Family", "Condo", "Townhouse", "Multi-Family", or "Land". + purchase_date: Optional ISO date string (YYYY-MM-DD). + """ + if not is_property_tracking_enabled(): + return _FEATURE_DISABLED_RESPONSE + + tool_result_id = f"prop_add_{int(datetime.utcnow().timestamp())}" + + # Validation + if not address or not address.strip(): + return { + "tool_name": "property_tracker", + "success": False, + "tool_result_id": tool_result_id, + "error": { + "code": "PROPERTY_TRACKER_INVALID_INPUT", + "message": "address is required and cannot be empty.", + }, + } + if purchase_price <= 0: + return { + "tool_name": "property_tracker", + "success": False, + "tool_result_id": tool_result_id, + "error": { + "code": "PROPERTY_TRACKER_INVALID_INPUT", + "message": "purchase_price must be greater than zero.", + }, + } + + effective_value = current_value if current_value is not None else purchase_price + equity = round(effective_value - mortgage_balance, 2) + equity_pct = round((equity / effective_value * 100), 2) if effective_value > 0 else 0.0 + appreciation = round(effective_value - purchase_price, 2) + appreciation_pct = round((appreciation / purchase_price * 100), 2) if purchase_price > 0 else 0.0 + + prop_id = _next_id() + record = { + "id": prop_id, + "address": address.strip(), + "property_type": property_type, + "purchase_price": purchase_price, + "purchase_date": purchase_date, + "current_value": effective_value, + "mortgage_balance": mortgage_balance, + "equity": equity, + "equity_pct": equity_pct, + "appreciation": appreciation, + "appreciation_pct": appreciation_pct, + "county_key": county_key, + "added_at": datetime.utcnow().isoformat(), + } + _property_store[prop_id] = record + + return { + "tool_name": "property_tracker", + "success": True, + "tool_result_id": tool_result_id, + "timestamp": datetime.utcnow().isoformat(), + "result": { + "status": "added", + "property": record, + "message": ( + f"Property recorded: {address.strip()}. " + f"Current equity: ${equity:,.0f} " + f"({equity_pct:.1f}% of ${effective_value:,.0f} value)." + ), + }, + } + + +async def list_properties() -> dict: + """ + Returns all stored properties with per-property equity and portfolio totals. + """ + if not is_property_tracking_enabled(): + return _FEATURE_DISABLED_RESPONSE + + tool_result_id = f"prop_list_{int(datetime.utcnow().timestamp())}" + properties = list(_property_store.values()) + + if not properties: + return { + "tool_name": "property_tracker", + "success": True, + "tool_result_id": tool_result_id, + "timestamp": datetime.utcnow().isoformat(), + "result": { + "properties": [], + "summary": { + "property_count": 0, + "total_purchase_price": 0, + "total_current_value": 0, + "total_mortgage_balance": 0, + "total_equity": 0, + "total_equity_pct": 0.0, + }, + "message": ( + "No properties tracked yet. " + "Add a property with: \"Add my property at [address], " + "purchased for $X, worth $Y, mortgage $Z.\"" + ), + }, + } + + total_purchase = sum(p["purchase_price"] for p in properties) + 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 + + return { + "tool_name": "property_tracker", + "success": True, + "tool_result_id": tool_result_id, + "timestamp": datetime.utcnow().isoformat(), + "result": { + "properties": properties, + "summary": { + "property_count": len(properties), + "total_purchase_price": total_purchase, + "total_current_value": total_value, + "total_mortgage_balance": total_mortgage, + "total_equity": total_equity, + "total_equity_pct": total_equity_pct, + }, + }, + } + + +async def get_real_estate_equity() -> dict: + """ + Returns total real estate equity across all tracked properties. + Designed to be combined with portfolio_analysis for net worth calculation. + """ + if not is_property_tracking_enabled(): + return _FEATURE_DISABLED_RESPONSE + + tool_result_id = f"prop_equity_{int(datetime.utcnow().timestamp())}" + properties = list(_property_store.values()) + + 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) + + return { + "tool_name": "property_tracker", + "success": True, + "tool_result_id": tool_result_id, + "timestamp": datetime.utcnow().isoformat(), + "result": { + "property_count": len(properties), + "total_real_estate_value": total_value, + "total_mortgage_balance": total_mortgage, + "total_real_estate_equity": total_equity, + }, + } + + +async def remove_property(property_id: str) -> dict: + """ + Removes a property from the store by its ID (e.g. 'prop_001'). + """ + if not is_property_tracking_enabled(): + return _FEATURE_DISABLED_RESPONSE + + tool_result_id = f"prop_remove_{int(datetime.utcnow().timestamp())}" + prop_id = property_id.strip().lower() + + if prop_id not in _property_store: + return { + "tool_name": "property_tracker", + "success": False, + "tool_result_id": tool_result_id, + "error": { + "code": "PROPERTY_TRACKER_NOT_FOUND", + "message": ( + f"Property '{property_id}' not found. " + "Use list_properties() to see valid IDs." + ), + }, + } + + removed = _property_store.pop(prop_id) + return { + "tool_name": "property_tracker", + "success": True, + "tool_result_id": tool_result_id, + "timestamp": datetime.utcnow().isoformat(), + "result": { + "status": "removed", + "property_id": prop_id, + "address": removed["address"], + "message": f"Property removed: {removed['address']}.", + }, + } diff --git a/tools/real_estate.py b/tools/real_estate.py index ef00dcac1..d72fd57dd 100644 --- a/tools/real_estate.py +++ b/tools/real_estate.py @@ -120,17 +120,425 @@ def get_invocation_log() -> list[dict]: _MOCK_SNAPSHOTS: dict[str, dict] = { "austin": { + # ── Bridge fields required by get_neighborhood_snapshot ────────── "city": "Austin", "state": "TX", - "median_price": 485_000, "price_per_sqft": 285, - "median_dom": 24, "price_change_yoy_pct": -3.2, - "inventory_level": "low", "walk_score": 48, - "listings_count": 1_847, "rent_to_price_ratio": 0.48, + "price_per_sqft": 295, + "median_dom": 82, + "price_change_yoy_pct": -5.0, + "inventory_level": "moderate", + "walk_score": 48, + "listings_count": 3262, + "rent_to_price_ratio": 0.40, + # ── ACTRIS / Unlock MLS — January 2026 ────────────────────────── + "region": "City of Austin", + "data_source": "ACTRIS / Unlock MLS — January 2026", + "data_as_of": "January 2026", + "agent_note": ( + "Market data provided by a licensed Austin real estate " + "agent (ACTRIS member). Figures reflect current MLS " + "conditions as of January 2026." + ), + "ListPrice": 522500, + "median_price": 522500, + "ListPriceYoYChange": -0.05, + "ClosedSales": 509, + "ClosedSalesYoY": -0.088, + "SalesDollarVolume": 369_000_000, + "MonthsOfInventory": 3.9, + "MonthsOfInventoryYoY": -2.0, + "NewListings": 1169, + "NewListingsYoY": -0.12, + "ActiveListings": 3262, + "ActiveListingsYoY": -0.01, + "PendingSales": 797, + "PendingSalesYoY": 0.093, + "DaysOnMarket": 82, + "dom": 82, + "DaysOnMarketYoY": -5, + "CloseToListRatio": 0.908, + "CloseToListRatioPrevYear": 0.913, + "MedianRentMonthly": 2100, + "MedianRentYoY": -0.045, + "ClosedLeases": 1211, + "ClosedLeasesYoY": -0.018, + "LeaseDollarVolume": 2_850_000, + "LeaseMonthsOfInventory": 3.7, + "NewLeases": 1852, + "NewLeasesYoY": 0.244, + "ActiveLeases": 4016, + "ActiveLeasesYoY": 0.885, + "PendingLeases": 1387, + "PendingLeasesYoY": 0.044, + "LeaseDaysOnMarket": 64, + "LeaseDaysOnMarketYoY": 2, + "CloseToRentRatio": 0.954, + "CloseToRentRatioPrevYear": 0.951, + "market_summary": ( + "Austin City (Jan 2026): Median sale price $522,500 (down 5% YoY). " + "Homes sitting 82 days on average — buyers have negotiating power at " + "90.8 cents on the dollar. Rental market softer too: median rent " + "$2,100/mo (down 4.5%) with 3.7 months of rental inventory. " + "Pending sales up 9.3% — early signs of spring demand building." + ), + "AffordabilityScore": 5.8, + }, + "travis_county": { + # ── Bridge fields ──────────────────────────────────────────────── + "city": "Travis County", "state": "TX", + "price_per_sqft": 265, + "median_dom": 87, + "price_change_yoy_pct": -6.3, + "inventory_level": "moderate", + "walk_score": 45, + "listings_count": 4462, + "rent_to_price_ratio": 0.47, + # ── ACTRIS / Unlock MLS — January 2026 ────────────────────────── + "region": "Travis County", + "data_source": "ACTRIS / Unlock MLS — January 2026", + "data_as_of": "January 2026", + "agent_note": ( + "Market data provided by a licensed Austin real estate " + "agent (ACTRIS member). January 2026 figures." + ), + "ListPrice": 445000, + "median_price": 445000, + "ListPriceYoYChange": -0.063, + "ClosedSales": 684, + "ClosedSalesYoY": -0.124, + "SalesDollarVolume": 450_000_000, + "MonthsOfInventory": 3.9, + "MonthsOfInventoryYoY": -2.0, + "NewListings": 1624, + "ActiveListings": 4462, + "PendingSales": 1044, + "PendingSalesYoY": 0.111, + "DaysOnMarket": 87, + "dom": 87, + "DaysOnMarketYoY": 1, + "CloseToListRatio": 0.911, + "CloseToListRatioPrevYear": 0.919, + "MedianRentMonthly": 2100, + "MedianRentYoY": -0.042, + "ClosedLeases": 1347, + "ClosedLeasesYoY": -0.056, + "LeaseDollarVolume": 3_190_000, + "LeaseMonthsOfInventory": 3.7, + "NewLeases": 2035, + "NewLeasesYoY": 0.225, + "ActiveLeases": 4016, + "ActiveLeasesYoY": 0.590, + "PendingLeases": 1544, + "LeaseDaysOnMarket": 63, + "CloseToRentRatio": 0.955, + "CloseToRentRatioPrevYear": 0.952, + "market_summary": ( + "Travis County (Jan 2026): Median sale $445,000 (down 6.3%). " + "87 days on market. Sellers accepting 91.1 cents on the dollar. " + "Rental median $2,100/mo. Pending sales up 11.1% — market " + "showing early recovery signs heading into spring." + ), + "AffordabilityScore": 6.2, + }, + "austin_msa": { + # ── Bridge fields ──────────────────────────────────────────────── + "city": "Austin-Round Rock-San Marcos MSA", "state": "TX", + "price_per_sqft": 235, + "median_dom": 89, + "price_change_yoy_pct": -2.3, + "inventory_level": "moderate", + "walk_score": 40, + "listings_count": 10083, + "rent_to_price_ratio": 0.50, + # ── ACTRIS / Unlock MLS — January 2026 ────────────────────────── + "region": "Greater Austin Metro", + "data_source": "ACTRIS / Unlock MLS — January 2026", + "data_as_of": "January 2026", + "agent_note": ( + "MSA-level data covering Austin, Round Rock, and San Marcos. " + "Provided by a licensed ACTRIS member agent." + ), + "ListPrice": 400495, + "median_price": 400495, + "ListPriceYoYChange": -0.023, + "ClosedSales": 1566, + "ClosedSalesYoY": -0.148, + "SalesDollarVolume": 842_000_000, + "MonthsOfInventory": 4.0, + "MonthsOfInventoryYoY": -1.4, + "NewListings": 3470, + "ActiveListings": 10083, + "ActiveListingsYoY": 0.023, + "PendingSales": 2349, + "PendingSalesYoY": 0.101, + "DaysOnMarket": 89, + "dom": 89, + "DaysOnMarketYoY": 3, + "CloseToListRatio": 0.910, + "CloseToListRatioPrevYear": 0.923, + "MedianRentMonthly": 2000, + "MedianRentYoY": -0.048, + "ClosedLeases": 2266, + "ClosedLeasesYoY": -0.041, + "LeaseDollarVolume": 5_090_000, + "LeaseMonthsOfInventory": 3.5, + "NewLeases": 3218, + "NewLeasesYoY": 0.111, + "ActiveLeases": 6486, + "ActiveLeasesYoY": 0.473, + "PendingLeases": 2674, + "PendingLeasesYoY": 0.043, + "LeaseDaysOnMarket": 64, + "CloseToRentRatio": 0.955, + "CloseToRentRatioPrevYear": 0.953, + "market_summary": ( + "Austin-Round Rock-San Marcos MSA (Jan 2026): Broad metro median " + "sale $400,495 (down 2.3%). 10,000+ active listings — most supply " + "in years. Homes averaging 89 days. Median rent $2,000/mo (down 4.8%). " + "Buyer's market across the region with strong pending sales uptick " + "of 10.1% suggesting spring demand is building." + ), + "AffordabilityScore": 6.5, + }, + "williamson_county": { + # ── Bridge fields ──────────────────────────────────────────────── + "city": "Williamson County", "state": "TX", + "price_per_sqft": 215, + "median_dom": 92, + "price_change_yoy_pct": -0.5, + "inventory_level": "moderate", + "walk_score": 32, + "listings_count": 3091, + "rent_to_price_ratio": 0.49, + # ── ACTRIS / Unlock MLS — January 2026 ────────────────────────── + "region": "Williamson County (Round Rock, Cedar Park, Georgetown)", + "data_source": "ACTRIS / Unlock MLS — January 2026", + "data_as_of": "January 2026", + "agent_note": ( + "Williamson County covers Round Rock, Cedar Park, Georgetown, " + "and Leander. ACTRIS member data January 2026." + ), + "ListPrice": 403500, + "median_price": 403500, + "ListPriceYoYChange": -0.005, + "ClosedSales": 536, + "ClosedSalesYoY": -0.161, + "SalesDollarVolume": 246_000_000, + "MonthsOfInventory": 3.5, + "MonthsOfInventoryYoY": -1.1, + "NewListings": 1063, + "ActiveListings": 3091, + "ActiveListingsYoY": 0.056, + "PendingSales": 821, + "PendingSalesYoY": 0.131, + "DaysOnMarket": 92, + "dom": 92, + "DaysOnMarketYoY": 9, + "CloseToListRatio": 0.911, + "CloseToListRatioPrevYear": 0.929, + "MedianRentMonthly": 1995, + "MedianRentYoY": -0.048, + "ClosedLeases": 678, + "ClosedLeasesYoY": 0.012, + "LeaseDollarVolume": 1_400_000, + "LeaseMonthsOfInventory": 3.0, + "NewLeases": 867, + "ActiveLeases": 1726, + "ActiveLeasesYoY": 0.322, + "PendingLeases": 827, + "PendingLeasesYoY": 0.088, + "LeaseDaysOnMarket": 65, + "CloseToRentRatio": 0.955, + "CloseToRentRatioPrevYear": 0.957, + "market_summary": ( + "Williamson County (Jan 2026): Median sale $403,500 — flat YoY. " + "92 days on market, up 9 days from last year. " + "Rental median $1,995/mo — most affordable major county in metro. " + "Pending sales up 13.1%. Best value play in the Austin metro " + "for buyers who can commute 20-30 min north." + ), + "AffordabilityScore": 7.1, + }, + "hays_county": { + # ── Bridge fields ──────────────────────────────────────────────── + "city": "Hays County", "state": "TX", + "price_per_sqft": 195, + "median_dom": 86, + "price_change_yoy_pct": -4.0, + "inventory_level": "moderate", + "walk_score": 28, + "listings_count": 1567, + "rent_to_price_ratio": 0.56, + # ── ACTRIS / Unlock MLS — January 2026 ────────────────────────── + "region": "Hays County (San Marcos, Kyle, Buda, Wimberley)", + "data_source": "ACTRIS / Unlock MLS — January 2026", + "data_as_of": "January 2026", + "agent_note": ( + "Hays County covers San Marcos, Kyle, Buda, and Wimberley. " + "ACTRIS member data January 2026." + ), + "ListPrice": 344500, + "median_price": 344500, + "ListPriceYoYChange": -0.04, + "ClosedSales": 234, + "ClosedSalesYoY": -0.185, + "SalesDollarVolume": 107_000_000, + "MonthsOfInventory": 4.4, + "MonthsOfInventoryYoY": -1.2, + "NewListings": 483, + "ActiveListings": 1567, + "ActiveListingsYoY": -0.013, + "PendingSales": 347, + "PendingSalesYoY": 0.091, + "DaysOnMarket": 86, + "dom": 86, + "DaysOnMarketYoY": -3, + "CloseToListRatio": 0.920, + "CloseToListRatioPrevYear": 0.920, + "MedianRentMonthly": 1937, + "MedianRentYoY": -0.005, + "ClosedLeases": 172, + "ClosedLeasesYoY": -0.144, + "LeaseDollarVolume": 363_000, + "LeaseMonthsOfInventory": 3.3, + "NewLeases": 221, + "ActiveLeases": 513, + "ActiveLeasesYoY": 0.103, + "PendingLeases": 219, + "PendingLeasesYoY": 0.084, + "LeaseDaysOnMarket": 67, + "CloseToRentRatio": 0.945, + "CloseToRentRatioPrevYear": 0.945, + "market_summary": ( + "Hays County (Jan 2026): Median sale $344,500 — most affordable " + "county in the metro for buyers. 4.4 months inventory. " + "Close-to-list ratio stable at 92%. Rental median $1,937/mo " + "essentially flat YoY. Good value for tech workers priced out " + "of Travis County — 30-40 min commute to Austin." + ), + "AffordabilityScore": 7.4, + }, + "bastrop_county": { + # ── Bridge fields ──────────────────────────────────────────────── + "city": "Bastrop County", "state": "TX", + "price_per_sqft": 175, + "median_dom": 109, + "price_change_yoy_pct": -2.9, + "inventory_level": "high", + "walk_score": 20, + "listings_count": 711, + "rent_to_price_ratio": 0.55, + # ── ACTRIS / Unlock MLS — January 2026 ────────────────────────── + "region": "Bastrop County (Bastrop, Elgin, Smithville)", + "data_source": "ACTRIS / Unlock MLS — January 2026", + "data_as_of": "January 2026", + "agent_note": ( + "Bastrop County — exurban east of Austin. " + "ACTRIS member data January 2026." + ), + "ListPrice": 335970, + "median_price": 335970, + "ListPriceYoYChange": -0.029, + "ClosedSales": 77, + "ClosedSalesYoY": -0.206, + "SalesDollarVolume": 27_200_000, + "MonthsOfInventory": 5.8, + "MonthsOfInventoryYoY": -0.9, + "NewListings": 225, + "NewListingsYoY": 0.154, + "ActiveListings": 711, + "ActiveListingsYoY": 0.183, + "PendingSales": 100, + "PendingSalesYoY": -0.138, + "DaysOnMarket": 109, + "dom": 109, + "DaysOnMarketYoY": 8, + "CloseToListRatio": 0.884, + "CloseToListRatioPrevYear": 0.923, + "MedianRentMonthly": 1860, + "MedianRentYoY": 0.012, + "ClosedLeases": 52, + "ClosedLeasesYoY": 0.238, + "LeaseDollarVolume": 98_700, + "LeaseMonthsOfInventory": 3.1, + "NewLeases": 68, + "NewLeasesYoY": 0.214, + "ActiveLeases": 150, + "ActiveLeasesYoY": 1.083, + "PendingLeases": 60, + "PendingLeasesYoY": 0.132, + "LeaseDaysOnMarket": 58, + "CloseToRentRatio": 0.979, + "CloseToRentRatioPrevYear": 0.960, + "market_summary": ( + "Bastrop County (Jan 2026): Median sale $335,970. " + "5.8 months inventory — softening market, 109 avg days. " + "Sellers getting only 88.4 cents on the dollar. " + "Rental market actually heating up: closed leases +23.8%, " + "active leases up 108%. Growing rental demand from Austin " + "spillover. Rural/exurban lifestyle 40 min east of Austin." + ), + "AffordabilityScore": 7.8, + }, + "caldwell_county": { + # ── Bridge fields ──────────────────────────────────────────────── + "city": "Caldwell County", "state": "TX", + "price_per_sqft": 150, + "median_dom": 73, + "price_change_yoy_pct": -17.0, + "inventory_level": "very high", + "walk_score": 15, + "listings_count": 252, + "rent_to_price_ratio": 0.74, + # ── ACTRIS / Unlock MLS — January 2026 ────────────────────────── + "region": "Caldwell County (Lockhart, Luling)", + "data_source": "ACTRIS / Unlock MLS — January 2026", + "data_as_of": "January 2026", + "agent_note": ( + "Caldwell County — Lockhart and Luling area, south of Austin. " + "ACTRIS member data January 2026." + ), + "ListPrice": 237491, + "median_price": 237491, + "ListPriceYoYChange": -0.17, + "ClosedSales": 35, + "ClosedSalesYoY": 0.061, + "SalesDollarVolume": 9_450_000, + "MonthsOfInventory": 8.4, + "MonthsOfInventoryYoY": 3.5, + "NewListings": 75, + "NewListingsYoY": 0.119, + "ActiveListings": 252, + "ActiveListingsYoY": 0.703, + "PendingSales": 37, + "PendingSalesYoY": 0.088, + "DaysOnMarket": 73, + "dom": 73, + "DaysOnMarketYoY": 12, + "CloseToListRatio": 0.848, + "CloseToListRatioPrevYear": 0.927, + "MedianRentMonthly": 1750, + "MedianRentYoY": -0.028, + "ClosedLeases": 17, + "ClosedLeasesYoY": -0.227, + "LeaseDollarVolume": 27_700, + "LeaseMonthsOfInventory": 4.3, + "NewLeases": 27, + "NewLeasesYoY": 0.174, + "ActiveLeases": 81, + "ActiveLeasesYoY": 1.382, + "PendingLeases": 24, + "PendingLeasesYoY": -0.040, + "LeaseDaysOnMarket": 57, + "CloseToRentRatio": 0.982, + "CloseToRentRatioPrevYear": 0.974, "market_summary": ( - "Austin remains a seller's market with limited inventory. " - "Prices have cooled slightly YoY (-3.2%) after the pandemic spike, " - "creating buying opportunities for long-term investors. " - "Tech sector concentration adds income stability to the renter pool." + "Caldwell County (Jan 2026): Most affordable in the ACTRIS region " + "at $237,491 median — down 17% YoY. 8.4 months inventory signals " + "heavy buyer's market. Sellers getting only 84.8 cents on the dollar. " + "Rental median $1,750/mo. Best entry-level price point in the " + "Greater Austin area for buyers willing to commute 45+ min." ), + "AffordabilityScore": 8.5, }, "san francisco": { "city": "San Francisco", "state": "CA", @@ -433,7 +841,34 @@ def _normalize_city(location: str) -> str: """Maps query string to a canonical city key in mock data.""" loc = location.lower().strip() mapping = { + # Austin city "atx": "austin", "austin tx": "austin", "austin, tx": "austin", + # Travis County + "travis": "travis_county", "travis county": "travis_county", + "travis county tx": "travis_county", "travis county, tx": "travis_county", + # Williamson County (Round Rock / Cedar Park / Georgetown) + "round rock": "williamson_county", "cedar park": "williamson_county", + "georgetown": "williamson_county", "leander": "williamson_county", + "williamson": "williamson_county", "williamson county": "williamson_county", + "williamson county tx": "williamson_county", "williamson county, tx": "williamson_county", + # Hays County (Kyle / Buda / San Marcos) + "kyle": "hays_county", "buda": "hays_county", + "san marcos": "hays_county", "wimberley": "hays_county", + "hays": "hays_county", "hays county": "hays_county", + "hays county tx": "hays_county", "hays county, tx": "hays_county", + # Bastrop County + "bastrop": "bastrop_county", "elgin": "bastrop_county", + "smithville": "bastrop_county", "bastrop county": "bastrop_county", + "bastrop county tx": "bastrop_county", "bastrop county, tx": "bastrop_county", + # Caldwell County + "lockhart": "caldwell_county", "luling": "caldwell_county", + "caldwell": "caldwell_county", "caldwell county": "caldwell_county", + "caldwell county tx": "caldwell_county", "caldwell county, tx": "caldwell_county", + # Austin MSA + "greater austin": "austin_msa", "austin metro": "austin_msa", + "austin msa": "austin_msa", "austin-round rock": "austin_msa", + "austin round rock": "austin_msa", + # Other US metros "sf": "san francisco", "sfo": "san francisco", "san francisco ca": "san francisco", "nyc": "new york", "new york city": "new york", "manhattan": "new york", "brooklyn": "new york", "denver co": "denver", "denver, co": "denver",