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.
528 lines
21 KiB
528 lines
21 KiB
"""
|
|
Life Decision Advisor
|
|
Orchestrates multiple financial tools into a single recommendation
|
|
for any major life decision: job offer, relocation, home purchase,
|
|
rent vs buy, or general financial guidance.
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import asyncio
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
try:
|
|
from wealth_bridge import (
|
|
calculate_job_offer_affordability,
|
|
calculate_down_payment_power,
|
|
)
|
|
WEALTH_BRIDGE_AVAILABLE = True
|
|
except ImportError:
|
|
WEALTH_BRIDGE_AVAILABLE = False
|
|
|
|
try:
|
|
from relocation_runway import calculate_relocation_runway
|
|
RUNWAY_AVAILABLE = True
|
|
except ImportError:
|
|
RUNWAY_AVAILABLE = False
|
|
|
|
try:
|
|
from wealth_visualizer import analyze_wealth_position
|
|
VISUALIZER_AVAILABLE = True
|
|
except ImportError:
|
|
VISUALIZER_AVAILABLE = False
|
|
|
|
try:
|
|
from real_estate import get_neighborhood_snapshot
|
|
RE_AVAILABLE = True
|
|
except ImportError:
|
|
RE_AVAILABLE = False
|
|
|
|
|
|
def _run_async(coro):
|
|
"""Run an async coroutine from sync context safely."""
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
if loop.is_running():
|
|
import concurrent.futures
|
|
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
future = pool.submit(asyncio.run, coro)
|
|
return future.result(timeout=30)
|
|
else:
|
|
return loop.run_until_complete(coro)
|
|
except Exception:
|
|
try:
|
|
return asyncio.run(coro)
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
|
|
def analyze_life_decision(decision_type: str, user_context: dict) -> dict:
|
|
"""
|
|
Orchestrate all financial tools into a single recommendation.
|
|
|
|
decision_type: "job_offer" | "relocation" | "home_purchase" |
|
|
"rent_or_buy" | "general"
|
|
user_context: dict with optional keys:
|
|
current_salary, offer_salary, current_city, destination_city,
|
|
portfolio_value, age, annual_income, has_family, num_dependents,
|
|
timeline_years, priority
|
|
"""
|
|
ctx = user_context or {}
|
|
tools_used = []
|
|
data_sources = []
|
|
results = {}
|
|
|
|
# ── Job Offer decision ────────────────────────────────────────────────────
|
|
if decision_type == "job_offer":
|
|
current_salary = ctx.get("current_salary")
|
|
offer_salary = ctx.get("offer_salary")
|
|
current_city = ctx.get("current_city", "")
|
|
destination_city = ctx.get("destination_city", "")
|
|
portfolio_value = ctx.get("portfolio_value", 0)
|
|
age = ctx.get("age")
|
|
annual_income = ctx.get("annual_income", offer_salary or current_salary or 0)
|
|
|
|
# COL comparison via wealth_bridge
|
|
if (WEALTH_BRIDGE_AVAILABLE and current_salary and offer_salary
|
|
and current_city and destination_city):
|
|
try:
|
|
col_result = _run_async(
|
|
calculate_job_offer_affordability(
|
|
current_salary=current_salary,
|
|
offer_salary=offer_salary,
|
|
current_city=current_city,
|
|
destination_city=destination_city,
|
|
)
|
|
)
|
|
if col_result and "error" not in col_result:
|
|
results["col"] = col_result
|
|
tools_used.append("wealth_bridge")
|
|
data_sources.append("Cost of living index")
|
|
except Exception as e:
|
|
results["col"] = {"error": str(e)}
|
|
|
|
# Relocation runway
|
|
if (RUNWAY_AVAILABLE and current_salary and offer_salary
|
|
and current_city and destination_city):
|
|
try:
|
|
runway_result = calculate_relocation_runway(
|
|
current_salary=current_salary,
|
|
offer_salary=offer_salary,
|
|
current_city=current_city,
|
|
destination_city=destination_city,
|
|
portfolio_value=portfolio_value or 0,
|
|
)
|
|
if runway_result and "error" not in runway_result:
|
|
results["runway"] = runway_result
|
|
if "relocation_runway" not in tools_used:
|
|
tools_used.append("relocation_runway")
|
|
data_sources.append("ACTRIS MLS + Teleport API")
|
|
except Exception as e:
|
|
results["runway"] = {"error": str(e)}
|
|
|
|
# Wealth position
|
|
if VISUALIZER_AVAILABLE and age and portfolio_value:
|
|
try:
|
|
wealth_result = analyze_wealth_position(
|
|
portfolio_value=portfolio_value,
|
|
age=age,
|
|
annual_income=annual_income,
|
|
)
|
|
if wealth_result:
|
|
results["wealth"] = wealth_result
|
|
tools_used.append("wealth_visualizer")
|
|
data_sources.append("Federal Reserve SCF 2022")
|
|
except Exception as e:
|
|
results["wealth"] = {"error": str(e)}
|
|
|
|
return _synthesize_job_offer(
|
|
ctx, results, tools_used, data_sources
|
|
)
|
|
|
|
# ── Home Purchase decision ────────────────────────────────────────────────
|
|
elif decision_type == "home_purchase":
|
|
portfolio_value = ctx.get("portfolio_value", 0)
|
|
current_city = ctx.get("current_city", "Austin")
|
|
age = ctx.get("age")
|
|
annual_income = ctx.get("annual_income", 0)
|
|
|
|
if WEALTH_BRIDGE_AVAILABLE and portfolio_value:
|
|
try:
|
|
dp_result = calculate_down_payment_power(
|
|
portfolio_value=portfolio_value
|
|
)
|
|
if dp_result:
|
|
results["down_payment"] = dp_result
|
|
tools_used.append("wealth_bridge")
|
|
data_sources.append("ACTRIS MLS Jan 2026")
|
|
except Exception as e:
|
|
results["down_payment"] = {"error": str(e)}
|
|
|
|
if VISUALIZER_AVAILABLE and age and annual_income:
|
|
try:
|
|
wealth_result = analyze_wealth_position(
|
|
portfolio_value=portfolio_value,
|
|
age=age,
|
|
annual_income=annual_income,
|
|
)
|
|
results["wealth"] = wealth_result
|
|
tools_used.append("wealth_visualizer")
|
|
except Exception as e:
|
|
results["wealth"] = {"error": str(e)}
|
|
|
|
return _synthesize_home_purchase(ctx, results, tools_used, data_sources)
|
|
|
|
# ── Rent or Buy decision ──────────────────────────────────────────────────
|
|
elif decision_type == "rent_or_buy":
|
|
portfolio_value = ctx.get("portfolio_value", 0)
|
|
current_city = ctx.get("current_city", "Austin")
|
|
annual_income = ctx.get("annual_income", 0)
|
|
|
|
if WEALTH_BRIDGE_AVAILABLE and portfolio_value:
|
|
try:
|
|
dp_result = calculate_down_payment_power(
|
|
portfolio_value=portfolio_value
|
|
)
|
|
results["down_payment"] = dp_result
|
|
tools_used.append("wealth_bridge")
|
|
data_sources.append("ACTRIS MLS Jan 2026")
|
|
except Exception as e:
|
|
results["down_payment"] = {"error": str(e)}
|
|
|
|
return _synthesize_rent_or_buy(ctx, results, tools_used, data_sources)
|
|
|
|
# ── Relocation decision ───────────────────────────────────────────────────
|
|
elif decision_type == "relocation":
|
|
current_salary = ctx.get("current_salary")
|
|
offer_salary = ctx.get("offer_salary", current_salary)
|
|
current_city = ctx.get("current_city", "")
|
|
destination_city = ctx.get("destination_city", "")
|
|
portfolio_value = ctx.get("portfolio_value", 0)
|
|
|
|
if (RUNWAY_AVAILABLE and current_salary and current_city
|
|
and destination_city):
|
|
try:
|
|
runway_result = calculate_relocation_runway(
|
|
current_salary=current_salary,
|
|
offer_salary=offer_salary,
|
|
current_city=current_city,
|
|
destination_city=destination_city,
|
|
portfolio_value=portfolio_value,
|
|
)
|
|
results["runway"] = runway_result
|
|
tools_used.append("relocation_runway")
|
|
data_sources.append("ACTRIS MLS + Teleport API")
|
|
except Exception as e:
|
|
results["runway"] = {"error": str(e)}
|
|
|
|
return _synthesize_relocation(ctx, results, tools_used, data_sources)
|
|
|
|
# ── General / unknown ─────────────────────────────────────────────────────
|
|
else:
|
|
return {
|
|
"decision_type": "general",
|
|
"summary": (
|
|
"I can help you with any major financial life decision. "
|
|
"Tell me what you're considering and I'll run the numbers."
|
|
),
|
|
"message": (
|
|
"Please share more context. I can help with: "
|
|
"(1) Job offer evaluation — is it a real raise after cost of living? "
|
|
"(2) Relocation planning — how long until you're financially stable? "
|
|
"(3) Home purchase — can your portfolio cover a down payment? "
|
|
"(4) Rent vs buy — what makes sense right now? "
|
|
"Just describe your situation and I'll analyze it."
|
|
),
|
|
"recommendation": (
|
|
"Share your current salary, any offer details, and your city to get started."
|
|
),
|
|
"financial_verdict": "Need more context",
|
|
"confidence": "low",
|
|
"key_numbers": {},
|
|
"tradeoffs": [],
|
|
"next_steps": [
|
|
"Tell me your current salary and city",
|
|
"Describe the decision you're facing",
|
|
"Share your portfolio value if relevant",
|
|
],
|
|
"tools_used": [],
|
|
"data_sources": [],
|
|
}
|
|
|
|
|
|
# ── Synthesis helpers ──────────────────────────────────────────────────────────
|
|
|
|
def _synthesize_job_offer(ctx, results, tools_used, data_sources):
|
|
offer_salary = ctx.get("offer_salary", 0)
|
|
current_salary = ctx.get("current_salary", 0)
|
|
destination_city = ctx.get("destination_city", "destination city")
|
|
current_city = ctx.get("current_city", "current city")
|
|
|
|
# Extract key numbers
|
|
key_numbers = {}
|
|
tradeoffs = []
|
|
verdict = "Need more context"
|
|
confidence = "low"
|
|
|
|
runway = results.get("runway")
|
|
col = results.get("col")
|
|
wealth = results.get("wealth")
|
|
|
|
if runway and "error" not in runway:
|
|
dest_monthly = runway.get("destination_monthly", {})
|
|
curr_monthly = runway.get("current_monthly", {})
|
|
dest_surplus = dest_monthly.get("monthly_surplus", 0)
|
|
curr_surplus = curr_monthly.get("monthly_surplus", 0)
|
|
surplus_delta = dest_surplus - curr_surplus
|
|
|
|
key_numbers["destination_monthly_surplus"] = dest_surplus
|
|
key_numbers["current_monthly_surplus"] = curr_surplus
|
|
key_numbers["surplus_change"] = surplus_delta
|
|
|
|
milestones = runway.get("milestones_if_you_move", {})
|
|
months_down = milestones.get("months_to_down_payment_20pct", 9999)
|
|
if months_down < 9999:
|
|
key_numbers["months_to_down_payment"] = months_down
|
|
|
|
if surplus_delta > 500:
|
|
verdict = "Take it"
|
|
confidence = "high"
|
|
tradeoffs.append(f"PRO: ${surplus_delta:,.0f}/mo more surplus than now")
|
|
elif surplus_delta > 0:
|
|
verdict = "Negotiate"
|
|
confidence = "medium"
|
|
tradeoffs.append(f"NEUTRAL: Marginal improvement (${surplus_delta:,.0f}/mo more)")
|
|
elif dest_monthly.get("monthly_surplus_warning"):
|
|
verdict = "Pass"
|
|
confidence = "high"
|
|
tradeoffs.append("CON: Monthly costs exceed take-home pay at destination")
|
|
else:
|
|
verdict = "Negotiate"
|
|
confidence = "medium"
|
|
tradeoffs.append(f"CON: ${abs(surplus_delta):,.0f}/mo less surplus than now")
|
|
|
|
tradeoffs.append(
|
|
f"NEUTRAL: {destination_city} median home ${runway['milestones_if_you_move'].get('destination_median_home_price', 'N/A'):,}"
|
|
if isinstance(runway['milestones_if_you_move'].get('destination_median_home_price'), (int, float))
|
|
else f"NEUTRAL: Moving to {destination_city}"
|
|
)
|
|
|
|
if col and "error" not in col:
|
|
is_real = col.get("is_real_raise", col.get("verdict", {}).get("is_real_raise"))
|
|
if is_real is not None:
|
|
key_numbers["is_real_raise"] = is_real
|
|
if is_real:
|
|
tradeoffs.append("PRO: Salary increase beats cost of living difference")
|
|
else:
|
|
tradeoffs.append("CON: Higher cost of living erodes salary increase")
|
|
|
|
if wealth and "error" not in wealth:
|
|
peer_pos = wealth.get("current_position", {}).get("vs_peers", "")
|
|
if peer_pos:
|
|
tradeoffs.append(f"NEUTRAL: You are currently {peer_pos} vs peers")
|
|
|
|
salary_pct = ((offer_salary - current_salary) / current_salary * 100
|
|
if current_salary else 0)
|
|
key_numbers["salary_increase_pct"] = round(salary_pct, 1)
|
|
|
|
summary = (
|
|
f"You have a {salary_pct:+.1f}% salary offer (${offer_salary:,} vs "
|
|
f"${current_salary:,}) with a move from {current_city} to {destination_city}. "
|
|
)
|
|
if runway and "error" not in runway:
|
|
summary += runway.get("verdict", "")
|
|
|
|
recommendation = (
|
|
f"Verdict: {verdict}. "
|
|
+ (runway.get("key_insight", "") if runway and "error" not in runway else
|
|
f"Consider negotiating to at least ${int(current_salary * 1.15):,} to account for relocation costs.")
|
|
)
|
|
|
|
next_steps = [
|
|
f"Calculate your exact take-home after {destination_city} taxes",
|
|
"Negotiate relocation assistance and signing bonus",
|
|
f"Research {destination_city} neighborhoods within your budget",
|
|
]
|
|
|
|
return {
|
|
"decision_type": "job_offer",
|
|
"summary": summary,
|
|
"financial_verdict": verdict,
|
|
"confidence": confidence,
|
|
"key_numbers": key_numbers,
|
|
"tradeoffs": tradeoffs,
|
|
"recommendation": recommendation,
|
|
"next_steps": next_steps,
|
|
"tools_used": tools_used,
|
|
"data_sources": data_sources,
|
|
}
|
|
|
|
|
|
def _synthesize_home_purchase(ctx, results, tools_used, data_sources):
|
|
portfolio_value = ctx.get("portfolio_value", 0)
|
|
current_city = ctx.get("current_city", "Austin")
|
|
|
|
dp = results.get("down_payment", {})
|
|
tradeoffs = []
|
|
key_numbers = {}
|
|
|
|
if dp and "error" not in dp:
|
|
summary_data = dp.get("summary", dp)
|
|
affordable = summary_data.get("homes_you_can_afford", [])
|
|
if affordable:
|
|
key_numbers["homes_in_range"] = len(affordable)
|
|
tradeoffs.append(f"PRO: Portfolio can fund down payment on {len(affordable)} market segments")
|
|
else:
|
|
tradeoffs.append("CON: Portfolio may be thin for a down payment right now")
|
|
|
|
liquid_available = summary_data.get("liquid_available_for_down_payment",
|
|
portfolio_value * 0.2)
|
|
key_numbers["available_for_down_payment"] = round(liquid_available)
|
|
|
|
tradeoffs.append("NEUTRAL: Owning builds equity; renting preserves flexibility")
|
|
tradeoffs.append("CON: Transaction costs (3-6%) mean you need 3+ year horizon")
|
|
|
|
verdict = "Consider it" if portfolio_value > 50000 else "Build savings first"
|
|
confidence = "medium"
|
|
|
|
return {
|
|
"decision_type": "home_purchase",
|
|
"summary": (
|
|
f"With ${portfolio_value:,} in your portfolio, "
|
|
f"you have options for a down payment in {current_city}."
|
|
),
|
|
"financial_verdict": verdict,
|
|
"confidence": confidence,
|
|
"key_numbers": key_numbers,
|
|
"tradeoffs": tradeoffs,
|
|
"recommendation": (
|
|
"A home purchase makes sense if you plan to stay 3+ years. "
|
|
"Your portfolio gives you down payment flexibility — consider keeping "
|
|
"20% in the market to avoid PMI while maintaining an emergency fund."
|
|
),
|
|
"next_steps": [
|
|
"Get pre-approved to understand your buying power",
|
|
f"Research {current_city} neighborhoods in your price range",
|
|
"Ensure 6-month emergency fund remains after down payment",
|
|
],
|
|
"tools_used": tools_used,
|
|
"data_sources": data_sources,
|
|
}
|
|
|
|
|
|
def _synthesize_rent_or_buy(ctx, results, tools_used, data_sources):
|
|
portfolio_value = ctx.get("portfolio_value", 0)
|
|
current_city = ctx.get("current_city", "Austin")
|
|
annual_income = ctx.get("annual_income", 0)
|
|
|
|
dp = results.get("down_payment", {})
|
|
tradeoffs = []
|
|
key_numbers = {}
|
|
|
|
if dp and "error" not in dp:
|
|
summary_data = dp.get("summary", dp)
|
|
liquid = summary_data.get("liquid_available_for_down_payment",
|
|
portfolio_value * 0.2)
|
|
key_numbers["down_payment_available"] = round(liquid)
|
|
|
|
monthly_income = annual_income / 12 if annual_income else 0
|
|
if monthly_income > 0:
|
|
key_numbers["monthly_gross_income"] = round(monthly_income)
|
|
|
|
tradeoffs.extend([
|
|
"PRO (buy): Builds equity over time, fixed payment, tax benefits",
|
|
"PRO (rent): Flexibility, lower upfront cost, no maintenance",
|
|
"CON (buy): Illiquid, high transaction costs, market risk",
|
|
"CON (rent): No equity growth, rent increases possible",
|
|
])
|
|
|
|
verdict = (
|
|
"Buy if staying 3+ years"
|
|
if portfolio_value > 40000
|
|
else "Rent and save first"
|
|
)
|
|
|
|
return {
|
|
"decision_type": "rent_or_buy",
|
|
"summary": (
|
|
f"The rent vs buy decision in {current_city} depends on your timeline. "
|
|
"With current mortgage rates, buying makes sense only if you plan to stay 3+ years."
|
|
),
|
|
"financial_verdict": verdict,
|
|
"confidence": "medium",
|
|
"key_numbers": key_numbers,
|
|
"tradeoffs": tradeoffs,
|
|
"recommendation": (
|
|
"In Austin's current market (rates ~7%), the break-even for buying vs renting "
|
|
"is roughly 3-4 years. If you're staying longer, buying locks in your housing cost "
|
|
"and builds equity. If uncertain about your timeline, renting preserves flexibility."
|
|
),
|
|
"next_steps": [
|
|
"Calculate your 5-year break-even (buy vs rent)",
|
|
"Check if your portfolio can cover 20% down + 6mo emergency fund",
|
|
"Compare total cost of ownership vs equivalent rent",
|
|
],
|
|
"tools_used": tools_used,
|
|
"data_sources": data_sources,
|
|
}
|
|
|
|
|
|
def _synthesize_relocation(ctx, results, tools_used, data_sources):
|
|
destination_city = ctx.get("destination_city", "destination")
|
|
current_city = ctx.get("current_city", "current city")
|
|
runway = results.get("runway", {})
|
|
|
|
tradeoffs = []
|
|
key_numbers = {}
|
|
verdict = "Evaluate carefully"
|
|
confidence = "medium"
|
|
|
|
if runway and "error" not in runway:
|
|
dest = runway.get("destination_monthly", {})
|
|
curr = runway.get("current_monthly", {})
|
|
surplus_delta = dest.get("monthly_surplus", 0) - curr.get("monthly_surplus", 0)
|
|
key_numbers["monthly_surplus_change"] = round(surplus_delta)
|
|
key_numbers["destination_monthly_surplus"] = dest.get("monthly_surplus", 0)
|
|
|
|
milestones = runway.get("milestones_if_you_move", {})
|
|
key_numbers["months_to_stability"] = milestones.get(
|
|
"months_to_6mo_emergency_fund", "N/A"
|
|
)
|
|
|
|
if surplus_delta > 0:
|
|
verdict = "Good move financially"
|
|
confidence = "high"
|
|
tradeoffs.append(f"PRO: ${surplus_delta:,.0f}/mo better financially")
|
|
else:
|
|
verdict = "Financial step back"
|
|
confidence = "medium"
|
|
tradeoffs.append(f"CON: ${abs(surplus_delta):,.0f}/mo worse financially")
|
|
|
|
tradeoffs.append(
|
|
f"NEUTRAL: {runway.get('key_insight', '')}"
|
|
)
|
|
|
|
return {
|
|
"decision_type": "relocation",
|
|
"summary": (
|
|
f"Relocating from {current_city} to {destination_city}. "
|
|
+ (runway.get("verdict", "") if runway and "error" not in runway else "")
|
|
),
|
|
"financial_verdict": verdict,
|
|
"confidence": confidence,
|
|
"key_numbers": key_numbers,
|
|
"tradeoffs": tradeoffs,
|
|
"recommendation": (
|
|
runway.get("key_insight", f"Evaluate the full cost of living in {destination_city} "
|
|
"before committing to the relocation.")
|
|
if runway and "error" not in runway
|
|
else f"Research cost of living in {destination_city} before deciding."
|
|
),
|
|
"next_steps": [
|
|
f"Research specific neighborhoods in {destination_city}",
|
|
"Negotiate relocation assistance from employer",
|
|
"Build 3-month emergency fund before the move",
|
|
],
|
|
"tools_used": tools_used,
|
|
"data_sources": data_sources,
|
|
}
|
|
|