File size: 17,352 Bytes
e814ba8 2fbf8c3 07c171e c7db9b3 dfd5b73 4b862e6 2a0814c 8f481af 6c814fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 | """Deterministic decision layer and LLM-output coercion — the code that must never let a
model's mistake reach the user. No network."""
from src import personas as P
from src.market_data import sanitize
from src.revival import _for_narrative, plan_mode
def _panel(**convs):
return {k: {"X": {"conviction": v, "key_point": "", "key_risk": ""}} for k, v in convs.items()}
def test_actions_from_conviction_bands():
assert P.decide_actions(_panel(a=70, b=66), ["X"])["X"]["action"] == "Buy More"
assert P.decide_actions(_panel(a=50, b=52), ["X"])["X"]["action"] == "Keep"
assert P.decide_actions(_panel(a=40, b=38), ["X"])["X"]["action"] == "Trim"
assert P.decide_actions(_panel(a=20, b=25), ["X"])["X"]["action"] == "Sell"
def test_ejection_rule_three_low_votes():
d = P.decide_actions(_panel(a=25, b=28, c=29, d=60, e=65), ["X"])["X"]
assert d["action"] == "Sell" and d["votes_low"] == 3
def test_weight_aware_no_buy_more_when_oversized():
d = P.decide_actions(_panel(a=70, b=68), ["X"] + [f"H{i}" for i in range(19)], {"X": 0.35})["X"]
assert d["action"] == "Keep" and "cap" in d["size_note"]
def test_fund_cap_relaxed():
d = P.decide_actions(_panel(a=70, b=68), ["X", "Y"], {"X": 0.44}, funds={"X"})["X"]
assert d["action"] == "Buy More"
def test_small_book_not_trimmed_mechanically():
tickers = ["X", "A", "B", "C", "D", "E"]
d = P.decide_actions(_panel(a=48, b=50), tickers, {"X": 0.16})["X"]
assert d["action"] == "Keep"
d2 = P.decide_actions(_panel(a=48, b=50), tickers, {"X": 0.33})["X"]
assert d2["action"] == "Trim"
def test_sample_merge_uses_median():
s = [{"X": {"conviction": 40, "key_point": "a", "key_risk": ""}},
{"X": {"conviction": 60, "key_point": "b", "key_risk": ""}},
{"X": {"conviction": 65, "key_point": "c", "key_risk": ""}}]
m = P._merge_samples(s)
assert m["X"]["conviction"] == 60 and m["X"]["n_samples"] == 3 and m["X"]["key_point"] == "a"
def test_coerce_cio_tolerates_garbage():
assert P.coerce_cio("not json") is None
out = P.coerce_cio({"verdict": 12, "highlights": [{"severity": "BAD!", "title": "x"}, "junk"],
"rationale": {"aapl": 5}, "alternatives": [{"ticker": "msft"}, {"nope": 1}]})
assert out is not None
assert out["verdict"] == "12" and out["highlights"][0]["severity"] == "warn"
assert out["rationale"] == {"AAPL": "5"} and out["alternatives"] == [{"ticker": "MSFT", "reason": ""}]
def test_coerce_strategist_shapes():
out = P.coerce_strategist({"strategy_read": "x", "gaps": "not a list", "findings": None})
assert out is not None
assert out["gaps"] == [] and out["findings"] == [] and out["style_tilt"] == ""
def test_sanitize_drops_unit_errors():
d = {"price": 100.0, "pe": 36.0, "de": 954.0, "div_yield": 86.0, "roe": 1.4, "hi52": 20000.0,
"eps_ttm": 0.01}
sanitize(d)
assert d["de"] is None and d["div_yield"] is None and d["hi52"] is None and d["eps_ttm"] is None
assert d["pe"] == 36.0 and d["roe"] == 1.4 and len(d["data_flags"]) == 4
def test_plan_mode_never_revival_for_winners():
alerts = [{"status": "ALERT"}, {"status": "ALERT"}]
assert plan_mode(30, 0.5, alerts) == "enhancement"
assert plan_mode(30, -0.2, alerts) == "revival"
assert plan_mode(30, 0.02, alerts) == "revival"
def test_narrative_payload_is_percent_safe():
p = _for_narrative({"outlook": {"current": {"expected_excess_12m": 0.0708, "top_sector": ["T", 0.47]},
"stress": [{"portfolio": -0.31, "benchmark": -0.2}]},
"orders": [{"delta_pct": -0.05}]})
assert p["outlook"]["current"]["expected_excess_12m"] == "7.1%"
assert p["orders"][0]["delta_pct"].endswith("of the book")
def test_isin_resolution_prefers_primary_listing(monkeypatch):
from src import portfolio as PF
import json
rows = [{"ticker": "BSP", "exchCode": "GR", "marketSector": "Equity"},
{"ticker": "BA/", "exchCode": "LN", "marketSector": "Equity"},
{"ticker": "BAESY", "exchCode": "US", "marketSector": "Equity"}]
class _R:
def __init__(self, data): self.data = json.dumps([{"data": data}]).encode()
def read(self): return self.data
def __enter__(self): return self
def __exit__(self, *a): return False
monkeypatch.setattr(PF, "_quote_exists", lambda s: {"name": s, "price": 1.0} if s == "BA.L" else None)
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=20, context=None: _R(rows))
assert PF.resolve_isin("GB0002634946")[0] == "BA.L"
assert PF.resolve_symbol("GB0002634946")["symbol"] == "BA.L"
def test_av_venue_map_and_reverse():
from src import market_data as MD
import os
os.environ.setdefault("ALPHAVANTAGE_API_KEY", "x"); MD.AV_KEY = MD.AV_KEY or "x"
assert MD.av_symbol("RELIANCE.NS") == "RELIANCE.BSE" and MD.av_symbol("600519.SS") == "600519.SHH"
assert MD.av_symbol("BMW.F") == "BMW.FRK" and MD.av_symbol("GALP.LS") == "GALP.LIS" and MD.av_symbol("BRK-B") == "BRK.B"
assert MD.av_symbol("7203.T") is None and MD.av_symbol("^NSEI") is None
assert MD.AV_TO_YAHOO["SHZ"] == ".SZ" and MD.AV_TO_YAHOO["BSE"] == ".BO"
def test_resolver_prefers_home_market_over_otc_adr(monkeypatch):
from src import portfolio as PF
monkeypatch.setattr(PF, "av_symbol_search", lambda q: [
{"av": "RLNIY", "yahoo": "RLNIY", "name": "Reliance Industries ADR", "region": "United States", "currency": "USD", "type": "Equity"},
{"av": "RELIANCE.BSE", "yahoo": "RELIANCE.BO", "name": "Reliance Industries", "region": "India/Bombay", "currency": "INR", "type": "Equity"}])
monkeypatch.setattr(PF, "_quote_exists", lambda s: {"name": s, "price": 1300.0} if s == "RELIANCE.NS" else None)
monkeypatch.setattr(PF, "resolve_isin", lambda i: [])
assert PF.resolve_symbol("Reliance Industries")["symbol"] == "RELIANCE.NS"
def test_resolver_demotes_secondary_venues(monkeypatch):
from src import portfolio as PF
monkeypatch.setattr(PF, "av_symbol_search", lambda q: [
{"av": "SQ3.FRK", "yahoo": "SQ3.F", "name": "Block Inc", "region": "Frankfurt", "currency": "EUR", "type": "Equity"}])
monkeypatch.setattr(PF, "_quote_exists", lambda s: {"name": s, "price": 1.0} if s in ("SQ3.F", "XYZ") else None)
monkeypatch.setattr(PF, "resolve_isin", lambda i: [])
monkeypatch.setattr(PF, "call_llm", lambda *a, **k: '{"ticker": "XYZ", "name": "Block"}')
assert PF.resolve_symbol("SQ")["symbol"] == "XYZ" # not the Frankfurt line
assert PF.resolve_symbol("Block Inc")["symbol"] == "SQ3.F" # name tokens all present → the only listing found
assert PF.resolve_symbol("Block payments")["symbol"] == "XYZ" # 'payments' absent from the name → LLM path
def test_adr_bridge_uses_ratios_and_derives_local_eps(monkeypatch):
from src import market_data as MD
monkeypatch.setattr(MD, "NDL_KEY", "x")
monkeypatch.setattr(MD, "av_symbol_search", lambda q: [
{"av": "HDFCBANK.BSE", "yahoo": "HDFCBANK.BO", "name": "HDFC Bank Ltd", "region": "India/Bombay", "currency": "INR", "type": "Equity"},
{"av": "HDB", "yahoo": "HDB", "name": "HDFC Bank Ltd", "region": "United States", "currency": "USD", "type": "Equity"}])
monkeypatch.setattr(MD, "_sharadar_ticker_meta", lambda t: {"category": "ADR Common Stock", "location": "India"} if t == "HDB" else {})
monkeypatch.setattr(MD, "fetch_sharadar", lambda t: {"pe": 16.5, "pb": 1.42, "roe": 0.089, "de": 5.3, "eps_ttm": 137.8, "bvps": 1593.0, "fund_asof": "2026-07-29"})
d = {"ticker": "HDFCBANK.NS", "name": "HDFC BANK LTD", "price": 950.0, "quote_type": "EQUITY", "eps_ttm": 999.0}
MD.bridge_adr_fundamentals(d)
assert d["fund_source"].startswith("sharadar (via HDB") and d["roe"] == 0.089
assert abs(d["eps_ttm"] - 950 / 16.5) < 1e-3 and abs(d["bvps"] - 950 / 1.42) < 1e-3 # derived, not copied
def test_lots_average_and_net_sales(monkeypatch):
from src import portfolio as PF
monkeypatch.setattr(PF, "resolve_symbol", lambda q: {"symbol": "NVDA", "name": "NVIDIA", "price": 180.0,
"data": {"currency": "USD"}})
monkeypatch.setattr(PF, "fx_to_usd", lambda c: 1.0)
rows = [{"query": "NVDA", "entry": 100, "qty": 10, "bought": "2024-01-10"},
{"query": "nvidia", "entry": 150, "qty": 10, "bought": "2026-06-01"},
{"query": "NVDA", "entry": 170, "qty": -5}] # negative qty = sale
hs, errs = PF.build_holdings(rows)
assert not errs and len(hs) == 1
h = hs[0]
assert h["entry"] == 125.0 and h["qty"] == 15 and h["n_lots"] == 3 and h["bought"] == "2024-01-10"
assert h["market_value"] == 15 * 180.0
# no quantities → plain mean, unsized, with a nudge
hs, errs = PF.build_holdings([{"query": "NVDA", "entry": 100}, {"query": "NVDA", "entry": 200}])
assert hs[0]["entry"] == 150.0 and hs[0]["qty"] is None and "quantities" in hs[0]["lot_note"]
# fully sold → skipped with an error, not a phantom holding
hs, errs = PF.build_holdings([{"query": "NVDA", "entry": 100, "qty": 10}, {"query": "NVDA", "entry": 120, "qty": -10}])
assert not hs and "closed" in errs[0]["error"]
def test_tax_note_names_short_term_lots():
from src.metrics import tax_note
from datetime import date, timedelta
recent = (date.today() - timedelta(days=100)).isoformat()
h = {"symbol": "NVDA", "entry": 125.0, "price": 180.0, "qty": 20, "pl_pct": 0.44, "bought": "2023-01-10",
"data": {"currency": "USD"},
"lots": [{"entry": 100, "qty": 10, "bought": "2023-01-10", "side": "buy"},
{"entry": 150, "qty": 10, "bought": recent, "side": "buy"}]}
note = tax_note(h, "Trim")
assert "2 lots" in note and "1 SHORT-TERM" in note and recent in note
def test_buy_more_blocked_in_overweight_sector():
panel = {k: {"X": {"conviction": v, "key_point": "", "key_risk": ""}, "Y": {"conviction": v, "key_point": "", "key_risk": ""}}
for k, v in {"a": 70, "b": 68}.items()}
d = P.decide_actions(panel, ["X", "Y"], {"X": 0.1, "Y": 0.1}, sector_over={"X": "Technology is 76% of the book"})
assert d["X"]["action"] == "Keep" and "deepens" in d["X"]["size_note"] and d["Y"]["action"] == "Buy More"
def test_ballast_fund_detection():
from src.metrics import is_ballast_fund
assert is_ballast_fund({"symbol": "BND", "data": {"quote_type": "ETF", "name": "Vanguard Total Bond Market"}})
assert is_ballast_fund({"symbol": "ZZZ", "data": {"quote_type": "ETF", "name": "iShares 1-3 Year Treasury Bond ETF"}})
assert not is_ballast_fund({"symbol": "VOO", "data": {"quote_type": "ETF", "name": "Vanguard S&P 500 ETF"}})
assert not is_ballast_fund({"symbol": "BND", "data": {"quote_type": "EQUITY", "name": "Bond Corp"}})
def test_switch_pairs_follow_order_plan():
from src import revival as R
hs = [{"symbol": "A", "data": {"sector": "Technology"}}]
dec = {"A": {"action": "Sell", "conviction": 20}}
cands = [{"ticker": "P", "sector": "Healthcare", "trend_ok": True, "flags": [], "live": {}, "fit": {}},
{"ticker": "Q", "sector": "Energy", "trend_ok": True, "flags": [], "live": {}, "fit": {}}]
assert R.switch_pairs(hs, dec, cands)[0]["in"] == "P"
assert R.switch_pairs(hs, dec, cands, prefer=["Q"])[0]["in"] == "Q"
def test_tiny_trim_becomes_exit_or_keep():
tickers = ["X"] + [f"H{i}" for i in range(30)]
weak = P.decide_actions(_panel(a=36, b=38), tickers, {"X": 0.006})["X"]
assert weak["action"] == "Sell" and "exit" in weak["size_note"]
band = P.decide_actions(_panel(a=43, b=44), tickers, {"X": 0.006})["X"]
assert band["action"] == "Sell" # below-average and tiny: exit, don't shave
option = P.decide_actions(_panel(a=43, b=44), tickers, {"X": 0.006},
asym={"X": {"expected_return": 0.3, "up_down_ratio": 3.0, "clears_hurdle": True}})["X"]
assert option["action"] == "Keep" and "option" in option["size_note"] # asymmetric tiny bet is kept
tilt = P.decide_actions(_panel(a=55, b=61, c=65, d=47, e=45), tickers, {"X": 0.006}, cyclical={"X"})["X"]
assert tilt["action"] == "Keep" # a backed name stays even if a tilt said Trim
def test_rationale_writer_cannot_contradict_the_action():
from src.personas import _strip_action
assert _strip_action("Keep because revenue grew 28%, although the position is large.") == "Revenue grew 28%, although the position is large."
assert _strip_action("Sell — profits collapsed and the finances are fragile.") == "Profits collapsed and the finances are fragile."
assert _strip_action("The business keeps growing and the price is fair.") == "The business keeps growing and the price is fair."
def test_oversized_lukewarm_position_is_trimmed_toward_cap():
tickers = ["X"] + [f"H{i}" for i in range(46)] # 47 names → cap 15%
d = P.decide_actions(_panel(a=55, b=61, c=65, d=47, e=45), tickers, {"X": 0.227})["X"]
assert d["action"] == "Trim" and "toward the cap" in d["size_note"]
strong = P.decide_actions(_panel(a=70, b=68, c=66, d=67, e=65), tickers, {"X": 0.227})["X"]
assert strong["action"] == "Keep" # strongly backed: size note only
def test_two_lens_dissent_only_for_cyclicals():
tickers = ["X"] + [f"H{i}" for i in range(20)]
panel = _panel(a=35, b=69, c=72, d=83, e=35)
from src.metrics import is_cyclical
assert is_cyclical("Technology", "Semiconductor Memory") and is_cyclical("Energy", None)
assert not is_cyclical("Healthcare", "Drug Manufacturers") and not is_cyclical("Technology", "Software - Application")
cyc = P.decide_actions(panel, tickers, {"X": 0.079}, cyclical={"X"})["X"]
assert cyc["action"] == "Trim" and "cyclical" in cyc["size_note"]
secular = P.decide_actions(panel, tickers, {"X": 0.079}, cyclical=set())["X"]
assert secular["action"] in ("Keep", "Buy More") # a winner is not sold early
ok = P.decide_actions(_panel(a=45, b=69, c=72, d=83, e=40), tickers, {"X": 0.079}, cyclical={"X"})["X"]
assert ok["action"] in ("Keep", "Buy More")
def test_gated_add_and_final_tiny_pass():
tickers = ["X"] + [f"H{i}" for i in range(20)]
bad = {"X": {"expected_return": 0.02, "up_down_ratio": 1.1, "clears_hurdle": False}}
d = P.decide_actions(_panel(a=70, b=68), tickers, {"X": 0.03}, asym=bad)["X"]
assert d["action"] == "Keep" and d["gated_add"] is True
# a Trim produced by the cyclical dissent tilt on a 0.4% name still resolves to keep-or-exit
tiny = P.decide_actions(_panel(a=35, b=69, c=72, d=83, e=35), tickers, {"X": 0.004}, cyclical={"X"})["X"]
assert tiny["action"] in ("Keep", "Sell") and "friction" in tiny["size_note"] or "exit" in tiny["size_note"]
def test_damage_tilts_run_before_add_gates():
tickers = ["X"] + [f"H{i}" for i in range(20)]
bad = {"X": {"expected_return": -0.55, "up_down_ratio": 0.0, "clears_hurdle": False}}
d = P.decide_actions(_panel(a=35, b=76, c=68, d=83, e=35), tickers, {"X": 0.079}, asym=bad, cyclical={"X"})["X"]
assert d["action"] == "Trim" and "cyclical" in d["size_note"] # MU pattern: dissent beats the hurdle gate
def test_sub_two_percent_trim_exits_or_holds_never_shaves():
from src.personas import decide_actions
panel = {k: {"PG": {"conviction": 44}, "BIG": {"conviction": 44}} for k in ("value", "risk", "growth", "technical", "macro")}
d = decide_actions(panel, ["PG", "BIG"], {"PG": 0.015, "BIG": 0.05}, set())
assert d["PG"]["action"] == "Sell" and "exit cleanly" in d["PG"]["size_note"]
assert d["BIG"]["action"] == "Trim"
def test_tiny_threshold_scales_with_the_book():
from src.personas import decide_actions
tick = ["SBUX"] + [f"H{i}" for i in range(46)] # 47 names → band 1.06%
panel = {k: {t: {"conviction": 44} for t in tick} for k in ("value", "risk", "growth", "technical", "macro")}
d = decide_actions(panel, tick, {"SBUX": 0.019, **{f"H{i}": 0.02 for i in range(46)}}, set())
assert d["SBUX"]["action"] == "Trim" # 1.9% is above the band in a 47-name book
def test_sub_band_keep_with_weak_backing_exits_and_watch_tier_marks_sources_of_funds():
from src.personas import decide_actions
tick = ["XYZ", "SPOT"] + [f"H{i}" for i in range(45)] # 47 names → band 1.06%
conv = {"XYZ": 49, "SPOT": 45, **{f"H{i}": 55 for i in range(45)}}
panel = {k: {t: {"conviction": c} for t, c in conv.items()} for k in ("value", "risk", "growth", "technical", "macro")}
w = {"XYZ": 0.008, "SPOT": 0.049, **{f"H{i}": 0.02 for i in range(45)}}
asym = {"SPOT": {"expected_return": -0.41, "clears_hurdle": False}, "XYZ": {"expected_return": -0.6, "clears_hurdle": False}}
d = decide_actions(panel, tick, w, set(), asym)
assert d["XYZ"]["action"] == "Sell" and "dead money" in d["XYZ"]["size_note"] # sub-band, weak, no option value
assert d["SPOT"]["action"] == "Keep" and d["SPOT"]["watch"] and "source of funds" in d["SPOT"]["size_note"]
assert d["H0"]["action"] == "Keep" and not d["H0"]["watch"]
|