Spaces:
Sleeping
Sleeping
Fix bond portfolios and add advisor chat
Browse files- App/analysis/portfolio_advisor.py +300 -0
- App/routers/portfolio/routes.py +122 -2
- App/routers/portfolio/schemas.py +22 -1
- App/routers/portfolio/service.py +35 -15
App/analysis/portfolio_advisor.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import os
|
| 3 |
+
from decimal import Decimal
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
|
| 8 |
+
from App.routers.funds.models import FundPerformance, MutualFund
|
| 9 |
+
from App.routers.portfolio.service import PortfolioService
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _safe_float(value: Any) -> float:
|
| 13 |
+
if value in (None, ""):
|
| 14 |
+
return 0.0
|
| 15 |
+
try:
|
| 16 |
+
return float(value)
|
| 17 |
+
except (TypeError, ValueError):
|
| 18 |
+
return 0.0
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _pct(value: Any) -> float:
|
| 22 |
+
numeric = _safe_float(value)
|
| 23 |
+
return numeric if numeric <= 1 else numeric / 100
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _money(value: float) -> str:
|
| 27 |
+
return f"TZS {value:,.0f}"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _horizon_label(months: int | None) -> str:
|
| 31 |
+
if not months:
|
| 32 |
+
return "not specified"
|
| 33 |
+
if months < 12:
|
| 34 |
+
return f"{months} months"
|
| 35 |
+
years = months / 12
|
| 36 |
+
return f"{years:.1f} years"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def _fund_snapshot(limit: int = 18) -> list[dict[str, Any]]:
|
| 40 |
+
funds = await MutualFund.all().prefetch_related("manager")
|
| 41 |
+
rows: list[dict[str, Any]] = []
|
| 42 |
+
for fund in funds:
|
| 43 |
+
latest = await FundPerformance.filter(fund_id=fund.id).order_by("-record_date").first()
|
| 44 |
+
fund_type = str(fund.fund_type or "").lower()
|
| 45 |
+
name = str(fund.name or "")
|
| 46 |
+
is_liquid = (
|
| 47 |
+
"liquid" in fund_type
|
| 48 |
+
or "money" in fund_type
|
| 49 |
+
or "cash" in name.lower()
|
| 50 |
+
or (fund.redemption_days is not None and fund.redemption_days <= 3)
|
| 51 |
+
)
|
| 52 |
+
rows.append(
|
| 53 |
+
{
|
| 54 |
+
"id": fund.id,
|
| 55 |
+
"name": fund.name,
|
| 56 |
+
"manager": getattr(fund.manager, "name", None),
|
| 57 |
+
"fund_type": fund.fund_type,
|
| 58 |
+
"nav_per_unit": _safe_float(getattr(latest, "nav_per_unit", None)),
|
| 59 |
+
"entry_load": _safe_float(fund.entry_load),
|
| 60 |
+
"redemption_days": fund.redemption_days,
|
| 61 |
+
"pays_income": bool(fund.pays_income),
|
| 62 |
+
"income_frequency": fund.income_frequency,
|
| 63 |
+
"min_initial": fund.min_initial,
|
| 64 |
+
"liquidity_bucket": "liquid" if is_liquid else "standard",
|
| 65 |
+
}
|
| 66 |
+
)
|
| 67 |
+
return sorted(
|
| 68 |
+
rows,
|
| 69 |
+
key=lambda item: (
|
| 70 |
+
0 if item["liquidity_bucket"] == "liquid" else 1,
|
| 71 |
+
item["entry_load"],
|
| 72 |
+
item["name"] or "",
|
| 73 |
+
),
|
| 74 |
+
)[:limit]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
async def _portfolio_context(portfolio_id: int) -> dict[str, Any]:
|
| 78 |
+
summary = await PortfolioService.get_portfolio_summary(portfolio_id)
|
| 79 |
+
total_value = _safe_float(summary.total_market_value)
|
| 80 |
+
total_cost = _safe_float(summary.total_cost_basis)
|
| 81 |
+
allocation = summary.asset_allocation
|
| 82 |
+
positions: list[dict[str, Any]] = []
|
| 83 |
+
|
| 84 |
+
for holding in summary.stock_holdings:
|
| 85 |
+
value = _safe_float(holding.market_value)
|
| 86 |
+
positions.append(
|
| 87 |
+
{
|
| 88 |
+
"asset_type": "STOCK",
|
| 89 |
+
"symbol": holding.stock_symbol,
|
| 90 |
+
"name": holding.stock_name,
|
| 91 |
+
"value": value,
|
| 92 |
+
"weight": value / total_value if total_value else 0,
|
| 93 |
+
"gain_loss_pct": _safe_float(holding.gain_loss_percentage),
|
| 94 |
+
}
|
| 95 |
+
)
|
| 96 |
+
for holding in summary.fund_holdings:
|
| 97 |
+
value = _safe_float(holding.market_value)
|
| 98 |
+
positions.append(
|
| 99 |
+
{
|
| 100 |
+
"asset_type": "FUND",
|
| 101 |
+
"symbol": holding.fund_name,
|
| 102 |
+
"name": holding.fund_name,
|
| 103 |
+
"value": value,
|
| 104 |
+
"weight": value / total_value if total_value else 0,
|
| 105 |
+
"gain_loss_pct": _safe_float(holding.gain_loss_percentage),
|
| 106 |
+
}
|
| 107 |
+
)
|
| 108 |
+
for holding in summary.bond_holdings:
|
| 109 |
+
value = _safe_float(holding.market_value)
|
| 110 |
+
positions.append(
|
| 111 |
+
{
|
| 112 |
+
"asset_type": "BOND",
|
| 113 |
+
"symbol": str(holding.auction_number or holding.bond_id),
|
| 114 |
+
"name": holding.instrument_type,
|
| 115 |
+
"value": value,
|
| 116 |
+
"weight": value / total_value if total_value else 0,
|
| 117 |
+
"maturity_date": str(holding.maturity_date),
|
| 118 |
+
}
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
return {
|
| 122 |
+
"portfolio_name": summary.portfolio.name,
|
| 123 |
+
"total_value": total_value,
|
| 124 |
+
"total_cost_basis": total_cost,
|
| 125 |
+
"unrealized_gain_loss": _safe_float(summary.unrealized_gain_loss),
|
| 126 |
+
"unrealized_gain_loss_pct": _safe_float(summary.unrealized_gain_loss_pct),
|
| 127 |
+
"allocation": {
|
| 128 |
+
"stocks": _pct(allocation.stocks_percentage),
|
| 129 |
+
"funds": _pct(allocation.funds_percentage),
|
| 130 |
+
"bonds": _pct(allocation.bonds_percentage),
|
| 131 |
+
"cash": _pct(getattr(allocation, "cash_percentage", Decimal("0"))),
|
| 132 |
+
},
|
| 133 |
+
"positions": sorted(positions, key=lambda item: item["weight"], reverse=True),
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _heuristic_advice(
|
| 138 |
+
*,
|
| 139 |
+
message: str,
|
| 140 |
+
context: dict[str, Any],
|
| 141 |
+
funds: list[dict[str, Any]],
|
| 142 |
+
goal: str | None,
|
| 143 |
+
employment_status: str | None,
|
| 144 |
+
liquidity_need: str | None,
|
| 145 |
+
risk_profile: str | None,
|
| 146 |
+
horizon_months: int | None,
|
| 147 |
+
) -> dict[str, Any]:
|
| 148 |
+
allocation = context["allocation"]
|
| 149 |
+
total_value = context["total_value"]
|
| 150 |
+
unemployed = "unemploy" in str(employment_status or message).lower()
|
| 151 |
+
high_liquidity = any(
|
| 152 |
+
word in str(liquidity_need or message).lower()
|
| 153 |
+
for word in ["high", "liquid", "emergency", "cash", "unemploy"]
|
| 154 |
+
)
|
| 155 |
+
growth_goal = any(
|
| 156 |
+
word in str(goal or message).lower()
|
| 157 |
+
for word in ["grow", "growth", "capital", "compound", "wealth"]
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
if unemployed or high_liquidity:
|
| 161 |
+
target = {"liquid_funds": 0.45, "bonds_income": 0.25, "stocks": 0.20, "opportunity_cash": 0.10}
|
| 162 |
+
elif growth_goal and (horizon_months or 0) >= 36:
|
| 163 |
+
target = {"liquid_funds": 0.15, "bonds_income": 0.20, "stocks": 0.45, "growth_funds": 0.20}
|
| 164 |
+
else:
|
| 165 |
+
target = {"liquid_funds": 0.25, "bonds_income": 0.25, "stocks": 0.30, "growth_funds": 0.20}
|
| 166 |
+
|
| 167 |
+
liquid_funds = [fund for fund in funds if fund["liquidity_bucket"] == "liquid"][:3]
|
| 168 |
+
income_funds = [fund for fund in funds if fund["pays_income"]][:3]
|
| 169 |
+
standard_funds = [fund for fund in funds if fund["liquidity_bucket"] != "liquid"][:3]
|
| 170 |
+
|
| 171 |
+
concentration = context["positions"][0] if context["positions"] else None
|
| 172 |
+
warnings = []
|
| 173 |
+
if concentration and concentration["weight"] > 0.55:
|
| 174 |
+
warnings.append(
|
| 175 |
+
f"{concentration['symbol']} is about {concentration['weight'] * 100:.1f}% of the portfolio, so one asset is driving most of the outcome."
|
| 176 |
+
)
|
| 177 |
+
if unemployed and allocation["stocks"] > 0.35:
|
| 178 |
+
warnings.append("Because income is uncertain, pure stock exposure above 35% may create liquidity stress during drawdowns.")
|
| 179 |
+
if high_liquidity and allocation["funds"] + allocation["cash"] < 0.30:
|
| 180 |
+
warnings.append("Your liquid bucket appears low for someone prioritising liquidity.")
|
| 181 |
+
|
| 182 |
+
key_actions = [
|
| 183 |
+
f"Build a liquid reserve first: target about {target['liquid_funds'] * 100:.0f}% in liquid or money-market style funds before adding more volatile assets.",
|
| 184 |
+
f"Use bonds or income funds for stability: target about {target['bonds_income'] * 100:.0f}% so capital growth is not forced to fund short-term spending.",
|
| 185 |
+
f"Keep growth exposure intentional: target about {target.get('stocks', 0) * 100:.0f}% in stocks if you can tolerate volatility and hold through market dips.",
|
| 186 |
+
]
|
| 187 |
+
if liquid_funds:
|
| 188 |
+
key_actions.append(
|
| 189 |
+
"Liquid fund candidates to review: "
|
| 190 |
+
+ ", ".join(f"{fund['name']} ({fund['manager'] or 'manager n/a'})" for fund in liquid_funds[:2])
|
| 191 |
+
+ "."
|
| 192 |
+
)
|
| 193 |
+
if income_funds:
|
| 194 |
+
key_actions.append(
|
| 195 |
+
"Income/stability candidates to review: "
|
| 196 |
+
+ ", ".join(f"{fund['name']}" for fund in income_funds[:2])
|
| 197 |
+
+ "."
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
answer = (
|
| 201 |
+
f"For your stated goal ({goal or 'not specified'}) and horizon ({_horizon_label(horizon_months)}), "
|
| 202 |
+
f"I would arrange the portfolio around liquidity first, then controlled growth. "
|
| 203 |
+
f"Current tracked value is {_money(total_value)} with roughly stocks {allocation['stocks'] * 100:.1f}%, "
|
| 204 |
+
f"funds {allocation['funds'] * 100:.1f}%, and bonds {allocation['bonds'] * 100:.1f}%.\n\n"
|
| 205 |
+
"Suggested structure:\n"
|
| 206 |
+
+ "\n".join(f"- {label.replace('_', ' ').title()}: {weight * 100:.0f}%" for label, weight in target.items())
|
| 207 |
+
+ "\n\nNext steps:\n"
|
| 208 |
+
+ "\n".join(f"- {action}" for action in key_actions[:5])
|
| 209 |
+
)
|
| 210 |
+
if warnings:
|
| 211 |
+
answer += "\n\nRisk flags:\n" + "\n".join(f"- {warning}" for warning in warnings)
|
| 212 |
+
answer += (
|
| 213 |
+
"\n\nThis is planning guidance, not a guaranteed outcome. Before acting, compare fees, minimum investments, "
|
| 214 |
+
"redemption days, tax treatment, and whether the fund/bond actually matches your cash-flow need."
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
return {
|
| 218 |
+
"reply": answer,
|
| 219 |
+
"provider": "heuristic_fallback",
|
| 220 |
+
"target_allocation": target,
|
| 221 |
+
"recommended_funds": {
|
| 222 |
+
"liquid": liquid_funds,
|
| 223 |
+
"income": income_funds,
|
| 224 |
+
"growth_or_standard": standard_funds,
|
| 225 |
+
},
|
| 226 |
+
"warnings": warnings,
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _openrouter_reply(prompt: str) -> tuple[str | None, str | None]:
|
| 231 |
+
api_key = os.getenv("OPENROUTER_API_KEY")
|
| 232 |
+
if not api_key:
|
| 233 |
+
return None, None
|
| 234 |
+
model = os.getenv("PORTFOLIO_ADVISOR_MODEL", "openai/gpt-oss-20b:free")
|
| 235 |
+
try:
|
| 236 |
+
response = requests.post(
|
| 237 |
+
"https://openrouter.ai/api/v1/chat/completions",
|
| 238 |
+
headers={
|
| 239 |
+
"Authorization": f"Bearer {api_key}",
|
| 240 |
+
"Content-Type": "application/json",
|
| 241 |
+
},
|
| 242 |
+
json={
|
| 243 |
+
"model": model,
|
| 244 |
+
"messages": [
|
| 245 |
+
{
|
| 246 |
+
"role": "system",
|
| 247 |
+
"content": (
|
| 248 |
+
"You are a Tanzanian portfolio advisor for an investing app. "
|
| 249 |
+
"Give practical allocation guidance, discuss liquidity, fees, funds, bonds, and stocks. "
|
| 250 |
+
"Do not promise returns. Keep the answer concise and action-oriented."
|
| 251 |
+
),
|
| 252 |
+
},
|
| 253 |
+
{"role": "user", "content": prompt},
|
| 254 |
+
],
|
| 255 |
+
"temperature": 0.35,
|
| 256 |
+
},
|
| 257 |
+
timeout=35,
|
| 258 |
+
)
|
| 259 |
+
response.raise_for_status()
|
| 260 |
+
data = response.json()
|
| 261 |
+
return data["choices"][0]["message"]["content"], model
|
| 262 |
+
except Exception:
|
| 263 |
+
return None, None
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
async def generate_portfolio_advisor_reply(
|
| 267 |
+
portfolio_id: int,
|
| 268 |
+
request: dict[str, Any],
|
| 269 |
+
) -> dict[str, Any]:
|
| 270 |
+
context, funds = await asyncio.gather(_portfolio_context(portfolio_id), _fund_snapshot())
|
| 271 |
+
heuristic = _heuristic_advice(
|
| 272 |
+
message=request.get("message") or "",
|
| 273 |
+
context=context,
|
| 274 |
+
funds=funds,
|
| 275 |
+
goal=request.get("goal"),
|
| 276 |
+
employment_status=request.get("employment_status"),
|
| 277 |
+
liquidity_need=request.get("liquidity_need"),
|
| 278 |
+
risk_profile=request.get("risk_profile"),
|
| 279 |
+
horizon_months=request.get("horizon_months"),
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
prompt = (
|
| 283 |
+
"User question:\n"
|
| 284 |
+
f"{request.get('message')}\n\n"
|
| 285 |
+
f"Goal: {request.get('goal') or 'not specified'}\n"
|
| 286 |
+
f"Employment status: {request.get('employment_status') or 'not specified'}\n"
|
| 287 |
+
f"Liquidity need: {request.get('liquidity_need') or 'not specified'}\n"
|
| 288 |
+
f"Risk profile: {request.get('risk_profile') or 'not specified'}\n"
|
| 289 |
+
f"Horizon months: {request.get('horizon_months') or 'not specified'}\n"
|
| 290 |
+
f"Hypothetical portfolio: {request.get('hypothetical_portfolio') or 'none'}\n\n"
|
| 291 |
+
f"Current portfolio context: {context}\n\n"
|
| 292 |
+
f"Available mutual fund candidates: {funds[:12]}\n\n"
|
| 293 |
+
f"Rule-based baseline advice to improve or refine: {heuristic['reply']}"
|
| 294 |
+
)
|
| 295 |
+
llm_reply, provider = await asyncio.to_thread(_openrouter_reply, prompt)
|
| 296 |
+
if llm_reply:
|
| 297 |
+
heuristic["reply"] = llm_reply
|
| 298 |
+
heuristic["provider"] = provider or "openrouter"
|
| 299 |
+
heuristic["portfolio_context"] = context
|
| 300 |
+
return heuristic
|
App/routers/portfolio/routes.py
CHANGED
|
@@ -12,6 +12,7 @@ import calendar as month_calendar
|
|
| 12 |
import re
|
| 13 |
|
| 14 |
from tortoise.contrib.pydantic import pydantic_model_creator
|
|
|
|
| 15 |
from tortoise.expressions import Q
|
| 16 |
|
| 17 |
from App.schemas import ResponseModel, AppException
|
|
@@ -19,6 +20,7 @@ from App.routers.users.utils import get_current_user
|
|
| 19 |
from App.routers.stocks.models import Dividend, Stock, StockPriceData
|
| 20 |
from App.routers.funds.models import MutualFund, FundPerformance, FundIncomeOption
|
| 21 |
from App.routers.bonds.models import Bond
|
|
|
|
| 22 |
from App.analysis.portfolio_optimizer import run_portfolio_analysis_task
|
| 23 |
from App.routers.tasks.models import ImportTask, TaskStatus
|
| 24 |
|
|
@@ -33,6 +35,7 @@ from .models import (
|
|
| 33 |
)
|
| 34 |
from .schemas import (
|
| 35 |
PortfolioCreate,
|
|
|
|
| 36 |
PortfolioUpdate,
|
| 37 |
StockHoldingCreate,
|
| 38 |
StockHoldingUpdate,
|
|
@@ -543,6 +546,24 @@ async def get_growth_allocation_analysis(
|
|
| 543 |
)
|
| 544 |
|
| 545 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
@router.put("/{portfolio_id}", summary="Update portfolio")
|
| 547 |
async def update_portfolio(
|
| 548 |
portfolio_id: int,
|
|
@@ -973,8 +994,107 @@ async def update_bond_holding(
|
|
| 973 |
raise AppException(status_code=404, message="Bond holding not found")
|
| 974 |
|
| 975 |
update_data = payload.model_dump(exclude_unset=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 976 |
if update_data:
|
| 977 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 978 |
unit_price = (
|
| 979 |
holding.purchase_price / Decimal(str(holding.face_value_held))
|
| 980 |
if holding.face_value_held
|
|
@@ -983,7 +1103,7 @@ async def update_bond_holding(
|
|
| 983 |
await _replace_position_transactions(
|
| 984 |
portfolio_id=portfolio_id,
|
| 985 |
asset_type="BOND",
|
| 986 |
-
asset_id=bond_id,
|
| 987 |
quantity=Decimal(str(holding.face_value_held)),
|
| 988 |
unit_price=unit_price,
|
| 989 |
txn_date=holding.purchase_date,
|
|
|
|
| 12 |
import re
|
| 13 |
|
| 14 |
from tortoise.contrib.pydantic import pydantic_model_creator
|
| 15 |
+
from tortoise.exceptions import IntegrityError
|
| 16 |
from tortoise.expressions import Q
|
| 17 |
|
| 18 |
from App.schemas import ResponseModel, AppException
|
|
|
|
| 20 |
from App.routers.stocks.models import Dividend, Stock, StockPriceData
|
| 21 |
from App.routers.funds.models import MutualFund, FundPerformance, FundIncomeOption
|
| 22 |
from App.routers.bonds.models import Bond
|
| 23 |
+
from App.analysis.portfolio_advisor import generate_portfolio_advisor_reply
|
| 24 |
from App.analysis.portfolio_optimizer import run_portfolio_analysis_task
|
| 25 |
from App.routers.tasks.models import ImportTask, TaskStatus
|
| 26 |
|
|
|
|
| 35 |
)
|
| 36 |
from .schemas import (
|
| 37 |
PortfolioCreate,
|
| 38 |
+
PortfolioAdvisorChatRequest,
|
| 39 |
PortfolioUpdate,
|
| 40 |
StockHoldingCreate,
|
| 41 |
StockHoldingUpdate,
|
|
|
|
| 546 |
)
|
| 547 |
|
| 548 |
|
| 549 |
+
@router.post("/{portfolio_id}/advisor-chat", summary="Chat with portfolio advisor")
|
| 550 |
+
async def chat_with_portfolio_advisor(
|
| 551 |
+
portfolio_id: int,
|
| 552 |
+
payload: PortfolioAdvisorChatRequest,
|
| 553 |
+
current_user=Depends(get_current_user),
|
| 554 |
+
):
|
| 555 |
+
await _verify_ownership(portfolio_id, current_user, active_only=True)
|
| 556 |
+
result = await generate_portfolio_advisor_reply(
|
| 557 |
+
portfolio_id=portfolio_id,
|
| 558 |
+
request=payload.model_dump(exclude_none=True),
|
| 559 |
+
)
|
| 560 |
+
return ResponseModel(
|
| 561 |
+
success=True,
|
| 562 |
+
message="Portfolio advisor response generated",
|
| 563 |
+
data=result,
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
|
| 567 |
@router.put("/{portfolio_id}", summary="Update portfolio")
|
| 568 |
async def update_portfolio(
|
| 569 |
portfolio_id: int,
|
|
|
|
| 994 |
raise AppException(status_code=404, message="Bond holding not found")
|
| 995 |
|
| 996 |
update_data = payload.model_dump(exclude_unset=True)
|
| 997 |
+
holding_fields = {
|
| 998 |
+
"holding_number",
|
| 999 |
+
"holding_status",
|
| 1000 |
+
"face_value_held",
|
| 1001 |
+
"purchase_price",
|
| 1002 |
+
"purchase_date",
|
| 1003 |
+
"notes",
|
| 1004 |
+
}
|
| 1005 |
+
|
| 1006 |
if update_data:
|
| 1007 |
+
target_bond = None
|
| 1008 |
+
if payload.bond_id is not None:
|
| 1009 |
+
target_bond = await Bond.get_or_none(id=payload.bond_id)
|
| 1010 |
+
if not target_bond:
|
| 1011 |
+
raise AppException(status_code=404, message="Target bond not found")
|
| 1012 |
+
elif payload.auction_number is not None:
|
| 1013 |
+
bond_query = Bond.filter(auction_number=payload.auction_number)
|
| 1014 |
+
if payload.effective_date:
|
| 1015 |
+
bond_query = bond_query.filter(effective_date=payload.effective_date)
|
| 1016 |
+
if payload.maturity_date:
|
| 1017 |
+
bond_query = bond_query.filter(maturity_date=payload.maturity_date)
|
| 1018 |
+
if payload.coupon_rate is not None:
|
| 1019 |
+
bond_query = bond_query.filter(coupon_rate=float(payload.coupon_rate))
|
| 1020 |
+
target_bond = await bond_query.first()
|
| 1021 |
+
|
| 1022 |
+
if not target_bond:
|
| 1023 |
+
source_bond = holding.bond
|
| 1024 |
+
effective_date = payload.effective_date or source_bond.effective_date
|
| 1025 |
+
maturity_date = payload.maturity_date or source_bond.maturity_date
|
| 1026 |
+
if not effective_date or not maturity_date:
|
| 1027 |
+
raise AppException(
|
| 1028 |
+
status_code=400,
|
| 1029 |
+
message="Provide effective_date and maturity_date when creating a missing reissue bond",
|
| 1030 |
+
)
|
| 1031 |
+
|
| 1032 |
+
synthetic_isin = (
|
| 1033 |
+
payload.isin
|
| 1034 |
+
or f"{payload.auction_number}{effective_date.strftime('%y%m%d')}"
|
| 1035 |
+
)[:12]
|
| 1036 |
+
existing_by_isin = await Bond.get_or_none(isin=synthetic_isin)
|
| 1037 |
+
if existing_by_isin:
|
| 1038 |
+
target_bond = existing_by_isin
|
| 1039 |
+
else:
|
| 1040 |
+
try:
|
| 1041 |
+
target_bond = await Bond.create(
|
| 1042 |
+
instrument_type=payload.instrument_type or source_bond.instrument_type,
|
| 1043 |
+
auction_number=payload.auction_number,
|
| 1044 |
+
auction_date=effective_date,
|
| 1045 |
+
maturity_years=payload.maturity_years or source_bond.maturity_years,
|
| 1046 |
+
maturity_date=maturity_date,
|
| 1047 |
+
effective_date=effective_date,
|
| 1048 |
+
dtm=payload.dtm if payload.dtm is not None else source_bond.dtm,
|
| 1049 |
+
bond_auction_number=payload.bond_auction_number
|
| 1050 |
+
or payload.auction_number
|
| 1051 |
+
or source_bond.bond_auction_number,
|
| 1052 |
+
holding_number=payload.holding_number
|
| 1053 |
+
if payload.holding_number is not None
|
| 1054 |
+
else source_bond.holding_number,
|
| 1055 |
+
face_value=int(payload.face_value_held or holding.face_value_held),
|
| 1056 |
+
price_per_100=float(
|
| 1057 |
+
payload.price_per_100
|
| 1058 |
+
if payload.price_per_100 is not None
|
| 1059 |
+
else source_bond.price_per_100
|
| 1060 |
+
),
|
| 1061 |
+
coupon_rate=float(
|
| 1062 |
+
payload.coupon_rate
|
| 1063 |
+
if payload.coupon_rate is not None
|
| 1064 |
+
else source_bond.coupon_rate
|
| 1065 |
+
),
|
| 1066 |
+
isin=synthetic_isin,
|
| 1067 |
+
)
|
| 1068 |
+
except IntegrityError:
|
| 1069 |
+
target_bond = await Bond.filter(
|
| 1070 |
+
auction_number=payload.auction_number,
|
| 1071 |
+
auction_date=effective_date,
|
| 1072 |
+
).first()
|
| 1073 |
+
if not target_bond:
|
| 1074 |
+
raise AppException(
|
| 1075 |
+
status_code=400,
|
| 1076 |
+
message="A matching reissue bond already exists but could not be resolved",
|
| 1077 |
+
)
|
| 1078 |
+
|
| 1079 |
+
holding_update_data = {
|
| 1080 |
+
key: value for key, value in update_data.items() if key in holding_fields
|
| 1081 |
+
}
|
| 1082 |
+
if target_bond and target_bond.id != holding.bond_id:
|
| 1083 |
+
existing_holding = await PortfolioBond.get_or_none(
|
| 1084 |
+
portfolio_id=portfolio_id,
|
| 1085 |
+
bond_id=target_bond.id,
|
| 1086 |
+
)
|
| 1087 |
+
if existing_holding and existing_holding.id != holding.id:
|
| 1088 |
+
raise AppException(
|
| 1089 |
+
status_code=400,
|
| 1090 |
+
message="Portfolio already has a holding for the target auction bond",
|
| 1091 |
+
)
|
| 1092 |
+
holding.bond_id = target_bond.id
|
| 1093 |
+
|
| 1094 |
+
if holding_update_data:
|
| 1095 |
+
await holding.update_from_dict(holding_update_data)
|
| 1096 |
+
await holding.save()
|
| 1097 |
+
await holding.fetch_related("bond")
|
| 1098 |
unit_price = (
|
| 1099 |
holding.purchase_price / Decimal(str(holding.face_value_held))
|
| 1100 |
if holding.face_value_held
|
|
|
|
| 1103 |
await _replace_position_transactions(
|
| 1104 |
portfolio_id=portfolio_id,
|
| 1105 |
asset_type="BOND",
|
| 1106 |
+
asset_id=holding.bond_id,
|
| 1107 |
quantity=Decimal(str(holding.face_value_held)),
|
| 1108 |
unit_price=unit_price,
|
| 1109 |
txn_date=holding.purchase_date,
|
App/routers/portfolio/schemas.py
CHANGED
|
@@ -3,7 +3,7 @@ Portfolio schemas β ONLY pydantic/stdlib imports.
|
|
| 3 |
NEVER import from .models, .service, .routes, or .utils
|
| 4 |
"""
|
| 5 |
from pydantic import BaseModel, Field, ConfigDict
|
| 6 |
-
from typing import Optional, Dict
|
| 7 |
from datetime import date, datetime
|
| 8 |
from decimal import Decimal
|
| 9 |
|
|
@@ -33,6 +33,16 @@ class PortfolioUpdate(BaseModel):
|
|
| 33 |
is_active: Optional[bool] = None
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
# ββββββββββββββββ STOCK HOLDINGS ββββββββββββββββ
|
| 37 |
|
| 38 |
|
|
@@ -150,6 +160,17 @@ class BondHoldingCreate(BaseModel):
|
|
| 150 |
|
| 151 |
|
| 152 |
class BondHoldingUpdate(BaseModel):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
holding_number: Optional[int] = None
|
| 154 |
holding_status: Optional[str] = None
|
| 155 |
face_value_held: Optional[Decimal] = Field(None, gt=0)
|
|
|
|
| 3 |
NEVER import from .models, .service, .routes, or .utils
|
| 4 |
"""
|
| 5 |
from pydantic import BaseModel, Field, ConfigDict
|
| 6 |
+
from typing import Any, Optional, Dict
|
| 7 |
from datetime import date, datetime
|
| 8 |
from decimal import Decimal
|
| 9 |
|
|
|
|
| 33 |
is_active: Optional[bool] = None
|
| 34 |
|
| 35 |
|
| 36 |
+
class PortfolioAdvisorChatRequest(BaseModel):
|
| 37 |
+
message: str = Field(..., min_length=2, max_length=3000)
|
| 38 |
+
goal: Optional[str] = Field(None, max_length=500)
|
| 39 |
+
employment_status: Optional[str] = Field(None, max_length=80)
|
| 40 |
+
liquidity_need: Optional[str] = Field(None, max_length=80)
|
| 41 |
+
risk_profile: Optional[str] = Field(None, max_length=80)
|
| 42 |
+
horizon_months: Optional[int] = Field(None, ge=1, le=600)
|
| 43 |
+
hypothetical_portfolio: Optional[Dict[str, Any]] = None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
# ββββββββββββββββ STOCK HOLDINGS ββββββββββββββββ
|
| 47 |
|
| 48 |
|
|
|
|
| 160 |
|
| 161 |
|
| 162 |
class BondHoldingUpdate(BaseModel):
|
| 163 |
+
bond_id: Optional[int] = None
|
| 164 |
+
auction_number: Optional[int] = None
|
| 165 |
+
instrument_type: Optional[str] = None
|
| 166 |
+
maturity_years: Optional[str] = None
|
| 167 |
+
maturity_date: Optional[date] = None
|
| 168 |
+
effective_date: Optional[date] = None
|
| 169 |
+
dtm: Optional[int] = None
|
| 170 |
+
bond_auction_number: Optional[int] = None
|
| 171 |
+
price_per_100: Optional[Decimal] = Field(None, gt=0)
|
| 172 |
+
coupon_rate: Optional[Decimal] = Field(None, gt=0)
|
| 173 |
+
isin: Optional[str] = None
|
| 174 |
holding_number: Optional[int] = None
|
| 175 |
holding_status: Optional[str] = None
|
| 176 |
face_value_held: Optional[Decimal] = Field(None, gt=0)
|
App/routers/portfolio/service.py
CHANGED
|
@@ -7,6 +7,7 @@ from decimal import Decimal
|
|
| 7 |
from datetime import date, timedelta
|
| 8 |
from typing import Optional, Generator
|
| 9 |
|
|
|
|
| 10 |
from tortoise.transactions import in_transaction
|
| 11 |
|
| 12 |
from App.schemas import AppException
|
|
@@ -48,6 +49,12 @@ def _pct(part: Decimal, total: Decimal) -> Decimal:
|
|
| 48 |
return (part / total * HUNDRED) if total > 0 else ZERO
|
| 49 |
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
def _gain(market_value: Optional[Decimal], cost: Decimal):
|
| 52 |
if market_value is None:
|
| 53 |
return None, None
|
|
@@ -535,8 +542,8 @@ class PortfolioService:
|
|
| 535 |
)
|
| 536 |
results = []
|
| 537 |
for h in holdings:
|
| 538 |
-
price_pct = getattr(h.bond, "price_per_100", None)
|
| 539 |
-
market_value = Decimal(h.face_value_held) * price_pct / HUNDRED
|
| 540 |
gl, _ = _gain(market_value, h.purchase_price)
|
| 541 |
|
| 542 |
results.append(
|
|
@@ -770,20 +777,33 @@ class PortfolioService:
|
|
| 770 |
total_cost += h.purchase_price
|
| 771 |
|
| 772 |
total_value = stock_val + utt_val + bond_val
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 773 |
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 787 |
return snapshot
|
| 788 |
|
| 789 |
# ββββββββββββββββ REGENERATE SNAPSHOTS ββββββββββββββββ
|
|
|
|
| 7 |
from datetime import date, timedelta
|
| 8 |
from typing import Optional, Generator
|
| 9 |
|
| 10 |
+
from tortoise.exceptions import IntegrityError
|
| 11 |
from tortoise.transactions import in_transaction
|
| 12 |
|
| 13 |
from App.schemas import AppException
|
|
|
|
| 49 |
return (part / total * HUNDRED) if total > 0 else ZERO
|
| 50 |
|
| 51 |
|
| 52 |
+
def _to_decimal(value, default: Decimal = ZERO) -> Decimal:
|
| 53 |
+
if value in (None, ""):
|
| 54 |
+
return default
|
| 55 |
+
return Decimal(str(value))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
def _gain(market_value: Optional[Decimal], cost: Decimal):
|
| 59 |
if market_value is None:
|
| 60 |
return None, None
|
|
|
|
| 542 |
)
|
| 543 |
results = []
|
| 544 |
for h in holdings:
|
| 545 |
+
price_pct = _to_decimal(getattr(h.bond, "price_per_100", None), HUNDRED)
|
| 546 |
+
market_value = Decimal(str(h.face_value_held)) * price_pct / HUNDRED
|
| 547 |
gl, _ = _gain(market_value, h.purchase_price)
|
| 548 |
|
| 549 |
results.append(
|
|
|
|
| 777 |
total_cost += h.purchase_price
|
| 778 |
|
| 779 |
total_value = stock_val + utt_val + bond_val
|
| 780 |
+
defaults = {
|
| 781 |
+
"total_value": total_value,
|
| 782 |
+
"stock_value": stock_val,
|
| 783 |
+
"bond_value": bond_val,
|
| 784 |
+
"fund_value": utt_val,
|
| 785 |
+
"cash_value": ZERO,
|
| 786 |
+
"total_cost": total_cost,
|
| 787 |
+
"unrealized_gain_loss": total_value - total_cost,
|
| 788 |
+
}
|
| 789 |
|
| 790 |
+
try:
|
| 791 |
+
snapshot, _ = await PortfolioSnapshot.update_or_create(
|
| 792 |
+
portfolio_id=portfolio_id,
|
| 793 |
+
snapshot_date=target,
|
| 794 |
+
defaults=defaults,
|
| 795 |
+
)
|
| 796 |
+
except IntegrityError:
|
| 797 |
+
# Another task may have inserted the same portfolio/day snapshot
|
| 798 |
+
# between the lookup and insert phases. Treat that as an update.
|
| 799 |
+
await PortfolioSnapshot.filter(
|
| 800 |
+
portfolio_id=portfolio_id,
|
| 801 |
+
snapshot_date=target,
|
| 802 |
+
).update(**defaults)
|
| 803 |
+
snapshot = await PortfolioSnapshot.get(
|
| 804 |
+
portfolio_id=portfolio_id,
|
| 805 |
+
snapshot_date=target,
|
| 806 |
+
)
|
| 807 |
return snapshot
|
| 808 |
|
| 809 |
# ββββββββββββββββ REGENERATE SNAPSHOTS ββββββββββββββββ
|