Mbonea commited on
Commit
c06c2d9
Β·
1 Parent(s): ef59ff7

Fix bond portfolios and add advisor chat

Browse files
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
- await holding.update_from_dict(update_data).save()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) or HUNDRED
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
- snapshot, _ = await PortfolioSnapshot.update_or_create(
775
- portfolio_id=portfolio_id,
776
- snapshot_date=target,
777
- defaults={
778
- "total_value": total_value,
779
- "stock_value": stock_val,
780
- "bond_value": bond_val,
781
- "fund_value": utt_val,
782
- "cash_value": ZERO,
783
- "total_cost": total_cost,
784
- "unrealized_gain_loss": total_value - total_cost,
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 ────────────────