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.
530 lines
20 KiB
530 lines
20 KiB
"""
|
|
Wealth Bridge Tool — AgentForge integration
|
|
============================================
|
|
Bridges live Ghostfolio portfolio data with real estate purchasing power.
|
|
|
|
Three capabilities:
|
|
1. calculate_down_payment_power(portfolio_value, target_cities)
|
|
— which markets can your portfolio fund a 20% down payment?
|
|
2. calculate_job_offer_affordability(offer_salary, offer_city,
|
|
current_salary, current_city)
|
|
— is the job offer a real raise in purchasing power terms?
|
|
3. get_portfolio_real_estate_summary(target_cities)
|
|
— master function: reads live portfolio then runs down payment calc
|
|
|
|
Data routing:
|
|
Austin TX areas → _MOCK_SNAPSHOTS in real_estate.py (real ACTRIS data)
|
|
All other cities → teleport_api.py (live Teleport API + fallback)
|
|
|
|
Mortgage assumption: 30-year fixed at 6.95%, 20% down, payment × 1.25
|
|
for estimated taxes + insurance.
|
|
"""
|
|
|
|
import asyncio
|
|
from typing import Optional
|
|
|
|
from agent.tools.real_estate import _MOCK_SNAPSHOTS, _normalize_city
|
|
from agent.tools.teleport_api import (
|
|
HARDCODED_FALLBACK,
|
|
_is_austin_area,
|
|
get_city_housing_data,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# COL index values for Austin TX sub-markets (ACTRIS coverage areas)
|
|
# National average = 100; lower = more affordable
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_AUSTIN_COL_INDEX: dict[str, float] = {
|
|
"austin": 95.4,
|
|
"travis_county": 95.4,
|
|
"austin_msa": 91.0,
|
|
"williamson_county": 88.2,
|
|
"hays_county": 82.1,
|
|
"bastrop_county": 78.3,
|
|
"caldwell_county": 71.2,
|
|
}
|
|
|
|
# State income tax lookup (no tax = not in this dict)
|
|
_STATE_INCOME_TAX: dict[str, float] = {
|
|
"CA": 0.093, "NY": 0.0685, "OR": 0.099, "MN": 0.0985,
|
|
"NJ": 0.1075, "VT": 0.0875, "DC": 0.0895, "HI": 0.11,
|
|
"ME": 0.0715, "IA": 0.0575, "SC": 0.07, "CT": 0.0699,
|
|
"WI": 0.0765, "MA": 0.05, "IL": 0.0495, "IN": 0.032,
|
|
"MI": 0.0425, "GA": 0.055, "NC": 0.0499, "VA": 0.0575,
|
|
"MD": 0.0575, "CO": 0.044, "AZ": 0.025, "UT": 0.0485,
|
|
"KS": 0.057, "MO": 0.0495, "OH": 0.0399, "PA": 0.0307,
|
|
"NM": 0.059, "LA": 0.06, "MS": 0.05, "AL": 0.05,
|
|
"AR": 0.044, "NE": 0.0664, "ID": 0.058, "MT": 0.069,
|
|
"ND": 0.029, "OK": 0.0475, "KY": 0.045,
|
|
}
|
|
|
|
_NO_INCOME_TAX_STATES = {"TX", "WA", "FL", "NV", "WY", "SD", "AK", "NH", "TN"}
|
|
|
|
# Default target markets (all 7 Austin ACTRIS areas)
|
|
_DEFAULT_AUSTIN_MARKETS = [
|
|
"austin", "travis_county", "williamson_county",
|
|
"hays_county", "bastrop_county", "caldwell_county", "austin_msa",
|
|
]
|
|
|
|
# Mortgage constants
|
|
_MORTGAGE_RATE = 0.0695
|
|
_MORTGAGE_TERM = 360 # 30 years in months
|
|
_TAX_INSURANCE_MULTIPLIER = 1.25
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _monthly_payment(price: float) -> float:
|
|
"""Calculates estimated total monthly payment (PITI) at 6.95% 30yr, 20% down."""
|
|
principal = price * 0.80
|
|
r = _MORTGAGE_RATE / 12
|
|
n = _MORTGAGE_TERM
|
|
base_payment = principal * (r * (1 + r) ** n) / ((1 + r) ** n - 1)
|
|
return round(base_payment * _TAX_INSURANCE_MULTIPLIER, 0)
|
|
|
|
|
|
def _get_austin_data(city_key: str) -> Optional[dict]:
|
|
"""Reads ACTRIS snapshot for a given city_key from real_estate._MOCK_SNAPSHOTS."""
|
|
return _MOCK_SNAPSHOTS.get(city_key)
|
|
|
|
|
|
def _resolve_city_data_sync(city_name: str) -> tuple[str, dict]:
|
|
"""
|
|
Synchronously resolves city data for wealth bridge calculations.
|
|
Returns (display_name, data_dict).
|
|
|
|
Austin areas: uses _MOCK_SNAPSHOTS (real ACTRIS data, sync)
|
|
Other cities: uses HARDCODED_FALLBACK (sync) or generic estimate
|
|
"""
|
|
if _is_austin_area(city_name):
|
|
city_key = _normalize_city(city_name)
|
|
snap = _MOCK_SNAPSHOTS.get(city_key, {})
|
|
display = snap.get("city", city_name)
|
|
return display, snap
|
|
|
|
# Non-Austin: look up in fallback dict
|
|
lower = city_name.lower().strip()
|
|
slug_guess = lower.replace(" ", "-")
|
|
# Direct slug match
|
|
if slug_guess in HARDCODED_FALLBACK:
|
|
return HARDCODED_FALLBACK[slug_guess]["city"], dict(HARDCODED_FALLBACK[slug_guess])
|
|
# Partial match
|
|
for slug, data in HARDCODED_FALLBACK.items():
|
|
if lower in data["city"].lower() or slug.replace("-", " ") in lower:
|
|
return data["city"], dict(data)
|
|
# Generic
|
|
return city_name, {
|
|
"city": city_name,
|
|
"median_price": 500_000,
|
|
"MedianRentMonthly": 2000,
|
|
"col_index": 100.0,
|
|
"AffordabilityScore": 5.0,
|
|
"data_source": "Estimate (city not in database)",
|
|
}
|
|
|
|
|
|
def _col_index_for_city(city_name: str, city_data: dict) -> float:
|
|
"""
|
|
Returns the COL index for a city (100 = US average).
|
|
Austin areas: use _AUSTIN_COL_INDEX table.
|
|
Others: use col_index from city_data, or derive from Teleport col_score.
|
|
"""
|
|
if _is_austin_area(city_name):
|
|
city_key = _normalize_city(city_name)
|
|
return _AUSTIN_COL_INDEX.get(city_key, 95.4)
|
|
|
|
if "col_index" in city_data:
|
|
return float(city_data["col_index"])
|
|
|
|
# Derive from Teleport col_score (0–10): 10=cheap, 0=expensive
|
|
col_score = city_data.get("col_score", 5.0)
|
|
return round((10.0 - col_score) * 18.0 + 20.0, 1)
|
|
|
|
|
|
def _state_tax_note(city_a: str, city_b: str) -> str:
|
|
"""Builds a human-readable state income tax note for two cities."""
|
|
def _state_code(city: str) -> Optional[str]:
|
|
# Simple heuristics from known cities
|
|
city_lower = city.lower()
|
|
state_map = {
|
|
"tx": "TX", "austin": "TX", "dallas": "TX", "houston": "TX", "san antonio": "TX",
|
|
"wa": "WA", "seattle": "WA",
|
|
"fl": "FL", "miami": "FL", "orlando": "FL", "tampa": "FL",
|
|
"nv": "NV", "las vegas": "NV",
|
|
"ca": "CA", "san francisco": "CA", "los angeles": "CA", "san diego": "CA",
|
|
"ny": "NY", "new york": "NY", "brooklyn": "NY",
|
|
"co": "CO", "denver": "CO",
|
|
"il": "IL", "chicago": "IL",
|
|
"ma": "MA", "boston": "MA",
|
|
"tn": "TN", "nashville": "TN",
|
|
"ga": "GA", "atlanta": "GA",
|
|
"or": "OR", "portland": "OR",
|
|
"az": "AZ", "phoenix": "AZ",
|
|
"mn": "MN", "minneapolis": "MN",
|
|
"nc": "NC", "charlotte": "NC",
|
|
"va": "VA", "arlington": "VA",
|
|
}
|
|
for keyword, code in state_map.items():
|
|
if keyword in city_lower:
|
|
return code
|
|
return None
|
|
|
|
state_a = _state_code(city_a)
|
|
state_b = _state_code(city_b)
|
|
|
|
if not state_a or not state_b:
|
|
return "State income tax comparison not available for one or both cities."
|
|
|
|
a_notax = state_a in _NO_INCOME_TAX_STATES
|
|
b_notax = state_b in _NO_INCOME_TAX_STATES
|
|
|
|
if a_notax and b_notax:
|
|
return (
|
|
f"Both {state_a} and {state_b} have no state income tax, "
|
|
"so this does not affect the comparison."
|
|
)
|
|
if a_notax and not b_notax:
|
|
rate = _STATE_INCOME_TAX.get(state_b, 0.05) * 100
|
|
return (
|
|
f"{state_a} has no state income tax. {state_b} charges ~{rate:.1f}% — "
|
|
"this makes the offer worth even less than the purchasing power calculation shows."
|
|
)
|
|
if not a_notax and b_notax:
|
|
rate = _STATE_INCOME_TAX.get(state_a, 0.05) * 100
|
|
return (
|
|
f"{state_b} has no state income tax vs {state_a}'s ~{rate:.1f}% — "
|
|
"the move actually improves your after-tax position beyond the COL calculation."
|
|
)
|
|
rate_a = _STATE_INCOME_TAX.get(state_a, 0.05) * 100
|
|
rate_b = _STATE_INCOME_TAX.get(state_b, 0.05) * 100
|
|
return (
|
|
f"{state_a} taxes income at ~{rate_a:.1f}%, {state_b} at ~{rate_b:.1f}%. "
|
|
"Factor this into your take-home pay comparison."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public tool functions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def calculate_down_payment_power(
|
|
portfolio_value: float,
|
|
target_cities: Optional[list[str]] = None,
|
|
) -> dict:
|
|
"""
|
|
Calculates which housing markets a portfolio can fund at 20% down.
|
|
|
|
Scenarios:
|
|
full = portfolio_value (liquidate everything)
|
|
conservative = portfolio_value * 0.80 (keep 20% buffer)
|
|
safe = portfolio_value * 0.60 (maintain diversification)
|
|
|
|
Args:
|
|
portfolio_value: Total liquid portfolio value in USD.
|
|
target_cities: List of city names. Defaults to all 7 Austin ACTRIS markets.
|
|
|
|
Returns:
|
|
Dict with portfolio_value, down_payment_scenarios, markets list,
|
|
top_recommendation, mortgage_assumptions, data_source.
|
|
"""
|
|
full = portfolio_value
|
|
conservative = round(portfolio_value * 0.80, 2)
|
|
safe = round(portfolio_value * 0.60, 2)
|
|
|
|
if target_cities is None:
|
|
# Default: all 7 Austin ACTRIS markets (from _MOCK_SNAPSHOTS directly)
|
|
city_keys = _DEFAULT_AUSTIN_MARKETS
|
|
markets_data = [
|
|
(key, _MOCK_SNAPSHOTS[key])
|
|
for key in city_keys
|
|
if key in _MOCK_SNAPSHOTS
|
|
]
|
|
else:
|
|
markets_data = []
|
|
for city in target_cities:
|
|
display, data = _resolve_city_data_sync(city)
|
|
markets_data.append((city, data))
|
|
|
|
markets_out = []
|
|
affordable_markets = []
|
|
|
|
for city_ref, snap in markets_data:
|
|
price = snap.get("median_price") or snap.get("ListPrice") or 500_000
|
|
rent = snap.get("MedianRentMonthly") or snap.get("median_rent") or 0
|
|
area_name = snap.get("region") or snap.get("city") or city_ref
|
|
ds = snap.get("data_source", "estimate")
|
|
|
|
required_down_20 = round(price * 0.20, 2)
|
|
can_full = full >= required_down_20
|
|
can_conservative = conservative >= required_down_20
|
|
can_safe = safe >= required_down_20
|
|
|
|
monthly_payment = _monthly_payment(price)
|
|
rent_vs_buy_diff = round(monthly_payment - rent, 0) if rent else None
|
|
rent_vs_buy_verdict = (
|
|
f"Buying costs ${abs(rent_vs_buy_diff):,.0f}/mo "
|
|
f"{'more' if rent_vs_buy_diff > 0 else 'less'} than renting"
|
|
if rent_vs_buy_diff is not None else "Rental data unavailable"
|
|
)
|
|
|
|
# Simple break-even: closing costs ~3% + transaction costs ~6% = ~9% of price
|
|
# Break even = (9% of price) / monthly_savings_vs_rent
|
|
monthly_savings = -(rent_vs_buy_diff or 1)
|
|
if monthly_savings > 0 and rent_vs_buy_diff is not None:
|
|
break_even_years = round((price * 0.09) / (monthly_savings * 12), 1)
|
|
elif rent_vs_buy_diff is not None and rent_vs_buy_diff <= 0:
|
|
break_even_years = 0.0 # Already cheaper to buy
|
|
else:
|
|
break_even_years = None
|
|
|
|
entry = {
|
|
"area": area_name,
|
|
"city_ref": city_ref,
|
|
"median_price": price,
|
|
"required_down_20pct": required_down_20,
|
|
"can_afford_full": can_full,
|
|
"can_afford_conservative": can_conservative,
|
|
"can_afford_safe": can_safe,
|
|
"monthly_payment_estimate": int(monthly_payment),
|
|
"median_rent": rent,
|
|
"rent_vs_buy_monthly_diff": rent_vs_buy_diff,
|
|
"rent_vs_buy_verdict": rent_vs_buy_verdict,
|
|
"break_even_years": break_even_years,
|
|
"data_source": ds,
|
|
}
|
|
markets_out.append(entry)
|
|
|
|
if can_full:
|
|
affordable_markets.append(area_name)
|
|
|
|
# Build recommendation
|
|
max_home_price = round(portfolio_value / 0.20, 0)
|
|
affordable_conservatively = [
|
|
m for m in markets_out if m["can_afford_conservative"]
|
|
]
|
|
|
|
if not affordable_markets and not affordable_conservatively:
|
|
recommendation = (
|
|
f"Your ${portfolio_value:,.0f} portfolio covers 20% down on homes up to "
|
|
f"${max_home_price:,.0f}. None of the target markets fall below this threshold. "
|
|
"Consider markets in Caldwell County ($237,491) or increase savings before buying."
|
|
)
|
|
else:
|
|
reachable_names = [m["area"] for m in affordable_conservatively] or affordable_markets
|
|
recommendation = (
|
|
f"Your ${portfolio_value:,.0f} portfolio could fund a 20% down payment on "
|
|
f"homes up to ${max_home_price:,.0f}. "
|
|
+ (
|
|
f"Reachable markets (conservatively): {', '.join(reachable_names[:3])}."
|
|
if reachable_names else
|
|
f"Reachable with full liquidation: {', '.join(affordable_markets[:3])}."
|
|
)
|
|
)
|
|
|
|
return {
|
|
"portfolio_value": portfolio_value,
|
|
"down_payment_scenarios": {
|
|
"full": full,
|
|
"conservative": conservative,
|
|
"safe": safe,
|
|
},
|
|
"markets": markets_out,
|
|
"top_recommendation": recommendation,
|
|
"mortgage_assumptions": {
|
|
"rate": _MORTGAGE_RATE,
|
|
"term_years": 30,
|
|
"down_payment_pct": 20,
|
|
"disclaimer": "Rate is an estimate (6.95% 30yr fixed). Verify with lender.",
|
|
},
|
|
"data_source": (
|
|
"ACTRIS/Unlock MLS Jan 2026 (Austin areas) + Teleport API (other cities)"
|
|
),
|
|
}
|
|
|
|
|
|
async def calculate_job_offer_affordability(
|
|
offer_salary: float,
|
|
offer_city: str,
|
|
current_salary: float,
|
|
current_city: str,
|
|
) -> dict:
|
|
"""
|
|
Determines whether a job offer is a real raise in purchasing power terms.
|
|
|
|
Works for any two cities worldwide:
|
|
- Austin TX areas: uses ACTRIS COL index (real data)
|
|
- All other cities: uses Teleport API live data (or fallback)
|
|
|
|
Args:
|
|
offer_salary: Gross annual salary of the new offer, in USD.
|
|
offer_city: Destination city for the offer.
|
|
current_salary: Current gross annual salary, in USD.
|
|
current_city: Current city of residence.
|
|
|
|
Returns:
|
|
Full comparison dict including adjusted purchasing power, verdict,
|
|
break-even salary, state tax note, and housing cost comparison.
|
|
"""
|
|
# Fetch city data (async for Teleport; sync-cached for Austin)
|
|
async def _get_data(city: str) -> tuple[float, dict]:
|
|
if _is_austin_area(city):
|
|
city_key = _normalize_city(city)
|
|
snap = _MOCK_SNAPSHOTS.get(city_key, {})
|
|
col = _AUSTIN_COL_INDEX.get(city_key, 95.4)
|
|
return col, snap
|
|
data = await get_city_housing_data(city)
|
|
col = _col_index_for_city(city, data)
|
|
return col, data
|
|
|
|
current_col, current_data = await _get_data(current_city)
|
|
offer_col, offer_data = await _get_data(offer_city)
|
|
|
|
# Core purchasing power calculation
|
|
adjusted_offer = round(offer_salary * (current_col / offer_col), 2)
|
|
real_raise = round(adjusted_offer - current_salary, 2)
|
|
is_real_raise = real_raise > 0
|
|
pct_change = round((real_raise / current_salary), 4) if current_salary > 0 else 0.0
|
|
breakeven_salary = round(current_salary * (offer_col / current_col), 2)
|
|
|
|
current_city_display = current_data.get("city") or current_city
|
|
offer_city_display = offer_data.get("city") or offer_city
|
|
|
|
# Housing comparison
|
|
current_rent = (
|
|
current_data.get("MedianRentMonthly")
|
|
or current_data.get("median_rent")
|
|
or 0
|
|
)
|
|
offer_rent = (
|
|
offer_data.get("MedianRentMonthly")
|
|
or offer_data.get("median_rent")
|
|
or 0
|
|
)
|
|
rent_diff = round(offer_rent - current_rent, 0) if current_rent and offer_rent else None
|
|
|
|
# Verdict
|
|
if is_real_raise:
|
|
verdict = (
|
|
f"The {offer_city_display} offer is a REAL raise. "
|
|
f"${offer_salary:,.0f}/yr in {offer_city_display} is worth "
|
|
f"${adjusted_offer:,.0f} in {current_city_display} purchasing power terms — "
|
|
f"a genuine ${real_raise:,.0f} improvement ({pct_change * 100:.1f}%)."
|
|
)
|
|
else:
|
|
verdict = (
|
|
f"Despite looking like a ${offer_salary - current_salary:,.0f} raise, "
|
|
f"the {offer_city_display} offer is worth ${abs(real_raise):,.0f} LESS "
|
|
f"than your {current_city_display} salary in real purchasing power. "
|
|
f"You would need ${breakeven_salary:,.0f} in {offer_city_display} "
|
|
f"to match your current lifestyle."
|
|
)
|
|
|
|
tax_note = _state_tax_note(current_city, offer_city)
|
|
|
|
return {
|
|
"current_salary": current_salary,
|
|
"current_city": current_city_display,
|
|
"current_col_index": current_col,
|
|
"offer_salary": offer_salary,
|
|
"offer_city": offer_city_display,
|
|
"offer_col_index": offer_col,
|
|
"adjusted_offer_in_current_city_terms": adjusted_offer,
|
|
"real_raise_amount": real_raise,
|
|
"is_real_raise": is_real_raise,
|
|
"percentage_real_change": pct_change,
|
|
"breakeven_salary_needed": breakeven_salary,
|
|
"verdict": verdict,
|
|
"tax_note": tax_note,
|
|
"housing_comparison": {
|
|
"current_city_median_rent": current_rent,
|
|
"offer_city_median_rent": offer_rent,
|
|
"monthly_rent_difference": rent_diff,
|
|
},
|
|
"data_source": (
|
|
f"ACTRIS MLS (Austin areas) + Teleport API ({offer_city_display})"
|
|
),
|
|
"offer_data_source": offer_data.get("data_source", ""),
|
|
"current_data_source": current_data.get("data_source", ""),
|
|
}
|
|
|
|
|
|
async def get_portfolio_real_estate_summary(
|
|
target_cities: Optional[list[str]] = None,
|
|
) -> dict:
|
|
"""
|
|
Master function: reads live Ghostfolio portfolio then runs down payment analysis.
|
|
|
|
Steps:
|
|
1. Calls portfolio_analysis to get total portfolio value and top holdings
|
|
2. Passes total to calculate_down_payment_power() with target_cities
|
|
3. Returns combined result with quick plain-English answer
|
|
|
|
Args:
|
|
target_cities: Optional list of cities to analyze. Defaults to all Austin markets.
|
|
|
|
Returns:
|
|
Combined dict with portfolio_summary, down_payment_analysis, quick_answer.
|
|
"""
|
|
from agent.tools.portfolio import portfolio_analysis
|
|
|
|
portfolio_result = await portfolio_analysis()
|
|
|
|
portfolio_value = 0.0
|
|
top_holdings: list[str] = []
|
|
portfolio_error: Optional[str] = None
|
|
|
|
if portfolio_result.get("success"):
|
|
summary = portfolio_result.get("result", {}).get("summary", {})
|
|
portfolio_value = summary.get("total_current_value_usd", 0.0)
|
|
holdings = portfolio_result.get("result", {}).get("holdings", [])
|
|
top_holdings = [
|
|
h.get("symbol") or h.get("name", "")
|
|
for h in holdings[:5]
|
|
if h.get("symbol") or h.get("name")
|
|
]
|
|
else:
|
|
portfolio_error = portfolio_result.get("error", {}).get("message", "Unknown error")
|
|
|
|
down_payment_analysis = calculate_down_payment_power(portfolio_value, target_cities)
|
|
|
|
max_home_price = round(portfolio_value / 0.20, 0) if portfolio_value > 0 else 0
|
|
affordable = [
|
|
m for m in down_payment_analysis["markets"]
|
|
if m["can_afford_conservative"]
|
|
]
|
|
|
|
if portfolio_value == 0.0:
|
|
quick_answer = (
|
|
"Could not retrieve portfolio value. "
|
|
+ (f"Error: {portfolio_error}" if portfolio_error else "")
|
|
)
|
|
elif affordable:
|
|
names = [m["area"] for m in affordable[:3]]
|
|
quick_answer = (
|
|
f"Your ${portfolio_value:,.0f} portfolio could fund a 20% down payment "
|
|
f"on homes up to ${max_home_price:,.0f}. "
|
|
f"Reachable markets: {', '.join(names)}."
|
|
)
|
|
else:
|
|
quick_answer = (
|
|
f"Your ${portfolio_value:,.0f} portfolio covers 20% down on homes up to "
|
|
f"${max_home_price:,.0f}. Check Caldwell County at $237,491 — "
|
|
"most affordable in the Austin area."
|
|
)
|
|
|
|
return {
|
|
"portfolio_summary": {
|
|
"total_value": portfolio_value,
|
|
"top_holdings": top_holdings,
|
|
"portfolio_error": portfolio_error,
|
|
"allocation_note": (
|
|
"Liquidating portfolio would trigger capital gains taxes — "
|
|
"consult a financial advisor before using investments for a down payment."
|
|
),
|
|
},
|
|
"down_payment_analysis": down_payment_analysis,
|
|
"quick_answer": quick_answer,
|
|
}
|
|
|