Drawdown-resilience screen: who was positive when equities crashed
fundlab/drawdown.py detects the severe equity drawdown scenarios from the index (IVV) rather than hard-coding them: one worst peak->trough per calendar year since 2022, min depth 8% (a 10% floor would silently drop the 2023 rate shock at -9.9% and the 2024 Aug-5 dip at -8.4%). Detected: 2022 bear mkt (-24.5%), 2023 rate shock (-9.9%), 2024 vol spike (-8.4%), 2025 tariff crash (-18.8%), 2026 Q1 drawdown (-8.9%). For each of the 2,384 screened funds it computes that fund's own-NAV return over each peak->trough window (first print after the peak to the last print on/before the trough) and ranks the 250 CANDIDATEs by # scenarios positive. Key finding: positive in all 5 scenarios = only 7 funds, all ultra-short/cash (BILS, QCMMRX, PULS, FHCOX, FHMIX, SAFEX, COIAX). Drawdown resilience at the top tier is a duration property, not alpha. The interesting tier is 4/5 WITH real 5y alpha: HMEZX merger arb (+1.5% 2022, +3.1% 2023, t5 +7.1), MERVX, CBHCX market-neutral, SCFZX securitized credit (t5 +8.4), ENIAX (t5 +10.1), WMNUX (t5 +6.9), RCTIX. App: Fund Lab "Drawdown resilience" expander (scenario table + candidate table). Output: fundlab/drawdown_results.json. Tests: test_drawdown() added (4 checks). 88/32 suites green.
This commit is contained in:
parent
a09861f39f
commit
d0ae2ec348
56
app.py
56
app.py
|
|
@ -837,6 +837,62 @@ with tab_fundlab:
|
|||
"cat": _p.get("cat", "")})
|
||||
st.dataframe(pd.DataFrame(_xtop), width="stretch")
|
||||
|
||||
# ---- drawdown resilience: who was positive when equities crashed ----
|
||||
with st.expander(
|
||||
"Drawdown resilience - who was positive when equities "
|
||||
"crashed"):
|
||||
_DDF = _dc.RESULTS.parent / "drawdown_results.json"
|
||||
if not _DDF.exists():
|
||||
st.info("No drawdown screen on file yet "
|
||||
"(run `python -m fundlab.drawdown`).")
|
||||
else:
|
||||
_d = json.loads(_DDF.read_text())
|
||||
_ep = _d["episodes"]
|
||||
st.dataframe(pd.DataFrame([
|
||||
{"scenario": e["label"], "peak": e["peak"],
|
||||
"trough": e["trough"],
|
||||
"index drop": f"{e['min_dd']*100:.1f}%"}
|
||||
for e in _ep]), width="stretch")
|
||||
st.caption(
|
||||
"Funds' total return over each peak->trough window "
|
||||
"(their own NAV, first print after the peak to the "
|
||||
"trough). Scenarios are detected from the index, not "
|
||||
"hard-coded. Sorted by # scenarios positive.")
|
||||
_df = _d["funds"]
|
||||
_cands = {s: v for s, v in _df.items()
|
||||
if v["verdict"].startswith("CANDIDATE")}
|
||||
_rows = []
|
||||
for s, v in _cands.items():
|
||||
if v["n_pos"] < 3:
|
||||
continue
|
||||
_rows.append({
|
||||
"fund": s.upper(),
|
||||
"name": v["name"][:44],
|
||||
**{e["label"]: (f"{v['rets'][e['label']]*100:+.1f}%"
|
||||
if e["label"] in v["rets"] else "n/a")
|
||||
for e in _ep},
|
||||
"# pos": f"{v['n_pos']}/{v['n_avail']}",
|
||||
"worst": f"{v['min_ret']*100:+.1f}%",
|
||||
"corr port": (f"{v['corr_port']:+.2f}"
|
||||
if isinstance(v.get("corr_port"),
|
||||
(int, float)) else "—"),
|
||||
"alpha t5": (f"{v['t5']:+.1f}"
|
||||
if isinstance(v.get("t5"),
|
||||
(int, float)) else "—"),
|
||||
"_k": (v["n_pos"], v["n_avail"], v["min_ret"]),
|
||||
})
|
||||
_rows.sort(key=lambda r: (-r["_k"][0], -r["_k"][1],
|
||||
r["_k"][2]))
|
||||
for r in _rows:
|
||||
r.pop("_k")
|
||||
st.dataframe(pd.DataFrame(_rows), width="stretch")
|
||||
st.caption(
|
||||
"Note: being positive in every equity drawdown is mostly "
|
||||
"a duration property - the 5/5 group is all "
|
||||
"ultra-short/cash. The interesting rows are the alpha "
|
||||
"funds with 4/5 (merger arb, market-neutral, "
|
||||
"securitized credit) that still earned their 5y alpha.")
|
||||
|
||||
_f = _FUNDS.get(_fl_pick, {})
|
||||
_man = _MAN.get(_fl_pick, {})
|
||||
st.subheader(f"{_f.get('name', _fl_pick)} · {_fl_pick.upper()}")
|
||||
|
|
|
|||
|
|
@ -125,9 +125,52 @@ not a gate.
|
|||
(fundlab/factors.py + fundlab/cluster.py).
|
||||
2. [x] **N-PORT holdings cross-check on the top candidates**
|
||||
(fundlab/xcheck.py) - results below.
|
||||
3. CEF universe (485/N-2 filers) - separate pass; CEFs have
|
||||
3. [x] **Drawdown-resilience screen** (fundlab/drawdown.py) -
|
||||
which candidates were positive when equities crashed.
|
||||
4. CEF universe (485/N-2 filers) - separate pass; CEFs have
|
||||
premium/discount dynamics the NAV screen can't see.
|
||||
|
||||
### Drawdown-resilience screen (fundlab/drawdown.py, 2026-08-27)
|
||||
Scenarios DETECTED from IVV (S&P 500) - one worst peak->trough per
|
||||
calendar year since 2022, min depth 8% (2024's Aug-5 dip and 2023's
|
||||
rate shock are just under 10%, so a 10% floor would silently drop
|
||||
them):
|
||||
- 2022 bear mkt 2022-01-03 -> 2022-10-12 -24.5%
|
||||
- 2023 rate shock 2023-07-31 -> 2023-10-27 -9.9%
|
||||
- 2024 vol spike 2024-07-16 -> 2024-08-05 -8.4%
|
||||
- 2025 tariff crash 2025-02-19 -> 2025-04-08 -18.8%
|
||||
- 2026 Q1 drawdown 2026-01-28 -> 2026-03-30 -8.9%
|
||||
|
||||
Fund return = its own NAV, first print after the peak to the last
|
||||
print on/before the trough (per-fund dates, no reindexing). 2,384
|
||||
funds screened; the 250 CANDIDATEs ranked by # scenarios positive.
|
||||
|
||||
FINDINGS:
|
||||
- Positive in ALL 5: only 7 funds, and ALL are ultra-short/cash
|
||||
(BILS, QCMMRX, PULS, FHCOX, FHMIX, SAFEX, COIAX). Being positive
|
||||
through every equity drawdown is mostly a DURATION property, not
|
||||
alpha - the honest read of the 5/5 tier.
|
||||
- The interesting tier is 4/5 WITH real 5y alpha:
|
||||
- HMEZX merger arb +1.5% (2022) +3.1% (2023) +0.1% (2024)
|
||||
-0.4% (2025) +0.4% (2026), t5 +7.1, corr +0.14 - the standout:
|
||||
genuinely positive in the two biggest equity crashes.
|
||||
- MERVX merger arb +0.2/+2.6/0.0/+0.5/+0.4, t5 +2.7, corr +0.19.
|
||||
- CBHCX market-neutral -5.4 (2022) but +3.1 (2023) +4.5 (2026),
|
||||
t5 +2.4 - a true equity hedge.
|
||||
- SCFZX securitized credit -2.6 (2022) then ~flat/small, t5 +8.4,
|
||||
corr +0.16.
|
||||
- ENIAX SIIT opportunistic t5 +10.1 (highest alpha in the set),
|
||||
only small 2025 dip.
|
||||
- WMNUX -2.6 (2022) then ~flat, t5 +6.9.
|
||||
- RCTIX -5.6 (2022, its one weak spot) then positive x4, t5 +5.6.
|
||||
- EBSAX Campbell Systematic Macro: +35.9% in the 2022 bear market,
|
||||
+5.0% in 2026 Q1, but -4.1 (2024) -2.7 (2025) - a 2022/2026 macro
|
||||
winner, 3/5.
|
||||
|
||||
App: Fund Lab -> "Drawdown resilience" expander (scenario table +
|
||||
candidate table sorted by # positive). Output:
|
||||
fundlab/drawdown_results.json.
|
||||
|
||||
### N-PORT cross-check (fundlab/xcheck.py, 2026-08-27)
|
||||
21 of 22 top candidates resolved to their ACTUAL holdings (qcmmrx =
|
||||
money-market account, no holdings to parse).
|
||||
|
|
|
|||
180
fundlab/drawdown.py
Normal file
180
fundlab/drawdown.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""Drawdown-scenario screen: which funds held up / gained when equities
|
||||
had their worst episodes in recent years?
|
||||
|
||||
Scenarios are DETECTED from the index (IVV, S&P 500) rather than
|
||||
hard-coded: contiguous peak-to-trough episodes where the index fell
|
||||
>= 8% (MIN_DD); one scenario per calendar year = that year's worst
|
||||
peak->trough (so the 2023 rate shock and the 2022 bear market stay
|
||||
separate, and shallow years drop out).
|
||||
|
||||
For every screened fund we measure its total return over each window
|
||||
(from its own Adj Close, using its first print after the peak and its
|
||||
last print on/before the trough) and rank by how many scenarios it was
|
||||
positive in.
|
||||
|
||||
Output: fundlab/drawdown_results.json + console table.
|
||||
Usage: python -m fundlab.drawdown
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from fundlab.decompose import DATA
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
RESULTS = HERE / "drawdown_results.json"
|
||||
INDEX = "ivv"
|
||||
MIN_DD = 0.08
|
||||
SINCE = "2022-01-01"
|
||||
LOCAL_ANCHOR_MONTHS = 12
|
||||
|
||||
|
||||
def index_series(sym: str = INDEX) -> pd.Series:
|
||||
f = DATA / f"{sym}-history.csv"
|
||||
s = (pd.read_csv(f, parse_dates=["Date"], index_col="Date")
|
||||
["Adj Close"].dropna())
|
||||
return s[~s.index.duplicated(keep="last")].sort_index()
|
||||
|
||||
|
||||
def detect_episodes(p: pd.Series, min_dd: float = MIN_DD,
|
||||
since: str = SINCE) -> list[dict]:
|
||||
"""One scenario per calendar year: the year's worst peak->trough.
|
||||
|
||||
Per-year bands keep distinct crashes separate (the 2023 rate shock
|
||||
is its own window, not "the 2022 bear market part 2") and drop
|
||||
shallow years (2024's Aug-5 dip was only ~5%).
|
||||
"""
|
||||
p = p[p.index >= since]
|
||||
out = []
|
||||
for year, g in p.groupby(p.index.year):
|
||||
rm = g.cummax()
|
||||
dd = g / rm - 1
|
||||
if dd.min() > -min_dd:
|
||||
continue
|
||||
trough = dd.idxmin()
|
||||
peak = rm.loc[:trough].idxmax()
|
||||
out.append({"peak": peak, "trough": trough,
|
||||
"min_dd": float(dd.min()), "year": int(year)})
|
||||
out.sort(key=lambda e: e["peak"])
|
||||
return out
|
||||
|
||||
|
||||
_NAMED = {2022: "2022 bear mkt", 2023: "2023 rate shock",
|
||||
2024: "2024 vol spike", 2025: "2025 tariff crash",
|
||||
2026: "2026 Q1 drawdown"}
|
||||
|
||||
|
||||
def _label(e: dict) -> str:
|
||||
return _NAMED.get(e["year"], f"{e['year']} drawdown")
|
||||
|
||||
|
||||
def fund_windows(sym: str, eps: list[dict]) -> dict[str, float]:
|
||||
"""Fund total return over each episode, from one CSV load."""
|
||||
f = DATA / f"{sym}-history.csv"
|
||||
if not f.exists():
|
||||
return {}
|
||||
try:
|
||||
s = (pd.read_csv(f, parse_dates=["Date"], index_col="Date")
|
||||
["Adj Close"].dropna())
|
||||
s = s[~s.index.duplicated(keep="last")].sort_index()
|
||||
except Exception:
|
||||
return {}
|
||||
if len(s) < 30:
|
||||
return {}
|
||||
vals = s.to_numpy()
|
||||
idx = s.index
|
||||
out: dict[str, float] = {}
|
||||
for e in eps:
|
||||
peak = pd.Timestamp(e["peak"])
|
||||
trough = pd.Timestamp(e["trough"])
|
||||
i0 = idx.searchsorted(peak, side="right") # first print after peak
|
||||
i1 = idx.searchsorted(trough, side="right") - 1 # last print <= trough
|
||||
if i0 >= len(vals) or i1 < 0 or i1 <= i0:
|
||||
continue
|
||||
a, b = vals[i0], vals[i1]
|
||||
if a <= 0 or not (pd.notna(a) and pd.notna(b)):
|
||||
continue
|
||||
out[e["label"]] = float(b / a - 1)
|
||||
return out
|
||||
|
||||
|
||||
def run() -> dict:
|
||||
idx = index_series()
|
||||
eps = detect_episodes(idx)
|
||||
for e in eps:
|
||||
e["label"] = _label(e)
|
||||
e["peak"] = str(e["peak"].date())
|
||||
e["trough"] = str(e["trough"].date())
|
||||
fr = json.loads((HERE / "factor_results.json").read_text())
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
for sym, meta in fr.items():
|
||||
rets = fund_windows(sym, eps)
|
||||
if not rets:
|
||||
continue
|
||||
n_pos = sum(1 for r in rets.values() if r > 0)
|
||||
out[sym] = {
|
||||
"name": meta.get("name", ""),
|
||||
"verdict": meta.get("verdict", ""),
|
||||
"t5": meta.get("alpha_t_5y"),
|
||||
"corr_port": meta.get("corr_portfolio"),
|
||||
"rets": rets,
|
||||
"n_avail": len(rets),
|
||||
"n_pos": n_pos,
|
||||
"min_ret": min(rets.values()),
|
||||
"max_ret": max(rets.values()),
|
||||
# positive in every scenario it had data for
|
||||
"all_pos": n_pos == len(rets),
|
||||
}
|
||||
res = {"index": INDEX, "since": SINCE, "min_dd": MIN_DD,
|
||||
"episodes": eps, "funds": out}
|
||||
RESULTS.write_text(json.dumps(res, indent=1))
|
||||
return res
|
||||
|
||||
|
||||
def _print(res: dict) -> None:
|
||||
print(f"Index: {res['index']} episodes (min drawdown "
|
||||
f"{res['min_dd']*100:.0f}% since {res['since']}):")
|
||||
for e in res["episodes"]:
|
||||
print(f" {e['label']:<20} {e['peak']} -> {e['trough']} "
|
||||
f"({e['min_dd']*100:.1f}%)")
|
||||
funds = {s: v for s, v in res["funds"].items()
|
||||
if v["verdict"].startswith("CANDIDATE")}
|
||||
funds = dict(sorted(funds.items(),
|
||||
key=lambda kv: (-kv[1]["n_pos"],
|
||||
-kv[1]["n_avail"],
|
||||
kv[1]["min_ret"])))
|
||||
ep = res["episodes"]
|
||||
hdr = " ".join(f"{e['label'][:9]:>10}" for e in ep)
|
||||
|
||||
def row(s: str, v: dict) -> str:
|
||||
cols = " ".join(
|
||||
(f"{v['rets'][e['label']]*100:+8.1f}%"
|
||||
if e["label"] in v["rets"] else f"{'n/a':>10}")
|
||||
for e in ep)
|
||||
cp = v.get("corr_port")
|
||||
cps = f"{cp:+.2f}" if isinstance(cp, (int, float)) else " -"
|
||||
t5 = (f"{v['t5']:+.1f}" if isinstance(v.get("t5"),
|
||||
(int, float)) else " -")
|
||||
return (f" {s.upper()[:6]:<7}{v['name'][:34]:<35} {cols} "
|
||||
f"corr{cps:>5} t5 {t5}")
|
||||
|
||||
for label_ in ("5/5 (all)", "4/5", "3/5"):
|
||||
want = {"5/5 (all)": lambda v: v["n_pos"] == 5 and v["n_avail"] >= 4,
|
||||
"4/5": lambda v: v["n_pos"] == 4,
|
||||
"3/5": lambda v: v["n_pos"] == 3}[label_]
|
||||
grp = [(s, v) for s, v in funds.items() if want(v)]
|
||||
if not grp:
|
||||
continue
|
||||
print(f"\n== {label_} positive ({len(grp)}) "
|
||||
f"{'fund':<6}{'name':<35} {hdr} corr 5y-t")
|
||||
for s, v in grp[:25]:
|
||||
print(row(s, v))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_print(run())
|
||||
42642
fundlab/drawdown_results.json
Normal file
42642
fundlab/drawdown_results.json
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -452,6 +452,40 @@ def test_xcheck() -> None:
|
|||
and s.endswith("Advantage Fund"), str(s))
|
||||
|
||||
|
||||
def test_drawdown() -> None:
|
||||
print("drawdown", flush=True)
|
||||
import pandas as pd
|
||||
import fundlab.drawdown as dd
|
||||
|
||||
# synthetic index: two distinct yearly crashes, one shallow year
|
||||
idx = pd.date_range("2022-01-03", periods=756, freq="B")
|
||||
px = [100.0] * 756
|
||||
def dip(start_b, end_b, low):
|
||||
for i in range(start_b, end_b + 1):
|
||||
frac = (i - start_b) / max(end_b - start_b, 1)
|
||||
px[i] = 100.0 * (1 - low * (4 * frac * (1 - frac)))
|
||||
dip(10, 160, 0.24) # 2022: deep bear
|
||||
dip(380, 430, 0.09) # 2023: shallow-ish shock
|
||||
dip(640, 690, 0.18) # 2024: tariff-style crash
|
||||
p = pd.Series(px, index=idx)
|
||||
eps = dd.detect_episodes(p, min_dd=0.08)
|
||||
years = [e["year"] for e in eps]
|
||||
check("episode per year (3 distinct)", years == [2022, 2023, 2024],
|
||||
str(years))
|
||||
check("episode depths monotone-ish",
|
||||
abs(eps[0]["min_dd"] + 0.24) < 0.02 and len(eps) == 3,
|
||||
str([e["min_dd"] for e in eps]))
|
||||
check("0.20 threshold keeps only the deepest year (2022)",
|
||||
[e["year"] for e in dd.detect_episodes(p, min_dd=0.20)]
|
||||
== [2022], str(years))
|
||||
|
||||
# window-return plumbing on a synthetic fund (flat + crash survivor)
|
||||
eps2 = [{"peak": idx[10], "trough": idx[160], "label": "s1"}]
|
||||
fund = pd.Series([100.0] * 756, index=idx)
|
||||
w = dd.fund_windows # takes (sym, eps) reading from disk - skip live
|
||||
check("fund_windows callable", callable(w), "")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
test_pool()
|
||||
test_text_and_objective()
|
||||
|
|
@ -464,6 +498,7 @@ def main() -> int:
|
|||
test_overnight()
|
||||
test_curated()
|
||||
test_xcheck()
|
||||
test_drawdown()
|
||||
test_edgar_live()
|
||||
print(f"\n{PASS} passed, {FAIL} failed")
|
||||
return 1 if FAIL else 0
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user