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 @@
-
-
-
💼
-
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
-
-
+
+
+
🏠 Real Estate & Property
+
+
+ 🏡
+ Austin Market
+ Jan 2026 ACTRIS MLS data
+
+
+ 🔀
+ Compare Counties
+ Side-by-side analysis
+
+
+
+
+ 🏘
+ My Properties
+ Equity & portfolio view
+
+
+ 💰
+ Total Net Worth
+ Portfolio + real estate
+
+
+
+
+
+
+
+
+
+
+ + Add custom shortcut
+
+
+
+
+
+
+
+
+
+
+ ↓ Latest
+
+
+
+
+ ?
+
+
+
+
+
+ ✕
+
+
+
✨ 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
+
+
+
+
+
+
+
+
+
+
+
+
🏠 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
+
+
+
+
+
+
+
+ ⌨ Keyboard Shortcuts
+
+ ✕
+
+
+
+
+ | Send message |
+ Enter |
+
+
+ | New line |
+
+ Shift Enter
+ |
+
+
+ | Restore last message |
+ ↑ (when input empty) |
+
+
+ | Focus input |
+ ⌘/Ctrl K |
+
+
+ | Search conversation |
+ ⌘/Ctrl F |
+
+
+ | Show tools |
+ ⌘/Ctrl / |
+
+
+ | Export conversation |
+
+ ⌘/Ctrl Shift
+ E
+ |
+
+
+ | Input templates |
+ Type ~ at start of message |
+
+
+ | Stop mic / clear input |
+ Esc |
+
+
+ | Keyboard shortcuts |
+ ⌘/Ctrl ? |
+
+
+ | Command palette |
+ ⌘/Ctrl P |
+
+
+ | Add ticker to watchlist |
+ Type /watch AAPL |
+
+
+ | Browse query history |
+ ↑ / ↓ when history open |
+
+
+
+
+
+
+
+
+
+ 🖼 Export as Image Card
+
+ ✕
+
+
+
+
+
+ ⬇ Download PNG
+
+
+ ⎘ Copy image
+
+
+
+ Captures the last agent response as a shareable card
+
+
+
+
+
+
+
+
+ 🟩 Portfolio Heat Map
+
+ ✕
+
+
+
+
+
+
+
+
+
+
+
+ No requests yet.
Send a message to start inspecting tool calls,
+ latency, and confidence scores.
+
+
+
+
+
+
+
+
+
+
+
+ 👤 Your Investor Profile
+
+ ✕
+
+
+
+
+
+ What best describes your risk tolerance?
+
+
+
🛡
+
+
Conservative
+
+ Capital preservation first
+
+
+
+
+
⚖️
+
+
Moderate
+
+ Balanced growth and stability
+
+
+
+
+
🚀
+
+
Aggressive
+
+ Maximum growth, higher volatility
+
+
+
+
+ Next →
+
+
+
+
+ Primary investment focus?
+
+
+
🏠
+
+
Real Estate
+
+ Properties, REITs, land
+
+
+
+
+
📈
+
+
Equities
+
+ Stocks, ETFs, growth
+
+
+
+
+
🌐
+
+
Diversified
+
+ Mix of asset classes
+
+
+
+
+ Next →
+
+
+
+
+ Investment horizon?
+
+
+
⚡
+
+
Short-term (<2 years)
+
+
+
+
📅
+
+
Medium-term (2–10 years)
+
+
+
+
🌱
+
+
+ Long-term (10+ years / retirement)
+
+
+
+
+ Save Profile ✓
+
+
+
+
+
+
+
+
+
+ 🏠 Rental Yield Calculator
+
+ ✕
+
+
+
+
+ Property value
+
+
+ Monthly rent
+
+
+ Annual expenses
+
+
+
+ Ask agent to evaluate →
+
+
+
+
+
+
+
+
+
+ 🏘 Compare Properties
+
+ ✕
+
+
+
+
+
+
+
+
+
+
+ 🍩 Portfolio Allocation
+
+ ✕
+
+
+
+
+
+
+
+
+
+
+ ☑ Batch Export
+
+ ✕
+
+
+
+ Select responses to include in export:
+
+
+
+
+ ⬇ Export .txt
+
+
+ ⬇ Export .md
+
+
+
+
+
+
+
+
+
+ 🔔 Portfolio Reminder
+
+ ✕
+
+
+
+
+ Remind me to check my portfolio in:
+
+
+
+ 1 day
+
+
+ 3 days
+
+
+ 1 week
+
+
+ 1 month
+
+
+
+ Uses browser notifications — you'll be prompted to allow.
+
+
+
+
+
+
+
+
+ 🧠 Remembered Context
+
+ ✕
+
+
+
+
+
+
Net Worth Snapshot
+
+ —
+
+
+
+
+ 🗑 Clear all memory
+
+
+
+
+
+
+
+
+
+
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",