File size: 9,119 Bytes
a45d45a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
"""Multi-symbol, multi-day backtest for the MACD + Parabolic SAR strategy.

Runs the exact entry/exit rules (see ../indicators_macd_psar.py, exit_mode=both)
across a set of stocks over a date range, priced on the REAL ATM CE/PE option
candles, and reports per-symbol and aggregate net P&L.

Usage:
    python macd_psar/backtest_range.py 2026-07-01 2026-07-17
    python macd_psar/backtest_range.py 2026-07-01 2026-07-17 AXISBANK,SBIN

Defaults to the priority_rank==1 stocks in option_stock_universe.csv.
"""

import os
import sys
from pathlib import Path

import pandas as pd

ENTRY_MODE = os.getenv("MP_ENTRY_MODE", "state").strip().lower()  # state | cross_zero

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from indicators_macd_psar import add_macd_psar_indicators, mp_exit_reason  # noqa: E402
from macd_psar.backtest_macd_psar import (  # noqa: E402
    TZ, SESSION_START, ENTRY_CUTOFF, EOD_EXIT, EXIT_MODE, WARMUP_DAYS,
    _get_kite, _instrument_token, _round_trip_charges, _option_close_at,
)

# Preload the NFO chain once (the single-day helper re-reads it per call).
_NFO = pd.read_csv(Path(__file__).resolve().parent.parent / "instruments_nfo.csv")
_NFO["name"] = _NFO["name"].astype(str).str.upper()
_NFO["expiry_d"] = pd.to_datetime(_NFO["expiry"], errors="coerce").dt.date


def _priority1_symbols():
    uni = pd.read_csv(Path(__file__).resolve().parent.parent / "option_stock_universe.csv")
    return uni[uni["priority_rank"] == 1.0]["symbol"].astype(str).str.upper().tolist()


def _atm_contract(symbol, direction, ref_price, trade_day):
    opt_type = "CE" if direction == "CALL" else "PE"
    c = _NFO[(_NFO["name"] == symbol.upper()) & (_NFO["instrument_type"] == opt_type)]
    future = c[c["expiry_d"] >= trade_day]
    if future.empty:
        return None
    nearest_exp = min(future["expiry_d"].unique())
    chain = future[future["expiry_d"] == nearest_exp].copy()
    chain["dist"] = (chain["strike"].astype(float) - float(ref_price)).abs()
    pick = chain.sort_values(["dist", "strike"]).iloc[0]
    return {
        "tradingsymbol": str(pick["tradingsymbol"]).upper(),
        "instrument_token": int(pick["instrument_token"]),
        "strike": int(pick["strike"]),
        "lot_size": int(pick["lot_size"]),
    }


def _fetch_5m(kite, token, frm_dt, to_dt):
    candles = kite.historical_data(token, frm_dt.to_pydatetime(), to_dt.to_pydatetime(), interval="5minute")
    df = pd.DataFrame(candles)
    if df.empty:
        return df
    df["date"] = pd.to_datetime(df["date"])
    df["date"] = (df["date"].dt.tz_localize(TZ) if df["date"].dt.tz is None
                  else df["date"].dt.tz_convert(TZ))
    return df.rename(columns={"date": "timestamp"}).reset_index(drop=True)


def _replay_day(day_df, symbol, target, kite, opt_cache, span_frm, span_to):
    """Replay one day; return a list of priced trade dicts."""
    trades = []
    position = None
    for i in range(len(day_df)):
        row = day_df.iloc[i]
        ts = row["timestamp"]
        t = ts.time()
        if t < SESSION_START:
            continue
        if position is not None and ts > position["signal_time"]:
            reason = mp_exit_reason(row, position["direction"], EXIT_MODE)
            if reason or t >= EOD_EXIT:
                position["exit_time"] = ts
                position["exit_reason"] = reason or "EOD_SQUARE_OFF"
                trades.append(position)
                position = None
        if position is None and t < ENTRY_CUTOFF:
            direction = "CALL" if bool(row["macd_call_signal"]) else \
                        "PUT" if bool(row["macd_put_signal"]) else None
            if direction:
                px = float(row["close"])
                contract = _atm_contract(symbol, direction, px, target)
                position = {"symbol": symbol, "direction": direction, "date": target,
                            "signal_time": ts, "entry_time": ts, "entry_px": px,
                            "contract": contract}
    if position is not None:
        last = day_df.iloc[-1]
        position["exit_time"] = last["timestamp"]
        position["exit_reason"] = "EOD_LAST_BAR"
        trades.append(position)

    # price on real option candles
    for tr in trades:
        c = tr["contract"]
        tr["net"] = None
        if not c:
            continue
        tsym = c["tradingsymbol"]
        if tsym not in opt_cache:
            try:
                opt_cache[tsym] = _fetch_5m(kite, c["instrument_token"], span_frm, span_to)
            except Exception:
                opt_cache[tsym] = pd.DataFrame()
        odf = opt_cache[tsym]
        ep = _option_close_at(odf, tr["entry_time"])
        xp = _option_close_at(odf, tr["exit_time"])
        if ep and xp:
            qty = c["lot_size"]
            gross = (xp - ep) * qty
            tr["entry_prem"], tr["exit_prem"] = ep, xp
            tr["net"] = round(gross - _round_trip_charges(ep, xp, qty), 2)
    return trades


def run(start, end, symbols):
    kite = _get_kite()
    start_d = pd.Timestamp(start).date()
    end_d = pd.Timestamp(end).date()
    warm_frm = pd.Timestamp(start).tz_localize(TZ) - pd.Timedelta(days=WARMUP_DAYS)
    span_to = pd.Timestamp(end).tz_localize(TZ).replace(hour=15, minute=30)
    opt_frm = pd.Timestamp(start).tz_localize(TZ).replace(hour=9, minute=15)

    all_trades = []
    per_symbol = {}
    opt_cache = {}

    for sym in symbols:
        try:
            token = _instrument_token(sym)
            udf = _fetch_5m(kite, token, warm_frm.replace(hour=9, minute=15), span_to)
        except Exception as e:
            print(f"[skip] {sym}: {e}")
            continue
        if udf.empty:
            print(f"[skip] {sym}: no underlying data")
            continue
        ind = add_macd_psar_indicators(udf, entry_mode=ENTRY_MODE).reset_index(drop=True)
        sym_trades = []
        for d in sorted(set(ind["timestamp"].dt.date)):
            if d < start_d or d > end_d:
                continue
            day_df = ind[ind["timestamp"].dt.date == d].reset_index(drop=True)
            if day_df.empty:
                continue
            # Option candles are cached per contract over the FULL range so a
            # strike reused on multiple days is fetched once and priced correctly.
            sym_trades += _replay_day(day_df, sym, d, kite, opt_cache, opt_frm, span_to)
        priced = [t for t in sym_trades if t.get("net") is not None]
        net = round(sum(t["net"] for t in priced), 2)
        wins = sum(1 for t in priced if t["net"] > 0)
        per_symbol[sym] = {"trades": len(sym_trades), "priced": len(priced),
                           "wins": wins, "net": net}
        all_trades += sym_trades
        print(f"  {sym:12s} trades={len(sym_trades):3d} priced={len(priced):3d} "
              f"wins={wins:3d} net={net:+10.0f}")

    # Dump every priced trade so timing / other filters can be studied offline
    # without re-hitting the API.
    dump = [{
        "symbol": t["symbol"], "date": str(t["date"]), "direction": t["direction"],
        "entry_time": t["entry_time"], "exit_time": t["exit_time"],
        "entry_hhmm": pd.Timestamp(t["entry_time"]).strftime("%H:%M"),
        "entry_prem": t.get("entry_prem"), "exit_prem": t.get("exit_prem"),
        "exit_reason": t.get("exit_reason"), "net": t.get("net"),
    } for t in all_trades if t.get("net") is not None]
    out_path = Path(__file__).resolve().parent / f"trades_{ENTRY_MODE}_{start}_{end}.csv"
    pd.DataFrame(dump).to_csv(out_path, index=False)
    print(f"\n[dump] {len(dump)} priced trades -> {out_path}")

    priced = [t for t in all_trades if t.get("net") is not None]
    total_net = round(sum(t["net"] for t in priced), 2)
    wins = sum(1 for t in priced if t["net"] > 0)
    losses = sum(1 for t in priced if t["net"] < 0)
    gross_win = sum(t["net"] for t in priced if t["net"] > 0)
    gross_loss = sum(t["net"] for t in priced if t["net"] < 0)

    print("\n" + "=" * 64)
    print(f"MACD+PSAR (exit=both, entry={ENTRY_MODE})  {start} -> {end}  |  {len(symbols)} symbols")
    print("=" * 64)
    print(f"Trades taken        : {len(all_trades)}  (priced on real options: {len(priced)})")
    print(f"Wins / Losses       : {wins} / {losses}  "
          f"(win rate {100*wins/len(priced):.1f}%)" if priced else "no priced trades")
    if priced:
        print(f"Gross winners       : {gross_win:+.0f}")
        print(f"Gross losers        : {gross_loss:+.0f}")
        print(f"Profit factor       : {(-gross_win/gross_loss):.2f}" if gross_loss else "inf")
        print(f"Avg net / trade     : {total_net/len(priced):+.0f}")
    print(f"\nNET P&L (1 lot/trade, incl. charges): {total_net:+,.0f}")
    print("VERDICT:", "PROFITABLE" if total_net > 0 else "NOT PROFITABLE")


if __name__ == "__main__":
    start = sys.argv[1] if len(sys.argv) > 1 else "2026-07-01"
    end = sys.argv[2] if len(sys.argv) > 2 else "2026-07-17"
    syms = (sys.argv[3].split(",") if len(sys.argv) > 3 else _priority1_symbols())
    print(f"Symbols ({len(syms)}): {', '.join(syms)}\n")
    run(start, end, [s.strip().upper() for s in syms])