"""
combined_ftmo_mc_v3.py  -  FTMO 2-STEP SWING, $100k, ACCURATE RULES
===================================================================
Models the EXACT account you'll run: FTMO 2-Step Swing $100k.

RULES (verified from FTMO docs):
  Phase 1 target: +10% (+$10,000)
  Phase 2 target: +5%  (+$5,000)
  Daily loss limit: 5% = $5,000, RESETS nightly from each day's OPENING balance,
                    includes floating (open-position) P&L
  Max loss: 10% = STATIC $90,000 floor from initial balance, never trails,
                    includes floating P&L
  No time limit (slow paths are NOT failed; we just measure how long)
  Min 4 trading days per phase
  Pass = pass Phase 1 THEN Phase 2 (sequential)

SIZING: $300k notional/pair. pnl already includes tier multiplier (verified).
mae SCALING (verified): UJ mae_pct is PERCENT (/100*notional);
                        EU mae is NEGATIVE DECIMAL (|x|*notional).

FLOATING-LOSS DAY ASSIGNMENT (the one real data limitation):
  We have each trade's mae (worst floating point) but not which calendar day
  within a multi-day hold it occurred. Two modes, both reported to bound it:
    CONSERVATIVE : full mae floating loss assumed within a single day (worst case)
    SPREAD       : mae floating loss spread across the hold's calendar days

METHODS: A sequential replay, B block bootstrap, C day-level MC, D intervention.
"""
import csv, sys
import numpy as np
from pathlib import Path
from datetime import datetime, timedelta
from collections import defaultdict

ACCOUNT=100_000
P1_TARGET=10_000        # +10%
P2_TARGET=5_000         # +5%
DAILY_LIMIT=5_000       # 5% resets nightly
MAXLOSS_FLOOR=90_000    # static 10% floor
N_SIMS=5_000; SEED=42
EU_HOLD_H=52; UJ_HOLD_H=24
EU_NOTIONAL=300_000; UJ_NOTIONAL=300_000
rng=np.random.default_rng(SEED)

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):
    s=(s or "").strip()
    for f in ("%Y-%m-%d %H:%M:%S","%Y-%m-%d %H:%M","%d/%m/%Y %H:%M"):
        try: return datetime.strptime(s[:19], f)
        except ValueError: continue
    return None

def load_trades():
    eu_p=BASE/"data"/"processed"/"trades"/"trades_real_costs.csv"
    uj_p=BASE/"data"/"processed"/"usdjpy_trades_real_costs.csv"
    T=[]
    with open(eu_p, newline="", encoding="utf-8-sig") as f:
        for r in csv.DictReader(f):
            e=parse_dt(r.get("entry_time"))
            if e is None: continue
            try: pnl=float(r["dollar_pnl_real"]); mae=float(r.get("mae",0) or 0)
            except (ValueError,KeyError): continue
            T.append({"pair":"EU","entry":e,"hold_h":EU_HOLD_H,
                      "pnl":pnl,"float_loss":abs(mae)*EU_NOTIONAL})
    with open(uj_p, newline="", encoding="utf-8-sig") as f:
        for r in csv.DictReader(f):
            e=parse_dt(r.get("entry_time"))
            if e is None: continue
            try: pnl=float(r["pnl_real"]); mae=float(r.get("mae_pct",0) or 0)
            except (ValueError,KeyError): continue
            T.append({"pair":"UJ","entry":e,"hold_h":UJ_HOLD_H,
                      "pnl":pnl,"float_loss":(mae/100.0)*UJ_NOTIONAL})
    T.sort(key=lambda t:t["entry"])
    return T

def group_by_day(trades, spread):
    """
    Build per-day records: (date, day_realized_pnl, day_worst_float_loss, n).
    day_worst_float_loss = the worst floating loss to apply intraday that day.
      CONSERVATIVE (spread=False): each trade's full mae hits its entry day;
        day's worst = max single-trade float loss entering that day.
      SPREAD (spread=True): a trade's mae is divided across the calendar days
        its hold spans; day's worst = max per-day apportioned float loss.
    """
    day_real=defaultdict(float)
    day_float=defaultdict(float)   # we take max, not sum (one trade dominates)
    day_n=defaultdict(int)
    for t in trades:
        d0=t["entry"].date()
        day_real[d0]+=t["pnl"]
        day_n[d0]+=1
        if not spread:
            day_float[d0]=max(day_float[d0], t["float_loss"])
        else:
            # spread mae across the hold's calendar days
            ndays=max(1, int(np.ceil(t["hold_h"]/24.0)))
            per=t["float_loss"]/ndays
            for k in range(ndays):
                dk=(t["entry"]+timedelta(days=k)).date()
                day_float[dk]=max(day_float[dk], per)
    days=[]
    alld=sorted(set(day_real)|set(day_float))
    for d in alld:
        days.append((d, day_real.get(d,0.0), day_float.get(d,0.0), day_n.get(d,0)))
    return days

def run_phase(day_seq, target, start_idx=0):
    """
    Walk days from start_idx. Returns (result, end_idx, elapsed_days).
    result in {'pass','daily','maxloss'}.  No timeout fail (FTMO has none).
    Daily limit resets each day from that day's OPENING equity.
    Max loss is a static floor at (ACCOUNT - 10%) = MAXLOSS_FLOOR, incl floating.
    Equity tracked as ACCOUNT + cumulative realized; floating applied intraday.
    """
    equity=ACCOUNT
    trading_days=0
    first_date=None
    i=start_idx
    n=len(day_seq)
    while i<n:
        d, day_pnl, day_float, ntr = day_seq[i]
        if first_date is None: first_date=d
        day_open=equity
        # Daily limit for today (resets): floor at day_open - 5000
        daily_floor=day_open - DAILY_LIMIT
        # Intraday low today = open + (negative part of day's realized) - floating
        intraday_low = day_open + min(0.0, day_pnl) - day_float
        # --- Max loss (static floor), checked at intraday low
        if intraday_low <= MAXLOSS_FLOOR:
            return ("maxloss", i, (d-first_date).days)
        # --- Daily loss (resets), checked at intraday low vs today's floor
        if intraday_low <= daily_floor:
            return ("daily", i, (d-first_date).days)
        # close the day (realized)
        equity += day_pnl
        if ntr>0: trading_days+=1
        # --- checks at close too (max loss static)
        if equity <= MAXLOSS_FLOOR:
            return ("maxloss", i, (d-first_date).days)
        # --- profit target (realized), need >=4 trading days
        if equity >= ACCOUNT + target and trading_days>=4:
            return ("pass", i, (d-first_date).days)
        i+=1
    return ("incomplete", n-1, (day_seq[-1][0]-first_date).days if first_date else 0)

def run_challenge(day_seq):
    """Phase1 (+10%) then Phase2 (+5%). Returns (outcome, total_elapsed_days)."""
    r1, idx1, el1 = run_phase(day_seq, P1_TARGET, 0)
    if r1 != "pass":
        return (("fail_p1_"+r1), el1)
    # Phase 2 starts AFTER phase 1 end, fresh $100k base, fresh daily limits
    r2, idx2, el2 = run_phase(day_seq, P2_TARGET, idx1+1)
    if r2 != "pass":
        return (("fail_p2_"+r2), el1+el2)
    return ("pass", el1+el2)

def summarize(label, outcomes, pass_days, n):
    passes=sum(1 for o in outcomes if o=="pass")
    f1d=sum(1 for o in outcomes if o.startswith("fail_p1_daily"))
    f1m=sum(1 for o in outcomes if o.startswith("fail_p1_maxloss"))
    f1i=sum(1 for o in outcomes if o.startswith("fail_p1_incomplete"))
    f2=sum(1 for o in outcomes if o.startswith("fail_p2"))
    r={"label":label,"n":n,
       "pass_rate":100*passes/n if n else 0,
       "f_p1_daily":100*f1d/n if n else 0,
       "f_p1_maxloss":100*f1m/n if n else 0,
       "f_p1_incomplete":100*f1i/n if n else 0,
       "f_p2":100*f2/n if n else 0}
    if pass_days:
        a=np.array(pass_days)/30.0
        r.update({"med":float(np.median(a)),"p25":float(np.percentile(a,25)),
                  "p75":float(np.percentile(a,75)),"p90":float(np.percentile(a,90)),
                  "min":float(np.min(a))})
    else:
        r.update({"med":0,"p25":0,"p75":0,"p90":0,"min":0})
    return r

def redate(seq):
    out=[]; d=datetime(2000,1,3).date()
    for (_od,pnl,fl,n) in seq:
        out.append((d,pnl,fl,n)); d+=timedelta(days=1)
        while d.weekday()>=5: d+=timedelta(days=1)
    return out

def m_seq(days):
    outs=[]; pd=[]; n=len(days)
    for s in range(n):
        if n-s<8: break
        o,el=run_challenge(days[s:]); outs.append(o)
        if o=="pass": pd.append(el)
    return summarize("A. Sequential replay", outs, pd, len(outs))

def m_block(days, n_sims=N_SIMS, blk=21):
    outs=[]; pd=[]; n=len(days); tl=min(n, 24*21)  # up to ~24 months of days
    for _ in range(n_sims):
        seq=[]
        while len(seq)<tl:
            st=rng.integers(0,max(1,n-blk)); seq.extend(days[st:st+blk])
        seq=redate(seq); o,el=run_challenge(seq); outs.append(o)
        if o=="pass": pd.append(el)
    return summarize("B. Block bootstrap", outs, pd, n_sims)

def m_day(days, n_sims=N_SIMS):
    outs=[]; pd=[]; n=len(days); tl=min(n,24*21); pool=np.arange(n)
    for _ in range(n_sims):
        pick=rng.choice(pool,size=tl,replace=True)
        seq=redate([days[i] for i in pick]); o,el=run_challenge(seq); outs.append(o)
        if o=="pass": pd.append(el)
    return summarize("C. Day-level MC", outs, pd, n_sims)

def m_interv(days, n_sims=2000, n_events=3, extra=2):
    outs=[]; pd=[]; n=len(days); tl=min(n,24*21)
    dp=sorted(d[1] for d in days); med=dp[len(dp)//2]
    bad=[d for d in days if d[1]<med]
    for _ in range(n_sims):
        pick=rng.choice(np.arange(n),size=tl,replace=True)
        seq=[list(days[i]) for i in pick]
        for _e in range(n_events):
            pos=rng.integers(0,len(seq)); sp=seq[pos][1]; sf=seq[pos][2]; sn=seq[pos][3]
            for _c in range(extra):
                bd=bad[rng.integers(0,len(bad))]; sp+=bd[1]; sf=max(sf,bd[2]); sn+=bd[3]
            seq[pos][1]=sp; seq[pos][2]=sf; seq[pos][3]=sn
        seq=redate([tuple(s) for s in seq]); o,el=run_challenge(seq); outs.append(o)
        if o=="pass": pd.append(el)
    return summarize(f"D. Intervention stress", outs, pd, n_sims)

def show(r):
    print(f"\n  {'-'*66}\n  {r['label']}   (n={r['n']:,})\n  {'-'*66}")
    print(f"    PASS (both phases)   : {r['pass_rate']:6.2f}%")
    print(f"    Fail P1 daily-DD     : {r['f_p1_daily']:6.2f}%")
    print(f"    Fail P1 max-loss     : {r['f_p1_maxloss']:6.2f}%")
    print(f"    Fail P1 incomplete   : {r['f_p1_incomplete']:6.2f}%  (ran out of data)")
    print(f"    Fail Phase 2 (any)   : {r['f_p2']:6.2f}%")
    print(f"    Median pass time     : {r['med']:.1f} months")
    print(f"    P25/P75/P90          : {r['p25']:.1f} / {r['p75']:.1f} / {r['p90']:.1f} months")
    print(f"    Fastest              : {r['min']:.1f} months")

def run_mode(spread, tag, trades):
    days=group_by_day(trades, spread)
    mfl=max(d[2] for d in days)
    print(f"\n{'='*72}\nFLOATING MODE: {tag}\n"
          f"  days={len(days):,}  max single-day floating loss=${mfl:,.0f}\n{'='*72}")
    rA=m_seq(days);  print("  A done",flush=True)
    rB=m_block(days);print("  B done",flush=True)
    rC=m_day(days);  print("  C done",flush=True)
    rD=m_interv(days);print("  D done",flush=True)
    for r in (rA,rB,rC,rD): show(r)
    meds=[rA['med'],rB['med'],rC['med']]; prs=[rA['pass_rate'],rB['pass_rate'],rC['pass_rate']]
    print(f"\n  Convergence ({tag}):")
    print(f"    Pass% A/B/C: {prs[0]:.1f}/{prs[1]:.1f}/{prs[2]:.1f}  | D {rD['pass_rate']:.1f}")
    print(f"    Median A/B/C: {meds[0]:.1f}/{meds[1]:.1f}/{meds[2]:.1f} mo "
          f"(spread {max(meds)-min(meds):.1f})")
    return rA,rB,rC,rD

def main():
    print("="*72)
    print("FTMO 2-STEP SWING $100k | P1 +10% / P2 +5% | daily 5% resets | maxloss 10% static")
    print("$300k notional/pair | no time limit | min 4 trading days/phase")
    print("="*72)
    trades=load_trades()
    print(f"Trades {len(trades):,} (EU {sum(1 for t in trades if t['pair']=='EU'):,}, "
          f"UJ {sum(1 for t in trades if t['pair']=='UJ'):,})")
    # Bound the answer: conservative vs spread floating-loss assignment
    run_mode(False, "CONSERVATIVE (full mae in one day)", trades)
    run_mode(True,  "SPREAD (mae across hold days)", trades)
    print(f"\n{'='*72}")
    print("INTERPRETATION:")
    print("  CONSERVATIVE = pessimistic bound (worst-case intraday timing)")
    print("  SPREAD       = realistic for multi-day holds")
    print("  True pass rate is between these. Daily-DD resets nightly (correct).")
    print("  vs old v1 claim ~1.7mo/100%: that ignored phase 2, intraday, and real time.")
    print("="*72)

if __name__=="__main__":
    main()
