"""
model_teardown_html_v1.py — Dual FX Macro Model tear-down report
=================================================================
Run: python src\\research\\model_teardown_html_v1.py
Reads both trade CSVs, writes reports\\macro_model_teardown.html
(standalone file — no internet needed, opens on phone/VPS/anywhere).

Conventions: $ figures at backtest sizing ($300k = 1x tier). Times UK.
FTMO panel numbers are from eu_boost_mc_v3 (2026-07-04, true intraday
equity path) — constants below, labelled as such.
"""
import csv, sys, html
from pathlib import Path
from datetime import datetime
from collections import defaultdict
import statistics as st

def resolve_base():
    for b in [Path(r"C:\Users\Administrator\OneDrive\fx_macro_intraday"),
              Path(r"C:\Users\paul_\OneDrive\fx_macro_intraday"),
              Path(__file__).resolve().parents[2]]:
        if b.exists(): return b
    sys.exit("base not found")
BASE=resolve_base()

def parse_dt(s):
    for f in ("%Y-%m-%d %H:%M:%S","%Y-%m-%d %H:%M"):
        try: return datetime.strptime((s or "").strip()[:19], f)
        except ValueError: continue
    return None

# ── load ────────────────────────────────────────────────────────────────
def load():
    T={"EU":[],"UJ":[]}
    with open(BASE/"data"/"processed"/"trades"/"trades_real_costs.csv",
              newline="", encoding="utf-8-sig") as f:
        for r in csv.DictReader(f):
            e=parse_dt(r.get("entry_time")); x=parse_dt(r.get("exit_time")) or e
            if e is None: continue
            try: pnl=float(r["dollar_pnl_real"])
            except (ValueError,KeyError): continue
            T["EU"].append({"e":e,"x":x,"pnl":pnl,
                            "reason":(r.get("exit_reason") or "?").strip(),
                            "z":float(r.get("zscore_abs") or 0)})
    with open(BASE/"data"/"processed"/"usdjpy_trades_real_costs.csv",
              newline="", encoding="utf-8-sig") as f:
        for r in csv.DictReader(f):
            e=parse_dt(r.get("entry_time")); x=parse_dt(r.get("exit_time")) or e
            if e is None: continue
            try: pnl=float(r["pnl_real"])
            except (ValueError,KeyError): continue
            T["UJ"].append({"e":e,"x":x,"pnl":pnl,
                            "reason":(r.get("exit_reason") or "?").strip(),
                            "z":float(r.get("zscore_abs") or 0)})
    for k in T: T[k].sort(key=lambda t:t["e"])
    return T

# ── stats helpers ───────────────────────────────────────────────────────
def money(v,signed=True):
    s=f"{abs(v):,.0f}"
    return (("+" if v>0 else "−") if signed and v!=0 else ("" if v>=0 else "−"))+"$"+s

def streaks(trades):
    runs=[]; cur=0; sign=None
    for t in trades:
        w=t["pnl"]>0
        if sign is None or w!=sign:
            if sign is not None: runs.append((sign,cur,start,prev))
            sign=w; cur=1; start=t["e"]
        else: cur+=1
        prev=t["e"]
    if sign is not None: runs.append((sign,cur,start,prev))
    return runs

def pair_stats(tr):
    p=[t["pnl"] for t in tr]; w=[x for x in p if x>0]; l=[x for x in p if x<=0]
    runs=streaks(tr)
    wruns=[r for r in runs if r[0]]; lruns=[r for r in runs if not r[0]]
    holds=[(t["x"]-t["e"]).total_seconds()/3600 for t in tr]
    reasons=defaultdict(lambda:[0,0.0])
    for t in tr:
        reasons[t["reason"]][0]+=1; reasons[t["reason"]][1]+=t["pnl"]
    return {
        "n":len(tr),"pnl":sum(p),"wr":100*len(w)/len(p),
        "pf":(sum(w)/abs(sum(l))) if l and sum(l)<0 else float("inf"),
        "avg_w":st.mean(w) if w else 0,"avg_l":st.mean(l) if l else 0,
        "exp":st.mean(p),"best":max(p),"worst":min(p),
        "hold_med":st.median(holds),
        "max_w":max((r[1] for r in wruns),default=0),
        "max_l":max((r[1] for r in lruns),default=0),
        "top_l":sorted(lruns,key=lambda r:-r[1])[:3],
        "top_w":sorted(wruns,key=lambda r:-r[1])[:3],
        "wdist":_dist(wruns),"ldist":_dist(lruns),
        "reasons":dict(reasons),
    }

def _dist(runs):
    d=defaultdict(int)
    for _,n,_,_ in runs: d[min(n,6)]+=1
    return d

# ── svg helpers ─────────────────────────────────────────────────────────
def svg_equity(events, W=920, H=260):
    """events: sorted (date, cum_pnl)."""
    if not events: return ""
    xs=[e[0].toordinal() for e in events]; ys=[e[1] for e in events]
    x0,x1=min(xs),max(xs); y0,y1=min(ys+[0]),max(ys)
    def X(v): return 8+(v-x0)/(x1-x0)*(W-16)
    def Y(v): return 8+(y1-v)/(y1-y0 or 1)*(H-40)
    step=max(1,len(events)//800)
    pts=" ".join(f"{X(xs[i]):.1f},{Y(ys[i]):.1f}" for i in range(0,len(events),step))
    # drawdown shading: peak line
    peak=[]; pk=-1e18
    for i in range(0,len(events),step):
        pk=max(pk,ys[i]); peak.append((xs[i],pk))
    dd_path=("M "+" L ".join(f"{X(a):.1f} {Y(b):.1f}" for a,b in peak)
             +" L "+" ".join(f"{X(xs[i]):.1f} {Y(ys[i]):.1f}" for i in
                             range(len(events)-1,-1,-step))+" Z")
    yrs=range(events[0][0].year, events[-1][0].year+1, 3)
    ticks="".join(
        f'<text x="{X(datetime(y,1,1).toordinal()):.0f}" y="{H-6}" '
        f'class="tick">{y}</text>' for y in yrs)
    zl=Y(0)
    return (f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="equity curve">'
            f'<path d="{dd_path}" fill="#E0525218" stroke="none"/>'
            f'<line x1="8" x2="{W-8}" y1="{zl:.1f}" y2="{zl:.1f}" class="zero"/>'
            f'<polyline points="{pts}" fill="none" class="eq"/>'
            f'{ticks}</svg>')

def svg_bars(items, W=920, H=170, fmt=lambda v:f"{v}"):
    """items: [(label, value, cls)]"""
    if not items: return ""
    mx=max(abs(v) for _,v,_ in items) or 1
    n=len(items); bw=(W-16)/n
    out=[f'<svg viewBox="0 0 {W} {H}">']
    for i,(lab,v,cls) in enumerate(items):
        h=abs(v)/mx*(H-46); y=(H-34)-h
        out.append(f'<rect x="{8+i*bw+1:.1f}" y="{y:.1f}" width="{bw-2:.1f}" '
                   f'height="{max(h,1):.1f}" class="{cls}"/>')
        if n<=26 or i%2==0:
            out.append(f'<text x="{8+i*bw+bw/2:.1f}" y="{H-20}" class="tick" '
                       f'text-anchor="middle">{lab}</text>')
    out.append("</svg>")
    return "".join(out)

# ── report ──────────────────────────────────────────────────────────────
def build(T):
    allt=sorted(T["EU"]+T["UJ"], key=lambda t:t["x"])
    S={k:pair_stats(v) for k,v in T.items()}
    SC=pair_stats(allt)
    # monthly grids
    bym=defaultdict(float); bym_p={"EU":defaultdict(float),"UJ":defaultdict(float)}
    for k in T:
        for t in T[k]:
            key=(t["x"].year,t["x"].month)
            bym[key]+=t["pnl"]; bym_p[k][key]+=t["pnl"]
    years=sorted({y for y,_ in bym})
    mmax=max(abs(v) for v in bym.values())
    # yearly
    byy=defaultdict(float); byyn=defaultdict(int); byyw=defaultdict(int)
    for t in allt:
        byy[t["x"].year]+=t["pnl"]; byyn[t["x"].year]+=1
        if t["pnl"]>0: byyw[t["x"].year]+=1
    # equity events + daily
    cum=0.0; ev=[]; daily=defaultdict(float)
    for t in allt:
        cum+=t["pnl"]; ev.append((t["x"],cum)); daily[t["x"].date()]+=t["pnl"]
    dvals=list(daily.values())
    sharpe=(st.mean(dvals)/st.pstdev(dvals))*(252**0.5) if len(dvals)>2 else 0
    peak=0; mdd=0
    for _,c in ev:
        peak=max(peak,c); mdd=min(mdd,c-peak)
    worst_days=sorted(daily.items(), key=lambda kv:kv[1])[:5]
    # entry hours UK (= EET-2)
    hours={k:defaultdict(int) for k in T}
    for k in T:
        for t in T[k]: hours[k][(t["e"].hour-2)%24]+=1

    def heat_rows(grid,years):
        rows=[]
        for y in years:
            tds=[f'<th>{y}</th>']
            ytot=0
            for m in range(1,13):
                v=grid.get((y,m))
                if v is None: tds.append('<td class="nul"></td>'); continue
                ytot+=v
                a=min(1.0,abs(v)/mmax*1.6)
                col=(f"rgba(63,183,118,{0.12+0.68*a:.2f})" if v>0
                     else f"rgba(224,82,82,{0.12+0.68*a:.2f})")
                rows_v=f"{v/1000:+.1f}k" if abs(v)>=1000 else f"{v:+.0f}"
                tds.append(f'<td style="background:{col}">{rows_v}</td>')
            tds.append(f'<td class="yt">{money(ytot)}</td>')
            rows.append("<tr>"+"".join(tds)+"</tr>")
        return "".join(rows)

    def reason_rows(k):
        out=[]
        tot=S[k]["n"]
        for r,(n,p) in sorted(S[k]["reasons"].items(),key=lambda x:-x[1][0]):
            out.append(f"<tr><td>{html.escape(r)}</td><td>{n} ({100*n/tot:.0f}%)</td>"
                       f'<td class="{"pos" if p>0 else "neg"}">{money(p)}</td></tr>')
        return "".join(out)

    def streak_rows(k):
        out=[]
        for lab,key in (("Longest wins","top_w"),("Longest losses","top_l")):
            for sign,n,a,b in S[k][key]:
                out.append(f"<tr><td>{lab}</td><td>{n}</td>"
                           f"<td>{a:%Y-%m-%d} → {b:%Y-%m-%d}</td></tr>")
        return "".join(out)

    def dist_row(d):
        return " · ".join(f"{k}{'+' if k==6 else ''}×{d[k]}" for k in sorted(d))

    span=f"{allt[0]['e']:%b %Y} – {allt[-1]['x']:%b %Y}"
    gen=datetime.now().strftime("%Y-%m-%d %H:%M")

    hour_bars={k:svg_bars(
        [(f"{h:02d}",hours[k].get(h,0),"amber") for h in range(24)],fmt=str)
        for k in T}
    year_bars=svg_bars([(str(y)[2:],byy[y],"pos" if byy[y]>0 else "neg")
                        for y in years])

    def card(lbl,val,cls=""):
        return (f'<div class="card"><div class="cl">{lbl}</div>'
                f'<div class="cv {cls}">{val}</div></div>')

    pairname={"EU":"EURUSD","UJ":"USDJPY"}
    pair_blocks=""
    for k in ("EU","UJ"):
        s=S[k]
        pair_blocks+=f"""
<section>
<h2>{pairname[k]}</h2>
<div class="cards">
{card("Trades",f"{s['n']:,}")}{card("Win rate",f"{s['wr']:.1f}%")}
{card("Profit factor",f"{s['pf']:.2f}")}{card("Net P&L",money(s['pnl']),"pos")}
{card("Avg win",money(s['avg_w']),"pos")}{card("Avg loss",money(s['avg_l']),"neg")}
{card("Expectancy/trade",money(s['exp']),"pos" if s['exp']>0 else "neg")}
{card("Median hold",f"{s['hold_med']:.1f}h")}
{card("Max consec wins",f"{s['max_w']}","pos")}{card("Max consec losses",f"{s['max_l']}","neg")}
{card("Best trade",money(s['best']),"pos")}{card("Worst trade",money(s['worst']),"neg")}
</div>
<h3>Exit reasons</h3>
<table class="mini"><tr><th>Exit</th><th>Count</th><th>P&L</th></tr>{reason_rows(k)}</table>
<h3>Streak record</h3>
<table class="mini"><tr><th></th><th>Len</th><th>When</th></tr>{streak_rows(k)}</table>
<p class="fn">Win-streak lengths: {dist_row(s['wdist'])} &nbsp;|&nbsp; loss-streak lengths: {dist_row(s['ldist'])}</p>
<h3>Entry hours (UK time)</h3>
{hour_bars[k]}
</section>"""

    worst_rows="".join(f"<tr><td>{d}</td><td class='neg'>{money(v)}</td></tr>"
                       for d,v in worst_days)

    return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dual FX Macro Model — Tear-down</title>
<style>
:root{{--bg:#101418;--panel:#161c22;--line:#232c35;--ink:#E9E5DA;
--mut:#8B94A1;--amber:#E4B457;--pos:#3FB776;--neg:#E05252}}
*{{box-sizing:border-box;margin:0}}
body{{background:var(--bg);color:var(--ink);
font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;padding:0 0 60px}}
.wrap{{max-width:960px;margin:0 auto;padding:0 14px}}
header{{border-bottom:2px solid var(--amber);padding:26px 0 18px;margin-bottom:8px}}
h1{{font-size:26px;font-weight:800;letter-spacing:.4px}}
h1 span{{color:var(--amber)}}
.spec{{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
gap:1px;background:var(--line);border:1px solid var(--line);margin-top:14px}}
.spec div{{background:var(--panel);padding:7px 10px;font-size:12px;color:var(--mut)}}
.spec b{{display:block;color:var(--ink);
font:13px ui-monospace,Consolas,monospace}}
h2{{font-size:15px;letter-spacing:2.5px;text-transform:uppercase;
color:var(--amber);border-bottom:1px solid var(--line);
padding:26px 0 6px;margin-bottom:12px}}
h3{{font-size:12px;letter-spacing:1.5px;text-transform:uppercase;
color:var(--mut);margin:16px 0 6px}}
.cards{{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px}}
.card{{background:var(--panel);border:1px solid var(--line);padding:9px 11px}}
.cl{{font-size:10.5px;letter-spacing:1px;text-transform:uppercase;color:var(--mut)}}
.cv{{font:600 19px ui-monospace,Consolas,monospace;margin-top:2px}}
.pos{{color:var(--pos)}}.neg{{color:var(--neg)}}
.scroll{{overflow-x:auto}}
table.heat{{border-collapse:collapse;font:11px ui-monospace,Consolas,monospace;
white-space:nowrap}}
table.heat th{{color:var(--mut);font-weight:400;padding:2px 6px;text-align:left}}
table.heat td{{padding:3px 6px;text-align:right;min-width:44px;color:#fff}}
table.heat td.nul{{background:#151a20}}
table.heat td.yt{{color:var(--amber);background:none;font-weight:700}}
table.mini{{border-collapse:collapse;width:100%;max-width:560px;
font:13px ui-monospace,Consolas,monospace}}
table.mini th{{text-align:left;color:var(--mut);font-weight:400;
border-bottom:1px solid var(--line);padding:4px 8px 4px 0;font-size:11px;
text-transform:uppercase;letter-spacing:1px}}
table.mini td{{padding:4px 8px 4px 0;border-bottom:1px solid #1a2129}}
svg{{width:100%;height:auto;display:block;background:var(--panel);
border:1px solid var(--line)}}
.eq{{stroke:var(--amber);stroke-width:1.6}}
.zero{{stroke:#3a4553;stroke-dasharray:3 4}}
.tick{{fill:var(--mut);font:10px ui-monospace,monospace}}
rect.pos{{fill:var(--pos)}}rect.neg{{fill:var(--neg)}}rect.amber{{fill:var(--amber)}}
.fn{{color:var(--mut);font-size:12px;margin-top:6px}}
.ftmo{{background:var(--panel);border:1px solid var(--amber);padding:14px 16px}}
.ftmo table{{margin-top:8px}}
.log li{{margin:6px 0 6px 18px;font-size:13.5px}}
</style></head><body><div class="wrap">
<header>
<h1>DUAL FX MACRO MODEL <span>· TEAR-DOWN</span></h1>
<p class="fn">Yield-spread z-score mean reversion · EURUSD + USDJPY · real-cost backtest {span} · generated {gen} · executor v3.3</p>
<div class="spec">
<div>EURUSD signal<b>|z| ≥ 2.75 · TP 0.20% · SL 0.25% · hold 52h</b></div>
<div>USDJPY signal<b>|z| ≥ 2.0 · TP 0.70% · SL 0.40% · hold 24h</b></div>
<div>Sessions (UK)<b>EU 05:00–14:59 · UJ 03:00–13:59</b></div>
<div>EU boost<b>×2.0 when UJ flat (92% of entries)</b></div>
<div>UJ re-entry<b>1 entry / 24h cooldown</b></div>
<div>Tail guard<b>halt new entries at −$4k day</b></div>
</div>
</header>

<section><h2>Combined ledger</h2>
<div class="cards">
{card("Net P&L (real costs)",money(SC['pnl']),"pos")}
{card("Trades",f"{SC['n']:,}")}
{card("Win rate",f"{SC['wr']:.1f}%")}
{card("Profit factor",f"{SC['pf']:.2f}")}
{card("Expectancy/trade",money(SC['exp']),"pos")}
{card("Daily Sharpe (ann.)",f"{sharpe:.2f}")}
{card("Max drawdown",money(mdd),"neg")}
{card("Max consec wins",f"{SC['max_w']}","pos")}
{card("Max consec losses",f"{SC['max_l']}","neg")}
</div>
<h3>Equity curve — cumulative real-cost P&L (drawdown shaded)</h3>
{svg_equity(ev)}
<h3>Yearly P&L</h3>
{year_bars}
</section>

<section><h2>The ledger wall — monthly P&L</h2>
<div class="scroll"><table class="heat">
<tr><th></th>{"".join(f"<th>{m}</th>" for m in
["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"])}<th>Year</th></tr>
{heat_rows(bym,years)}
</table></div>
<p class="fn">Cell colour intensity scales with $ magnitude at $300k-base tier sizing (pre-boost). Combined EU+UJ, P&L booked on exit date.</p>
<h3>5 worst realized days in {len(years)} years</h3>
<table class="mini"><tr><th>Date</th><th>Realized</th></tr>{worst_rows}</table>
</section>

{pair_blocks}

<section><h2>FTMO — corrected numbers (eu_boost_mc_v3, 2026-07-04)</h2>
<div class="ftmo">
<p>True point-in-time intraday equity (phantom concurrency &amp; post-exit
float removed). 2-Step Swing $100k, base $300k = 1x tier, EU ×2 boost:</p>
<table class="mini">
<tr><th>Setting</th><th>Pass%</th><th>Daily fail</th><th>Worst day-low</th><th>Median</th></tr>
<tr><td>1.0× baseline</td><td>95–100%</td><td>0.0%</td><td>−$2,904</td><td>1.9–3.5 mo</td></tr>
<tr><td>1.5×</td><td>95–100%</td><td>0.0%</td><td>−$3,177</td><td>1.5–2.8 mo</td></tr>
<tr><td><b>2.0× (live)</b></td><td>95–100%</td><td>0.0%</td><td>−$3,794</td><td>1.2–2.3 mo</td></tr>
</table>
<p class="fn">Zero daily-limit breach days in the full history at any setting.
Current regime runs slower than the quiet-year pool — realistic anchor
≈ 3 months, ≈ $5.2k/month expected pace at 2×. Known tail: two boosted
2x-tier stops in one day = −$6k (never occurred; −$4k halt guards it).</p>
</div>
</section>

<section><h2>Executor changelog — 2026-07-04 audit</h2>
<ul class="log">
<li><b>v3.1</b> Session windows corrected to the validated backtest's EET hours (EU was shifted +2h; UJ was blocking 45% of its entry hours).</li>
<li><b>v3.2</b> USDJPY 24h entry cooldown (backtest last_exit rule) — ends same-day re-entries on the persistent daily signal. UJ session end tightened to 15:59 EET.</li>
<li><b>v3.3</b> EU ×2.0 conditional boost (no UJ open at fill) + −$4k daily-loss entry halt (0 backtest triggers).</li>
<li><b>Data</b> UJ trade list regenerated with true exit times; MFE/MAE truncated at exit. v4 floating-drawdown study superseded — its risk figures were overstated by phantom concurrency.</li>
</ul>
</section>

<p class="fn">All $ at backtest convention ($300k notional = 1x tier), real
costs included. Live risk-based sizing (0.75%/trade × tier × boost) scales
proportionally. Generated locally by model_teardown_html_v1.py — no data
leaves the machine.</p>
</div></body></html>"""

def main():
    T=load()
    out=BASE/"reports"
    out.mkdir(exist_ok=True)
    p=out/"macro_model_teardown.html"
    p.write_text(build(T), encoding="utf-8")
    print(f"Report written: {p}")
    print("Open it in any browser (double-click, or copy to your phone).")

if __name__=="__main__":
    main()
