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"]