Mbonea commited on
Commit
76adf9f
·
1 Parent(s): a1d7826

Use market yields for bond allocation advice

Browse files
App/analysis/portfolio_optimizer.py CHANGED
@@ -448,6 +448,14 @@ async def _build_bond_asset(position: dict[str, Any]) -> OptimizerAsset | None:
448
  quantity = _safe_float(position["quantity"])
449
  current_value = quantity * current_price
450
  coupon_rate = _safe_float(bond.coupon_rate) / 100
 
 
 
 
 
 
 
 
451
 
452
  return OptimizerAsset(
453
  key=f"BOND:{bond.id}",
@@ -458,9 +466,9 @@ async def _build_bond_asset(position: dict[str, Any]) -> OptimizerAsset | None:
458
  quantity=quantity,
459
  current_price=current_price,
460
  current_value=current_value,
461
- expected_return=max(_safe_float(pricing_context.get("latest_yield_percent"), coupon_rate * 100) / 100, coupon_rate, 0.10),
462
  volatility=0.04,
463
- income_yield=coupon_rate,
464
  fee_rate=0.0,
465
  liquidity={
466
  "liquidity_score": pricing_context.get("liquidity_score", 45),
@@ -468,7 +476,9 @@ async def _build_bond_asset(position: dict[str, Any]) -> OptimizerAsset | None:
468
  "execution_caveat": pricing_context.get("execution_caveat"),
469
  "latest_trade_date": pricing_context.get("latest_trade_date"),
470
  "price_source": pricing_context.get("price_source"),
471
- "latest_yield_percent": pricing_context.get("latest_yield_percent"),
 
 
472
  "same_bond_secondary_available": pricing_context.get("same_bond_secondary_available"),
473
  "comparable_secondary_market": pricing_context.get("comparable_secondary_market") or [],
474
  "comparable_median_yield_percent": pricing_context.get("comparable_median_yield_percent"),
@@ -1053,17 +1063,41 @@ def _bond_execution_note(asset: OptimizerAsset) -> str:
1053
  if primary:
1054
  top = primary[0]
1055
  parts.append(
1056
- f"similar primary auction reference {top.get('bond_no')} "
 
1057
  f"({top.get('maturity_years')}y) coupon/yield proxy "
1058
  f"{_safe_float(top.get('implied_yield_percent')):.2f}% at price "
1059
  f"{_safe_float(top.get('price_per_100')):.2f}"
1060
  )
 
 
1061
  caveat = liquidity.get("execution_caveat")
1062
  if caveat:
1063
  parts.append(str(caveat))
1064
  return "Secondary/primary market check: " + "; ".join(parts)
1065
 
1066
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1067
  def _optimize_sync(
1068
  assets_payload: list[dict[str, Any]],
1069
  simulations: int,
@@ -1137,13 +1171,13 @@ def _optimize_sync(
1137
  else "Increase for growth-adjusted return after fees"
1138
  )
1139
  if asset.asset_type == "BOND":
1140
- reason = f"{reason}. {_bond_execution_note(asset)}"
1141
  elif difference < -0.03:
1142
  reason = "Reduce concentration or weaker risk-adjusted return"
1143
  if asset.asset_type == "BOND":
1144
- reason = f"{reason}. {_bond_execution_note(asset)}"
1145
  elif asset.asset_type == "BOND":
1146
- reason = f"{reason}. {_bond_execution_note(asset)}"
1147
  allocations.append(
1148
  {
1149
  "asset_id": asset.asset_id,
 
448
  quantity = _safe_float(position["quantity"])
449
  current_value = quantity * current_price
450
  coupon_rate = _safe_float(bond.coupon_rate) / 100
451
+ latest_yield_percent = pricing_context.get("latest_yield_percent")
452
+ market_yield = (
453
+ _safe_float(latest_yield_percent) / 100
454
+ if latest_yield_percent not in (None, "")
455
+ else 0.0
456
+ )
457
+ expected_return = market_yield if market_yield > 0 else max(coupon_rate, 0.10)
458
+ income_yield = coupon_rate / current_price if current_price > 0 else coupon_rate
459
 
460
  return OptimizerAsset(
461
  key=f"BOND:{bond.id}",
 
466
  quantity=quantity,
467
  current_price=current_price,
468
  current_value=current_value,
469
+ expected_return=expected_return,
470
  volatility=0.04,
471
+ income_yield=income_yield,
472
  fee_rate=0.0,
473
  liquidity={
474
  "liquidity_score": pricing_context.get("liquidity_score", 45),
 
476
  "execution_caveat": pricing_context.get("execution_caveat"),
477
  "latest_trade_date": pricing_context.get("latest_trade_date"),
478
  "price_source": pricing_context.get("price_source"),
479
+ "latest_yield_percent": latest_yield_percent,
480
+ "coupon_rate_percent": _safe_float(bond.coupon_rate),
481
+ "price_percent": price_per_100,
482
  "same_bond_secondary_available": pricing_context.get("same_bond_secondary_available"),
483
  "comparable_secondary_market": pricing_context.get("comparable_secondary_market") or [],
484
  "comparable_median_yield_percent": pricing_context.get("comparable_median_yield_percent"),
 
1063
  if primary:
1064
  top = primary[0]
1065
  parts.append(
1066
+ f"recent primary auction reference {top.get('bond_no')} "
1067
+ f"from {top.get('auction_date')} "
1068
  f"({top.get('maturity_years')}y) coupon/yield proxy "
1069
  f"{_safe_float(top.get('implied_yield_percent')):.2f}% at price "
1070
  f"{_safe_float(top.get('price_per_100')):.2f}"
1071
  )
1072
+ else:
1073
+ parts.append("no recent comparable primary auction reference is stored")
1074
  caveat = liquidity.get("execution_caveat")
1075
  if caveat:
1076
  parts.append(str(caveat))
1077
  return "Secondary/primary market check: " + "; ".join(parts)
1078
 
1079
 
1080
+ def _bond_return_worth_note(asset: OptimizerAsset) -> str:
1081
+ liquidity = asset.liquidity or {}
1082
+ net_return = asset.expected_return - asset.fee_rate
1083
+ hurdle = DEFAULT_RISK_FREE_RATE
1084
+ spread = net_return - hurdle
1085
+ price_percent = _safe_float(liquidity.get("price_percent"), asset.current_price * 100)
1086
+ latest_yield = _safe_float(liquidity.get("latest_yield_percent"), net_return * 100)
1087
+ income_yield = asset.income_yield * 100
1088
+ verdict = "adequate" if spread >= 0.015 else "thin" if spread >= 0 else "weak"
1089
+ premium_note = (
1090
+ f" premium price {price_percent:.2f}% means coupon income is not the same as total return;"
1091
+ if price_percent > 110
1092
+ else ""
1093
+ )
1094
+ return (
1095
+ f"Return worth check: {verdict}; market yield {latest_yield:.2f}% "
1096
+ f"vs {hurdle * 100:.2f}% hurdle ({spread * 100:+.2f}%), "
1097
+ f"income yield on invested price about {income_yield:.2f}%.{premium_note}"
1098
+ )
1099
+
1100
+
1101
  def _optimize_sync(
1102
  assets_payload: list[dict[str, Any]],
1103
  simulations: int,
 
1171
  else "Increase for growth-adjusted return after fees"
1172
  )
1173
  if asset.asset_type == "BOND":
1174
+ reason = f"{reason}. {_bond_return_worth_note(asset)} {_bond_execution_note(asset)}"
1175
  elif difference < -0.03:
1176
  reason = "Reduce concentration or weaker risk-adjusted return"
1177
  if asset.asset_type == "BOND":
1178
+ reason = f"{reason}. {_bond_return_worth_note(asset)} {_bond_execution_note(asset)}"
1179
  elif asset.asset_type == "BOND":
1180
+ reason = f"{reason}. {_bond_return_worth_note(asset)} {_bond_execution_note(asset)}"
1181
  allocations.append(
1182
  {
1183
  "asset_id": asset.asset_id,
App/routers/economy/valuation.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  from datetime import date
 
4
  from decimal import Decimal
5
  from statistics import median
6
  from typing import Any
@@ -79,23 +80,27 @@ async def get_comparable_primary_bonds(
79
  bond: Any,
80
  *,
81
  limit: int = 5,
 
82
  ) -> list[dict[str, Any]]:
 
 
83
  target_years = None
84
  try:
85
  target_years = float(str(getattr(bond, "maturity_years", "")).split()[0])
86
  except Exception:
87
  target_years = None
88
  target_coupon = float(getattr(bond, "coupon_rate", 0) or 0)
89
- rows = await Bond.all().order_by("-auction_date").limit(500)
90
 
91
- def score(row: Bond) -> tuple[float, float, int]:
92
  try:
93
  years = float(str(row.maturity_years or "").split()[0])
94
  except Exception:
95
  years = target_years if target_years is not None else 0
96
  years_gap = abs((years or 0) - (target_years or years or 0))
97
  coupon_gap = abs(float(row.coupon_rate or 0) - target_coupon)
98
- return (years_gap, coupon_gap, -int(row.auction_number or 0))
 
99
 
100
  comparable = [row for row in rows if row.id != getattr(bond, "id", None)]
101
  return [
 
1
  from __future__ import annotations
2
 
3
  from datetime import date
4
+ from datetime import timedelta
5
  from decimal import Decimal
6
  from statistics import median
7
  from typing import Any
 
80
  bond: Any,
81
  *,
82
  limit: int = 5,
83
+ recent_days: int = 370,
84
  ) -> list[dict[str, Any]]:
85
+ target = date.today()
86
+ min_auction_date = target - timedelta(days=recent_days)
87
  target_years = None
88
  try:
89
  target_years = float(str(getattr(bond, "maturity_years", "")).split()[0])
90
  except Exception:
91
  target_years = None
92
  target_coupon = float(getattr(bond, "coupon_rate", 0) or 0)
93
+ rows = await Bond.filter(auction_date__gte=min_auction_date).order_by("-auction_date").limit(500)
94
 
95
+ def score(row: Bond) -> tuple[float, int, float, int]:
96
  try:
97
  years = float(str(row.maturity_years or "").split()[0])
98
  except Exception:
99
  years = target_years if target_years is not None else 0
100
  years_gap = abs((years or 0) - (target_years or years or 0))
101
  coupon_gap = abs(float(row.coupon_rate or 0) - target_coupon)
102
+ auction_recency = -int(row.auction_date.toordinal()) if row.auction_date else 0
103
+ return (years_gap, auction_recency, coupon_gap, -int(row.auction_number or 0))
104
 
105
  comparable = [row for row in rows if row.id != getattr(bond, "id", None)]
106
  return [
tests/test_portfolio_optimizer_fundamentals.py CHANGED
@@ -4,6 +4,7 @@ from decimal import Decimal
4
  from App.analysis.portfolio_optimizer import (
5
  OptimizerAsset,
6
  _apply_liquidity_caps,
 
7
  _fundamental_adjusted_stock_return,
8
  _liquidity_capacity_penalty,
9
  )
@@ -110,3 +111,32 @@ def test_liquidity_capacity_penalty_and_cap_reduce_illiquid_stock_buy():
110
  assert penalty > 0
111
  assert capped[0] <= 0.25
112
  assert warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  from App.analysis.portfolio_optimizer import (
5
  OptimizerAsset,
6
  _apply_liquidity_caps,
7
+ _bond_return_worth_note,
8
  _fundamental_adjusted_stock_return,
9
  _liquidity_capacity_penalty,
10
  )
 
111
  assert penalty > 0
112
  assert capped[0] <= 0.25
113
  assert warnings
114
+
115
+
116
+ def test_bond_worth_note_uses_market_yield_not_coupon():
117
+ asset = OptimizerAsset(
118
+ key="BOND:1",
119
+ asset_id=1,
120
+ asset_type="BOND",
121
+ symbol="TZ1996103283",
122
+ name="20 Yr Treasury Bond",
123
+ quantity=1,
124
+ current_price=1.518367,
125
+ current_value=151.8367,
126
+ expected_return=0.0903,
127
+ volatility=0.04,
128
+ income_yield=0.155 / 1.518367,
129
+ fee_rate=0.0,
130
+ liquidity={
131
+ "price_percent": 151.8367,
132
+ "latest_yield_percent": 9.03,
133
+ "coupon_rate_percent": 15.5,
134
+ },
135
+ )
136
+
137
+ note = _bond_return_worth_note(asset)
138
+
139
+ assert "weak" in note
140
+ assert "market yield 9.03%" in note
141
+ assert "15.50%" not in note
142
+ assert "coupon income is not the same as total return" in note