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

Protect high coupon bond income streams

Browse files
App/analysis/portfolio_optimizer.py CHANGED
@@ -51,6 +51,10 @@ RISK_PROFILES = {
51
  }
52
  DEFAULT_RISK_FREE_RATE = 0.12
53
  DEFAULT_SIMULATIONS = 6000
 
 
 
 
54
 
55
 
56
  @dataclass
@@ -135,6 +139,13 @@ def _annualized_return(values: list[tuple[date, float]]) -> float | None:
135
  return (1 + total_return) ** (365 / days) - 1
136
 
137
 
 
 
 
 
 
 
 
138
  def _stock_fee_band(consideration: float) -> dict[str, Any] | None:
139
  payload = load_dse_transaction_fee_seed_data()
140
  for band in payload.get("bands", []):
@@ -334,6 +345,7 @@ async def _build_stock_asset(position: dict[str, Any]) -> OptimizerAsset | None:
334
  capital_return = _annualized_return(price_values)
335
  dividend_yield = _safe_float(metrics.get("dividend_yield")) / 100
336
  liquidity = metrics.get("liquidity") or await calculate_liquidity_metrics(stock)
 
337
  fundamentals = {
338
  "eps": metrics.get("eps"),
339
  "pe_ratio": metrics.get("pe_ratio"),
@@ -433,6 +445,7 @@ async def _build_fund_asset(position: dict[str, Any]) -> OptimizerAsset | None:
433
  liquidity={
434
  **prospectus_terms,
435
  "liquidity_score": round(max(liquidity_score, 20), 2),
 
436
  },
437
  )
438
 
@@ -479,6 +492,11 @@ async def _build_bond_asset(position: dict[str, Any]) -> OptimizerAsset | None:
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"),
@@ -1065,7 +1083,7 @@ def _bond_execution_note(asset: OptimizerAsset) -> str:
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
  )
@@ -1098,6 +1116,138 @@ def _bond_return_worth_note(asset: OptimizerAsset) -> str:
1098
  )
1099
 
1100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1101
  def _optimize_sync(
1102
  assets_payload: list[dict[str, Any]],
1103
  simulations: int,
@@ -1147,6 +1297,13 @@ def _optimize_sync(
1147
  total_value,
1148
  settings,
1149
  )
 
 
 
 
 
 
 
1150
  liquidity_warnings.extend(
1151
  _liquidity_trade_warnings(current_weights, best_weights, assets, total_value)
1152
  )
@@ -1177,6 +1334,11 @@ def _optimize_sync(
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
  {
 
51
  }
52
  DEFAULT_RISK_FREE_RATE = 0.12
53
  DEFAULT_SIMULATIONS = 6000
54
+ HIGH_COUPON_ADVANTAGE_BPS = 150
55
+ LEGACY_BOND_REPLACEMENT_MARGIN = 0.06
56
+ LOW_MODERATE_VOLATILITY_MAX = 0.16
57
+ MIN_REPLACEMENT_HISTORY_YEARS = 3.0
58
 
59
 
60
  @dataclass
 
139
  return (1 + total_return) ** (365 / days) - 1
140
 
141
 
142
+ def _history_years(values: list[tuple[date, float]]) -> float:
143
+ ordered = [item for item in sorted(values, key=lambda item: item[0]) if item[1] > 0]
144
+ if len(ordered) < 2:
145
+ return 0.0
146
+ return max((ordered[-1][0] - ordered[0][0]).days / 365.25, 0.0)
147
+
148
+
149
  def _stock_fee_band(consideration: float) -> dict[str, Any] | None:
150
  payload = load_dse_transaction_fee_seed_data()
151
  for band in payload.get("bands", []):
 
345
  capital_return = _annualized_return(price_values)
346
  dividend_yield = _safe_float(metrics.get("dividend_yield")) / 100
347
  liquidity = metrics.get("liquidity") or await calculate_liquidity_metrics(stock)
348
+ liquidity["history_years"] = round(_history_years(price_values), 2)
349
  fundamentals = {
350
  "eps": metrics.get("eps"),
351
  "pe_ratio": metrics.get("pe_ratio"),
 
445
  liquidity={
446
  **prospectus_terms,
447
  "liquidity_score": round(max(liquidity_score, 20), 2),
448
+ "history_years": round(_history_years(nav_values), 2),
449
  },
450
  )
451
 
 
492
  "latest_yield_percent": latest_yield_percent,
493
  "coupon_rate_percent": _safe_float(bond.coupon_rate),
494
  "price_percent": price_per_100,
495
+ "primary_reinvestment_yield_percent": (
496
+ _safe_float((pricing_context.get("comparable_primary_market") or [{}])[0].get("implied_yield_percent"))
497
+ if pricing_context.get("comparable_primary_market")
498
+ else None
499
+ ),
500
  "same_bond_secondary_available": pricing_context.get("same_bond_secondary_available"),
501
  "comparable_secondary_market": pricing_context.get("comparable_secondary_market") or [],
502
  "comparable_median_yield_percent": pricing_context.get("comparable_median_yield_percent"),
 
1083
  parts.append(
1084
  f"recent primary auction reference {top.get('bond_no')} "
1085
  f"from {top.get('auction_date')} "
1086
+ f"({top.get('maturity_years')}y) approximate reinvestment yield "
1087
  f"{_safe_float(top.get('implied_yield_percent')):.2f}% at price "
1088
  f"{_safe_float(top.get('price_per_100')):.2f}"
1089
  )
 
1116
  )
1117
 
1118
 
1119
+ def _bond_coupon_advantage(asset: OptimizerAsset) -> float:
1120
+ liquidity = asset.liquidity or {}
1121
+ coupon = _safe_float(liquidity.get("coupon_rate_percent")) / 100
1122
+ reinvestment_yield = _safe_float(liquidity.get("primary_reinvestment_yield_percent")) / 100
1123
+ if coupon <= 0 or reinvestment_yield <= 0:
1124
+ return 0.0
1125
+ return coupon - reinvestment_yield
1126
+
1127
+
1128
+ def _is_irreplaceable_income_bond(asset: OptimizerAsset) -> bool:
1129
+ return (
1130
+ asset.asset_type == "BOND"
1131
+ and _bond_coupon_advantage(asset) >= HIGH_COUPON_ADVANTAGE_BPS / 10_000
1132
+ )
1133
+
1134
+
1135
+ def _has_rock_solid_fundamentals(asset: OptimizerAsset) -> bool:
1136
+ fundamentals = asset.fundamentals or {}
1137
+ if asset.asset_type != "STOCK":
1138
+ return False
1139
+ pe_ratio = _safe_float(fundamentals.get("pe_ratio"))
1140
+ pb_ratio = _safe_float(fundamentals.get("pb_ratio"))
1141
+ eps = _safe_float(fundamentals.get("eps"))
1142
+ debt_to_equity = _safe_float(fundamentals.get("debt_to_equity"), default=-1)
1143
+ return (
1144
+ eps > 0
1145
+ and 0 < pe_ratio <= 22
1146
+ and 0 < pb_ratio <= 3.5
1147
+ and 0 <= debt_to_equity <= 150
1148
+ )
1149
+
1150
+
1151
+ def _has_visible_macro_tailwind(asset: OptimizerAsset) -> bool:
1152
+ data = {**(asset.fundamentals or {}), **(asset.liquidity or {})}
1153
+ return bool(
1154
+ data.get("macro_tailwind")
1155
+ or data.get("sector_tailwind")
1156
+ or data.get("rate_tailwind")
1157
+ or str(data.get("macro_context") or "").lower() in {"tailwind", "supportive", "positive"}
1158
+ )
1159
+
1160
+
1161
+ def _clean_exit_liquidity(asset: OptimizerAsset, trade_value: float) -> bool:
1162
+ liquidity = asset.liquidity or {}
1163
+ if asset.asset_type == "STOCK":
1164
+ max_buy_value = _safe_float(liquidity.get("max_buy_value_20d"))
1165
+ return max_buy_value > 0 and trade_value <= max_buy_value
1166
+ if asset.asset_type == "FUND":
1167
+ redemption_days = _safe_float(liquidity.get("redemption_days"), default=999)
1168
+ return redemption_days <= 7 and bool(liquidity.get("prospectus_backed"))
1169
+ return False
1170
+
1171
+
1172
+ def _replacement_quality_for_irreplaceable_bond(
1173
+ bond: OptimizerAsset,
1174
+ assets: list[OptimizerAsset],
1175
+ suggested_weights: np.ndarray,
1176
+ current_weights: np.ndarray,
1177
+ total_value: float,
1178
+ ) -> tuple[bool, list[str]]:
1179
+ coupon_advantage = _bond_coupon_advantage(bond)
1180
+ required_excess = coupon_advantage + LEGACY_BOND_REPLACEMENT_MARGIN
1181
+ increased_assets = [
1182
+ (asset, float(suggested - current) * total_value)
1183
+ for asset, suggested, current in zip(assets, suggested_weights, current_weights)
1184
+ if asset.key != bond.key and suggested > current + 0.01
1185
+ ]
1186
+ failures: list[str] = []
1187
+ for asset, trade_value in increased_assets:
1188
+ net_return_advantage = asset.expected_return - asset.fee_rate - bond.income_yield
1189
+ gates = {
1190
+ "return_margin": net_return_advantage >= required_excess,
1191
+ "low_moderate_volatility": asset.volatility <= LOW_MODERATE_VOLATILITY_MAX,
1192
+ "rock_solid_fundamentals": _has_rock_solid_fundamentals(asset),
1193
+ "macro_tailwind": _has_visible_macro_tailwind(asset),
1194
+ "history_and_liquidity": (
1195
+ _safe_float((asset.liquidity or {}).get("history_years")) >= MIN_REPLACEMENT_HISTORY_YEARS
1196
+ and _clean_exit_liquidity(asset, trade_value)
1197
+ ),
1198
+ }
1199
+ if all(gates.values()):
1200
+ return True, []
1201
+ failed = ", ".join(name for name, passed in gates.items() if not passed)
1202
+ failures.append(f"{asset.symbol} failed replacement gates: {failed}")
1203
+ if not failures:
1204
+ failures.append("No increased replacement asset was available for the income stream.")
1205
+ return False, failures
1206
+
1207
+
1208
+ def _protect_irreplaceable_bonds(
1209
+ current_weights: np.ndarray,
1210
+ suggested_weights: np.ndarray,
1211
+ assets: list[OptimizerAsset],
1212
+ total_value: float,
1213
+ ) -> tuple[np.ndarray, list[str]]:
1214
+ protected = suggested_weights.copy()
1215
+ warnings: list[str] = []
1216
+ for index, asset in enumerate(assets):
1217
+ if not _is_irreplaceable_income_bond(asset) or protected[index] >= current_weights[index]:
1218
+ continue
1219
+ qualified, failures = _replacement_quality_for_irreplaceable_bond(
1220
+ asset,
1221
+ assets,
1222
+ protected,
1223
+ current_weights,
1224
+ total_value,
1225
+ )
1226
+ if qualified:
1227
+ continue
1228
+ restored = float(current_weights[index] - protected[index])
1229
+ protected[index] = current_weights[index]
1230
+ warnings.append(
1231
+ f"{asset.symbol} reduction blocked: coupon materially exceeds recent primary reinvestment yield, so default is hold to maturity unless all replacement gates pass. "
1232
+ + " ".join(failures[:2])
1233
+ )
1234
+ receiver_indices = [
1235
+ i
1236
+ for i, (current, suggested) in enumerate(zip(current_weights, protected))
1237
+ if i != index and suggested > current
1238
+ ]
1239
+ receiver_total = sum(float(protected[i] - current_weights[i]) for i in receiver_indices)
1240
+ if receiver_total > 0:
1241
+ for i in receiver_indices:
1242
+ reduction = restored * float(protected[i] - current_weights[i]) / receiver_total
1243
+ protected[i] = max(current_weights[i], protected[i] - reduction)
1244
+
1245
+ total = float(protected.sum())
1246
+ if total > 0:
1247
+ protected = protected / total
1248
+ return protected, warnings
1249
+
1250
+
1251
  def _optimize_sync(
1252
  assets_payload: list[dict[str, Any]],
1253
  simulations: int,
 
1297
  total_value,
1298
  settings,
1299
  )
1300
+ best_weights, bond_protection_warnings = _protect_irreplaceable_bonds(
1301
+ current_weights,
1302
+ best_weights,
1303
+ assets,
1304
+ total_value,
1305
+ )
1306
+ liquidity_warnings.extend(bond_protection_warnings)
1307
  liquidity_warnings.extend(
1308
  _liquidity_trade_warnings(current_weights, best_weights, assets, total_value)
1309
  )
 
1334
  if asset.asset_type == "BOND":
1335
  reason = f"{reason}. {_bond_return_worth_note(asset)} {_bond_execution_note(asset)}"
1336
  elif asset.asset_type == "BOND":
1337
+ if _is_irreplaceable_income_bond(asset):
1338
+ reason = (
1339
+ "Hold to maturity by default: coupon materially exceeds recent primary reinvestment yields, "
1340
+ "and no replacement passed all return, volatility, fundamentals, macro, history, and liquidity gates"
1341
+ )
1342
  reason = f"{reason}. {_bond_return_worth_note(asset)} {_bond_execution_note(asset)}"
1343
  allocations.append(
1344
  {
App/routers/economy/valuation.py CHANGED
@@ -92,13 +92,22 @@ async def get_comparable_primary_bonds(
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
 
@@ -113,7 +122,7 @@ async def get_comparable_primary_bonds(
113
  "maturity_date": row.maturity_date.isoformat(),
114
  "coupon_rate": row.coupon_rate,
115
  "price_per_100": row.price_per_100,
116
- "implied_yield_percent": row.coupon_rate,
117
  "source": "primary_auction_reference",
118
  }
119
  for row in sorted(comparable, key=score)[:limit]
 
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 maturity_years(row: Bond) -> float:
96
  try:
97
+ return float(str(row.maturity_years or "").split()[0])
98
  except Exception:
99
+ return target_years if target_years is not None else 0
100
+
101
+ def approximate_yield(row: Bond) -> float:
102
+ years = max(maturity_years(row), 1.0)
103
+ coupon = float(row.coupon_rate or 0)
104
+ price = float(row.price_per_100 or 100)
105
+ return max(((coupon + ((100 - price) / years)) / ((100 + price) / 2)) * 100, 0.0)
106
+
107
+ def score(row: Bond) -> tuple[float, int, float, int]:
108
+ years = maturity_years(row)
109
  years_gap = abs((years or 0) - (target_years or years or 0))
110
+ coupon_gap = abs(approximate_yield(row) - target_coupon)
111
  auction_recency = -int(row.auction_date.toordinal()) if row.auction_date else 0
112
  return (years_gap, auction_recency, coupon_gap, -int(row.auction_number or 0))
113
 
 
122
  "maturity_date": row.maturity_date.isoformat(),
123
  "coupon_rate": row.coupon_rate,
124
  "price_per_100": row.price_per_100,
125
+ "implied_yield_percent": round(approximate_yield(row), 4),
126
  "source": "primary_auction_reference",
127
  }
128
  for row in sorted(comparable, key=score)[:limit]
tests/test_portfolio_optimizer_fundamentals.py CHANGED
@@ -7,6 +7,7 @@ from App.analysis.portfolio_optimizer import (
7
  _bond_return_worth_note,
8
  _fundamental_adjusted_stock_return,
9
  _liquidity_capacity_penalty,
 
10
  )
11
  from App.routers.stocks.fundamentals import parse_market_table, parse_stock_page
12
 
@@ -140,3 +141,54 @@ def test_bond_worth_note_uses_market_yield_not_coupon():
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  _bond_return_worth_note,
8
  _fundamental_adjusted_stock_return,
9
  _liquidity_capacity_penalty,
10
+ _protect_irreplaceable_bonds,
11
  )
12
  from App.routers.stocks.fundamentals import parse_market_table, parse_stock_page
13
 
 
141
  assert "market yield 9.03%" in note
142
  assert "15.50%" not in note
143
  assert "coupon income is not the same as total return" in note
144
+
145
+
146
+ def test_irreplaceable_bond_reduction_is_blocked_without_all_replacement_gates():
147
+ current = np.array([0.5, 0.5])
148
+ suggested = np.array([0.2, 0.8])
149
+ bond = OptimizerAsset(
150
+ key="BOND:1",
151
+ asset_id=1,
152
+ asset_type="BOND",
153
+ symbol="TZ1996103283",
154
+ name="Legacy Treasury Bond",
155
+ quantity=1,
156
+ current_price=1.5,
157
+ current_value=500_000,
158
+ expected_return=0.09,
159
+ volatility=0.04,
160
+ income_yield=0.103,
161
+ fee_rate=0.0,
162
+ liquidity={
163
+ "coupon_rate_percent": 15.5,
164
+ "primary_reinvestment_yield_percent": 9.0,
165
+ "price_percent": 150,
166
+ },
167
+ )
168
+ stock = OptimizerAsset(
169
+ key="STOCK:1",
170
+ asset_id=1,
171
+ asset_type="STOCK",
172
+ symbol="SPEC",
173
+ name="Speculative Stock",
174
+ quantity=1,
175
+ current_price=1,
176
+ current_value=500_000,
177
+ expected_return=0.3,
178
+ volatility=0.28,
179
+ income_yield=0.0,
180
+ fee_rate=0.0206,
181
+ fundamentals={"eps": 10, "pe_ratio": 12, "pb_ratio": 2, "debt_to_equity": 50},
182
+ liquidity={"history_years": 4, "max_buy_value_20d": 1_000_000},
183
+ )
184
+
185
+ protected, warnings = _protect_irreplaceable_bonds(
186
+ current,
187
+ suggested,
188
+ [bond, stock],
189
+ 1_000_000,
190
+ )
191
+
192
+ assert protected[0] >= current[0]
193
+ assert protected[1] <= current[1]
194
+ assert warnings