#!/usr/bin/env python3
"""Mingyang Fund — risk check.

Reads data.json (holdings, weights, kill levels) and data/risk-prices.json (daily closes and the
Treasury curve), computes the book's concentration, duration, volatility, correlation, beta, VaR,
drawdown and the rule checks, then writes data/risk.json and rebuilds risk.html.

Run from the site folder:   python3 scripts/risk-check.py
Standard library only. To refresh, append new closes and curve rows to data/risk-prices.json
(see its "howToUpdate" note) and re-run.
"""
import json, math, os, sys, html, datetime as dt
from statistics import mean, stdev

SITE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
data = json.load(open(os.path.join(SITE, 'data.json'), encoding='utf-8'))
PX = json.load(open(os.path.join(SITE, 'data', 'risk-prices.json'), encoding='utf-8'))
RULES = PX['rules']
WINDOW = PX.get('window', 60)

# ───────────────────────── inputs ─────────────────────────
hold = data['holdings']
w = {h['sym']: h['weight'] / 100.0 for h in hold}
cash = max(0.0, 1.0 - sum(w.values()))
series = {k: [(d_, p) for d_, p in v] for k, v in PX['closes'].items()}
asof = PX['asOf']
last = {k: v[-1][1] for k, v in series.items()}
curve = PX['treasury']['rows']; cur_curve = dict(zip(PX['treasury']['columns'], curve[-1]))

def rets(s):
    return [(s[i][1] / s[i - 1][1] - 1.0) for i in range(1, len(s))]
def tail(x, n): return x[-n:] if len(x) > n else x
R = {k: tail(rets(v), WINDOW) for k, v in series.items()}
n_obs = min(len(v) for v in R.values())
R = {k: v[-n_obs:] for k, v in R.items()}
dates_r = [d_ for d_, _ in series['SPY']][-n_obs:]

def corr(a, b):
    ma, mb = mean(a), mean(b)
    num = sum((x - ma) * (y - mb) for x, y in zip(a, b))
    den = math.sqrt(sum((x - ma) ** 2 for x in a) * sum((y - mb) ** 2 for y in b))
    return num / den if den else 0.0
def cov(a, b):
    ma, mb = mean(a), mean(b)
    return sum((x - ma) * (y - mb) for x, y in zip(a, b)) / (len(a) - 1)

syms = [h['sym'] for h in hold]
ANN = math.sqrt(252)
vol = {k: stdev(R[k]) * ANN for k in R}
beta = {k: cov(R[k], R['SPY']) / cov(R['SPY'], R['SPY']) for k in R}
# shock beta: the same regression with each name's own gap days (|return| > 15%, i.e. earnings sessions) removed
def beta_ex_gaps(k):
    pairs = [(a, b) for a, b in zip(R[k], R['SPY']) if abs(a) <= 0.15]
    if len(pairs) < 20: return beta[k]
    x = [b for _, b in pairs]; y = [a for a, _ in pairs]
    return cov(y, x) / cov(x, x)
beta_shock = {k: beta_ex_gaps(k) for k in R}
cm = {a: {b: corr(R[a], R[b]) for b in R} for a in R}

# portfolio daily returns at fixed current weights (cash earns 0 in the window)
port = [sum(w[s] * R[s][i] for s in syms) for i in range(n_obs)]
port_vol = stdev(port) * ANN
port_beta = sum(w[s] * beta[s] for s in syms)
port_corr_spy = corr(port, R['SPY'])
z95, z99 = 1.6449, 2.3263
var95 = z95 * stdev(port); var99 = z99 * stdev(port)
sorted_port = sorted(port)
hist95 = -sorted_port[max(0, int(0.05 * n_obs) - 1)]
worst_day = min(port); worst_i = port.index(worst_day)
# drawdown of the fixed-weight book over the window
cum = [1.0]
for r in port: cum.append(cum[-1] * (1 + r))
peak = cum[0]; mdd = 0.0
for v in cum:
    peak = max(peak, v); mdd = min(mdd, v / peak - 1)

# ───────────────────────── concentration ─────────────────────────
inv = sum(w.values())
hhi_total = sum(x * x for x in w.values()) + cash * cash
hhi_inv = sum((x / inv) ** 2 for x in w.values())
top = sorted(w.items(), key=lambda kv: -kv[1])
sleeves = {}
for h in hold:
    sl = PX['sleeveOf'].get(h['sym'], h.get('sector', 'other'))
    sleeves[sl] = sleeves.get(sl, 0.0) + w[h['sym']]

# ───────────────────────── duration & rate shocks ─────────────────────────
dur = PX['duration']  # assumed effective durations
book_dur = sum(w[s] * dur.get(s, 0.0) for s in syms)
def rate_shock(bp):  # parallel shift, price effect only
    return {s: -dur[s] * bp / 10000.0 for s in syms if s in dur}
shocks = []
for bp in (25, 50, 100):
    eff = rate_shock(bp); shocks.append({"shock": f"+{bp}bp parallel", "nav": sum(w[s] * eff[s] for s in eff), "byPosition": {s: eff[s] for s in eff}})
# shock to the kill levels: long end to 30y 5.67 (TLT) and 2y to 5.0 (IEI)
d30 = (RULES['killLevels']['y30'] - cur_curve['y30']) * 100; d2 = (RULES['killLevels']['y2'] - cur_curve['y2']) * 100
shocks.append({"shock": f"To the kill levels (30y +{d30:.0f}bp, 2y +{d2:.0f}bp)", "nav": -w['TLT'] * dur['TLT'] * d30 / 10000 - w['IEI'] * dur['IEI'] * d2 / 10000,
               "byPosition": {"TLT": -dur['TLT'] * d30 / 10000, "IEI": -dur['IEI'] * d2 / 10000}})
# equity shocks via beta
eq_shocks = []
for m in (-0.10, -0.20):
    by = {s: beta_shock[s] * m for s in syms}
    eq_shocks.append({"shock": f"SPY {m*100:+.0f}% (via {WINDOW}-day beta, gap days removed)", "nav": sum(w[s] * by[s] for s in syms), "byPosition": by})

# ───────────────────────── position P&L vs entry ─────────────────────────
pos = []
for h in hold:
    s = h['sym']; p = last[s]; r = (p / h['entry'] - 1) if h.get('entry') else None
    pos.append({"sym": s, "weight": w[s], "entry": h['entry'], "approx": bool(h.get('approx')), "last": p, "ret": r, "navContribution": (w[s] * r) if r is not None else None,
                "vol": vol[s], "beta": beta[s], "corrSPY": cm[s]['SPY'], "conviction": h.get('conviction'), "sleeve": PX['sleeveOf'].get(s, '')})

# ───────────────────────── rule checks ─────────────────────────
checks = []
def chk(rule, status, value, detail):
    checks.append({"rule": rule, "status": status, "value": value, "detail": detail})
chk("Single position ≤ 35% of NAV", "pass" if top[0][1] <= 0.35 + 1e-9 else "fail", f"{top[0][0]} {top[0][1]*100:.0f}%", "Largest position against the cap; at the cap counts as a pass.")
rs = sleeves.get('rates', 0)
chk("One macro view (sleeve) ≤ 50% of NAV", "pass" if rs <= 0.50 + 1e-9 else "fail", f"rates {rs*100:.0f}%", "TLT + IEI share one view: lower long-end yields into a credit slowdown.")
chk("Top two positions ≤ 60% of NAV", "pass" if top[0][1] + top[1][1] <= 0.60 + 1e-9 else "fail", f"{(top[0][1]+top[1][1])*100:.0f}%", "The August book ran at 60%; the cap is set there.")
chk("Cash ≥ 10% of NAV", "pass" if cash >= 0.10 else "fail", f"{cash*100:.0f}%", "Dry powder for the re-entry rules without selling the Treasury sleeve.")
lowc = sum(w[h['sym']] for h in hold if h.get('conviction') == 'low')
chk("Low-conviction positions ≤ 5% combined", "pass" if lowc <= 0.05 + 1e-9 else "fail", f"{lowc*100:.0f}%", "INTC and AMAT are 2% each.")
y10, y30, y2 = cur_curve['y10'], cur_curve['y30'], cur_curve['y2']
K = RULES['killLevels']
chk("TLT kill: 10y close > 5.31% or 30y close > 5.67%", "fail" if (y10 > K['y10'] or y30 > K['y30']) else "pass", f"10y {y10:.2f}% · 30y {y30:.2f}% ({curve[-1][0]})",
    f"Margin {100*(K['y10']-y10):.0f}bp on the 10-year and {100*(K['y30']-y30):.0f}bp on the 30-year. Closest approach: 10y 5.31% on Oct 5 and 30y 5.67% on Oct 7 — at, not through.")
chk("IEI kill: 2y close > 5.00%", "fail" if y2 > K['y2'] else "pass", f"2y {y2:.2f}%", f"Margin {100*(K['y2']-y2):.0f}bp.")
for s, stop in RULES['stops'].items():
    p = last[s]; chk(f"{s} hard stop {stop:.2f}", "fail" if p < stop else "pass", f"last {p:.2f}", f"Cushion {100*(p/stop-1):.0f}% above the stop.")
for ps in pos:
    if ps['ret'] is not None and ps['ret'] <= -0.25:
        chk(f"{ps['sym']} drawdown vs cost > 25% → review", "flag", f"{ps['ret']*100:.0f}%", RULES['reviews'].get(ps['sym'], "Review required."))
chk("Portfolio 1-day 99% VaR ≤ 3% of NAV", "pass" if var99 <= 0.03 else "flag", f"{var99*100:.2f}%", "Parametric, fixed weights, last %d trading days." % n_obs)

risk = {
 "asOf": asof, "window": n_obs, "windowFrom": dates_r[0], "windowTo": dates_r[-1],
 "weights": {**{s: w[s] for s in syms}, "cash": cash}, "sleeves": {**sleeves, "cash": cash},
 "concentration": {"hhiTotal": hhi_total, "hhiInvested": hhi_inv, "effectiveN": 1 / hhi_inv, "top1": top[0], "top2": top[0][1] + top[1][1]},
 "duration": {"assumed": dur, "bookYears": book_dur, "dv01pctPer100bp": -book_dur / 100 * 100},
 "rateShocks": shocks, "equityShocks": eq_shocks,
 "vol": {**{s: vol[s] for s in syms}, "SPY": vol['SPY'], "SOXX": vol.get('SOXX')},
 "beta": {**{s: beta[s] for s in syms}, "portfolio": port_beta}, "betaExGaps": {s: beta_shock[s] for s in syms},
 "corr": {a: {b: cm[a][b] for b in cm} for a in cm},
 "portfolio": {"volAnn": port_vol, "corrSPY": port_corr_spy, "var95_1d": var95, "var99_1d": var99, "histVar95_1d": hist95, "worstDay": worst_day, "worstDayDate": dates_r[worst_i], "maxDrawdownWindow": mdd, "dailyVol": stdev(port)},
 "positions": pos, "unrealisedNav": sum(p['navContribution'] for p in pos if p['navContribution'] is not None),
 "curve": {"date": curve[-1][0], **{k: cur_curve[k] for k in ('y2', 'y5', 'y10', 'y30')}},
 "checks": checks, "rules": RULES,
 "notes": [
  f"Window: the last {n_obs} trading days to {asof}; daily simple returns; volatility annualised by √252. Correlations and betas are sample statistics over the same window and move with it.",
  "Weights are the Oct 16 snapshot (TLT 35, IEI 15, OSCR 10, INTC 2, AMAT 2, cash 36) held fixed over the window, so the VaR and drawdown describe today's book run back over the window, not the realised history.",
  "Durations are assumed (TLT 16, IEI 4.4 years) from the funds' published effective durations; the rate shocks are price effects of a parallel move with no convexity and no carry.",
  "Parametric VaR assumes normal daily returns; the historical 95% figure is the 5th-percentile day of the fixed-weight book over the window. With one quarter of data these are order-of-magnitude numbers, not limits.",
  "INTC's 60-day statistics include the Oct 30 and Nov 2 sessions (−29% and −8%), which dominate its volatility and the book's worst day."
 ]
}
out = os.path.join(SITE, 'data', 'risk.json')
json.dump(risk, open(out, 'w', encoding='utf-8'), indent=1)
print('wrote', out)

# ───────────────────────── page ─────────────────────────
def esc(s): return html.escape(str(s), quote=False).replace("'", '&#39;')
def pct(x, dp=1, sign=True):
    if x is None: return '—'
    v = x * 100; return ('+' if sign and v > 0 else '') + f'{v:.{dp}f}%'
def cls(x): return 'pos' if x is not None and x > 0 else 'neg' if x is not None and x < 0 else ''
def stat(label, val, sub, c=''):
    return f'<div class="stat"><div class="stat-label">{esc(label)}</div><div class="stat-val {c}">{val}</div><div class="stat-sub">{esc(sub)}</div></div>'
def pill(st):
    return {'pass': '<span class="pill pill-open">Pass</span>', 'fail': '<span class="pill pill-red">Breach</span>', 'flag': '<span class="pill pill-amber">Review</span>'}[st]

h = []
h.append(f'<main id="main" ><div class="fund-page-head wrap"><div class="fund-overline">Risk</div><h1>What the book can lose, and the rules that cut it.</h1><p>A quantitative check on the current holdings — concentration, duration, volatility, correlation, VaR and drawdown — and the kill levels and position rules run against the latest prices and the Treasury curve. Generated by <code>scripts/risk-check.py</code> from the site data; nothing on this page is a forecast.</p><div class="fund-head-links"><a class="" href="/data/risk.json">risk.json ↓</a><a class="" href="/scripts/risk-check.py">risk-check.py ↓</a><a class="" href="/track-record#holdings">Holdings →</a></div></div><div class="fund-content wrap">')
h.append('<div class="fund-metrics">' +
  f'<div class="fund-metric"><div class="fund-metric-label">As of</div><div class="fund-metric-value">{esc(dt.datetime.strptime(asof, "%Y-%m-%d").strftime("%b %-d, %Y"))}</div><div class="fund-metric-note">Prices and curve</div></div>' +
  f'<div class="fund-metric"><div class="fund-metric-label">Rules</div><div class="fund-metric-value">{sum(1 for c in checks if c["status"]=="pass")} pass · {sum(1 for c in checks if c["status"]=="flag")} review · {sum(1 for c in checks if c["status"]=="fail")} breach</div><div class="fund-metric-note">{len(checks)} checks</div></div>' +
  f'<div class="fund-metric"><div class="fund-metric-label">1-day VaR (99%)</div><div class="fund-metric-value">{pct(var99, 2, False)}</div><div class="fund-metric-note">of NAV · parametric · {n_obs}-day window</div></div>' +
  f'<div class="fund-metric"><div class="fund-metric-label">Book duration</div><div class="fund-metric-value">{book_dur:.1f} years</div><div class="fund-metric-note">−{book_dur:.1f}% of NAV per +100bp</div></div></div>')

# rule checks
h.append('<section class="fund-section"><div class="section-head"><h2>Rule checks</h2><span class="line"></span></div><p class="section-intro">Position and sleeve caps, kill levels and stops, run against the latest close. A breach means the rule says sell; a review means the rule says look and record the decision.</p><div class="tbl-wrap"><table class="tbl tbl-risk"><thead><tr><th>Rule</th><th>Status</th><th class="num">Value</th><th>Detail</th></tr></thead><tbody>')
for c in checks:
    h.append(f'<tr><td>{esc(c["rule"])}</td><td>{pill(c["status"])}</td><td class="num nowrap">{esc(c["value"])}</td><td class="lesson">{esc(c["detail"])}</td></tr>')
h.append('</tbody></table></div></section>')

# positions
h.append('<section class="fund-section"><div class="section-head"><h2>Positions</h2><span class="line"></span></div><div class="tbl-wrap"><table class="tbl tbl-risk"><thead><tr><th>Ticker</th><th>Sleeve</th><th class="num">Weight</th><th class="num">Entry</th><th class="num">Last</th><th class="num">vs cost</th><th class="num">NAV effect</th><th class="num">Vol (ann.)</th><th class="num">Beta</th><th class="num">Corr SPY</th></tr></thead><tbody>')
for p in pos:
    h.append(f'<tr><td><span class="sym">{esc(p["sym"])}</span></td><td class="muted">{esc(p["sleeve"])}</td><td class="num">{p["weight"]*100:.0f}%</td><td class="num">{"≈" if p["approx"] else ""}{p["entry"]:.2f}</td><td class="num">{p["last"]:.2f}</td><td class="num {cls(p["ret"])}">{pct(p["ret"])}</td><td class="num {cls(p["navContribution"])}">{pct(p["navContribution"], 2)}</td><td class="num">{pct(p["vol"], 0, False)}</td><td class="num">{p["beta"]:.2f}</td><td class="num">{p["corrSPY"]:.2f}</td></tr>')
h.append(f'<tr><td><strong>Book</strong></td><td class="muted">cash {cash*100:.0f}%</td><td class="num">{inv*100:.0f}%</td><td></td><td></td><td></td><td class="num {cls(risk["unrealisedNav"])}"><strong>{pct(risk["unrealisedNav"], 2)}</strong></td><td class="num">{pct(port_vol, 0, False)}</td><td class="num">{port_beta:.2f}</td><td class="num">{port_corr_spy:.2f}</td></tr>')
h.append('</tbody></table></div><p class="chart-note">vs cost is the price return against the (approximate) average cost; NAV effect is weight × return, so it is the unrealised contribution of each position on current weights. Vol, beta and correlation use the window below.</p></section>')

# concentration & sleeves
h.append('<section class="fund-section"><div class="section-head"><h2>Concentration</h2><span class="line"></span></div><div class="stat-row">' +
  stat('Largest position', f'{top[0][0]} {top[0][1]*100:.0f}%', f'Top two {risk["concentration"]["top2"]*100:.0f}% · cap 35% / 60%') +
  stat('Effective positions', f'{1/hhi_inv:.1f}', f'1 / HHI on invested capital ({inv*100:.0f}% invested)') +
  stat('One macro view', f'{rs*100:.0f}%', 'TLT + IEI · cap 50%') + '</div>')
h.append('<div class="tbl-wrap mt16"><table class="tbl"><thead><tr><th>Sleeve</th><th class="num">Weight</th><th>What it shares</th></tr></thead><tbody>')
for sl, wt in sorted(sleeves.items(), key=lambda kv: -kv[1]):
    h.append(f'<tr><td>{esc(sl)}</td><td class="num">{wt*100:.0f}%</td><td class="lesson">{esc(PX["sleeveNotes"].get(sl, ""))}</td></tr>')
h.append(f'<tr><td>cash</td><td class="num">{cash*100:.0f}%</td><td class="lesson">Earns the bill rate; not in the risk statistics.</td></tr></tbody></table></div></section>')

# rate shocks
h.append('<section class="fund-section"><div class="section-head"><h2>Duration and rate shocks</h2><span class="line"></span></div>' +
  f'<p class="section-intro">Assumed effective durations: TLT {dur["TLT"]} years, IEI {dur["IEI"]} years. Book duration {book_dur:.1f} years on NAV. Curve on {curve[-1][0]}: 2y {y2:.2f}% · 5y {cur_curve["y5"]:.2f}% · 10y {y10:.2f}% · 30y {y30:.2f}%.</p>' +
  '<div class="tbl-wrap"><table class="tbl"><thead><tr><th>Shock</th><th class="num">TLT</th><th class="num">IEI</th><th class="num">Book (NAV)</th></tr></thead><tbody>')
for s in shocks:
    h.append(f'<tr><td>{esc(s["shock"])}</td><td class="num neg">{pct(s["byPosition"].get("TLT"))}</td><td class="num neg">{pct(s["byPosition"].get("IEI"))}</td><td class="num neg"><strong>{pct(s["nav"], 2)}</strong></td></tr>')
h.append('</tbody></table></div><p class="chart-note">Price effect of a parallel shift, no convexity, no carry. The last row is what the book loses if yields rise to the kill levels — the rule cuts TLT at the 10-year/30-year levels and IEI at the 2-year level, so this is the budgeted loss on the Treasury sleeve.</p></section>')

# vol/corr/var
h.append('<section class="fund-section"><div class="section-head"><h2>Volatility, correlation and VaR</h2><span class="line"></span></div><div class="stat-row">' +
  stat('Book volatility', pct(port_vol, 0, False), f'annualised · fixed weights · {n_obs} days') +
  stat('1-day VaR 95% / 99%', f'{pct(var95, 2, False)} / {pct(var99, 2, False)}', f'parametric · historical 95% {pct(hist95, 2, False)}') +
  stat('Worst day in window', pct(worst_day, 2), f'{dates_r[worst_i]} · max drawdown {pct(mdd)}', 'neg') +
  stat('Beta to SPY', f'{port_beta:.2f}', f'correlation {port_corr_spy:.2f}') + '</div>')
keys = syms + ['SPY'] + (['SOXX'] if 'SOXX' in cm else [])
h.append('<div class="tbl-wrap"><table class="tbl tbl-corr"><thead><tr><th></th>' + ''.join(f'<th class="num">{esc(k)}</th>' for k in keys) + '<th class="num">Vol</th></tr></thead><tbody>')
for a in keys:
    h.append(f'<tr><td><span class="sym">{esc(a)}</span></td>' + ''.join(f'<td class="num">{cm[a][b]:.2f}</td>' for b in keys) + f'<td class="num">{pct(vol[a], 0, False)}</td></tr>')
h.append('</tbody></table></div>')
tl = cm['TLT']['SPY']
h.append(f'<p class="chart-note">Correlation matrix of daily returns, {dates_r[0]} to {dates_r[-1]}. The number that matters for this book is TLT against SPY: {tl:+.2f} over the window. {"Positive: Treasuries did not hedge the equity sleeve over this window, so the book is one macro bet with equity beta on top." if tl > 0.1 else "Near zero or negative: the Treasury sleeve diversified the equity sleeve over this window."}</p></section>')

# equity shocks
h.append('<section class="fund-section"><div class="section-head"><h2>Equity shocks</h2><span class="line"></span></div><div class="tbl-wrap"><table class="tbl"><thead><tr><th>Shock</th>' + ''.join(f'<th class="num">{esc(s)}</th>' for s in syms) + '<th class="num">Book (NAV)</th></tr></thead><tbody>')
for s in eq_shocks:
    h.append(f'<tr><td>{esc(s["shock"])}</td>' + ''.join(f'<td class="num {cls(s["byPosition"][k])}">{pct(s["byPosition"][k])}</td>' for k in syms) + f'<td class="num {cls(s["nav"])}"><strong>{pct(s["nav"], 2)}</strong></td></tr>')
h.append('</tbody></table></div><p class="chart-note">Beta-implied moves using each name\'s regression with its own gap days (|return| above 15%, i.e. earnings sessions) removed: ' + ', '.join(f"{s} {beta_shock[s]:.2f}" for s in syms) + '. Single-name risk is not captured this way — INTC on Oct 30 was a −29% day against −1.8% for SPY; the hard stop and the 2% cap are the control for that.</p></section>')

# rules text
h.append('<section class="fund-section"><div class="section-head"><h2>The rules</h2><span class="line"></span></div><ol class="method-list">' + ''.join(f'<li>{esc(r)}</li>' for r in RULES['text']) + '</ol>')
h.append('<div class="blk-label mt16">Method notes</div><ol class="method-list">' + ''.join(f'<li>{esc(n)}</li>' for n in risk['notes']) + '</ol>')
h.append('<p class="chart-note">To refresh: add new closes and curve rows to <code>data/risk-prices.json</code>, update weights in <code>data.json</code>, run <code>python3 scripts/risk-check.py</code> and deploy. The script has no dependencies beyond Python 3.</p></section>')
h.append('</div></main>')

tpl = open(os.path.join(SITE, 'methodology.html'), encoding='utf-8').read()
a = tpl.find('<main'); b = tpl.find('</main>') + 7
head = tpl[:a]; foot = tpl[b:]
head = head.replace('<title>Methodology &amp; data | Mingyang Fund</title>', '<title>Risk | Mingyang Fund</title>')
head = head.replace('content="Return definitions, data provenance and measurement limitations."', 'content="Concentration, duration, volatility, correlation, VaR and the kill-level checks on the current book, generated from the site data."')
head = head.replace('<link rel="canonical" href="https://mingyang.fund/methodology">', '<link rel="canonical" href="https://mingyang.fund/risk">')
head = head.replace('<meta property="og:title" content="Methodology &amp; data | Mingyang Fund">', '<meta property="og:title" content="Risk | Mingyang Fund">')
head = head.replace('<a href="/track-record">Track record</a>', '<a href="/track-record">Track record</a>')
page = head + ''.join(h) + foot
open(os.path.join(SITE, 'risk.html'), 'w', encoding='utf-8').write(page)
print('wrote risk.html', len(page))
