vitals / tests /test_recommend.py
s-ttp's picture
Sector-gap sourcing six deep, gap-driven second batch, gap-covering shortlist, per-sector search record, narrator share rules
06f8709 verified
Raw History Blame Contribute Delete
22.2 kB
"""Portfolio-aware recommendation engine — needs diagnosis and marginal-fit scoring."""
import numpy as np
import pandas as pd
from src import metrics as M
def _pm(**kw):
m = {"sector_allocation": {"Technology": 0.7, "Consumer Cyclical": 0.3}, "effective_n": 2.5,
"realized_beta": 1.6, "n_holdings": 3}
m.update(kw)
return m
def test_diagnose_needs_reads_the_book():
risk = {"stress": [{"name": "COVID crash", "window": "2020-02-19 → 2020-03-23", "portfolio": -0.40}],
"factor_tilts": {"tilts": {"value": -0.3, "quality": 0.2}}}
hs = [{"weight": 0.5, "dcf": {"upside": -0.4}}, {"weight": 0.5, "dcf": {"upside": -0.3}}]
needs = M.diagnose_needs(_pm(), risk, hs, {"loss_tolerance": "20%", "objective": "income"})
assert "Healthcare" in needs["sector_gaps"]["sectors"]
assert needs["sector_overweight"]["sector"] == "Technology"
assert needs["diversifier"]["weight"] > 0.5
assert needs["defensive"]["weight"] == 1.0 and "breaches" in needs["defensive"]["label"]
assert "income" in needs and "value" in needs
def test_diagnose_needs_healthy_book_falls_back_to_quality():
pm = _pm(sector_allocation={s: 1 / 11 for s in M.ALL_SECTORS}, effective_n=12, realized_beta=1.0)
needs = M.diagnose_needs(pm, {}, [], None)
assert list(needs) == ["quality"]
def _hist_pair(corr_target: float, n=700, seed=3):
rng = np.random.default_rng(seed)
idx = pd.bdate_range(end="2026-09-01", periods=n)
base = rng.normal(0, 0.012, n)
other = corr_target * base + np.sqrt(1 - corr_target ** 2) * rng.normal(0, 0.012, n)
px = lambda r: 100 * np.cumprod(1 + r)
return pd.DataFrame({"AAA": px(base), "BBB": px(base * 0.9), "CAND": px(other)}, index=idx)
def test_candidate_fit_prefers_diversifier_that_fills_gap():
hist = _hist_pair(0.1)
hs = [{"symbol": "AAA", "weight": 0.6, "data": {}}, {"symbol": "BBB", "weight": 0.4, "data": {}}]
pm = _pm(effective_n=1.9, realized_beta=1.5, sector_allocation={"Technology": 1.0})
needs = M.diagnose_needs(pm, {}, [], {"loss_tolerance": "20%"})
port = M.portfolio_returns(hs, hist)
good = {"ticker": "CAND", "sector": "Healthcare", "rank_pct": 80, "signals": [],
"live": {"beta": 0.7, "div_yield": 1.0}, "upside": 0.1}
bad = {"ticker": "CAND", "sector": "Technology", "rank_pct": 95, "signals": [],
"live": {"beta": 1.8, "div_yield": 0.0}, "upside": 0.1}
fg = M.candidate_fit(good, hs, pm, {}, needs, port, hist)
fb = M.candidate_fit(bad, hs, pm, {}, needs, port, hist)
assert fg["score"] > fb["score"]
assert any("adds Healthcare" in f for f in fg["fixes"])
assert any("correlation" in f for f in fg["fixes"]) and fg["corr_to_book"] < 0.5
assert fg["deltas"]["effective_n"][1] > fg["deltas"]["effective_n"][0]
assert any("beta" in f for f in fg["fixes"])
def test_conservative_income_prefers_payer_over_high_beta_diversifier():
hist = _hist_pair(-0.1)
hs = [{"symbol": "AAA", "weight": 0.5, "data": {}}, {"symbol": "BBB", "weight": 0.5, "data": {}}]
pm = _pm(effective_n=2.0, realized_beta=0.1, sector_allocation={"Real Estate": 1.0})
needs = M.diagnose_needs(pm, {}, [], {"risk": "conservative", "objective": "income"})
port = M.portfolio_returns(hs, hist)
tech = {"ticker": "CAND", "sector": "Technology", "rank_pct": 80, "signals": [],
"live": {"beta": 1.3, "div_yield": 1.0}, "upside": 0.1}
payer = {"ticker": "CAND", "sector": "Consumer Defensive", "rank_pct": 80, "signals": [],
"live": {"beta": 0.5, "div_yield": 6.0}, "upside": 0.1}
assert M.candidate_fit(payer, hs, pm, {}, needs, port, hist)["score"] > \
M.candidate_fit(tech, hs, pm, {}, needs, port, hist)["score"]
def test_switch_pairs_avoid_overweight_sector():
from src.revival import switch_pairs
hs = [{"symbol": "PLTR", "weight": 0.4, "data": {"sector": "Technology", "price": 1.0}}]
dec = {"PLTR": {"action": "Trim", "conviction": 38}}
cands = [{"ticker": "TSM", "sector": "Technology", "trend_ok": True, "live": {}},
{"ticker": "ED", "sector": "Utilities", "trend_ok": True, "live": {}}]
assert switch_pairs(hs, dec, cands)[0]["in"] == "TSM"
assert switch_pairs(hs, dec, cands, {"sector_overweight": {"sector": "Technology"}})[0]["in"] == "ED"
def test_peak_cycle_and_smart_money_guards():
hs = [{"symbol": "AAA", "weight": 1.0, "data": {}}]
pm = _pm(sector_allocation={"Technology": 1.0}, effective_n=1.0)
needs = M.diagnose_needs(pm, {}, [], None)
clean = {"ticker": "X", "sector": "Industrials", "rank_pct": 94, "signals": [],
"live": {"eps_growth": 0.08, "sma200": 100.0, "price": 104.0, "beta": 1.0}, "upside": 0.0}
expd = {"ticker": "X", "sector": "Industrials", "rank_pct": 94, "signals": [], "smart_money_neg": True,
"live": {"eps_growth": 0.51, "sma200": 158.0, "price": 186.0, "beta": 1.0}, "upside": 0.0}
fc = M.candidate_fit(clean, hs, pm, {}, needs, None, None)
fe = M.candidate_fit(expd, hs, pm, {}, needs, None, None)
assert fe["score"] < fc["score"] - 0.9
assert any("peak-cycle" in c for c in fe["cautions"]) and any("informed money" in c for c in fe["cautions"])
assert fc["cautions"] == []
def test_strong_ok_requires_clean_committee_verdict():
from src.revival import strong_ok
assert strong_ok({"committee": {"conviction": 64}, "fit": {"cautions": ["peak-cycle"]}}) # flagged: ≥62
assert not strong_ok({"committee": {"conviction": 60}, "fit": {"cautions": ["peak-cycle"]}})
assert strong_ok({"committee": {"conviction": 56}, "fit": {"cautions": []}}) # clean: ≥55
assert not strong_ok({"committee": {"conviction": 52}, "fit": {"cautions": []}})
assert not strong_ok({"committee": {"conviction": 45}, "fit": {}})
assert not strong_ok({"committee": {"conviction": 70}, "fit": {}, "trend_ok": False}) # broken trend: never
assert strong_ok({"fit": {"cautions": []}}) and not strong_ok({"fit": {"cautions": ["extended"]}})
def test_candidate_fit_flags_penalized():
hs = [{"symbol": "AAA", "weight": 1.0, "data": {}}]
c = {"ticker": "X", "sector": "Energy", "rank_pct": 99, "signals": [], "live": {}, "flags": ["VALUE_TRAP"]}
assert M.candidate_fit(c, hs, _pm(), {}, {"quality": {"weight": 0.5}}, None, None)["score"] < 0
def test_exclusions_short_tokens_match_whole_words(monkeypatch):
from src import orchestrator as O
monkeypatch.setattr(O, "fetch_holding", lambda t: {"name": {"CL": "Colgate-Palmolive", "MO": "Altria Group"}.get(t, t), "industry": ""})
cands = [{"ticker": "CL", "sector": "Consumer Defensive"}, {"ticker": "MO", "sector": "Consumer Defensive"},
{"ticker": "PM", "sector": "Consumer Defensive"}]
kept, rej = O._apply_exclusions(cands, {"exclusions": "tobacco, MO"})
assert [c["ticker"] for c in kept] == ["CL", "PM"] and rej[0]["ticker"] == "MO"
def test_instrument_gets_need_credit_and_measured_beta():
hist = _hist_pair(0.0)
hist["SPY"] = hist["AAA"] # SPY proxy = the book's driver
hs = [{"symbol": "AAA", "weight": 1.0, "data": {}}]
pm = _pm(effective_n=1.0, realized_beta=1.5, sector_allocation={"Technology": 1.0})
needs = M.diagnose_needs(pm, {}, [], {"loss_tolerance": "20%"})
port = M.portfolio_returns(hs, hist)
shy = {"ticker": "CAND", "sector": "Diversified fund", "instrument": True, "need_target": "defensive",
"signals": [], "live": {}}
f = M.candidate_fit(shy, hs, pm, {}, needs, port, hist)
assert any("ballast" in x for x in f["fixes"]) and isinstance(shy["live"].get("beta"), float)
assert f["score"] > 0.5
def test_european_benchmark_and_tax_note():
from src.market_data import benchmark_for
assert benchmark_for([{"symbol": "VOD.L", "weight": .4}, {"symbol": "SAP.DE", "weight": .3}, {"symbol": "MC.PA", "weight": .3}]) == "^STOXX"
assert benchmark_for([{"symbol": "VOD.L", "weight": .7}, {"symbol": "SAP.DE", "weight": .3}]) == "^FTSE"
assert benchmark_for([{"symbol": "AAPL", "weight": .5}, {"symbol": "SAP.DE", "weight": .5}]) == "ACWI"
h = {"symbol": "VOD.L", "price": 130, "entry": 100, "qty": 100, "pl_pct": 0.3, "data": {"currency": "GBp"}, "bought": "2026-08-01"}
note = M.tax_note(h, "Trim") or ""
assert "jurisdiction" in note and "SHORT-TERM" not in note
def test_europanel_scoring_is_cross_sectional():
import pandas as pd
from src import europanel as EP
df = pd.DataFrame({"pe": [10, 20, 40], "pb": [1, 2, 4], "ev_ebitda": [6, 10, 20], "div_yield": [5, 3, 1],
"roe": [0.2, 0.1, 0.05], "roa": [0.1, 0.05, 0.02], "op_margin": [0.3, 0.2, 0.1], "de": [0.2, 0.5, 1.0],
"mom_12_1": [0.1, 0.2, 0.3], "ret_26w": [0.05, 0.1, 0.2], "ret_13w": [0.02, 0.05, 0.1],
"vol_13w": [0.15, 0.25, 0.4], "dist_52w_high": [-0.05, -0.1, -0.2]}, index=["CHEAP.L", "MID.DE", "DEAR.PA"])
sc = EP._score(df)
assert sc.loc["CHEAP.L", "n_earnings_yield"] > sc.loc["DEAR.PA", "n_earnings_yield"]
assert sc.loc["CHEAP.L", "pctile"] > sc.loc["DEAR.PA", "pctile"]
assert -1 <= sc["n_mom_12_1"].min() and sc["n_mom_12_1"].max() <= 1
def test_uk_stamp_duty_in_order_plan():
from src import revival as R
hs = [{"symbol": "VOD.L", "weight": 1.0, "market_value": 10000.0, "price": 1.0, "fx_usd": 1.0,
"data": {"avg_volume": 1e9, "quote_type": "EQUITY"}}]
target = [{"ticker": "VOD.L", "current_w": 1.0, "target_w": 0.6, "delta": -0.4, "is_new": False},
{"ticker": "AZN.L", "current_w": 0.0, "target_w": 0.4, "delta": 0.4, "is_new": True}]
R.order_plan.quote_types = {"VOD.L": "EQUITY", "AZN.L": "EQUITY"}
orders = {o["ticker"]: o for o in R.order_plan(hs, target)}
assert orders["AZN.L"]["cost_note"] == "UK stamp duty 0.5%" and orders["AZN.L"]["est_cost_usd"] == 4 + 20
assert "cost_note" not in orders["VOD.L"] # sells carry no duty
def test_europanel_resolves_home_market_primary_lines(monkeypatch):
from src import europanel as EP
monkeypatch.setattr(EP, "av_symbol_search", lambda q: [
{"av": "SAP", "yahoo": "SAP", "name": "Sap SE", "region": "United States", "currency": "USD", "type": "Equity"},
{"av": "SAP.FRK", "yahoo": "SAP.F", "name": "SAP SE", "region": "Frankfurt", "currency": "EUR", "type": "Equity"},
{"av": "0NW4.LON", "yahoo": "0NW4.L", "name": "SAP SE", "region": "United Kingdom", "currency": "EUR", "type": "Equity"},
{"av": "SAP.DEX", "yahoo": "SAP.DE", "name": "SAP SE", "region": "XETRA", "currency": "EUR", "type": "Equity"}])
assert EP._resolve_name("SAP SE")["yahoo"] == "SAP.DE" # XETRA over Frankfurt, never the London 0-line
monkeypatch.setattr(EP, "av_symbol_search", lambda q: [
{"av": "YASM.LON", "yahoo": "YASM.L", "name": "ASML (ASML) OPTIONS ETP", "region": "United Kingdom", "currency": "GBX", "type": "ETF"},
{"av": "ASML.AMS", "yahoo": "ASML.AS", "name": "ASML Holding N.V.", "region": "Amsterdam", "currency": "EUR", "type": "Equity"}])
assert EP._resolve_name("ASML Holding NV")["yahoo"] == "ASML.AS" # not the leveraged ETP
def test_europanel_universe_dedupes_vendor_spellings(monkeypatch):
from src import europanel as EP
profiles = {"VGK": [{"description": "UNILEVER PLC", "weight": "0.009"}],
"SPEU": [{"description": "Unilever plc", "weight": "0.0085"}, {"description": "RELX PLC", "weight": "0.004"}],
"DBEU": [], "FEZ": []}
monkeypatch.setattr(EP, "_av_call", lambda params, raw=False: {"holdings": profiles[params["symbol"]]})
u = EP._universe(10)
assert [r["name"] for r in u] == ["UNILEVER PLC", "RELX PLC"] and u[0]["weight"] == 0.009
def test_legal_form_prefers_home_venue():
from src import europanel as EP
assert EP._home_sfx("HSBC HOLDINGS PLC") == {"L"}
assert "DE" in EP._home_sfx("Novo Nordisk AS Class B") and "F" in EP._home_sfx("Novo Nordisk AS Class B")
assert EP._home_sfx("Some Company") == set()
def test_fundamentals_scores_on_synthetic_statements():
from src import fundamentals as FU
y1 = {"assets": 1000, "liabilities": 400, "netinc": 120, "ncfo": 150, "debt": 100, "currentratio": 1.8, "sharesbas": 100,
"grossmargin": 0.45, "revenue": 900, "workingcapital": 200, "retearn": 300, "ebit": 160, "marketcap": 2400,
"receivables": 90, "gp": 405, "assetsc": 400, "ppnenet": 300, "depamor": 40, "sgna": 150, "capex": -50, "fcf": 100,
"sbcomp": 10, "dps": 1.0, "roic": 0.18, "invcap": 700, "taxexp": 30, "ebt": 150, "epsdil": 1.2, "bvps": 6.0, "pe": 20}
y0 = {**y1, "netinc": 100, "ncfo": 120, "debt": 120, "currentratio": 1.6, "sharesbas": 102, "grossmargin": 0.43,
"revenue": 820, "receivables": 80, "gp": 353, "epsdil": 0.98, "dps": 0.9}
older = [{**y0, "revenue": 700, "epsdil": 0.9, "dps": 0.8}, {**y0, "revenue": 650, "epsdil": 0.85, "dps": 0.7},
{**y0, "revenue": 600, "epsdil": 0.8, "dps": 0.6}, {**y0, "revenue": 560, "epsdil": 0.7, "dps": 0.5, "invcap": 500, "ebit": 100}]
a = [y1, y0] + older
f = FU.piotroski(a)
assert f["score"] == 9
assert FU.altman_z(y1, 2400) > 2.99
assert FU.beneish_m(a) < -1.78
e = FU.economics(a, 24.0, 2400, "Technology", "Software - Application")
assert e["cash_conversion"] == round(100 / 120, 2) and e["dividend_streak_years"] == 5
assert abs(e["buyback_yield_1y"] - (1 - 100 / 102)) < 1e-3 and e["fcf_payout"] == 1.0
assert e.get("greenblatt_ey") is None
assert e["lynch_category"] in ("stalwart", "fast grower")
us = {"piotroski": f, "altman_z": 5.0, "beneish_m": -2.5, "econ": e, "insiders": {}, "earnings": {}}
assert FU.forensic_flags(us) == [] and FU.sell_triggers({}, us)
def test_harvest_pairs_pick_correlated_non_identical_substitute():
import numpy as np, pandas as pd
from src import revival as R
idx = pd.bdate_range("2024-01-01", periods=300)
rng = np.random.default_rng(3)
base = rng.normal(0, 0.01, 300)
px = lambda r: pd.Series(100 * np.cumprod(1 + r), index=idx)
port_hist = pd.DataFrame({"PFE": px(base), "SPY": px(rng.normal(0, 0.01, 300))})
cand_hist = pd.DataFrame({"XLV": px(0.9 * base + rng.normal(0, 0.003, 300)), # same-sector ETF, highly correlated
"XLE": px(rng.normal(0, 0.01, 300))}) # unrelated
hs = [{"symbol": "PFE", "entry": 42.0, "price": 25.0, "qty": 100, "pl_pct": -0.4, "fx_usd": 1.0,
"data": {"sector": "Healthcare"}}]
dec = {"PFE": {"action": "Keep"}}
cands = [{"ticker": "XLV", "sector": "Healthcare", "instrument": True, "live": {"name": "Health Care SPDR"}},
{"ticker": "XLE", "sector": "Energy", "instrument": True, "live": {"name": "Energy SPDR"}}]
out = R.harvest_pairs(hs, dec, cands, cand_hist, port_hist)
assert len(out) == 1 and out[0]["buy"] == "XLV" and out[0]["loss_usd"] == 1700 and "31 days" in out[0]["note"]
assert R.harvest_pairs(hs, {"PFE": {"action": "Trim"}}, cands, cand_hist, port_hist) == [] # exits harvest themselves
def test_cautious_book_marks_needs_and_mutes_gaps():
from src import metrics as M
pm = {"sector_allocation": {"Consumer Defensive": 0.6, "Communication Services": 0.4}, "effective_n": 6.5}
n = M.diagnose_needs(pm, {}, [], {"risk": "conservative", "horizon": "under 3 years"})
assert "_cautious" in n and n["_cautious"]["label"] is None
n2 = M.diagnose_needs(pm, {}, [], {"risk": "aggressive"})
assert "_cautious" not in n2 and n["sector_gaps"]["weight"] < n2["sector_gaps"]["weight"]
def test_ballast_floor_in_bounds():
from src import construct as CX
hs = [{"symbol": "BND", "weight": 0.14}, {"symbol": "JNJ", "weight": 0.14}]
dec = {"BND": {"action": "Keep", "ballast": True}, "JNJ": {"action": "Trim"}}
b = CX.build_bounds(hs, dec, [], {"BND": 0.3, "JNJ": 0.3})
assert b["BND"][0] == 0.14 and abs(b["JNJ"][0] - 0.056) < 1e-9 # a Trim keeps ≥40%: never a silent full exit
def test_importer_reads_plain_tables_without_llm():
from src.importer import parse_table
csv = "Symbol,Description,Quantity,Avg Cost,Current Price,Market Value\nAAPL,Apple,10,150.25,320,3200\nNFLX,Netflix,5,\"1,210.50\",78,390\nTOTAL,,,,,3590\n"
rows = parse_table(csv)
assert rows == [{"query": "AAPL", "entry": 150.25, "qty": 10.0}, {"query": "NFLX", "entry": 1210.5, "qty": 5.0}]
assert parse_table("Date,Action,Symbol,Price,Quantity\n2024-01-01,BUY,AAPL,150,10\n") is None # transactions → LLM
assert parse_table("just some text\nwithout a table") is None
def test_presplit_entry_is_a_question_never_a_silent_rewrite(monkeypatch):
from src import portfolio as PF
monkeypatch.setattr(PF, "resolve_symbol", lambda q: {"symbol": "NFLX", "name": "Netflix", "price": 78.0, "data": {"currency": "USD"}})
monkeypatch.setattr(PF, "fx_to_usd", lambda c: 1.0)
monkeypatch.setattr(PF, "validate_row", lambda *a, **k: [{"code": "presplit", "message": "NFLX had a 10-for-1 split on 2025-11-17 — x",
"suggest": {"field": "entry", "value": 17.1}}])
hs, errs = PF.build_holdings([{"query": "NFLX", "entry": 171, "qty": 5}])
assert not hs and errs[0]["code"] == "needs_decision" and "keep as entered" in errs[0]["error"]
hs, _ = PF.build_holdings([{"query": "NFLX", "entry": 171, "qty": 5, "split": "both"}])
assert hs[0]["entry"] == 17.1 and hs[0]["qty"] == 50 and "split-adjusted" in hs[0]["entry_note"]
hs, _ = PF.build_holdings([{"query": "NFLX", "entry": 171, "qty": 5, "split": "price"}])
assert hs[0]["entry"] == 17.1 and hs[0]["qty"] == 5
hs, _ = PF.build_holdings([{"query": "NFLX", "entry": 171, "qty": 5, "split": "keep"}])
assert hs[0]["entry"] == 171 and hs[0]["qty"] == 5 and "kept as entered" in hs[0]["entry_note"]
def test_strong_ok_raises_the_bar_for_flagged_names_in_a_cautious_book():
from src.revival import strong_ok
flagged = {"committee": {"conviction": 64}, "fit": {"cautions": ["peak-cycle"]}}
strong_ok.cautious = True
try:
assert not strong_ok(flagged) # 64 with a flag: not for a retiree
assert strong_ok({"committee": {"conviction": 67}, "fit": {"cautions": ["peak-cycle"]}})
assert strong_ok({"committee": {"conviction": 56}, "fit": {"cautions": []}})
finally:
strong_ok.cautious = False
assert strong_ok(flagged)
def test_harvest_substitutes_a_broad_fund_with_a_broad_fund_only():
from src.revival import harvest_pairs
rng = np.random.default_rng(1)
base = rng.normal(0, 0.01, 400)
px = lambda r: pd.Series(100 * np.exp(np.cumsum(r)), index=pd.date_range("2024-01-01", periods=400))
port = pd.DataFrame({"VOO": px(base)})
cands = pd.DataFrame({"VTI": px(base + rng.normal(0, 0.002, 400)), # ≈0.98 correlated broad fund
"XLY": px(base + rng.normal(0, 0.006, 400))}) # ≈0.86 correlated sector fund
hs = [{"symbol": "VOO", "entry": 600.0, "price": 400.0, "qty": 10, "pl_pct": -0.33,
"data": {"quote_type": "ETF", "sector": None}}]
dec = {"VOO": {"action": "Keep"}}
only_sector = [{"ticker": "XLY", "sector": "Consumer Cyclical", "instrument": True, "live": {"name": "XLY"}}]
assert harvest_pairs(hs, dec, only_sector, cands, port) == []
both = only_sector + [{"ticker": "VTI", "sector": "Diversified fund", "instrument": True, "broad": True, "live": {"name": "VTI"}}]
out = harvest_pairs(hs, dec, both, cands, port)
assert out and out[0]["buy"] == "VTI" and out[0]["correlation"] >= 0.9
def test_distress_flag_disqualifies_a_candidate():
from src.revival import strong_ok
assert not strong_ok({"committee": {"conviction": 64}, "fit": {"cautions": ["forensic: distress zone (Altman Z 1.45)"]}})
assert strong_ok({"committee": {"conviction": 64}, "fit": {"cautions": ["peak-cycle risk"]}})
def test_tax_plan_and_triage_consistency():
from src.revival import tax_plan, triage
from datetime import date, timedelta
recent = (date.today() - timedelta(days=300)).isoformat()
hs = [{"symbol": "META", "weight": 0.24, "price": 700.0, "entry": 135.0, "qty": 95, "fx_usd": 1.0, "market_value": 66500.0,
"bought": None, "pl_pct": 4.2, "lots": [{"entry": 100.0, "qty": 50, "bought": "2021-01-05"}, {"entry": 170.0, "qty": 45, "bought": recent}], "data": {}},
{"symbol": "LULU", "weight": 0.003, "price": 98.0, "entry": 276.0, "qty": 3, "fx_usd": 1.0, "market_value": 294.0, "bought": None, "pl_pct": -0.64, "data": {}, "dcf": {}}]
orders = [{"side": "SELL", "ticker": "META", "delta_pct": -0.10}]
tp = tax_plan(hs, {}, orders, 277000.0)
note = tp[0]["note"]
assert "170.00 first" in note and "turns long-term" in note and "two tax years" in note
dec = {"META": {"action": "Trim", "conviction": 58}, "LULU": {"action": "Keep", "conviction": 38}}
t = {x["ticker"]: x["disposition"] for x in triage(hs, dec, bought=set(), sold={"META"})}
assert t["LULU"] != "exit to fund" # the plan keeps LULU, so triage must not say exit
def test_shortlist_covers_the_missing_sectors_first():
from src import orchestrator as O
meta = lambda t, s, conv: {"ticker": t, "sector": s, "committee": {"conviction": conv}, "fit": {"fixes": [f"adds {s}"], "cautions": []},
"trend_ok": True, "live": {"name": t}}
eligible = [meta("TSM", "Technology", 68), meta("NOC", "Industrials", 60), meta("SHW", "Basic Materials", 59), meta("EXC", "Utilities", 57)]
holdings = [{"symbol": f"H{i}", "data": {}} for i in range(14)]
O._ensure_single_names.needs = {"sector_gaps": {"sectors": ["Industrials", "Basic Materials", "Utilities"]}}
try:
out = O._ensure_single_names([{"ticker": "TSM", "reason": "x"}], eligible, holdings, set())
finally:
O._ensure_single_names.needs = None
tickers = [a["ticker"] for a in out]
assert tickers[:3] == ["TSM", "NOC", "SHW"] and "EXC" in tickers