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
18 changed files with 11994 additions and 712 deletions
@ -0,0 +1,183 @@ |
|||
# Ghostfolio AI Agent — AgentForge Integration |
|||
|
|||
## What I Built |
|||
|
|||
A LangGraph-powered portfolio assistant embedded directly inside Ghostfolio — a production open-source wealth management app. The agent runs as a FastAPI sidecar and adds a floating AI chat panel, nine specialized tools, and an optional real estate market feature, all as a brownfield addition that leaves the existing codebase untouched. |
|||
|
|||
--- |
|||
|
|||
## Architecture |
|||
|
|||
``` |
|||
Angular UI (port 4200) |
|||
└── GfAiChatComponent |
|||
├── AiChatService (event bus for Real Estate nav → chat) |
|||
└── HTTP calls |
|||
│ |
|||
▼ |
|||
FastAPI Agent (port 8000) ← agent/main.py |
|||
│ |
|||
▼ |
|||
LangGraph Graph ← agent/graph.py |
|||
│ |
|||
┌─────┴──────────────────────────────────────────┐ |
|||
│ 9 Tools (agent/tools/) │ |
|||
├── portfolio_analysis portfolio data │ |
|||
├── transaction_query filter transactions │ |
|||
├── compliance_check concentration risk │ |
|||
├── market_data live price context │ |
|||
├── tax_estimate capital gains math │ |
|||
├── write_transaction record buys/sells │ |
|||
├── categorize label transactions │ |
|||
├── real_estate city/listing search │ ← brownfield add |
|||
└── compare_neighborhoods side-by-side cities │ ← brownfield add |
|||
│ |
|||
▼ |
|||
Ghostfolio REST API (port 3333) |
|||
``` |
|||
|
|||
--- |
|||
|
|||
## How to Run Locally |
|||
|
|||
### Prerequisites |
|||
|
|||
- Node.js 18+, npm |
|||
- Python 3.11+ |
|||
- Ghostfolio account with a bearer token |
|||
|
|||
### Step 1 — Start Ghostfolio |
|||
|
|||
```bash |
|||
cd ghostfolio |
|||
|
|||
# Terminal 1 — API server |
|||
npm run start:server |
|||
# Wait for: "Nest application successfully started" |
|||
|
|||
# Terminal 2 — Angular client |
|||
npm run start:client |
|||
# Wait for: "Compiled successfully" |
|||
``` |
|||
|
|||
### Step 2 — Configure the Agent |
|||
|
|||
```bash |
|||
cd ghostfolio/agent |
|||
cp .env.example .env # if not already present |
|||
``` |
|||
|
|||
Edit `.env`: |
|||
|
|||
``` |
|||
GHOSTFOLIO_BASE_URL=http://localhost:3333 |
|||
GHOSTFOLIO_BEARER_TOKEN=<your token from Ghostfolio Settings> |
|||
ANTHROPIC_API_KEY=<your Anthropic key> |
|||
ENABLE_REAL_ESTATE=true |
|||
``` |
|||
|
|||
### Step 3 — Start the Agent |
|||
|
|||
```bash |
|||
cd ghostfolio/agent |
|||
python -m venv venv && source venv/bin/activate |
|||
pip install -r requirements.txt |
|||
uvicorn main:app --reload --port 8000 |
|||
# Wait for: "Application startup complete." |
|||
``` |
|||
|
|||
### Step 4 — Open the App |
|||
|
|||
Go to `http://localhost:4200` → sign in → click the **Ask AI** button (bottom right). |
|||
|
|||
Portfolio data seeds automatically when the agent detects an empty portfolio — no manual step needed. |
|||
|
|||
--- |
|||
|
|||
## Real Estate Feature Flag |
|||
|
|||
The real estate tools are gated behind `ENABLE_REAL_ESTATE` so they can be toggled without any code change. |
|||
|
|||
**Enable:** |
|||
|
|||
``` |
|||
ENABLE_REAL_ESTATE=true |
|||
``` |
|||
|
|||
**Disable (default):** |
|||
|
|||
``` |
|||
ENABLE_REAL_ESTATE=false |
|||
``` |
|||
|
|||
When enabled: |
|||
|
|||
- A **Real Estate** nav item appears in Ghostfolio's sidebar |
|||
- Real estate suggestion chips appear in the chat panel |
|||
- The `real_estate` and `compare_neighborhoods` tools are active |
|||
- Tool calls are logged to `GET /real-estate/log` |
|||
|
|||
When disabled, all real estate endpoints return a clear `REAL_ESTATE_FEATURE_DISABLED` error — no silent failures. |
|||
|
|||
--- |
|||
|
|||
## Test Suite |
|||
|
|||
```bash |
|||
cd ghostfolio/agent |
|||
source venv/bin/activate |
|||
|
|||
# Run all tests with verbose output |
|||
python -m pytest evals/ -v |
|||
|
|||
# Run just the real estate tests |
|||
python -m pytest evals/ -v -k "real_estate" |
|||
|
|||
# Run with coverage summary |
|||
python -m pytest evals/ -v 2>&1 | tail -10 |
|||
``` |
|||
|
|||
**Coverage:** 68+ test cases across: |
|||
|
|||
- Portfolio analysis accuracy |
|||
- Transaction query filtering |
|||
- Compliance / concentration risk detection |
|||
- Tax estimation logic |
|||
- Write operation confirmation flow |
|||
- Real estate listing search & filtering |
|||
- Neighborhood snapshot data |
|||
- City comparison (affordability, yield, DOM) |
|||
- Feature flag enforcement |
|||
|
|||
--- |
|||
|
|||
## 2-Minute Demo Script |
|||
|
|||
1. **Open** `localhost:4200`, sign in |
|||
2. **Click** the floating **Ask AI** button (bottom right) — note the green status dot = agent online |
|||
3. **Click** "📈 My portfolio performance" chip → agent calls `portfolio_analysis` + `market_data`; see tool chips on the response |
|||
4. **Click** "⚠️ Any concentration risk?" → agent calls `compliance_check` |
|||
5. **Click** "💰 Estimate my taxes" → agent calls `tax_estimate` |
|||
6. **Type** "buy 5 shares of AAPL at $185" → agent asks for confirmation → click Confirm |
|||
7. **Click** "Real Estate" in the sidebar → chat opens with Austin/Denver query pre-filled |
|||
8. **Click** "📊 Austin vs Denver" chip → side-by-side comparison with tool chips visible |
|||
9. **Click** Clear → suggestion chips reappear |
|||
|
|||
--- |
|||
|
|||
## What Makes This a Brownfield Integration |
|||
|
|||
- **Zero changes to Ghostfolio core** — no existing files were modified outside of Angular routing/module registration. The agent is a fully separate FastAPI process. |
|||
- **Feature-flagged addition** — `ENABLE_REAL_ESTATE=false` returns the app to its original state with no trace of the real estate feature. |
|||
- **Token passthrough** — the agent receives the user's existing Ghostfolio bearer token from the Angular client and uses it for all API calls, so authentication is reused rather than reimplemented. |
|||
|
|||
--- |
|||
|
|||
## Observability Endpoints |
|||
|
|||
| Endpoint | Purpose | |
|||
| ----------------------- | ----------------------------------------- | |
|||
| `GET /health` | Agent + Ghostfolio reachability check | |
|||
| `GET /real-estate/log` | Real estate tool invocation log (last 50) | |
|||
| `GET /feedback/summary` | 👍/👎 approval rate across all sessions | |
|||
| `GET /costs` | Estimated Anthropic API cost tracker | |
|||
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']}.", |
|||
}, |
|||
} |
|||
@ -0,0 +1,4 @@ |
|||
<div class="pc-card"> |
|||
<div class="pc-card__label">📊 Portfolio Allocation</div> |
|||
<canvas #canvas height="150" width="280"></canvas> |
|||
</div> |
|||
@ -0,0 +1,30 @@ |
|||
.pc-card { |
|||
border-radius: 0.75rem; |
|||
border: 1px solid rgba(99, 102, 241, 0.2); |
|||
background: var(--light-background, #fff); |
|||
padding: 0.6rem 0.75rem 0.5rem; |
|||
margin-top: 0.4rem; |
|||
max-width: 320px; |
|||
|
|||
:host-context(.theme-dark) & { |
|||
background: #22223a; |
|||
border-color: rgba(99, 102, 241, 0.25); |
|||
} |
|||
|
|||
&__label { |
|||
font-size: 0.72rem; |
|||
font-weight: 700; |
|||
color: #6366f1; |
|||
margin-bottom: 0.4rem; |
|||
letter-spacing: 0.02em; |
|||
|
|||
:host-context(.theme-dark) & { |
|||
color: #a5b4fc; |
|||
} |
|||
} |
|||
|
|||
canvas { |
|||
display: block; |
|||
max-width: 100%; |
|||
} |
|||
} |
|||
@ -0,0 +1,129 @@ |
|||
import { |
|||
AfterViewInit, |
|||
Component, |
|||
ElementRef, |
|||
Input, |
|||
OnChanges, |
|||
OnDestroy, |
|||
ViewChild |
|||
} from '@angular/core'; |
|||
import { |
|||
Chart, |
|||
ArcElement, |
|||
DoughnutController, |
|||
Legend, |
|||
Tooltip |
|||
} from 'chart.js'; |
|||
|
|||
Chart.register(ArcElement, DoughnutController, Legend, Tooltip); |
|||
|
|||
export interface ChartData { |
|||
type: 'allocation_pie'; |
|||
labels: string[]; |
|||
values: number[]; |
|||
} |
|||
|
|||
const PALETTE = [ |
|||
'#6366f1', |
|||
'#10b981', |
|||
'#f59e0b', |
|||
'#3b82f6', |
|||
'#ef4444', |
|||
'#8b5cf6', |
|||
'#06b6d4', |
|||
'#84cc16', |
|||
'#f97316', |
|||
'#ec4899' |
|||
]; |
|||
|
|||
@Component({ |
|||
imports: [], |
|||
selector: 'gf-portfolio-chart', |
|||
styleUrls: ['./portfolio-chart.component.scss'], |
|||
templateUrl: './portfolio-chart.component.html' |
|||
}) |
|||
export class GfPortfolioChartComponent |
|||
implements AfterViewInit, OnChanges, OnDestroy |
|||
{ |
|||
@Input() public chartData!: ChartData; |
|||
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>; |
|||
|
|||
private chart: Chart | null = null; |
|||
|
|||
public ngAfterViewInit(): void { |
|||
this.buildChart(); |
|||
} |
|||
|
|||
public ngOnChanges(): void { |
|||
if (this.chart) { |
|||
this.chart.destroy(); |
|||
this.chart = null; |
|||
} |
|||
if (this.canvasRef) { |
|||
this.buildChart(); |
|||
} |
|||
} |
|||
|
|||
public ngOnDestroy(): void { |
|||
this.chart?.destroy(); |
|||
} |
|||
|
|||
private buildChart(): void { |
|||
if (!this.canvasRef || !this.chartData) { |
|||
return; |
|||
} |
|||
const ctx = this.canvasRef.nativeElement.getContext('2d'); |
|||
if (!ctx) { |
|||
return; |
|||
} |
|||
|
|||
const colors = this.chartData.labels.map( |
|||
(_, i) => PALETTE[i % PALETTE.length] |
|||
); |
|||
|
|||
this.chart = new Chart(ctx, { |
|||
type: 'doughnut', |
|||
data: { |
|||
labels: this.chartData.labels, |
|||
datasets: [ |
|||
{ |
|||
data: this.chartData.values, |
|||
backgroundColor: colors, |
|||
borderColor: 'transparent', |
|||
hoverOffset: 6 |
|||
} |
|||
] |
|||
}, |
|||
options: { |
|||
responsive: false, |
|||
cutout: '62%', |
|||
plugins: { |
|||
legend: { |
|||
position: 'right', |
|||
labels: { |
|||
color: '#9ca3af', |
|||
font: { size: 11 }, |
|||
boxWidth: 10, |
|||
padding: 8, |
|||
generateLabels: (chart) => { |
|||
const data = chart.data; |
|||
return (data.labels as string[]).map((label, i) => ({ |
|||
text: `${label} ${(data.datasets[0].data[i] as number).toFixed(1)}%`, |
|||
fillStyle: (data.datasets[0].backgroundColor as string[])[i], |
|||
hidden: false, |
|||
index: i |
|||
})); |
|||
} |
|||
} |
|||
}, |
|||
tooltip: { |
|||
callbacks: { |
|||
label: (ctx) => |
|||
` ${ctx.label}: ${(ctx.raw as number).toFixed(1)}%` |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,142 @@ |
|||
<div class="re-card"> |
|||
<!-- Header row --> |
|||
<div class="re-card__header"> |
|||
<span class="re-card__label">🏠 Market Comparison</span> |
|||
<button class="re-card__copy" (click)="copyToClipboard()"> |
|||
{{ copyLabel }} |
|||
</button> |
|||
</div> |
|||
|
|||
<!-- City name row --> |
|||
<div class="re-table"> |
|||
<div class="re-table__row re-table__row--header"> |
|||
<div class="re-table__metric"></div> |
|||
<div |
|||
class="re-table__city" |
|||
[class.re-table__city--winner]=" |
|||
isWinner(card.city_a.name, 'median_price') |
|||
" |
|||
> |
|||
{{ card.city_a.name }} |
|||
@if (isWinner(card.city_a.name, 'median_price')) { |
|||
<span class="re-crown">★</span> |
|||
} |
|||
</div> |
|||
<div |
|||
class="re-table__city" |
|||
[class.re-table__city--winner]=" |
|||
isWinner(card.city_b.name, 'median_price') |
|||
" |
|||
> |
|||
{{ card.city_b.name }} |
|||
@if (isWinner(card.city_b.name, 'median_price')) { |
|||
<span class="re-crown">★</span> |
|||
} |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Median Price --> |
|||
<div class="re-table__row"> |
|||
<div class="re-table__metric">Median Price</div> |
|||
<div |
|||
class="re-table__val" |
|||
[class.re-table__val--win]="isWinner(card.city_a.name, 'median_price')" |
|||
> |
|||
{{ formatPrice(card.city_a.median_price) }} |
|||
</div> |
|||
<div |
|||
class="re-table__val" |
|||
[class.re-table__val--win]="isWinner(card.city_b.name, 'median_price')" |
|||
> |
|||
{{ formatPrice(card.city_b.median_price) }} |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Price per sqft --> |
|||
<div class="re-table__row"> |
|||
<div class="re-table__metric">Price / sqft</div> |
|||
<div |
|||
class="re-table__val" |
|||
[class.re-table__val--win]=" |
|||
isWinner(card.city_a.name, 'price_per_sqft') |
|||
" |
|||
> |
|||
${{ card.city_a.price_per_sqft }} |
|||
</div> |
|||
<div |
|||
class="re-table__val" |
|||
[class.re-table__val--win]=" |
|||
isWinner(card.city_b.name, 'price_per_sqft') |
|||
" |
|||
> |
|||
${{ card.city_b.price_per_sqft }} |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Days on Market --> |
|||
<div class="re-table__row"> |
|||
<div class="re-table__metric">Days on Market</div> |
|||
<div |
|||
class="re-table__val" |
|||
[class.re-table__val--win]=" |
|||
isWinner(card.city_a.name, 'days_on_market') |
|||
" |
|||
> |
|||
{{ card.city_a.days_on_market }}d |
|||
</div> |
|||
<div |
|||
class="re-table__val" |
|||
[class.re-table__val--win]=" |
|||
isWinner(card.city_b.name, 'days_on_market') |
|||
" |
|||
> |
|||
{{ card.city_b.days_on_market }}d |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Walk Score --> |
|||
<div class="re-table__row"> |
|||
<div class="re-table__metric">Walk Score</div> |
|||
<div |
|||
class="re-table__val" |
|||
[class.re-table__val--win]="isWinner(card.city_a.name, 'walk_score')" |
|||
> |
|||
{{ card.city_a.walk_score }} |
|||
</div> |
|||
<div |
|||
class="re-table__val" |
|||
[class.re-table__val--win]="isWinner(card.city_b.name, 'walk_score')" |
|||
> |
|||
{{ card.city_b.walk_score }} |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- YoY Change --> |
|||
<div class="re-table__row"> |
|||
<div class="re-table__metric">YoY Price</div> |
|||
<div class="re-table__val"> |
|||
<span [class]="yoyClass(card.city_a.yoy_change)">{{ |
|||
formatYoy(card.city_a.yoy_change) |
|||
}}</span> |
|||
</div> |
|||
<div class="re-table__val"> |
|||
<span [class]="yoyClass(card.city_b.yoy_change)">{{ |
|||
formatYoy(card.city_b.yoy_change) |
|||
}}</span> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Inventory --> |
|||
<div class="re-table__row"> |
|||
<div class="re-table__metric">Inventory</div> |
|||
<div class="re-table__val">{{ card.city_a.inventory }}</div> |
|||
<div class="re-table__val">{{ card.city_b.inventory }}</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Verdict --> |
|||
<div class="re-card__verdict"> |
|||
<span class="re-card__verdict-icon">⚖️</span> |
|||
{{ card.verdict }} |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,177 @@ |
|||
.re-card { |
|||
border-radius: 0.75rem; |
|||
overflow: hidden; |
|||
border: 1px solid rgba(16, 185, 129, 0.25); |
|||
background: var(--light-background, #fff); |
|||
font-size: 0.8rem; |
|||
width: 100%; |
|||
max-width: 360px; |
|||
|
|||
:host-context(.theme-dark) & { |
|||
background: #1e2a26; |
|||
border-color: rgba(16, 185, 129, 0.3); |
|||
} |
|||
|
|||
&__header { |
|||
background: linear-gradient(135deg, #059669 0%, #10b981 100%); |
|||
color: #fff; |
|||
padding: 0.5rem 0.75rem; |
|||
font-weight: 700; |
|||
font-size: 0.78rem; |
|||
letter-spacing: 0.02em; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
} |
|||
|
|||
&__copy { |
|||
background: rgba(255, 255, 255, 0.18); |
|||
border: 1px solid rgba(255, 255, 255, 0.3); |
|||
border-radius: 0.4rem; |
|||
color: #fff; |
|||
cursor: pointer; |
|||
font-size: 0.65rem; |
|||
font-weight: 600; |
|||
padding: 0.15rem 0.45rem; |
|||
transition: background 0.15s ease; |
|||
white-space: nowrap; |
|||
|
|||
&:hover { |
|||
background: rgba(255, 255, 255, 0.3); |
|||
} |
|||
} |
|||
|
|||
&__verdict { |
|||
padding: 0.5rem 0.75rem; |
|||
font-size: 0.75rem; |
|||
font-weight: 600; |
|||
color: #065f46; |
|||
background: rgba(16, 185, 129, 0.08); |
|||
border-top: 1px solid rgba(16, 185, 129, 0.15); |
|||
display: flex; |
|||
align-items: flex-start; |
|||
gap: 0.4rem; |
|||
line-height: 1.4; |
|||
|
|||
:host-context(.theme-dark) & { |
|||
color: #6ee7b7; |
|||
background: rgba(16, 185, 129, 0.12); |
|||
} |
|||
} |
|||
|
|||
&__verdict-icon { |
|||
flex-shrink: 0; |
|||
margin-top: 0.05rem; |
|||
} |
|||
} |
|||
|
|||
.re-table { |
|||
&__row { |
|||
display: grid; |
|||
grid-template-columns: 1fr 1fr 1fr; |
|||
border-bottom: 1px solid rgba(0, 0, 0, 0.06); |
|||
|
|||
:host-context(.theme-dark) & { |
|||
border-bottom-color: rgba(255, 255, 255, 0.06); |
|||
} |
|||
|
|||
&:last-child { |
|||
border-bottom: none; |
|||
} |
|||
|
|||
&--header { |
|||
background: rgba(0, 0, 0, 0.03); |
|||
|
|||
:host-context(.theme-dark) & { |
|||
background: rgba(255, 255, 255, 0.04); |
|||
} |
|||
} |
|||
} |
|||
|
|||
&__metric { |
|||
padding: 0.35rem 0.6rem; |
|||
color: #6b7280; |
|||
font-size: 0.72rem; |
|||
font-weight: 500; |
|||
display: flex; |
|||
align-items: center; |
|||
|
|||
:host-context(.theme-dark) & { |
|||
color: #9ca3af; |
|||
} |
|||
} |
|||
|
|||
&__city { |
|||
padding: 0.35rem 0.5rem; |
|||
font-weight: 700; |
|||
font-size: 0.72rem; |
|||
color: #111827; |
|||
text-align: center; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 0.2rem; |
|||
|
|||
:host-context(.theme-dark) & { |
|||
color: #e2e8f0; |
|||
} |
|||
|
|||
&--winner { |
|||
background: rgba(16, 185, 129, 0.1); |
|||
color: #065f46; |
|||
|
|||
:host-context(.theme-dark) & { |
|||
background: rgba(16, 185, 129, 0.18); |
|||
color: #6ee7b7; |
|||
} |
|||
} |
|||
} |
|||
|
|||
&__val { |
|||
padding: 0.35rem 0.5rem; |
|||
text-align: center; |
|||
color: #374151; |
|||
font-weight: 500; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
|
|||
:host-context(.theme-dark) & { |
|||
color: #d1d5db; |
|||
} |
|||
|
|||
&--win { |
|||
background: rgba(16, 185, 129, 0.1); |
|||
color: #065f46; |
|||
font-weight: 700; |
|||
|
|||
:host-context(.theme-dark) & { |
|||
background: rgba(16, 185, 129, 0.18); |
|||
color: #6ee7b7; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
.re-crown { |
|||
font-size: 0.65rem; |
|||
color: #f59e0b; |
|||
} |
|||
|
|||
.positive { |
|||
color: #059669; |
|||
font-weight: 600; |
|||
|
|||
:host-context(.theme-dark) & { |
|||
color: #34d399; |
|||
} |
|||
} |
|||
|
|||
.negative { |
|||
color: #dc2626; |
|||
font-weight: 600; |
|||
|
|||
:host-context(.theme-dark) & { |
|||
color: #f87171; |
|||
} |
|||
} |
|||
@ -0,0 +1,88 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { Component, Input } from '@angular/core'; |
|||
|
|||
export interface CityData { |
|||
name: string; |
|||
median_price: number; |
|||
price_per_sqft: number; |
|||
days_on_market: number; |
|||
walk_score: number; |
|||
yoy_change: number; |
|||
inventory: string; |
|||
} |
|||
|
|||
export interface ComparisonCard { |
|||
city_a: CityData; |
|||
city_b: CityData; |
|||
winners: { |
|||
median_price: string | null; |
|||
price_per_sqft: string | null; |
|||
days_on_market: string | null; |
|||
walk_score: string | null; |
|||
}; |
|||
verdict: string; |
|||
} |
|||
|
|||
@Component({ |
|||
imports: [CommonModule], |
|||
selector: 'gf-real-estate-card', |
|||
styleUrls: ['./real-estate-card.component.scss'], |
|||
templateUrl: './real-estate-card.component.html' |
|||
}) |
|||
export class GfRealEstateCardComponent { |
|||
@Input() public card!: ComparisonCard; |
|||
|
|||
public copyLabel = 'Copy'; |
|||
private copyTimer: ReturnType<typeof setTimeout> | null = null; |
|||
|
|||
public isWinner( |
|||
cityName: string, |
|||
metric: keyof ComparisonCard['winners'] |
|||
): boolean { |
|||
return this.card.winners[metric] === cityName; |
|||
} |
|||
|
|||
public formatPrice(value: number): string { |
|||
return `$${(value / 1000).toFixed(0)}k`; |
|||
} |
|||
|
|||
public formatYoy(value: number): string { |
|||
const sign = value >= 0 ? '+' : ''; |
|||
return `${sign}${value.toFixed(1)}%`; |
|||
} |
|||
|
|||
public yoyClass(value: number): string { |
|||
return value >= 0 ? 'positive' : 'negative'; |
|||
} |
|||
|
|||
public copyToClipboard(): void { |
|||
const a = this.card.city_a; |
|||
const b = this.card.city_b; |
|||
const w = this.card.winners; |
|||
|
|||
const winLabel = (_metric: string, winner: string | null) => |
|||
winner ? ` ✓ ${winner.split(',')[0]} wins` : ''; |
|||
|
|||
const text = [ |
|||
`${a.name} vs ${b.name} — Housing Comparison`, |
|||
'─'.repeat(46), |
|||
`Median Price: ${this.formatPrice(a.median_price).padEnd(10)} vs ${this.formatPrice(b.median_price)}${winLabel('median_price', w.median_price)}`, |
|||
`Price/sqft: $${String(a.price_per_sqft).padEnd(9)} vs $${b.price_per_sqft}${winLabel('price_per_sqft', w.price_per_sqft)}`, |
|||
`Days on Market: ${String(a.days_on_market).padEnd(10)} vs ${b.days_on_market}${winLabel('days_on_market', w.days_on_market)}`, |
|||
`Walk Score: ${String(a.walk_score).padEnd(10)} vs ${b.walk_score}${winLabel('walk_score', w.walk_score)}`, |
|||
`YoY Price: ${this.formatYoy(a.yoy_change).padEnd(10)} vs ${this.formatYoy(b.yoy_change)}`, |
|||
'─'.repeat(46), |
|||
`Verdict: ${this.card.verdict}` |
|||
].join('\n'); |
|||
|
|||
navigator.clipboard.writeText(text).then(() => { |
|||
this.copyLabel = 'Copied ✓'; |
|||
if (this.copyTimer !== null) { |
|||
clearTimeout(this.copyTimer); |
|||
} |
|||
this.copyTimer = setTimeout(() => { |
|||
this.copyLabel = 'Copy'; |
|||
}, 2000); |
|||
}); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue