mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
288 lines
10 KiB
288 lines
10 KiB
"""
|
|
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']}.",
|
|
},
|
|
}
|
|
|