mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
Backend: - Replace Austin mock data with real Jan 2026 ACTRIS/Unlock MLS figures covering 7 counties/MSAs (Travis, Williamson, Hays, Bastrop, Caldwell, Austin MSA) - Add property_tracker tool: add/list/remove properties with equity & gain calc - Expand graph.py routing for property tracking intent + 15 new city aliases - Add /health endpoint and improved SSE streaming in main.py - 81 passing pytest evals (test_portfolio, test_property_tracker, test_real_estate) Frontend (chat_ui.html) — 27 new features: - Command palette (Cmd+P) with 29 commands and fuzzy search - Cross-session search across all saved chats in the drawer - User investor profile (risk/focus/horizon) injected as AI context - Rental yield calculator (gross/net yield, cap rate, annual income) - Portfolio donut chart (SVG, click-to-query slices) - Property comparison table, market calendar strip, county alerts - Smart time-based suggestions, offline detection + message queue - PWA manifest + install prompt, scroll-to-bottom button - High contrast mode, reduced motion, keyboard focus rings - Response disclaimer toggle, copy-as-Markdown, batch export - Email digest, session reminders, conversation branching - Swipe-to-archive (mobile), collaborative annotations via URL - Settings menu scrollable (max-height fix for 20+ items) - Fix: broken paddingRight string literal silently killed all event listeners - Fix: extra stray </div> in help panel causing HTML parse error Angular ai-chat component: - Fix: prefer-optional-chain lint error in successRate() - Fix: prettier formatting on chat_ui.html - Add portfolio-chart and real-estate-card sub-components All 81 pytest evals pass. Lint: 0 errors. Prettier: all files formatted. Made-with: Cursorpull/6453/head
7 changed files with 10518 additions and 540 deletions
File diff suppressed because it is too large
@ -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) |
||||
@ -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) |
||||
@ -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']}.", |
||||
|
}, |
||||
|
} |
||||
Loading…
Reference in new issue