Spaces:
Paused
Paused
Deploy from GitHub Actions to nse-bot-backend (part 3)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- ob_breaker_luxalgo_nse_fo_strategy.pine +396 -0
- ob_intraday/README.md +71 -0
- ob_intraday/__init__.py +19 -0
- ob_intraday/backtest.py +222 -0
- ob_intraday/config.py +75 -0
- ob_intraday/data/NIFTY_5min.csv +0 -0
- ob_intraday/data/NIFTY_5min_synthetic.csv +0 -0
- ob_intraday/data_download.py +335 -0
- ob_intraday/detector.py +305 -0
- ob_intraday/output/equity_curve.png +0 -0
- ob_intraday/output/monthly_pnl.csv +2 -0
- ob_intraday/output/sweep.csv +13 -0
- ob_intraday/output/trades.csv +2 -0
- ob_intraday/output/validation/trade_00_short_20240731_1005.png +0 -0
- ob_intraday/output/validation/trade_00_short_20260521_0955.png +0 -0
- ob_intraday/output/validation/trade_01_long_20240807_1420.png +0 -0
- ob_intraday/output/validation/trade_02_long_20240924_1200.png +0 -0
- ob_intraday/output/validation/trade_03_short_20241009_0940.png +0 -0
- ob_intraday/output/validation/trade_04_long_20241203_1200.png +0 -0
- ob_intraday/output/validation/trade_05_long_20250123_1210.png +0 -0
- ob_intraday/output/validation/trade_06_long_20250704_1320.png +0 -0
- ob_intraday/output/validation/trade_07_long_20250718_1230.png +0 -0
- ob_intraday/output/validation/trade_08_long_20250804_1130.png +0 -0
- ob_intraday/output/validation/trade_09_long_20250829_1115.png +0 -0
- ob_intraday/output/validation/trade_10_long_20250829_1240.png +0 -0
- ob_intraday/output/validation/trade_11_long_20251013_1025.png +0 -0
- ob_intraday/output/validation/trade_12_long_20251202_1250.png +0 -0
- ob_intraday/output/validation/trade_13_long_20251218_0955.png +0 -0
- ob_intraday/output/validation/trade_14_long_20251218_1105.png +0 -0
- ob_intraday/output/validation/trade_15_long_20260302_1045.png +0 -0
- ob_intraday/output/validation/trade_16_short_20260303_0940.png +0 -0
- ob_intraday/output/validation/trade_17_long_20260414_1055.png +0 -0
- ob_intraday/output/validation/trade_18_long_20260505_1250.png +0 -0
- ob_intraday/output/validation/trade_19_short_20260603_1220.png +0 -0
- ob_intraday/run.py +97 -0
- ob_intraday/strategy.py +85 -0
- ob_intraday/sweep.py +41 -0
- ob_intraday/validation.py +99 -0
- ob_portfolio/__init__.py +10 -0
- ob_portfolio/charts/trade_00_TATAMOTORS_long_20240731_0940.png +0 -0
- ob_portfolio/charts/trade_01_ICICIBANK_short_20240909_0945.png +0 -0
- ob_portfolio/charts/trade_02_INFY_long_20240925_0945.png +0 -0
- ob_portfolio/charts/trade_03_BAJFINANCE_short_20241001_1230.png +0 -0
- ob_portfolio/charts/trade_04_INFY_long_20241014_1330.png +0 -0
- ob_portfolio/charts/trade_05_BAJFINANCE_short_20241017_1220.png +0 -0
- ob_portfolio/charts/trade_06_RELIANCE_long_20250312_1150.png +0 -0
- ob_portfolio/charts/trade_07_AXISBANK_short_20250430_1415.png +0 -0
- ob_portfolio/charts/trade_08_HDFCBANK_long_20250508_1400.png +0 -0
- ob_portfolio/charts/trade_09_HDFCBANK_short_20250708_0945.png +0 -0
- ob_portfolio/charts/trade_10_BAJFINANCE_short_20250714_1310.png +0 -0
ob_breaker_luxalgo_nse_fo_strategy.pine
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This work adapts trading logic from:
|
| 2 |
+
// "Order Blocks & Breaker Blocks [LuxAlgo]" © LuxAlgo
|
| 3 |
+
// (CC BY-NC-SA 4.0) https://creativecommons.org/licenses/by-nc-sa/4.0/
|
| 4 |
+
//
|
| 5 |
+
// Converted from a cosmetic indicator into a NON-REPAINTING backtesting STRATEGY
|
| 6 |
+
// for NSE F&O stock intraday option trading. Signals are generated on the
|
| 7 |
+
// UNDERLYING equity/futures chart. Option execution is handled separately later.
|
| 8 |
+
// This script only backtests the quality of the underlying directional signal.
|
| 9 |
+
//
|
| 10 |
+
//@version=5
|
| 11 |
+
strategy("OB Breaker Strategy - LuxAlgo Converted - NSE F&O Intraday"
|
| 12 |
+
, overlay = true
|
| 13 |
+
, pyramiding = 0
|
| 14 |
+
, initial_capital = 100000
|
| 15 |
+
, default_qty_type = strategy.percent_of_equity
|
| 16 |
+
, default_qty_value = 100
|
| 17 |
+
, commission_type = strategy.commission.percent
|
| 18 |
+
, commission_value = 0.03
|
| 19 |
+
, slippage = 1
|
| 20 |
+
, process_orders_on_close = false
|
| 21 |
+
, calc_on_every_tick = false
|
| 22 |
+
, max_boxes_count = 1
|
| 23 |
+
, max_lines_count = 1
|
| 24 |
+
, max_labels_count = 1)
|
| 25 |
+
|
| 26 |
+
//-----------------------------------------------------------------------------
|
| 27 |
+
// Inputs
|
| 28 |
+
//-----------------------------------------------------------------------------{
|
| 29 |
+
grpCore = "Core Logic"
|
| 30 |
+
length = input.int(10, "Swing Lookback", minval = 3, group = grpCore)
|
| 31 |
+
useBody = input.bool(false,"Use Candle Body (else High/Low)", group = grpCore)
|
| 32 |
+
mode = input.string("Normal OB Only", "Strategy Mode", options = ["Normal OB Only", "Breaker Only", "Both"], group = grpCore)
|
| 33 |
+
entryMode = input.string("Midpoint Reclaim", "Entry Mode", options = ["Midpoint Reclaim", "Full Zone Reclaim"], group = grpCore)
|
| 34 |
+
invMode = input.string("Close", "Invalidation Mode", options = ["Body", "Close", "Wick"], group = grpCore)
|
| 35 |
+
|
| 36 |
+
grpRisk = "Risk"
|
| 37 |
+
rr = input.float(1.5, "Risk Reward Ratio", minval = 0.1, step = 0.1, group = grpRisk)
|
| 38 |
+
stopBuf = input.float(0.0, "Stop Buffer Points", step = 0.05, group = grpRisk)
|
| 39 |
+
minObSize = input.float(0.0, "Minimum OB Size Points",group = grpRisk)
|
| 40 |
+
maxObSize = input.float(999999.0, "Maximum OB Size Points", group = grpRisk)
|
| 41 |
+
maxAge = input.int(100, "Maximum OB Age Bars", minval = 1, group = grpRisk)
|
| 42 |
+
|
| 43 |
+
grpOB = "OB Management"
|
| 44 |
+
maxBullOB = input.int(3, "Max Active Bullish OBs", minval = 1, group = grpOB)
|
| 45 |
+
maxBearOB = input.int(3, "Max Active Bearish OBs", minval = 1, group = grpOB)
|
| 46 |
+
|
| 47 |
+
grpTrade = "Trade Control"
|
| 48 |
+
maxTrades = input.int(4, "Max Trades Per Day", minval = 1, group = grpTrade)
|
| 49 |
+
enableLong = input.bool(true, "Enable Long Trades", group = grpTrade)
|
| 50 |
+
enableShort = input.bool(true, "Enable Short Trades", group = grpTrade)
|
| 51 |
+
oneTrade = input.bool(true, "One Trade At A Time", group = grpTrade)
|
| 52 |
+
|
| 53 |
+
grpSess = "Session / EOD"
|
| 54 |
+
useSession = input.bool(true, "Enable Session Filter", group = grpSess)
|
| 55 |
+
sess = input.session("0920-1515", "Trading Session", group = grpSess)
|
| 56 |
+
avoidFirstMin= input.int(15, "Avoid First Minutes", minval = 0, group = grpSess)
|
| 57 |
+
lastEntryStr = input.string("1500", "Last Entry Time (HHMM)", group = grpSess)
|
| 58 |
+
eodStr = input.string("1515", "EOD Exit Time (HHMM)", group = grpSess)
|
| 59 |
+
|
| 60 |
+
grpHTF = "HTF EMA Filter"
|
| 61 |
+
useHTF = input.bool(false, "Enable HTF EMA Filter", group = grpHTF)
|
| 62 |
+
htfTF = input.timeframe("15", "HTF Timeframe", group = grpHTF)
|
| 63 |
+
htfLen = input.int(50, "HTF EMA Length", minval = 1, group = grpHTF)
|
| 64 |
+
|
| 65 |
+
grpDbg = "Debug"
|
| 66 |
+
debugPlots= input.bool(true, "Debug Plots", group = grpDbg)
|
| 67 |
+
|
| 68 |
+
//-----------------------------------------------------------------------------}
|
| 69 |
+
// Time helpers (exchange timezone via hour/minute built-ins)
|
| 70 |
+
//-----------------------------------------------------------------------------{
|
| 71 |
+
f_hhmm(string s) =>
|
| 72 |
+
float h = str.tonumber(str.substring(s, 0, 2))
|
| 73 |
+
float m = str.tonumber(str.substring(s, 2, 4))
|
| 74 |
+
na(h) or na(m) ? na : int(h * 60 + m)
|
| 75 |
+
|
| 76 |
+
sessStartMin = f_hhmm(str.substring(sess, 0, 4))
|
| 77 |
+
lastEntryMin = f_hhmm(lastEntryStr)
|
| 78 |
+
eodMin = f_hhmm(eodStr)
|
| 79 |
+
|
| 80 |
+
tod = hour * 60 + minute
|
| 81 |
+
inSessRaw = not na(time(timeframe.period, sess))
|
| 82 |
+
inSession = not useSession or inSessRaw
|
| 83 |
+
afterFirst = na(sessStartMin) or tod >= (sessStartMin + avoidFirstMin)
|
| 84 |
+
beforeLast = na(lastEntryMin) or tod <= lastEntryMin
|
| 85 |
+
eodExit = not na(eodMin) and tod >= eodMin
|
| 86 |
+
|
| 87 |
+
// Daily trade counter reset
|
| 88 |
+
var int tradesToday = 0
|
| 89 |
+
var int curDay = na
|
| 90 |
+
if dayofmonth != curDay
|
| 91 |
+
curDay := dayofmonth
|
| 92 |
+
tradesToday := 0
|
| 93 |
+
|
| 94 |
+
//-----------------------------------------------------------------------------}
|
| 95 |
+
// HTF EMA filter (optional, lookahead_off = non-repainting on bar close)
|
| 96 |
+
//-----------------------------------------------------------------------------{
|
| 97 |
+
htfEma = request.security(syminfo.tickerid, htfTF, ta.ema(close, htfLen), lookahead = barmerge.lookahead_off)
|
| 98 |
+
longHtfOk = not useHTF or (not na(htfEma) and close > htfEma)
|
| 99 |
+
shortHtfOk = not useHTF or (not na(htfEma) and close < htfEma)
|
| 100 |
+
|
| 101 |
+
//-----------------------------------------------------------------------------}
|
| 102 |
+
// Swing detection (original swings(length) logic, non-repainting, len-bar delay)
|
| 103 |
+
//-----------------------------------------------------------------------------{
|
| 104 |
+
var int os = 0
|
| 105 |
+
var float swTopY = na
|
| 106 |
+
var int swTopX = na
|
| 107 |
+
var bool swTopCrossed = false
|
| 108 |
+
var float swBtmY = na
|
| 109 |
+
var int swBtmX = na
|
| 110 |
+
var bool swBtmCrossed = false
|
| 111 |
+
|
| 112 |
+
upper = ta.highest(length)
|
| 113 |
+
lower = ta.lowest(length)
|
| 114 |
+
os := high[length] > upper ? 0 : low[length] < lower ? 1 : os
|
| 115 |
+
|
| 116 |
+
if os == 0 and os[1] != 0
|
| 117 |
+
swTopY := high[length]
|
| 118 |
+
swTopX := bar_index[length]
|
| 119 |
+
swTopCrossed := false
|
| 120 |
+
if os == 1 and os[1] != 1
|
| 121 |
+
swBtmY := low[length]
|
| 122 |
+
swBtmX := bar_index[length]
|
| 123 |
+
swBtmCrossed := false
|
| 124 |
+
|
| 125 |
+
srcMax = useBody ? math.max(close, open) : high
|
| 126 |
+
srcMin = useBody ? math.min(close, open) : low
|
| 127 |
+
|
| 128 |
+
//-----------------------------------------------------------------------------}
|
| 129 |
+
// Order Block model (numeric only - NO boxes/lines/labels used for decisions)
|
| 130 |
+
//-----------------------------------------------------------------------------{
|
| 131 |
+
type OB
|
| 132 |
+
float top
|
| 133 |
+
float btm
|
| 134 |
+
float mid
|
| 135 |
+
int srcBar
|
| 136 |
+
int createdBar
|
| 137 |
+
bool valid
|
| 138 |
+
bool isBreaker
|
| 139 |
+
int breakBar
|
| 140 |
+
|
| 141 |
+
var array<OB> bullOBs = array.new<OB>(0)
|
| 142 |
+
var array<OB> bearOBs = array.new<OB>(0)
|
| 143 |
+
|
| 144 |
+
n = bar_index
|
| 145 |
+
|
| 146 |
+
// Event flags for alerts
|
| 147 |
+
bullObCreated = false
|
| 148 |
+
bearObCreated = false
|
| 149 |
+
bullBecameBrk = false
|
| 150 |
+
bearBecameBrk = false
|
| 151 |
+
|
| 152 |
+
//-----------------------------------------------------------------------------}
|
| 153 |
+
// Bullish OB creation (close breaks confirmed swing high)
|
| 154 |
+
//-----------------------------------------------------------------------------{
|
| 155 |
+
if not na(swTopY) and close > swTopY and not swTopCrossed
|
| 156 |
+
swTopCrossed := true
|
| 157 |
+
float minima = srcMin[1]
|
| 158 |
+
float maxima = srcMax[1]
|
| 159 |
+
int loc = bar_index[1]
|
| 160 |
+
int upTo = (n - swTopX) - 1
|
| 161 |
+
if upTo >= 1
|
| 162 |
+
for i = 1 to upTo
|
| 163 |
+
if srcMin[i] <= minima
|
| 164 |
+
minima := srcMin[i]
|
| 165 |
+
maxima := srcMax[i]
|
| 166 |
+
loc := bar_index[i]
|
| 167 |
+
obSize = math.abs(maxima - minima)
|
| 168 |
+
if obSize >= minObSize and obSize <= maxObSize
|
| 169 |
+
newOb = OB.new(maxima, minima, (maxima + minima) / 2.0, loc, bar_index, true, false, na)
|
| 170 |
+
array.unshift(bullOBs, newOb)
|
| 171 |
+
bullObCreated := true
|
| 172 |
+
while array.size(bullOBs) > maxBullOB
|
| 173 |
+
array.pop(bullOBs)
|
| 174 |
+
|
| 175 |
+
// Bullish OB breaker conversion / invalidation
|
| 176 |
+
if array.size(bullOBs) > 0
|
| 177 |
+
for i = array.size(bullOBs) - 1 to 0
|
| 178 |
+
el = array.get(bullOBs, i)
|
| 179 |
+
if el.valid
|
| 180 |
+
if not el.isBreaker
|
| 181 |
+
broke = invMode == "Body" ? math.min(close, open) < el.btm : invMode == "Close" ? close < el.btm : low < el.btm
|
| 182 |
+
if broke
|
| 183 |
+
el.isBreaker := true
|
| 184 |
+
el.breakBar := bar_index
|
| 185 |
+
bullBecameBrk := true
|
| 186 |
+
else
|
| 187 |
+
if close > el.top
|
| 188 |
+
array.remove(bullOBs, i)
|
| 189 |
+
|
| 190 |
+
//-----------------------------------------------------------------------------}
|
| 191 |
+
// Bearish OB creation (close breaks confirmed swing low)
|
| 192 |
+
//-----------------------------------------------------------------------------{
|
| 193 |
+
if not na(swBtmY) and close < swBtmY and not swBtmCrossed
|
| 194 |
+
swBtmCrossed := true
|
| 195 |
+
float maxima = srcMax[1]
|
| 196 |
+
float minima = srcMin[1]
|
| 197 |
+
int loc = bar_index[1]
|
| 198 |
+
int upTo = (n - swBtmX) - 1
|
| 199 |
+
if upTo >= 1
|
| 200 |
+
for i = 1 to upTo
|
| 201 |
+
if srcMax[i] >= maxima
|
| 202 |
+
maxima := srcMax[i]
|
| 203 |
+
minima := srcMin[i]
|
| 204 |
+
loc := bar_index[i]
|
| 205 |
+
obSize = math.abs(maxima - minima)
|
| 206 |
+
if obSize >= minObSize and obSize <= maxObSize
|
| 207 |
+
newOb = OB.new(maxima, minima, (maxima + minima) / 2.0, loc, bar_index, true, false, na)
|
| 208 |
+
array.unshift(bearOBs, newOb)
|
| 209 |
+
bearObCreated := true
|
| 210 |
+
while array.size(bearOBs) > maxBearOB
|
| 211 |
+
array.pop(bearOBs)
|
| 212 |
+
|
| 213 |
+
// Bearish OB breaker conversion / invalidation
|
| 214 |
+
if array.size(bearOBs) > 0
|
| 215 |
+
for i = array.size(bearOBs) - 1 to 0
|
| 216 |
+
el = array.get(bearOBs, i)
|
| 217 |
+
if el.valid
|
| 218 |
+
if not el.isBreaker
|
| 219 |
+
broke = invMode == "Body" ? math.max(close, open) > el.top : invMode == "Close" ? close > el.top : high > el.top
|
| 220 |
+
if broke
|
| 221 |
+
el.isBreaker := true
|
| 222 |
+
el.breakBar := bar_index
|
| 223 |
+
bearBecameBrk := true
|
| 224 |
+
else
|
| 225 |
+
if close < el.btm
|
| 226 |
+
array.remove(bearOBs, i)
|
| 227 |
+
|
| 228 |
+
//-----------------------------------------------------------------------------}
|
| 229 |
+
// Entry evaluation
|
| 230 |
+
//-----------------------------------------------------------------------------{
|
| 231 |
+
allowNormal = mode == "Normal OB Only" or mode == "Both"
|
| 232 |
+
allowBreaker = mode == "Breaker Only" or mode == "Both"
|
| 233 |
+
|
| 234 |
+
canEnter = inSession and afterFirst and beforeLast and not eodExit and tradesToday < maxTrades and (not oneTrade or strategy.position_size == 0)
|
| 235 |
+
|
| 236 |
+
// setup markers (pattern present, independent of gating)
|
| 237 |
+
normalLongSetup = false
|
| 238 |
+
normalShortSetup = false
|
| 239 |
+
brkLongSetup = false
|
| 240 |
+
brkShortSetup = false
|
| 241 |
+
|
| 242 |
+
// decided orders
|
| 243 |
+
bool doLong = false
|
| 244 |
+
bool doShort = false
|
| 245 |
+
float sigStop = na
|
| 246 |
+
float sigTgt = na
|
| 247 |
+
bool haveSig = false
|
| 248 |
+
|
| 249 |
+
// ---- Normal Long: retest of a valid non-breaker bullish OB ----
|
| 250 |
+
if allowNormal and array.size(bullOBs) > 0
|
| 251 |
+
for i = 0 to array.size(bullOBs) - 1
|
| 252 |
+
el = array.get(bullOBs, i)
|
| 253 |
+
age = bar_index - el.createdBar
|
| 254 |
+
if el.valid and not el.isBreaker and age >= 1 and age <= maxAge
|
| 255 |
+
trig = entryMode == "Midpoint Reclaim" ? (low <= el.top and close > el.mid and close > open) : (low <= el.top and close > el.top and close > open)
|
| 256 |
+
if trig
|
| 257 |
+
normalLongSetup := true
|
| 258 |
+
stp = el.btm - stopBuf
|
| 259 |
+
if not haveSig and enableLong and longHtfOk and canEnter and close > stp and (close - stp) > 0
|
| 260 |
+
doLong := true
|
| 261 |
+
sigStop := stp
|
| 262 |
+
sigTgt := close + (close - stp) * rr
|
| 263 |
+
haveSig := true
|
| 264 |
+
break
|
| 265 |
+
|
| 266 |
+
// ---- Breaker Long: retest (from above) of a bearish OB turned breaker = support ----
|
| 267 |
+
if allowBreaker and array.size(bearOBs) > 0
|
| 268 |
+
for i = 0 to array.size(bearOBs) - 1
|
| 269 |
+
el = array.get(bearOBs, i)
|
| 270 |
+
age = bar_index - el.createdBar
|
| 271 |
+
if el.valid and el.isBreaker and age >= 1 and age <= maxAge
|
| 272 |
+
trig = entryMode == "Midpoint Reclaim" ? (low <= el.top and close > el.mid and close > open) : (low <= el.top and close > el.top and close > open)
|
| 273 |
+
if trig
|
| 274 |
+
brkLongSetup := true
|
| 275 |
+
stp = el.btm - stopBuf
|
| 276 |
+
if not haveSig and enableLong and longHtfOk and canEnter and close > stp and (close - stp) > 0
|
| 277 |
+
doLong := true
|
| 278 |
+
sigStop := stp
|
| 279 |
+
sigTgt := close + (close - stp) * rr
|
| 280 |
+
haveSig := true
|
| 281 |
+
break
|
| 282 |
+
|
| 283 |
+
// ---- Normal Short: retest of a valid non-breaker bearish OB ----
|
| 284 |
+
if allowNormal and array.size(bearOBs) > 0
|
| 285 |
+
for i = 0 to array.size(bearOBs) - 1
|
| 286 |
+
el = array.get(bearOBs, i)
|
| 287 |
+
age = bar_index - el.createdBar
|
| 288 |
+
if el.valid and not el.isBreaker and age >= 1 and age <= maxAge
|
| 289 |
+
trig = entryMode == "Midpoint Reclaim" ? (high >= el.btm and close < el.mid and close < open) : (high >= el.btm and close < el.btm and close < open)
|
| 290 |
+
if trig
|
| 291 |
+
normalShortSetup := true
|
| 292 |
+
stp = el.top + stopBuf
|
| 293 |
+
if not haveSig and enableShort and shortHtfOk and canEnter and stp > close and (stp - close) > 0
|
| 294 |
+
doShort := true
|
| 295 |
+
sigStop := stp
|
| 296 |
+
sigTgt := close - (stp - close) * rr
|
| 297 |
+
haveSig := true
|
| 298 |
+
break
|
| 299 |
+
|
| 300 |
+
// ---- Breaker Short: retest (from below) of a bullish OB turned breaker = resistance ----
|
| 301 |
+
if allowBreaker and array.size(bullOBs) > 0
|
| 302 |
+
for i = 0 to array.size(bullOBs) - 1
|
| 303 |
+
el = array.get(bullOBs, i)
|
| 304 |
+
age = bar_index - el.createdBar
|
| 305 |
+
if el.valid and el.isBreaker and age >= 1 and age <= maxAge
|
| 306 |
+
trig = entryMode == "Midpoint Reclaim" ? (high >= el.btm and close < el.mid and close < open) : (high >= el.btm and close < el.btm and close < open)
|
| 307 |
+
if trig
|
| 308 |
+
brkShortSetup := true
|
| 309 |
+
stp = el.top + stopBuf
|
| 310 |
+
if not haveSig and enableShort and shortHtfOk and canEnter and stp > close and (stp - close) > 0
|
| 311 |
+
doShort := true
|
| 312 |
+
sigStop := stp
|
| 313 |
+
sigTgt := close - (stp - close) * rr
|
| 314 |
+
haveSig := true
|
| 315 |
+
break
|
| 316 |
+
|
| 317 |
+
//-----------------------------------------------------------------------------}
|
| 318 |
+
// Order placement + exits (next-bar-open entries; stop/target as fixed levels)
|
| 319 |
+
//-----------------------------------------------------------------------------{
|
| 320 |
+
var float pendStop = na
|
| 321 |
+
var float pendTgt = na
|
| 322 |
+
|
| 323 |
+
justFlat = strategy.position_size == 0 and strategy.position_size[1] != 0
|
| 324 |
+
if justFlat
|
| 325 |
+
pendStop := na
|
| 326 |
+
pendTgt := na
|
| 327 |
+
|
| 328 |
+
if doLong
|
| 329 |
+
strategy.entry("Long", strategy.long)
|
| 330 |
+
pendStop := sigStop
|
| 331 |
+
pendTgt := sigTgt
|
| 332 |
+
tradesToday += 1
|
| 333 |
+
|
| 334 |
+
if doShort
|
| 335 |
+
strategy.entry("Short", strategy.short)
|
| 336 |
+
pendStop := sigStop
|
| 337 |
+
pendTgt := sigTgt
|
| 338 |
+
tradesToday += 1
|
| 339 |
+
|
| 340 |
+
// Attach SL/TP once a position is live
|
| 341 |
+
if strategy.position_size > 0 and not na(pendStop) and not na(pendTgt)
|
| 342 |
+
strategy.exit("XL", from_entry = "Long", stop = pendStop, limit = pendTgt)
|
| 343 |
+
if strategy.position_size < 0 and not na(pendStop) and not na(pendTgt)
|
| 344 |
+
strategy.exit("XS", from_entry = "Short", stop = pendStop, limit = pendTgt)
|
| 345 |
+
|
| 346 |
+
// EOD flat + no overnight holding
|
| 347 |
+
eodEvent = eodExit and strategy.position_size != 0
|
| 348 |
+
if eodEvent
|
| 349 |
+
strategy.close_all(comment = "EOD")
|
| 350 |
+
|
| 351 |
+
//-----------------------------------------------------------------------------}
|
| 352 |
+
// Debug plots
|
| 353 |
+
//-----------------------------------------------------------------------------{
|
| 354 |
+
bTop = array.size(bullOBs) > 0 ? array.get(bullOBs, 0).top : na
|
| 355 |
+
bBtm = array.size(bullOBs) > 0 ? array.get(bullOBs, 0).btm : na
|
| 356 |
+
bMid = array.size(bullOBs) > 0 ? array.get(bullOBs, 0).mid : na
|
| 357 |
+
sTop = array.size(bearOBs) > 0 ? array.get(bearOBs, 0).top : na
|
| 358 |
+
sBtm = array.size(bearOBs) > 0 ? array.get(bearOBs, 0).btm : na
|
| 359 |
+
sMid = array.size(bearOBs) > 0 ? array.get(bearOBs, 0).mid : na
|
| 360 |
+
|
| 361 |
+
plot(debugPlots ? bTop : na, "Bull OB Top", color = color.new(color.blue, 0), style = plot.style_linebr)
|
| 362 |
+
plot(debugPlots ? bBtm : na, "Bull OB Btm", color = color.new(color.blue, 40), style = plot.style_linebr)
|
| 363 |
+
plot(debugPlots ? bMid : na, "Bull OB Mid", color = color.new(color.blue, 60), style = plot.style_linebr)
|
| 364 |
+
plot(debugPlots ? sTop : na, "Bear OB Top", color = color.new(color.orange, 40), style = plot.style_linebr)
|
| 365 |
+
plot(debugPlots ? sBtm : na, "Bear OB Btm", color = color.new(color.orange, 0), style = plot.style_linebr)
|
| 366 |
+
plot(debugPlots ? sMid : na, "Bear OB Mid", color = color.new(color.orange, 60), style = plot.style_linebr)
|
| 367 |
+
plot(debugPlots ? swTopY : na, "Swing High", color = color.new(color.teal, 0), style = plot.style_circles)
|
| 368 |
+
plot(debugPlots ? swBtmY : na, "Swing Low", color = color.new(color.maroon, 0), style = plot.style_circles)
|
| 369 |
+
|
| 370 |
+
plotshape(debugPlots and normalLongSetup, "Long Setup", style = shape.triangleup, location = location.belowbar, color = color.new(color.green, 0), size = size.tiny)
|
| 371 |
+
plotshape(debugPlots and normalShortSetup, "Short Setup", style = shape.triangledown, location = location.abovebar, color = color.new(color.red, 0), size = size.tiny)
|
| 372 |
+
plotshape(debugPlots and brkLongSetup, "Breaker Long Setup", style = shape.diamond, location = location.belowbar, color = color.new(color.lime, 0), size = size.tiny)
|
| 373 |
+
plotshape(debugPlots and brkShortSetup, "Breaker Short Setup",style = shape.diamond, location = location.abovebar, color = color.new(color.fuchsia, 0),size = size.tiny)
|
| 374 |
+
|
| 375 |
+
//-----------------------------------------------------------------------------}
|
| 376 |
+
// Alerts
|
| 377 |
+
//-----------------------------------------------------------------------------{
|
| 378 |
+
longEntryEvent = strategy.position_size > 0 and strategy.position_size[1] <= 0
|
| 379 |
+
shortEntryEvent = strategy.position_size < 0 and strategy.position_size[1] >= 0
|
| 380 |
+
longExitEvent = strategy.position_size[1] > 0 and strategy.position_size <= 0
|
| 381 |
+
shortExitEvent = strategy.position_size[1] < 0 and strategy.position_size >= 0
|
| 382 |
+
|
| 383 |
+
alertcondition(bullObCreated, "Bullish OB Created", "Bullish OB created")
|
| 384 |
+
alertcondition(bearObCreated, "Bearish OB Created", "Bearish OB created")
|
| 385 |
+
alertcondition(bullBecameBrk, "Bullish OB Breaker", "Bullish OB became breaker")
|
| 386 |
+
alertcondition(bearBecameBrk, "Bearish OB Breaker", "Bearish OB became breaker")
|
| 387 |
+
alertcondition(normalLongSetup, "Normal Long Setup", "Normal long setup")
|
| 388 |
+
alertcondition(normalShortSetup, "Normal Short Setup", "Normal short setup")
|
| 389 |
+
alertcondition(brkLongSetup, "Breaker Long Setup", "Breaker long setup")
|
| 390 |
+
alertcondition(brkShortSetup, "Breaker Short Setup", "Breaker short setup")
|
| 391 |
+
alertcondition(longEntryEvent, "Long Entry", "Long entry")
|
| 392 |
+
alertcondition(shortEntryEvent, "Short Entry", "Short entry")
|
| 393 |
+
alertcondition(longExitEvent, "Long Exit", "Long exit")
|
| 394 |
+
alertcondition(shortExitEvent, "Short Exit", "Short exit")
|
| 395 |
+
alertcondition(eodEvent, "EOD Exit", "EOD exit")
|
| 396 |
+
//-----------------------------------------------------------------------------}
|
ob_intraday/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ob_intraday — Intraday Order-Block Backtest (NSE, NIFTY futures, 5-min)
|
| 2 |
+
|
| 3 |
+
Self-contained Python project implementing the numbered spec: bar-by-bar,
|
| 4 |
+
no-lookahead structure/order-block detection, an intraday long/short strategy,
|
| 5 |
+
a sequential backtest engine with costs, visual trade validation, and a small
|
| 6 |
+
`min_score × RR` parameter sweep.
|
| 7 |
+
|
| 8 |
+
It lives in its own package (`ob_intraday`) and uses **relative imports**, so it
|
| 9 |
+
never collides with the repository's top-level `config.py` / `strategy.py`.
|
| 10 |
+
|
| 11 |
+
## Layout
|
| 12 |
+
|
| 13 |
+
| File | Role |
|
| 14 |
+
|------|------|
|
| 15 |
+
| `config.py` | All tunables (`z_threshold`, `min_score`, `rr_target`, pivot lengths, ATR filters, costs). |
|
| 16 |
+
| `data_download.py` | Kite 5-min **continuous** NIFTY-futures download, chunked (<=90-day windows) and cached to `data/NIFTY_5min.csv`. Synthetic generator for offline/no-token runs. |
|
| 17 |
+
| `detector.py` | Pivots (len 5 intraday / len 50 swing), momentum z-score, BOS, order-block construction, scoring, mitigation, merging, swing trend. |
|
| 18 |
+
| `strategy.py` | Pure entry/exit rules (triggers, SL/TP, time windows). |
|
| 19 |
+
| `backtest.py` | Sequential engine, trade log, metrics (win rate, avg R, profit factor, max DD, expectancy), equity curve, monthly P&L. |
|
| 20 |
+
| `validation.py` | Random-trade candlestick snapshots (~100 bars) with the zone drawn. |
|
| 21 |
+
| `sweep.py` | `min_score ∈ {40,50,60,70} × RR ∈ {1.5,2,3}` comparison table. |
|
| 22 |
+
| `run.py` | CLI wiring it all together. |
|
| 23 |
+
| `../tests/test_ob_intraday.py` | Unit + integration tests (run with `pytest`). |
|
| 24 |
+
|
| 25 |
+
Outputs are written to `ob_intraday/output/` (`trades.csv`, `monthly_pnl.csv`,
|
| 26 |
+
`sweep.csv`, `equity_curve.png`, `validation/*.png`).
|
| 27 |
+
|
| 28 |
+
## Running
|
| 29 |
+
|
| 30 |
+
```bash
|
| 31 |
+
# Live: needs a valid Kite token (see below). Downloads 2y, caches, backtests,
|
| 32 |
+
# plots, validates and sweeps.
|
| 33 |
+
python -m ob_intraday.run --years 2
|
| 34 |
+
|
| 35 |
+
# Offline / expired token: use cached CSV if present, else synthesise data so
|
| 36 |
+
# the whole pipeline still runs. (Synthetic data is cached separately as
|
| 37 |
+
# NIFTY_5min_synthetic.csv and never overwrites the real cache.)
|
| 38 |
+
python -m ob_intraday.run --synthetic
|
| 39 |
+
|
| 40 |
+
# Override strategy params, or run just the sweep on cached data:
|
| 41 |
+
python -m ob_intraday.run --min-score 60 --rr 3
|
| 42 |
+
python -m ob_intraday.run --sweep-only
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
### Kite token
|
| 46 |
+
Zerodha access tokens reset **daily (~07:30 IST)**. If the download fails with
|
| 47 |
+
a token error, refresh `tokens.json` (or set `KITE_ACCESS_TOKEN`) using the
|
| 48 |
+
repo's normal auth flow, then re-run. The download reuses the repo's
|
| 49 |
+
`kite_client.get_kite()` and reads the near-month NIFTY FUT token from
|
| 50 |
+
`instruments_nfo.csv`, requesting with `continuous=True`.
|
| 51 |
+
|
| 52 |
+
## Key modelling choices (documented in code)
|
| 53 |
+
|
| 54 |
+
- **No lookahead.** Pivots are only acted on at their confirmation bar
|
| 55 |
+
(`pivot_bar + length`); triggers enter at the **next** bar's open; exits use
|
| 56 |
+
only the current bar's OHLC.
|
| 57 |
+
- **Session boundaries.** Intraday pivots (5), BOS and the OB leg are confined
|
| 58 |
+
to one session; the momentum z-score drops the overnight change and builds its
|
| 59 |
+
50-sample baseline from intraday changes only; ATR ignores the gap at each
|
| 60 |
+
session open; positions are never held overnight. The **swing** trend uses
|
| 61 |
+
length-50 pivots and, being a higher frame that cannot fit in one 75-bar
|
| 62 |
+
session, runs continuously across days.
|
| 63 |
+
- **Score** (per spec): `(0.6·min(dist/(5·height),1) + 0.4·min(vol/volSMA20,1))·100`,
|
| 64 |
+
with `dist = |break-close − broken pivot|`, `height` = OB candle range,
|
| 65 |
+
`vol` = OB candle volume.
|
| 66 |
+
- **Costs.** Slippage of `slippage_ticks` on every market-style fill (entry,
|
| 67 |
+
stop, square-off, EOD); take-profit fills exactly at target; `cost_points` is
|
| 68 |
+
a flat round-trip deduction.
|
| 69 |
+
|
| 70 |
+
The two LuxAlgo Pine files were used only to verify overlapping detection
|
| 71 |
+
semantics; no drawing/alert/UI logic was ported.
|
ob_intraday/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Intraday order-block backtesting project for NSE (NIFTY futures, 5-minute).
|
| 2 |
+
|
| 3 |
+
Self-contained package. Import paths use the ``ob_intraday`` namespace so the
|
| 4 |
+
project never collides with the repository's top-level ``config``/``strategy``
|
| 5 |
+
modules.
|
| 6 |
+
|
| 7 |
+
Modules
|
| 8 |
+
-------
|
| 9 |
+
config - all tunable parameters (:class:`Config`)
|
| 10 |
+
data_download - Kite 5-minute historical download + CSV cache (+ synthetic gen)
|
| 11 |
+
detector - bar-by-bar, no-lookahead structure/order-block detection
|
| 12 |
+
strategy - entry/exit rules (signals, SL/TP)
|
| 13 |
+
backtest - the sequential engine, trade log, metrics and plots
|
| 14 |
+
validation - random-trade candlestick snapshots for visual QA
|
| 15 |
+
sweep - min_score x RR grid comparison
|
| 16 |
+
run - CLI orchestrator that wires everything together
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from .config import Config # noqa: F401
|
ob_intraday/backtest.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sequential backtest engine, trade log, metrics and plots.
|
| 2 |
+
|
| 3 |
+
Bars are walked strictly in order. A trigger at bar ``t`` enters at bar
|
| 4 |
+
``t+1``'s open (next-bar-open fill); the entry bar and every later bar are
|
| 5 |
+
scanned for stop/target/square-off exits. Slippage of ``slippage_ticks`` is
|
| 6 |
+
applied to every *market-style* fill (entry, stop, square-off, end-of-day); a
|
| 7 |
+
take-profit is a limit fill exactly at the target. ``cost_points`` is a flat
|
| 8 |
+
round-trip cost deducted from each trade's P&L.
|
| 9 |
+
|
| 10 |
+
No lookahead: entry uses the *next* bar's open (unknown at trigger time only as
|
| 11 |
+
a price we commit to, never inspected before committing); exits only use the
|
| 12 |
+
current bar's OHLC.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from typing import List, Optional
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
|
| 22 |
+
from .config import Config, OUTPUT_DIR
|
| 23 |
+
from . import detector as det
|
| 24 |
+
from . import strategy as strat
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class BacktestResult:
|
| 29 |
+
trades: pd.DataFrame
|
| 30 |
+
metrics: dict
|
| 31 |
+
equity: pd.DataFrame
|
| 32 |
+
detector: det.DetectorResult
|
| 33 |
+
cfg: Config
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
# Engine
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
def run_backtest(df: pd.DataFrame, cfg: Config,
|
| 40 |
+
detector_result: Optional[det.DetectorResult] = None) -> BacktestResult:
|
| 41 |
+
dres = detector_result or det.detect(df, cfg)
|
| 42 |
+
d = dres.df
|
| 43 |
+
n = len(d)
|
| 44 |
+
|
| 45 |
+
ts = d["timestamp"].to_numpy()
|
| 46 |
+
ts_idx = d["timestamp"]
|
| 47 |
+
o = d["open"].to_numpy(); h = d["high"].to_numpy()
|
| 48 |
+
l = d["low"].to_numpy(); c = d["close"].to_numpy()
|
| 49 |
+
sess = d["session_date"].to_numpy()
|
| 50 |
+
atr_sl = d["atr_sl"].to_numpy()
|
| 51 |
+
trend = dres.trend
|
| 52 |
+
|
| 53 |
+
slip = cfg.slippage_ticks * cfg.tick_size
|
| 54 |
+
pos: Optional[dict] = None
|
| 55 |
+
trades: List[dict] = []
|
| 56 |
+
|
| 57 |
+
def close_trade(exit_idx, exit_price, reason, market=True):
|
| 58 |
+
nonlocal pos
|
| 59 |
+
fill = exit_price
|
| 60 |
+
if market:
|
| 61 |
+
fill += -slip if pos["direction"] == "bull" else slip
|
| 62 |
+
if pos["direction"] == "bull":
|
| 63 |
+
gross = fill - pos["entry_price"]
|
| 64 |
+
else:
|
| 65 |
+
gross = pos["entry_price"] - fill
|
| 66 |
+
net = gross - cfg.cost_points
|
| 67 |
+
r_mult = net / pos["risk"] if pos["risk"] > 0 else 0.0
|
| 68 |
+
trades.append(dict(
|
| 69 |
+
direction="long" if pos["direction"] == "bull" else "short",
|
| 70 |
+
entry_idx=pos["entry_idx"], exit_idx=int(exit_idx),
|
| 71 |
+
entry_time=pos["entry_time"], exit_time=ts_idx.iloc[int(exit_idx)],
|
| 72 |
+
entry_price=round(pos["entry_price"], 4), exit_price=round(fill, 4),
|
| 73 |
+
exit_reason=reason,
|
| 74 |
+
zone_id=pos["zone_id"], zone_score=round(pos["zone_score"], 2),
|
| 75 |
+
zone_top=round(pos["zone_top"], 4), zone_bottom=round(pos["zone_bottom"], 4),
|
| 76 |
+
stop=round(pos["stop"], 4), target=round(pos["target"], 4),
|
| 77 |
+
risk_points=round(pos["risk"], 4),
|
| 78 |
+
pnl_points_gross=round(gross, 4), cost_points=cfg.cost_points,
|
| 79 |
+
pnl_points=round(net, 4), r_multiple=round(r_mult, 4),
|
| 80 |
+
pnl_rupees=round(net * cfg.lot_size, 2),
|
| 81 |
+
bars_held=int(exit_idx) - pos["entry_idx"],
|
| 82 |
+
))
|
| 83 |
+
pos = None
|
| 84 |
+
|
| 85 |
+
for t in range(n):
|
| 86 |
+
# ---- manage an open position on this bar -------------------------
|
| 87 |
+
if pos is not None and t >= pos["entry_idx"]:
|
| 88 |
+
new_session = t == 0 or sess[t] != sess[t - 1]
|
| 89 |
+
if new_session and t != pos["entry_idx"]:
|
| 90 |
+
# safety: never hold across the overnight gap
|
| 91 |
+
close_trade(t, o[t], "eod")
|
| 92 |
+
elif strat.at_or_after_square_off(ts_idx.iloc[t], cfg):
|
| 93 |
+
close_trade(t, o[t], "square_off")
|
| 94 |
+
else:
|
| 95 |
+
if pos["direction"] == "bull":
|
| 96 |
+
if l[t] <= pos["stop"]:
|
| 97 |
+
close_trade(t, pos["stop"], "sl")
|
| 98 |
+
elif h[t] >= pos["target"]:
|
| 99 |
+
close_trade(t, pos["target"], "tp", market=False)
|
| 100 |
+
else:
|
| 101 |
+
if h[t] >= pos["stop"]:
|
| 102 |
+
close_trade(t, pos["stop"], "sl")
|
| 103 |
+
elif l[t] <= pos["target"]:
|
| 104 |
+
close_trade(t, pos["target"], "tp", market=False)
|
| 105 |
+
# last bar of a session with position still open -> square off
|
| 106 |
+
if pos is not None and (t + 1 >= n or sess[min(t + 1, n - 1)] != sess[t]):
|
| 107 |
+
close_trade(t, c[t], "eod")
|
| 108 |
+
|
| 109 |
+
# ---- look for a new entry trigger (flat only) --------------------
|
| 110 |
+
if pos is None and t + 1 < n and sess[t + 1] == sess[t]:
|
| 111 |
+
tr = trend[t]
|
| 112 |
+
direction = "bull" if tr == "bull" else ("bear" if tr == "bear" else None)
|
| 113 |
+
if direction and strat.within_entry_window(ts_idx.iloc[t], cfg):
|
| 114 |
+
zone = strat.select_zone(dres.zones, t, direction, cfg)
|
| 115 |
+
if zone is not None:
|
| 116 |
+
fired = (strat.long_trigger(l[t], c[t], zone) if direction == "bull"
|
| 117 |
+
else strat.short_trigger(h[t], c[t], zone))
|
| 118 |
+
if fired:
|
| 119 |
+
entry_price = o[t + 1] + (slip if direction == "bull" else -slip)
|
| 120 |
+
levels = strat.compute_levels(direction, entry_price, zone,
|
| 121 |
+
atr_sl[t], cfg)
|
| 122 |
+
if levels is not None:
|
| 123 |
+
zone.attempted = True
|
| 124 |
+
pos = dict(
|
| 125 |
+
direction=direction, entry_idx=t + 1,
|
| 126 |
+
entry_time=ts_idx.iloc[t + 1], entry_price=entry_price,
|
| 127 |
+
stop=levels.stop, target=levels.target, risk=levels.risk,
|
| 128 |
+
zone_id=zone.id, zone_score=zone.score,
|
| 129 |
+
zone_top=zone.top, zone_bottom=zone.bottom,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
trades_df = pd.DataFrame(trades)
|
| 133 |
+
metrics = compute_metrics(trades_df, cfg)
|
| 134 |
+
equity = build_equity(trades_df, cfg)
|
| 135 |
+
return BacktestResult(trades=trades_df, metrics=metrics, equity=equity,
|
| 136 |
+
detector=dres, cfg=cfg)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
# Metrics
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
def compute_metrics(trades: pd.DataFrame, cfg: Config) -> dict:
|
| 143 |
+
if trades.empty:
|
| 144 |
+
return dict(trades=0, win_rate=0.0, avg_R=0.0, profit_factor=0.0,
|
| 145 |
+
expectancy_points=0.0, expectancy_R=0.0, max_drawdown_points=0.0,
|
| 146 |
+
max_drawdown_rupees=0.0, total_points=0.0, total_rupees=0.0)
|
| 147 |
+
pnl = trades["pnl_points"]
|
| 148 |
+
wins = pnl[pnl > 0]
|
| 149 |
+
losses = pnl[pnl < 0]
|
| 150 |
+
gross_profit = wins.sum()
|
| 151 |
+
gross_loss = -losses.sum()
|
| 152 |
+
equity = pnl.cumsum()
|
| 153 |
+
running_max = equity.cummax()
|
| 154 |
+
dd = running_max - equity
|
| 155 |
+
max_dd = dd.max()
|
| 156 |
+
return dict(
|
| 157 |
+
trades=int(len(trades)),
|
| 158 |
+
wins=int((pnl > 0).sum()),
|
| 159 |
+
losses=int((pnl < 0).sum()),
|
| 160 |
+
win_rate=round(float((pnl > 0).mean()) * 100, 2),
|
| 161 |
+
avg_R=round(float(trades["r_multiple"].mean()), 3),
|
| 162 |
+
profit_factor=round(float(gross_profit / gross_loss), 3) if gross_loss > 0 else float("inf"),
|
| 163 |
+
expectancy_points=round(float(pnl.mean()), 3),
|
| 164 |
+
expectancy_R=round(float(trades["r_multiple"].mean()), 3),
|
| 165 |
+
max_drawdown_points=round(float(max_dd), 2),
|
| 166 |
+
max_drawdown_rupees=round(float(max_dd) * cfg.lot_size, 2),
|
| 167 |
+
total_points=round(float(pnl.sum()), 2),
|
| 168 |
+
total_rupees=round(float(pnl.sum()) * cfg.lot_size, 2),
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def build_equity(trades: pd.DataFrame, cfg: Config) -> pd.DataFrame:
|
| 173 |
+
if trades.empty:
|
| 174 |
+
return pd.DataFrame(columns=["exit_time", "pnl_points", "equity_points",
|
| 175 |
+
"equity_rupees"])
|
| 176 |
+
eq = trades[["exit_time", "pnl_points"]].copy().sort_values("exit_time")
|
| 177 |
+
eq["equity_points"] = eq["pnl_points"].cumsum()
|
| 178 |
+
eq["equity_rupees"] = eq["equity_points"] * cfg.lot_size
|
| 179 |
+
return eq.reset_index(drop=True)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def monthly_pnl(trades: pd.DataFrame, cfg: Config) -> pd.DataFrame:
|
| 183 |
+
if trades.empty:
|
| 184 |
+
return pd.DataFrame(columns=["month", "trades", "pnl_points", "pnl_rupees",
|
| 185 |
+
"win_rate"])
|
| 186 |
+
t = trades.copy()
|
| 187 |
+
t["month"] = pd.to_datetime(t["entry_time"]).dt.strftime("%Y-%m")
|
| 188 |
+
g = t.groupby("month")
|
| 189 |
+
out = g.agg(trades=("pnl_points", "size"),
|
| 190 |
+
pnl_points=("pnl_points", "sum"),
|
| 191 |
+
win_rate=("pnl_points", lambda s: round((s > 0).mean() * 100, 1)))
|
| 192 |
+
out["pnl_rupees"] = (out["pnl_points"] * cfg.lot_size).round(2)
|
| 193 |
+
out["pnl_points"] = out["pnl_points"].round(2)
|
| 194 |
+
return out.reset_index()
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ---------------------------------------------------------------------------
|
| 198 |
+
# Plots
|
| 199 |
+
# ---------------------------------------------------------------------------
|
| 200 |
+
def plot_equity(result: BacktestResult, path=None):
|
| 201 |
+
import matplotlib
|
| 202 |
+
matplotlib.use("Agg")
|
| 203 |
+
import matplotlib.pyplot as plt
|
| 204 |
+
|
| 205 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 206 |
+
path = path or (OUTPUT_DIR / "equity_curve.png")
|
| 207 |
+
eq = result.equity
|
| 208 |
+
fig, ax = plt.subplots(figsize=(11, 5))
|
| 209 |
+
if not eq.empty:
|
| 210 |
+
ax.plot(pd.to_datetime(eq["exit_time"]), eq["equity_points"],
|
| 211 |
+
color="#1565c0", lw=1.4)
|
| 212 |
+
ax.axhline(0, color="#888", lw=0.8, ls="--")
|
| 213 |
+
ax.set_title(f"Equity curve ({result.cfg.symbol} 5-min OB strategy) — "
|
| 214 |
+
f"{result.metrics['trades']} trades, "
|
| 215 |
+
f"PF={result.metrics['profit_factor']}, "
|
| 216 |
+
f"total={result.metrics['total_points']} pts")
|
| 217 |
+
ax.set_ylabel("Cumulative P&L (index points)")
|
| 218 |
+
ax.grid(alpha=0.3)
|
| 219 |
+
fig.tight_layout()
|
| 220 |
+
fig.savefig(path, dpi=110)
|
| 221 |
+
plt.close(fig)
|
| 222 |
+
return path
|
ob_intraday/config.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Central configuration for the intraday order-block backtest.
|
| 2 |
+
|
| 3 |
+
Every tunable the spec calls out is exposed here as a field on :class:`Config`:
|
| 4 |
+
``z_threshold``, ``min_score``, ``rr_target``, the pivot lengths and the ATR
|
| 5 |
+
filters. Defaults reproduce the base spec; the sweep (``sweep.py``) overrides
|
| 6 |
+
``min_score`` and ``rr_target`` only.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass, asdict, replace
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
PROJECT_DIR = Path(__file__).resolve().parent
|
| 14 |
+
DATA_DIR = PROJECT_DIR / "data"
|
| 15 |
+
OUTPUT_DIR = PROJECT_DIR / "output"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class Config:
|
| 20 |
+
# ---- Instrument / data -------------------------------------------------
|
| 21 |
+
symbol: str = "NIFTY" # cache file is data/{symbol}_5min.csv
|
| 22 |
+
exchange: str = "NFO"
|
| 23 |
+
interval: str = "5minute"
|
| 24 |
+
lot_size: int = 75 # for optional rupee conversion only
|
| 25 |
+
tick_size: float = 0.05 # 1 tick; drives slippage
|
| 26 |
+
session_start: str = "09:15" # NSE cash/F&O session (IST)
|
| 27 |
+
session_end: str = "15:30"
|
| 28 |
+
|
| 29 |
+
# ---- Detector: momentum z-score ---------------------------------------
|
| 30 |
+
z_window: int = 50 # SMA/stdev window for close-diff
|
| 31 |
+
z_threshold: float = 0.5 # |z| gate for a valid BOS
|
| 32 |
+
|
| 33 |
+
# ---- Detector: pivots --------------------------------------------------
|
| 34 |
+
pivot_len: int = 5 # intraday BOS pivots (both sides)
|
| 35 |
+
swing_pivot_len: int = 50 # swing-scale trend pivots
|
| 36 |
+
|
| 37 |
+
# ---- Detector: order-block filters ------------------------------------
|
| 38 |
+
atr_big_len: int = 200 # skip OB candles with range >= 2*ATR200
|
| 39 |
+
atr_big_mult: float = 2.0
|
| 40 |
+
atr_height_len: int = 10 # reject OB if height > 3.5*ATR10
|
| 41 |
+
atr_height_mult: float = 3.5
|
| 42 |
+
vol_sma_len: int = 20 # SMA of volume for the score
|
| 43 |
+
|
| 44 |
+
# ---- Detector: score ---------------------------------------------------
|
| 45 |
+
# score = (0.6*min(dist/(5*height),1) + 0.4*min(vol/volSMA20,1)) * 100
|
| 46 |
+
score_dist_weight: float = 0.6
|
| 47 |
+
score_vol_weight: float = 0.4
|
| 48 |
+
score_dist_height_mult: float = 5.0
|
| 49 |
+
|
| 50 |
+
# ---- Strategy ----------------------------------------------------------
|
| 51 |
+
min_score: float = 50.0 # OB quality gate to trade
|
| 52 |
+
rr_target: float = 2.0 # take-profit at rr_target * R
|
| 53 |
+
sl_atr_len: int = 10 # ATR used to pad the stop
|
| 54 |
+
sl_atr_mult: float = 0.25 # SL = zone far edge -/+ 0.25*ATR10
|
| 55 |
+
entry_start: str = "09:30" # first eligible trigger time
|
| 56 |
+
entry_cutoff: str = "14:30" # last eligible trigger time
|
| 57 |
+
square_off: str = "15:10" # force flat at/after this time
|
| 58 |
+
|
| 59 |
+
# ---- Costs -------------------------------------------------------------
|
| 60 |
+
slippage_ticks: float = 1.0 # applied on entry and on exit fills
|
| 61 |
+
cost_points: float = 0.0 # extra round-trip cost, index points
|
| 62 |
+
|
| 63 |
+
# -- helpers -------------------------------------------------------------
|
| 64 |
+
def to_dict(self) -> dict:
|
| 65 |
+
return asdict(self)
|
| 66 |
+
|
| 67 |
+
def with_overrides(self, **kwargs) -> "Config":
|
| 68 |
+
return replace(self, **kwargs)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
DEFAULT = Config()
|
| 72 |
+
|
| 73 |
+
# Sweep grid required by the spec (no optimisation beyond this).
|
| 74 |
+
SWEEP_MIN_SCORE = (40.0, 50.0, 60.0, 70.0)
|
| 75 |
+
SWEEP_RR = (1.5, 2.0, 3.0)
|
ob_intraday/data/NIFTY_5min.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
ob_intraday/data/NIFTY_5min_synthetic.csv
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
ob_intraday/data_download.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Download & cache 5-minute NIFTY-futures (continuous) candles from Kite.
|
| 2 |
+
|
| 3 |
+
Kite caps intraday history per request (~100 days for 5-minute), so we loop over
|
| 4 |
+
date windows and append. The result is cached to ``data/{symbol}_5min.csv`` with
|
| 5 |
+
exactly the columns the rest of the project expects::
|
| 6 |
+
|
| 7 |
+
timestamp, open, high, low, close, volume
|
| 8 |
+
|
| 9 |
+
``timestamp`` is tz-aware Asia/Kolkata. Rows are filtered to the regular NSE
|
| 10 |
+
session (09:15-15:30 IST) and de-duplicated/sorted.
|
| 11 |
+
|
| 12 |
+
If the Kite token is missing/expired (tokens die daily ~07:30 IST) the live
|
| 13 |
+
download raises with a clear message. ``generate_synthetic`` produces a
|
| 14 |
+
structurally-plausible 2-year 5-minute series so the detector/strategy/backtest
|
| 15 |
+
pipeline can be exercised and unit-tested without a live session.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import sys
|
| 20 |
+
import time
|
| 21 |
+
from datetime import datetime, timedelta, time as dtime
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
import pandas as pd
|
| 26 |
+
|
| 27 |
+
from .config import Config, DATA_DIR
|
| 28 |
+
|
| 29 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 30 |
+
if str(REPO_ROOT) not in sys.path:
|
| 31 |
+
sys.path.insert(0, str(REPO_ROOT))
|
| 32 |
+
|
| 33 |
+
OHLCV_COLS = ["timestamp", "open", "high", "low", "close", "volume"]
|
| 34 |
+
CHUNK_DAYS = 90 # under Kite's ~100-day 5-minute cap
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
# Cache path & IO
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
def cache_path(cfg: Config) -> Path:
|
| 41 |
+
return DATA_DIR / f"{cfg.symbol}_5min.csv"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def synthetic_cache_path(cfg: Config) -> Path:
|
| 45 |
+
# kept separate so a synthetic run never masquerades as real Kite data
|
| 46 |
+
return DATA_DIR / f"{cfg.symbol}_5min_synthetic.csv"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def load_cached(cfg: Config, path: Path | None = None) -> pd.DataFrame | None:
|
| 50 |
+
p = path or cache_path(cfg)
|
| 51 |
+
if not p.exists():
|
| 52 |
+
return None
|
| 53 |
+
df = pd.read_csv(p)
|
| 54 |
+
return normalize(df, cfg)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def save_cache(df: pd.DataFrame, cfg: Config, path: Path | None = None) -> Path:
|
| 58 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
p = path or cache_path(cfg)
|
| 60 |
+
out = df.copy()
|
| 61 |
+
out["timestamp"] = pd.to_datetime(out["timestamp"]).map(
|
| 62 |
+
lambda ts: ts.isoformat()
|
| 63 |
+
)
|
| 64 |
+
out[OHLCV_COLS].to_csv(p, index=False)
|
| 65 |
+
return p
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ---------------------------------------------------------------------------
|
| 69 |
+
# Normalisation / session handling
|
| 70 |
+
# ---------------------------------------------------------------------------
|
| 71 |
+
def normalize(df: pd.DataFrame, cfg: Config) -> pd.DataFrame:
|
| 72 |
+
df = df.rename(columns={c: c.lower() for c in df.columns})
|
| 73 |
+
if "date" in df.columns and "timestamp" not in df.columns:
|
| 74 |
+
df = df.rename(columns={"date": "timestamp"})
|
| 75 |
+
df["timestamp"] = pd.to_datetime(df["timestamp"], errors="coerce")
|
| 76 |
+
df = df.dropna(subset=["timestamp"]).copy()
|
| 77 |
+
|
| 78 |
+
ts = df["timestamp"]
|
| 79 |
+
if ts.dt.tz is None:
|
| 80 |
+
df["timestamp"] = ts.dt.tz_localize("Asia/Kolkata")
|
| 81 |
+
else:
|
| 82 |
+
df["timestamp"] = ts.dt.tz_convert("Asia/Kolkata")
|
| 83 |
+
|
| 84 |
+
for c in ("open", "high", "low", "close", "volume"):
|
| 85 |
+
if c not in df.columns:
|
| 86 |
+
df[c] = 0.0
|
| 87 |
+
df[c] = pd.to_numeric(df[c], errors="coerce")
|
| 88 |
+
df = df.dropna(subset=["open", "high", "low", "close"]).copy()
|
| 89 |
+
|
| 90 |
+
df = _filter_session(df, cfg)
|
| 91 |
+
df = (
|
| 92 |
+
df.drop_duplicates(subset=["timestamp"])
|
| 93 |
+
.sort_values("timestamp")
|
| 94 |
+
.reset_index(drop=True)
|
| 95 |
+
)
|
| 96 |
+
# session date used everywhere for intraday-scoped logic
|
| 97 |
+
df["session_date"] = df["timestamp"].dt.date
|
| 98 |
+
return df[OHLCV_COLS + ["session_date"]]
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _filter_session(df: pd.DataFrame, cfg: Config) -> pd.DataFrame:
|
| 102 |
+
start = _parse_hhmm(cfg.session_start)
|
| 103 |
+
end = _parse_hhmm(cfg.session_end)
|
| 104 |
+
t = df["timestamp"].dt.time
|
| 105 |
+
# keep bars whose stamp is within [start, end]; Kite stamps bars at their
|
| 106 |
+
# open, so the last 5-min bar opens at 15:25 (<=15:30).
|
| 107 |
+
mask = (t >= start) & (t <= end)
|
| 108 |
+
# drop weekends defensively
|
| 109 |
+
mask &= df["timestamp"].dt.dayofweek < 5
|
| 110 |
+
return df[mask]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _parse_hhmm(s: str) -> dtime:
|
| 114 |
+
hh, mm = str(s).split(":")
|
| 115 |
+
return dtime(int(hh), int(mm))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
+
# Live download (Kite)
|
| 120 |
+
# ---------------------------------------------------------------------------
|
| 121 |
+
def _resolve_front_future_token(cfg: Config) -> int:
|
| 122 |
+
"""Instrument token of the near-month NIFTY future.
|
| 123 |
+
|
| 124 |
+
NOTE: Kite only supports ``continuous=True`` for the *daily* interval, so at
|
| 125 |
+
5-minute we fetch this single contract; it only returns data over the
|
| 126 |
+
contract's own listing window (~a few months). Expired-contract tokens are
|
| 127 |
+
not exposed by the API, so a stitched multi-year 5-min futures series is not
|
| 128 |
+
obtainable this way.
|
| 129 |
+
"""
|
| 130 |
+
nfo = pd.read_csv(REPO_ROOT / "instruments_nfo.csv")
|
| 131 |
+
nfo["tradingsymbol"] = nfo["tradingsymbol"].astype(str).str.upper()
|
| 132 |
+
fut = nfo[
|
| 133 |
+
(nfo["name"].astype(str).str.upper() == cfg.symbol.upper())
|
| 134 |
+
& (nfo["instrument_type"].astype(str).str.upper() == "FUT")
|
| 135 |
+
].copy()
|
| 136 |
+
if fut.empty:
|
| 137 |
+
raise ValueError(f"No {cfg.symbol} FUT rows in instruments_nfo.csv")
|
| 138 |
+
fut["expiry"] = pd.to_datetime(fut["expiry"], errors="coerce")
|
| 139 |
+
fut = fut.sort_values("expiry")
|
| 140 |
+
return int(fut.iloc[0]["instrument_token"])
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
_INDEX_SYMBOL = {"NIFTY": "NIFTY 50", "BANKNIFTY": "NIFTY BANK",
|
| 144 |
+
"FINNIFTY": "NIFTY FIN SERVICE"}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _resolve_index_token(cfg: Config) -> int:
|
| 148 |
+
"""Spot-index token (2-year 5-min history, but volume == 0)."""
|
| 149 |
+
nse = pd.read_csv(REPO_ROOT / "instruments_nse.csv")
|
| 150 |
+
nse["tradingsymbol"] = nse["tradingsymbol"].astype(str).str.upper()
|
| 151 |
+
name = _INDEX_SYMBOL.get(cfg.symbol.upper(), cfg.symbol.upper())
|
| 152 |
+
row = nse[nse["tradingsymbol"] == name.upper()]
|
| 153 |
+
if row.empty:
|
| 154 |
+
raise ValueError(f"Index '{name}' not found in instruments_nse.csv")
|
| 155 |
+
return int(row.iloc[0]["instrument_token"])
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def kite_from_token_file(path: str | Path, verify: bool = True):
|
| 159 |
+
"""Build a KiteConnect by PAIRING the api_key + access_token stored in a
|
| 160 |
+
tokens json (e.g. tokens.paper.json — its api_key differs from .env)."""
|
| 161 |
+
import json
|
| 162 |
+
from kiteconnect import KiteConnect
|
| 163 |
+
|
| 164 |
+
data = json.loads(Path(path).read_text())
|
| 165 |
+
api_key = data.get("api_key")
|
| 166 |
+
access_token = data.get("access_token")
|
| 167 |
+
if not api_key or not access_token:
|
| 168 |
+
raise ValueError(f"{path} missing api_key/access_token")
|
| 169 |
+
kite = KiteConnect(api_key=api_key)
|
| 170 |
+
kite.set_access_token(access_token)
|
| 171 |
+
if verify:
|
| 172 |
+
kite.profile() # raises TokenException if stale
|
| 173 |
+
return kite
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def download(cfg: Config, years: float = 2.0, end: datetime | None = None,
|
| 177 |
+
pause_s: float = 0.35, verbose: bool = True, kite=None,
|
| 178 |
+
source: str = "futures") -> pd.DataFrame:
|
| 179 |
+
"""Download ``years`` of 5-minute candles and cache them.
|
| 180 |
+
|
| 181 |
+
``source``:
|
| 182 |
+
* ``"futures"`` - near-month NIFTY future (real volume, but only spans the
|
| 183 |
+
contract's listing window since Kite has no 5-min continuous / expired
|
| 184 |
+
tokens).
|
| 185 |
+
* ``"index"`` - NIFTY 50 spot (full multi-year history, volume == 0).
|
| 186 |
+
|
| 187 |
+
Loops over <=90-day windows (Kite's per-request cap). Pass ``kite`` to use a
|
| 188 |
+
specific session (e.g. built from tokens.paper.json).
|
| 189 |
+
"""
|
| 190 |
+
if kite is None:
|
| 191 |
+
from kite_client import get_kite # local import: keeps creds optional
|
| 192 |
+
try:
|
| 193 |
+
kite = get_kite()
|
| 194 |
+
except Exception as e: # expired/missing token -> actionable message
|
| 195 |
+
raise RuntimeError(
|
| 196 |
+
"Kite session unavailable (token likely expired — Zerodha tokens "
|
| 197 |
+
"reset daily ~07:30 IST). Refresh tokens.json / KITE_ACCESS_TOKEN, "
|
| 198 |
+
"then re-run. Underlying error: " + str(e)
|
| 199 |
+
) from e
|
| 200 |
+
|
| 201 |
+
if source == "index":
|
| 202 |
+
token = _resolve_index_token(cfg)
|
| 203 |
+
elif source == "futures":
|
| 204 |
+
token = _resolve_front_future_token(cfg)
|
| 205 |
+
else:
|
| 206 |
+
raise ValueError(f"unknown source: {source}")
|
| 207 |
+
end = end or datetime.now()
|
| 208 |
+
start = end - timedelta(days=int(365 * years))
|
| 209 |
+
|
| 210 |
+
frames: list[pd.DataFrame] = []
|
| 211 |
+
win_start = start
|
| 212 |
+
while win_start < end:
|
| 213 |
+
win_end = min(win_start + timedelta(days=CHUNK_DAYS), end)
|
| 214 |
+
if verbose:
|
| 215 |
+
print(f" fetch {win_start:%Y-%m-%d} -> {win_end:%Y-%m-%d}")
|
| 216 |
+
candles = kite.historical_data(
|
| 217 |
+
instrument_token=token,
|
| 218 |
+
from_date=win_start,
|
| 219 |
+
to_date=win_end,
|
| 220 |
+
interval="5minute",
|
| 221 |
+
oi=False,
|
| 222 |
+
)
|
| 223 |
+
if candles:
|
| 224 |
+
frames.append(pd.DataFrame(candles))
|
| 225 |
+
win_start = win_end + timedelta(days=1)
|
| 226 |
+
time.sleep(pause_s) # be gentle with rate limits
|
| 227 |
+
|
| 228 |
+
if not frames:
|
| 229 |
+
raise RuntimeError("Kite returned no candles for the requested range.")
|
| 230 |
+
|
| 231 |
+
raw = pd.concat(frames, ignore_index=True)
|
| 232 |
+
df = normalize(raw, cfg)
|
| 233 |
+
p = save_cache(df, cfg, cache_path(cfg) if source == "futures"
|
| 234 |
+
else DATA_DIR / f"{cfg.symbol}_index_5min.csv")
|
| 235 |
+
if verbose:
|
| 236 |
+
print(f" cached {len(df):,} bars ({source}) -> {p}")
|
| 237 |
+
return df
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def get_data(cfg: Config, years: float = 2.0, allow_synthetic: bool = False,
|
| 241 |
+
refresh: bool = False, verbose: bool = True, kite=None) -> pd.DataFrame:
|
| 242 |
+
"""Return the 5-minute dataset: cached CSV if present (and not ``refresh``),
|
| 243 |
+
else a live download. Falls back to synthetic only if ``allow_synthetic``.
|
| 244 |
+
"""
|
| 245 |
+
if not refresh:
|
| 246 |
+
cached = load_cached(cfg)
|
| 247 |
+
if cached is not None and not cached.empty:
|
| 248 |
+
if verbose:
|
| 249 |
+
print(f" using cache: {cache_path(cfg)} ({len(cached):,} bars)")
|
| 250 |
+
return cached
|
| 251 |
+
if allow_synthetic:
|
| 252 |
+
syn = load_cached(cfg, synthetic_cache_path(cfg))
|
| 253 |
+
if syn is not None and not syn.empty:
|
| 254 |
+
if verbose:
|
| 255 |
+
print(f" using SYNTHETIC cache: {synthetic_cache_path(cfg)} "
|
| 256 |
+
f"({len(syn):,} bars)")
|
| 257 |
+
return syn
|
| 258 |
+
try:
|
| 259 |
+
return download(cfg, years=years, verbose=verbose, kite=kite)
|
| 260 |
+
except Exception as e:
|
| 261 |
+
if not allow_synthetic:
|
| 262 |
+
raise
|
| 263 |
+
if verbose:
|
| 264 |
+
print(f" live download failed ({e}); generating synthetic data")
|
| 265 |
+
df = generate_synthetic(cfg, years=years)
|
| 266 |
+
save_cache(df, cfg, synthetic_cache_path(cfg)) # separate from real cache
|
| 267 |
+
return df
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
# ---------------------------------------------------------------------------
|
| 271 |
+
# Synthetic generator (pipeline testing / no-token fallback)
|
| 272 |
+
# ---------------------------------------------------------------------------
|
| 273 |
+
def generate_synthetic(cfg: Config, years: float = 2.0, seed: int = 7,
|
| 274 |
+
start_price: float = 20000.0) -> pd.DataFrame:
|
| 275 |
+
"""Structurally-plausible 5-minute NIFTY-like series.
|
| 276 |
+
|
| 277 |
+
Trending regimes + intraday mean reversion so that pivots, BOS and order
|
| 278 |
+
blocks actually form. Not a market model — only for exercising the pipeline.
|
| 279 |
+
"""
|
| 280 |
+
rng = np.random.default_rng(seed)
|
| 281 |
+
n_days = int(252 * years)
|
| 282 |
+
bars_per_day = 75 # 09:15..15:25 inclusive @ 5-min
|
| 283 |
+
slots = [dtime(9, 15)]
|
| 284 |
+
t = datetime(2000, 1, 3, 9, 15)
|
| 285 |
+
for _ in range(bars_per_day - 1):
|
| 286 |
+
t += timedelta(minutes=5)
|
| 287 |
+
slots.append(t.time())
|
| 288 |
+
|
| 289 |
+
rows = []
|
| 290 |
+
price = start_price
|
| 291 |
+
day = datetime.now().date() - timedelta(days=int(365 * years))
|
| 292 |
+
# drift regime that flips occasionally -> creates swing structure
|
| 293 |
+
drift = 0.0
|
| 294 |
+
added = 0
|
| 295 |
+
while added < n_days:
|
| 296 |
+
if day.weekday() < 5:
|
| 297 |
+
if rng.random() < 0.03:
|
| 298 |
+
drift = rng.normal(0, 3.0)
|
| 299 |
+
gap = rng.normal(0, 6.0)
|
| 300 |
+
price += gap
|
| 301 |
+
day_open = price
|
| 302 |
+
for slot in slots:
|
| 303 |
+
vol_base = rng.uniform(0.8, 1.4)
|
| 304 |
+
step = rng.normal(drift * 0.02, 6.0) * vol_base
|
| 305 |
+
o = price
|
| 306 |
+
c = o + step
|
| 307 |
+
hi = max(o, c) + abs(rng.normal(0, 3.0))
|
| 308 |
+
lo = min(o, c) - abs(rng.normal(0, 3.0))
|
| 309 |
+
v = int(abs(rng.normal(1.0, 0.4)) * 100000 + 20000)
|
| 310 |
+
ts = pd.Timestamp.combine(pd.Timestamp(day), slot).tz_localize(
|
| 311 |
+
"Asia/Kolkata"
|
| 312 |
+
)
|
| 313 |
+
rows.append((ts, o, hi, lo, c, v))
|
| 314 |
+
price = c
|
| 315 |
+
added += 1
|
| 316 |
+
day = day + timedelta(days=1)
|
| 317 |
+
|
| 318 |
+
df = pd.DataFrame(rows, columns=OHLCV_COLS)
|
| 319 |
+
return normalize(df, cfg)
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
if __name__ == "__main__": # manual: python -m ob_intraday.data_download
|
| 323 |
+
import argparse
|
| 324 |
+
|
| 325 |
+
ap = argparse.ArgumentParser(description="Download 5-min NIFTY futures")
|
| 326 |
+
ap.add_argument("--years", type=float, default=2.0)
|
| 327 |
+
ap.add_argument("--refresh", action="store_true")
|
| 328 |
+
ap.add_argument("--synthetic", action="store_true",
|
| 329 |
+
help="fall back to synthetic if live download fails")
|
| 330 |
+
args = ap.parse_args()
|
| 331 |
+
cfg = Config()
|
| 332 |
+
d = get_data(cfg, years=args.years, allow_synthetic=args.synthetic,
|
| 333 |
+
refresh=args.refresh)
|
| 334 |
+
print(d.head())
|
| 335 |
+
print(f"rows={len(d):,} {d['timestamp'].min()} -> {d['timestamp'].max()}")
|
ob_intraday/detector.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bar-by-bar structure & order-block detection — strictly no lookahead.
|
| 2 |
+
|
| 3 |
+
Everything a later bar needs is derived only from that bar and earlier bars. In
|
| 4 |
+
particular a pivot of length ``L`` is only *acted on* at its confirmation bar
|
| 5 |
+
(``pivot_bar + L``), which is exactly how ``ta.pivothigh(L, L)`` behaves on a
|
| 6 |
+
live chart.
|
| 7 |
+
|
| 8 |
+
Session handling (spec: "never let indicator lookbacks cross overnight gaps for
|
| 9 |
+
intraday logic"):
|
| 10 |
+
|
| 11 |
+
* Intraday pivots (len 5), BOS crossovers and the order-block leg are all
|
| 12 |
+
confined to a single session; the last-pivot memory is reset each session
|
| 13 |
+
open.
|
| 14 |
+
* The momentum z-score excludes the overnight gap: the change at each session's
|
| 15 |
+
first bar is dropped, and the 50-sample baseline is built from intraday
|
| 16 |
+
changes only.
|
| 17 |
+
* True range at a session's first bar ignores the prior close, so an overnight
|
| 18 |
+
gap never inflates ATR.
|
| 19 |
+
* The **swing** trend uses length-50 pivots, which cannot fit inside one 75-bar
|
| 20 |
+
session and are explicitly a higher ("swing-scale") frame — these run
|
| 21 |
+
continuously across sessions.
|
| 22 |
+
|
| 23 |
+
The score follows the spec exactly::
|
| 24 |
+
|
| 25 |
+
score = (0.6*min(dist/(5*height), 1) + 0.4*min(vol/volSMA20, 1)) * 100
|
| 26 |
+
|
| 27 |
+
with ``dist`` = |break-bar close - broken pivot level|, ``height`` = OB candle
|
| 28 |
+
high-low, ``vol`` = OB candle volume, ``volSMA20`` = SMA(20) of volume at the OB
|
| 29 |
+
candle.
|
| 30 |
+
"""
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
from dataclasses import dataclass, field
|
| 34 |
+
from typing import List, Optional
|
| 35 |
+
|
| 36 |
+
import numpy as np
|
| 37 |
+
import pandas as pd
|
| 38 |
+
|
| 39 |
+
from .config import Config
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class Zone:
|
| 44 |
+
id: int
|
| 45 |
+
direction: str # "bull" | "bear"
|
| 46 |
+
top: float
|
| 47 |
+
bottom: float
|
| 48 |
+
score: float
|
| 49 |
+
volume: float
|
| 50 |
+
created_idx: int # BOS bar; zone is tradeable from created_idx+1
|
| 51 |
+
ob_idx: int # bar index of the order-block candle
|
| 52 |
+
pivot_idx: int # broken pivot's bar
|
| 53 |
+
mitigated_idx: Optional[int] = None
|
| 54 |
+
attempted: bool = False # set by the strategy (one attempt per zone)
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def height(self) -> float:
|
| 58 |
+
return self.top - self.bottom
|
| 59 |
+
|
| 60 |
+
def active_at(self, t: int) -> bool:
|
| 61 |
+
if t <= self.created_idx:
|
| 62 |
+
return False
|
| 63 |
+
return self.mitigated_idx is None or t < self.mitigated_idx
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass
|
| 67 |
+
class DetectorResult:
|
| 68 |
+
df: pd.DataFrame # normalized OHLCV + indicator columns
|
| 69 |
+
zones: List[Zone]
|
| 70 |
+
trend: np.ndarray # per-bar "bull"/"bear"/"none"
|
| 71 |
+
bos_bull: np.ndarray # bool per bar (intraday BOS up)
|
| 72 |
+
bos_bear: np.ndarray
|
| 73 |
+
|
| 74 |
+
def active_zones_at(self, t: int, direction: Optional[str] = None) -> List[Zone]:
|
| 75 |
+
out = [z for z in self.zones if z.active_at(t)]
|
| 76 |
+
if direction:
|
| 77 |
+
out = [z for z in out if z.direction == direction]
|
| 78 |
+
return out
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
# Vectorised, causal indicators
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
def _session_start_mask(df: pd.DataFrame) -> np.ndarray:
|
| 85 |
+
sd = df["session_date"].to_numpy()
|
| 86 |
+
m = np.ones(len(sd), dtype=bool)
|
| 87 |
+
m[1:] = sd[1:] != sd[:-1]
|
| 88 |
+
return m
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _wilder_atr(high, low, close, sess_start, n) -> np.ndarray:
|
| 92 |
+
prev_close = close.shift(1)
|
| 93 |
+
tr = np.maximum.reduce([
|
| 94 |
+
(high - low).to_numpy(),
|
| 95 |
+
(high - prev_close).abs().to_numpy(),
|
| 96 |
+
(low - prev_close).abs().to_numpy(),
|
| 97 |
+
])
|
| 98 |
+
hl = (high - low).to_numpy()
|
| 99 |
+
tr[sess_start] = hl[sess_start] # no overnight gap in TR
|
| 100 |
+
tr = pd.Series(tr, index=high.index)
|
| 101 |
+
return tr.ewm(alpha=1.0 / n, adjust=False, min_periods=n).mean().to_numpy()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _momentum_z(close: pd.Series, sess_start: np.ndarray, window: int) -> np.ndarray:
|
| 105 |
+
ch = close.diff()
|
| 106 |
+
ch[sess_start] = np.nan # drop the overnight change
|
| 107 |
+
valid = ch.dropna()
|
| 108 |
+
avg = valid.rolling(window, min_periods=window).mean()
|
| 109 |
+
std = valid.rolling(window, min_periods=window).std(ddof=0) # Pine ta.stdev
|
| 110 |
+
avg = avg.reindex(close.index)
|
| 111 |
+
std = std.reindex(close.index)
|
| 112 |
+
z = (ch - avg) / std
|
| 113 |
+
z[std <= 0] = np.nan
|
| 114 |
+
return z.to_numpy()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _pivots(values: np.ndarray, sess: np.ndarray, length: int,
|
| 118 |
+
is_high: bool, intraday: bool) -> np.ndarray:
|
| 119 |
+
"""Return an array aligned to the *confirmation* bar (pivot_bar+length):
|
| 120 |
+
value = the pivot price, or NaN. Strict extreme on both sides."""
|
| 121 |
+
n = len(values)
|
| 122 |
+
out = np.full(n, np.nan)
|
| 123 |
+
for i in range(length, n - length):
|
| 124 |
+
conf = i + length
|
| 125 |
+
if intraday and (sess[i - length] != sess[i] or sess[i + length] != sess[i]):
|
| 126 |
+
continue
|
| 127 |
+
c = values[i]
|
| 128 |
+
left = values[i - length:i]
|
| 129 |
+
right = values[i + 1:i + length + 1]
|
| 130 |
+
if is_high:
|
| 131 |
+
if c > left.max() and c > right.max():
|
| 132 |
+
out[conf] = c
|
| 133 |
+
else:
|
| 134 |
+
if c < left.min() and c < right.min():
|
| 135 |
+
out[conf] = c
|
| 136 |
+
return out
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def add_indicators(df: pd.DataFrame, cfg: Config) -> pd.DataFrame:
|
| 140 |
+
df = df.copy()
|
| 141 |
+
sess_start = _session_start_mask(df)
|
| 142 |
+
high, low, close, vol = df["high"], df["low"], df["close"], df["volume"]
|
| 143 |
+
df["atr_big"] = _wilder_atr(high, low, close, sess_start, cfg.atr_big_len)
|
| 144 |
+
df["atr_height"] = _wilder_atr(high, low, close, sess_start, cfg.atr_height_len)
|
| 145 |
+
df["atr_sl"] = _wilder_atr(high, low, close, sess_start, cfg.sl_atr_len)
|
| 146 |
+
df["vol_sma"] = vol.rolling(cfg.vol_sma_len, min_periods=cfg.vol_sma_len).mean()
|
| 147 |
+
df["momentum_z"] = _momentum_z(close, sess_start, cfg.z_window)
|
| 148 |
+
return df
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# ---------------------------------------------------------------------------
|
| 152 |
+
# Order-block construction
|
| 153 |
+
# ---------------------------------------------------------------------------
|
| 154 |
+
def _build_ob(df: pd.DataFrame, pivot_idx: int, break_idx: int, direction: str,
|
| 155 |
+
pivot_level: float, cfg: Config, next_id: int) -> Optional[Zone]:
|
| 156 |
+
o = df["open"].to_numpy(); h = df["high"].to_numpy()
|
| 157 |
+
l = df["low"].to_numpy(); c = df["close"].to_numpy()
|
| 158 |
+
v = df["volume"].to_numpy()
|
| 159 |
+
atr_big = df["atr_big"].to_numpy()
|
| 160 |
+
atr_h = df["atr_height"].to_numpy()
|
| 161 |
+
vol_sma = df["vol_sma"].to_numpy()
|
| 162 |
+
|
| 163 |
+
lo_leg, hi_leg = pivot_idx, break_idx
|
| 164 |
+
best = None
|
| 165 |
+
for i in range(lo_leg, hi_leg + 1):
|
| 166 |
+
rng = h[i] - l[i]
|
| 167 |
+
big = atr_big[i]
|
| 168 |
+
if not np.isnan(big) and rng >= cfg.atr_big_mult * big:
|
| 169 |
+
continue # skip oversized candles
|
| 170 |
+
if direction == "bull":
|
| 171 |
+
if c[i] < o[i]: # opposing (bearish) candle
|
| 172 |
+
if best is None or l[i] < l[best]:
|
| 173 |
+
best = i
|
| 174 |
+
else:
|
| 175 |
+
if c[i] > o[i]: # opposing (bullish) candle
|
| 176 |
+
if best is None or h[best] < h[i]:
|
| 177 |
+
best = i
|
| 178 |
+
if best is None:
|
| 179 |
+
return None
|
| 180 |
+
|
| 181 |
+
top, bottom = h[best], l[best]
|
| 182 |
+
height = top - bottom
|
| 183 |
+
if height <= 0:
|
| 184 |
+
return None
|
| 185 |
+
atrh = atr_h[break_idx]
|
| 186 |
+
if not np.isnan(atrh) and height > cfg.atr_height_mult * atrh:
|
| 187 |
+
return None # OB too tall
|
| 188 |
+
|
| 189 |
+
dist = abs(c[break_idx] - pivot_level)
|
| 190 |
+
dist_term = min(dist / (cfg.score_dist_height_mult * height), 1.0)
|
| 191 |
+
vs = vol_sma[best]
|
| 192 |
+
if np.isnan(vs) or vs <= 0:
|
| 193 |
+
vol_term = 0.0
|
| 194 |
+
else:
|
| 195 |
+
vol_term = min(v[best] / vs, 1.0)
|
| 196 |
+
score = (cfg.score_dist_weight * dist_term
|
| 197 |
+
+ cfg.score_vol_weight * vol_term) * 100.0
|
| 198 |
+
|
| 199 |
+
return Zone(id=next_id, direction=direction, top=float(top),
|
| 200 |
+
bottom=float(bottom), score=float(score), volume=float(v[best]),
|
| 201 |
+
created_idx=break_idx, ob_idx=best, pivot_idx=pivot_idx)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _merge_into_active(zones: List[Zone], new: Zone) -> bool:
|
| 205 |
+
"""Merge ``new`` into an overlapping, same-direction, still-active zone.
|
| 206 |
+
Returns True if merged (caller then drops ``new``)."""
|
| 207 |
+
for z in zones:
|
| 208 |
+
if z.direction != new.direction or z.mitigated_idx is not None:
|
| 209 |
+
continue
|
| 210 |
+
if new.bottom <= z.top and new.top >= z.bottom: # overlap
|
| 211 |
+
z.top = max(z.top, new.top)
|
| 212 |
+
z.bottom = min(z.bottom, new.bottom)
|
| 213 |
+
z.volume += new.volume # sum volume
|
| 214 |
+
z.score = max(z.score, new.score) # keep max score
|
| 215 |
+
return True
|
| 216 |
+
return False
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ---------------------------------------------------------------------------
|
| 220 |
+
# Main detection pass
|
| 221 |
+
# ---------------------------------------------------------------------------
|
| 222 |
+
def detect(df: pd.DataFrame, cfg: Config) -> DetectorResult:
|
| 223 |
+
df = add_indicators(df, cfg)
|
| 224 |
+
n = len(df)
|
| 225 |
+
sess = df["session_date"].to_numpy()
|
| 226 |
+
close = df["close"].to_numpy()
|
| 227 |
+
z = df["momentum_z"].to_numpy()
|
| 228 |
+
|
| 229 |
+
ph = _pivots(df["high"].to_numpy(), sess, cfg.pivot_len, True, intraday=True)
|
| 230 |
+
pl = _pivots(df["low"].to_numpy(), sess, cfg.pivot_len, False, intraday=True)
|
| 231 |
+
sph = _pivots(df["high"].to_numpy(), sess, cfg.swing_pivot_len, True, intraday=False)
|
| 232 |
+
spl = _pivots(df["low"].to_numpy(), sess, cfg.swing_pivot_len, False, intraday=False)
|
| 233 |
+
|
| 234 |
+
zones: List[Zone] = []
|
| 235 |
+
active: List[Zone] = [] # unmitigated zones, for fast mitigation
|
| 236 |
+
trend_arr = np.array(["none"] * n, dtype=object)
|
| 237 |
+
bos_bull = np.zeros(n, dtype=bool)
|
| 238 |
+
bos_bear = np.zeros(n, dtype=bool)
|
| 239 |
+
|
| 240 |
+
last_ph = np.nan; last_ph_idx = -1
|
| 241 |
+
last_pl = np.nan; last_pl_idx = -1
|
| 242 |
+
last_sph = np.nan; last_spl = np.nan
|
| 243 |
+
trend = "none"
|
| 244 |
+
next_id = 0
|
| 245 |
+
|
| 246 |
+
for t in range(n):
|
| 247 |
+
new_session = t == 0 or sess[t] != sess[t - 1]
|
| 248 |
+
if new_session: # intraday pivot memory resets
|
| 249 |
+
last_ph = np.nan; last_ph_idx = -1
|
| 250 |
+
last_pl = np.nan; last_pl_idx = -1
|
| 251 |
+
|
| 252 |
+
# register newly-confirmed pivots at this bar
|
| 253 |
+
if not np.isnan(ph[t]):
|
| 254 |
+
last_ph = ph[t]; last_ph_idx = t - cfg.pivot_len
|
| 255 |
+
if not np.isnan(pl[t]):
|
| 256 |
+
last_pl = pl[t]; last_pl_idx = t - cfg.pivot_len
|
| 257 |
+
if not np.isnan(sph[t]):
|
| 258 |
+
last_sph = sph[t]
|
| 259 |
+
if not np.isnan(spl[t]):
|
| 260 |
+
last_spl = spl[t]
|
| 261 |
+
|
| 262 |
+
# ---- intraday BOS + order block (needs an in-session prior close) ---
|
| 263 |
+
if not new_session:
|
| 264 |
+
zt = z[t]
|
| 265 |
+
if (not np.isnan(last_ph) and not np.isnan(zt)
|
| 266 |
+
and close[t] > last_ph and close[t - 1] <= last_ph
|
| 267 |
+
and zt > cfg.z_threshold):
|
| 268 |
+
bos_bull[t] = True
|
| 269 |
+
zone = _build_ob(df, last_ph_idx, t, "bull", last_ph, cfg, next_id)
|
| 270 |
+
if zone is not None and not _merge_into_active(active, zone):
|
| 271 |
+
zones.append(zone); active.append(zone); next_id += 1
|
| 272 |
+
last_ph = np.nan # consume broken pivot
|
| 273 |
+
elif (not np.isnan(last_pl) and not np.isnan(zt)
|
| 274 |
+
and close[t] < last_pl and close[t - 1] >= last_pl
|
| 275 |
+
and zt < -cfg.z_threshold):
|
| 276 |
+
bos_bear[t] = True
|
| 277 |
+
zone = _build_ob(df, last_pl_idx, t, "bear", last_pl, cfg, next_id)
|
| 278 |
+
if zone is not None and not _merge_into_active(active, zone):
|
| 279 |
+
zones.append(zone); active.append(zone); next_id += 1
|
| 280 |
+
last_pl = np.nan
|
| 281 |
+
|
| 282 |
+
# ---- swing trend (continuous, no z filter) --------------------------
|
| 283 |
+
if t > 0:
|
| 284 |
+
if (not np.isnan(last_sph) and close[t] > last_sph
|
| 285 |
+
and close[t - 1] <= last_sph):
|
| 286 |
+
trend = "bull"; last_sph = np.nan
|
| 287 |
+
elif (not np.isnan(last_spl) and close[t] < last_spl
|
| 288 |
+
and close[t - 1] >= last_spl):
|
| 289 |
+
trend = "bear"; last_spl = np.nan
|
| 290 |
+
trend_arr[t] = trend
|
| 291 |
+
|
| 292 |
+
# ---- mitigation: close beyond the zone's far edge -------------------
|
| 293 |
+
if active:
|
| 294 |
+
still: List[Zone] = []
|
| 295 |
+
for zn in active:
|
| 296 |
+
if zn.created_idx < t and (
|
| 297 |
+
(zn.direction == "bull" and close[t] < zn.bottom)
|
| 298 |
+
or (zn.direction == "bear" and close[t] > zn.top)):
|
| 299 |
+
zn.mitigated_idx = t
|
| 300 |
+
else:
|
| 301 |
+
still.append(zn)
|
| 302 |
+
active = still
|
| 303 |
+
|
| 304 |
+
return DetectorResult(df=df, zones=zones, trend=trend_arr,
|
| 305 |
+
bos_bull=bos_bull, bos_bear=bos_bear)
|
ob_intraday/output/equity_curve.png
ADDED
|
ob_intraday/output/monthly_pnl.csv
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
month,trades,pnl_points,win_rate,pnl_rupees
|
| 2 |
+
2026-05,1,36.63,100.0,2746.92
|
ob_intraday/output/sweep.csv
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
min_score,rr,trades,win_rate,avg_R,profit_factor,expectancy_R,total_points,max_dd_points
|
| 2 |
+
40.0,1.5,7,42.86,0.071,0.578,0.071,-72.45,108.09
|
| 3 |
+
40.0,2.0,7,42.86,0.285,0.77,0.285,-39.39,100.55
|
| 4 |
+
40.0,3.0,7,28.57,0.142,0.49,0.142,-104.2,168.42
|
| 5 |
+
50.0,1.5,1,100.0,1.5,inf,1.5,27.47,0.0
|
| 6 |
+
50.0,2.0,1,100.0,2.0,inf,2.0,36.63,0.0
|
| 7 |
+
50.0,3.0,1,100.0,3.0,inf,3.0,54.94,0.0
|
| 8 |
+
60.0,1.5,1,100.0,1.5,inf,1.5,27.47,0.0
|
| 9 |
+
60.0,2.0,1,100.0,2.0,inf,2.0,36.63,0.0
|
| 10 |
+
60.0,3.0,1,100.0,3.0,inf,3.0,54.94,0.0
|
| 11 |
+
70.0,1.5,1,100.0,1.5,inf,1.5,27.47,0.0
|
| 12 |
+
70.0,2.0,1,100.0,2.0,inf,2.0,36.63,0.0
|
| 13 |
+
70.0,3.0,1,100.0,3.0,inf,3.0,54.94,0.0
|
ob_intraday/output/trades.csv
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
direction,entry_idx,exit_idx,entry_time,exit_time,entry_price,exit_price,exit_reason,zone_id,zone_score,zone_top,zone_bottom,stop,target,risk_points,pnl_points_gross,cost_points,pnl_points,r_multiple,pnl_rupees,bars_held
|
| 2 |
+
short,1133,1134,2026-05-21 09:55:00+05:30,2026-05-21 10:00:00+05:30,23979.95,23943.3244,tp,20,90.62,23991.0,23980.0,23998.2628,23943.3244,18.3128,36.6256,0.0,36.6256,2.0,2746.92,1
|
ob_intraday/output/validation/trade_00_short_20240731_1005.png
ADDED
|
ob_intraday/output/validation/trade_00_short_20260521_0955.png
ADDED
|
ob_intraday/output/validation/trade_01_long_20240807_1420.png
ADDED
|
ob_intraday/output/validation/trade_02_long_20240924_1200.png
ADDED
|
ob_intraday/output/validation/trade_03_short_20241009_0940.png
ADDED
|
ob_intraday/output/validation/trade_04_long_20241203_1200.png
ADDED
|
ob_intraday/output/validation/trade_05_long_20250123_1210.png
ADDED
|
ob_intraday/output/validation/trade_06_long_20250704_1320.png
ADDED
|
ob_intraday/output/validation/trade_07_long_20250718_1230.png
ADDED
|
ob_intraday/output/validation/trade_08_long_20250804_1130.png
ADDED
|
ob_intraday/output/validation/trade_09_long_20250829_1115.png
ADDED
|
ob_intraday/output/validation/trade_10_long_20250829_1240.png
ADDED
|
ob_intraday/output/validation/trade_11_long_20251013_1025.png
ADDED
|
ob_intraday/output/validation/trade_12_long_20251202_1250.png
ADDED
|
ob_intraday/output/validation/trade_13_long_20251218_0955.png
ADDED
|
ob_intraday/output/validation/trade_14_long_20251218_1105.png
ADDED
|
ob_intraday/output/validation/trade_15_long_20260302_1045.png
ADDED
|
ob_intraday/output/validation/trade_16_short_20260303_0940.png
ADDED
|
ob_intraday/output/validation/trade_17_long_20260414_1055.png
ADDED
|
ob_intraday/output/validation/trade_18_long_20260505_1250.png
ADDED
|
ob_intraday/output/validation/trade_19_short_20260603_1220.png
ADDED
|
ob_intraday/run.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLI orchestrator: data -> detect -> backtest -> report -> validate -> sweep.
|
| 2 |
+
|
| 3 |
+
Examples
|
| 4 |
+
--------
|
| 5 |
+
Live (needs a valid Kite token in tokens.json / KITE_ACCESS_TOKEN)::
|
| 6 |
+
|
| 7 |
+
python -m ob_intraday.run --years 2
|
| 8 |
+
|
| 9 |
+
Use the cached CSV if present, otherwise synthesise so the pipeline still runs::
|
| 10 |
+
|
| 11 |
+
python -m ob_intraday.run --synthetic
|
| 12 |
+
|
| 13 |
+
Just the sweep on already-cached data::
|
| 14 |
+
|
| 15 |
+
python -m ob_intraday.run --sweep-only
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import sys
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import pandas as pd
|
| 24 |
+
|
| 25 |
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 26 |
+
if str(REPO_ROOT) not in sys.path:
|
| 27 |
+
sys.path.insert(0, str(REPO_ROOT))
|
| 28 |
+
|
| 29 |
+
from ob_intraday.config import Config, OUTPUT_DIR
|
| 30 |
+
from ob_intraday import data_download as dl
|
| 31 |
+
from ob_intraday import backtest as bt
|
| 32 |
+
from ob_intraday import validation as val
|
| 33 |
+
from ob_intraday import sweep as sw
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def main(argv=None):
|
| 37 |
+
ap = argparse.ArgumentParser(description="Intraday order-block backtest")
|
| 38 |
+
ap.add_argument("--years", type=float, default=2.0)
|
| 39 |
+
ap.add_argument("--refresh", action="store_true", help="force re-download")
|
| 40 |
+
ap.add_argument("--synthetic", action="store_true",
|
| 41 |
+
help="fall back to synthetic data if live download fails")
|
| 42 |
+
ap.add_argument("--min-score", type=float, default=None)
|
| 43 |
+
ap.add_argument("--rr", type=float, default=None)
|
| 44 |
+
ap.add_argument("--no-plots", action="store_true")
|
| 45 |
+
ap.add_argument("--no-validation", action="store_true")
|
| 46 |
+
ap.add_argument("--no-sweep", action="store_true")
|
| 47 |
+
ap.add_argument("--sweep-only", action="store_true")
|
| 48 |
+
args = ap.parse_args(argv)
|
| 49 |
+
|
| 50 |
+
cfg = Config()
|
| 51 |
+
if args.min_score is not None:
|
| 52 |
+
cfg = cfg.with_overrides(min_score=args.min_score)
|
| 53 |
+
if args.rr is not None:
|
| 54 |
+
cfg = cfg.with_overrides(rr_target=args.rr)
|
| 55 |
+
|
| 56 |
+
print("== Loading data ==")
|
| 57 |
+
df = dl.get_data(cfg, years=args.years, allow_synthetic=args.synthetic,
|
| 58 |
+
refresh=args.refresh)
|
| 59 |
+
print(f" {len(df):,} bars {df['timestamp'].min()} -> {df['timestamp'].max()}")
|
| 60 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 61 |
+
|
| 62 |
+
if args.sweep_only:
|
| 63 |
+
_do_sweep(df, cfg)
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
print("\n== Backtest ==")
|
| 67 |
+
res = bt.run_backtest(df, cfg)
|
| 68 |
+
for k, v in res.metrics.items():
|
| 69 |
+
print(f" {k:22}: {v}")
|
| 70 |
+
print(f" detected zones : {len(res.detector.zones)}")
|
| 71 |
+
|
| 72 |
+
res.trades.to_csv(OUTPUT_DIR / "trades.csv", index=False)
|
| 73 |
+
monthly = bt.monthly_pnl(res.trades, cfg)
|
| 74 |
+
monthly.to_csv(OUTPUT_DIR / "monthly_pnl.csv", index=False)
|
| 75 |
+
print("\n== Monthly P&L ==")
|
| 76 |
+
print(monthly.to_string(index=False) if not monthly.empty else " (no trades)")
|
| 77 |
+
|
| 78 |
+
if not args.no_plots:
|
| 79 |
+
p = bt.plot_equity(res)
|
| 80 |
+
print(f"\n equity curve -> {p}")
|
| 81 |
+
if not args.no_validation:
|
| 82 |
+
paths = val.plot_random_trades(res, n=20)
|
| 83 |
+
print(f" {len(paths)} validation charts -> {OUTPUT_DIR / 'validation'}")
|
| 84 |
+
if not args.no_sweep:
|
| 85 |
+
_do_sweep(df, cfg)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _do_sweep(df, cfg):
|
| 89 |
+
print("\n== Sweep (min_score x RR) ==")
|
| 90 |
+
table = sw.run_sweep(df, cfg)
|
| 91 |
+
table.to_csv(OUTPUT_DIR / "sweep.csv", index=False)
|
| 92 |
+
print(sw.format_table(table))
|
| 93 |
+
print(f"\n sweep -> {OUTPUT_DIR / 'sweep.csv'}")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
if __name__ == "__main__":
|
| 97 |
+
main()
|
ob_intraday/strategy.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entry/exit *rules* for the order-block strategy (no market interaction here).
|
| 2 |
+
|
| 3 |
+
The sequential simulation lives in :mod:`ob_intraday.backtest`; this module is
|
| 4 |
+
the pure decision layer so the rules can be unit-tested in isolation.
|
| 5 |
+
|
| 6 |
+
Spec:
|
| 7 |
+
* Long = swing trend bullish, an active bull OB with score >= min_score, the
|
| 8 |
+
bar's low enters the zone, and the bar closes back at/above the zone top.
|
| 9 |
+
Enter next bar's open. Shorts are mirrored.
|
| 10 |
+
* SL = zone far edge -/+ 0.25*ATR(10); TP = rr_target * R.
|
| 11 |
+
* Entries only 09:30-14:30; force flat at 15:10; one attempt per zone; one
|
| 12 |
+
position at a time.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from datetime import time as dtime
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
from .config import Config
|
| 21 |
+
from .detector import Zone
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _hhmm(s: str) -> dtime:
|
| 25 |
+
hh, mm = str(s).split(":")
|
| 26 |
+
return dtime(int(hh), int(mm))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def within_entry_window(ts, cfg: Config) -> bool:
|
| 30 |
+
t = ts.time()
|
| 31 |
+
return _hhmm(cfg.entry_start) <= t <= _hhmm(cfg.entry_cutoff)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def at_or_after_square_off(ts, cfg: Config) -> bool:
|
| 35 |
+
return ts.time() >= _hhmm(cfg.square_off)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def long_trigger(low: float, close: float, zone: Zone) -> bool:
|
| 39 |
+
"""Bar dipped into the bull zone (low <= top) and closed back above it."""
|
| 40 |
+
return low <= zone.top and close >= zone.top
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def short_trigger(high: float, close: float, zone: Zone) -> bool:
|
| 44 |
+
"""Bar poked into the bear zone (high >= bottom) and closed back below it."""
|
| 45 |
+
return high >= zone.bottom and close <= zone.bottom
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass
|
| 49 |
+
class Levels:
|
| 50 |
+
entry: float
|
| 51 |
+
stop: float
|
| 52 |
+
target: float
|
| 53 |
+
risk: float # |entry - stop|, i.e. 1R in points
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def compute_levels(direction: str, entry_price: float, zone: Zone,
|
| 57 |
+
atr_sl: float, cfg: Config) -> Optional[Levels]:
|
| 58 |
+
pad = cfg.sl_atr_mult * (atr_sl if atr_sl and atr_sl == atr_sl else 0.0)
|
| 59 |
+
if direction == "bull":
|
| 60 |
+
stop = zone.bottom - pad
|
| 61 |
+
risk = entry_price - stop
|
| 62 |
+
if risk <= 0:
|
| 63 |
+
return None
|
| 64 |
+
target = entry_price + cfg.rr_target * risk
|
| 65 |
+
else:
|
| 66 |
+
stop = zone.top + pad
|
| 67 |
+
risk = stop - entry_price
|
| 68 |
+
if risk <= 0:
|
| 69 |
+
return None
|
| 70 |
+
target = entry_price - cfg.rr_target * risk
|
| 71 |
+
return Levels(entry=entry_price, stop=stop, target=target, risk=risk)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def select_zone(result_zones, t: int, direction: str, cfg: Config) -> Optional[Zone]:
|
| 75 |
+
"""Best eligible (active, score>=min, not yet attempted) zone at bar ``t``.
|
| 76 |
+
Highest score wins; ties -> most recently created."""
|
| 77 |
+
best: Optional[Zone] = None
|
| 78 |
+
for z in result_zones:
|
| 79 |
+
if z.direction != direction or z.attempted or not z.active_at(t):
|
| 80 |
+
continue
|
| 81 |
+
if z.score < cfg.min_score:
|
| 82 |
+
continue
|
| 83 |
+
if best is None or (z.score, z.created_idx) > (best.score, best.created_idx):
|
| 84 |
+
best = z
|
| 85 |
+
return best
|
ob_intraday/sweep.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small parameter sweep over min_score x RR (no optimisation beyond this grid).
|
| 2 |
+
|
| 3 |
+
Reuses one detection pass per RR-independent config where possible: the detector
|
| 4 |
+
only depends on ``min_score``/``rr_target`` at the *strategy* layer, not the
|
| 5 |
+
detection layer, so we detect once and re-run the cheap engine for every cell.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
from .config import Config, SWEEP_MIN_SCORE, SWEEP_RR
|
| 12 |
+
from . import detector as det
|
| 13 |
+
from . import backtest as bt
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def run_sweep(df: pd.DataFrame, cfg: Config,
|
| 17 |
+
min_scores=SWEEP_MIN_SCORE, rrs=SWEEP_RR) -> pd.DataFrame:
|
| 18 |
+
# Detection is independent of min_score/rr -> compute once and reuse.
|
| 19 |
+
dres = det.detect(df, cfg)
|
| 20 |
+
rows = []
|
| 21 |
+
for ms in min_scores:
|
| 22 |
+
for rr in rrs:
|
| 23 |
+
c = cfg.with_overrides(min_score=float(ms), rr_target=float(rr))
|
| 24 |
+
# zones carry an 'attempted' flag mutated by the engine; reset it.
|
| 25 |
+
for z in dres.zones:
|
| 26 |
+
z.attempted = False
|
| 27 |
+
res = bt.run_backtest(df, c, detector_result=dres)
|
| 28 |
+
m = res.metrics
|
| 29 |
+
rows.append(dict(
|
| 30 |
+
min_score=ms, rr=rr, trades=m["trades"], win_rate=m["win_rate"],
|
| 31 |
+
avg_R=m["avg_R"], profit_factor=m["profit_factor"],
|
| 32 |
+
expectancy_R=m["expectancy_R"], total_points=m["total_points"],
|
| 33 |
+
max_dd_points=m["max_drawdown_points"],
|
| 34 |
+
))
|
| 35 |
+
return pd.DataFrame(rows)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def format_table(sweep_df: pd.DataFrame) -> str:
|
| 39 |
+
cols = ["min_score", "rr", "trades", "win_rate", "avg_R", "profit_factor",
|
| 40 |
+
"expectancy_R", "total_points", "max_dd_points"]
|
| 41 |
+
return sweep_df[cols].to_string(index=False)
|
ob_intraday/validation.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Visual QA: save candlestick snapshots of random trades with the OB zone drawn.
|
| 2 |
+
|
| 3 |
+
For each sampled trade we render ~100 surrounding 5-minute candles (matplotlib,
|
| 4 |
+
no extra deps), shade the order-block zone, and mark entry, exit, stop and
|
| 5 |
+
target — so signals can be eyeballed against TradingView.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import pandas as pd
|
| 13 |
+
|
| 14 |
+
from .config import Config, OUTPUT_DIR
|
| 15 |
+
from .backtest import BacktestResult
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _draw_candles(ax, sub: pd.DataFrame, x):
|
| 19 |
+
up = sub["close"] >= sub["open"]
|
| 20 |
+
for xi, (_, r), is_up in zip(x, sub.iterrows(), up):
|
| 21 |
+
color = "#26a69a" if is_up else "#ef5350"
|
| 22 |
+
ax.vlines(xi, r["low"], r["high"], color=color, lw=0.7)
|
| 23 |
+
lo, hi = sorted((r["open"], r["close"]))
|
| 24 |
+
ax.add_patch(_bar_rect(xi, lo, hi, color))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _bar_rect(xi, lo, hi, color):
|
| 28 |
+
import matplotlib.patches as mpatches
|
| 29 |
+
height = max(hi - lo, 1e-6)
|
| 30 |
+
return mpatches.Rectangle((xi - 0.3, lo), 0.6, height, color=color,
|
| 31 |
+
alpha=0.9, linewidth=0)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def plot_random_trades(result: BacktestResult, n: int = 20, window: int = 100,
|
| 35 |
+
seed: int = 11, out_dir: Path | None = None) -> list[Path]:
|
| 36 |
+
import matplotlib
|
| 37 |
+
matplotlib.use("Agg")
|
| 38 |
+
import matplotlib.pyplot as plt
|
| 39 |
+
import matplotlib.patches as mpatches
|
| 40 |
+
|
| 41 |
+
trades = result.trades
|
| 42 |
+
if trades.empty:
|
| 43 |
+
return []
|
| 44 |
+
out_dir = Path(out_dir or (OUTPUT_DIR / "validation"))
|
| 45 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 46 |
+
|
| 47 |
+
d = result.detector.df.reset_index(drop=True)
|
| 48 |
+
rng = np.random.default_rng(seed)
|
| 49 |
+
k = min(n, len(trades))
|
| 50 |
+
picks = rng.choice(len(trades), size=k, replace=False)
|
| 51 |
+
|
| 52 |
+
half = window // 2
|
| 53 |
+
paths = []
|
| 54 |
+
for rank, ti in enumerate(sorted(picks)):
|
| 55 |
+
tr = trades.iloc[ti]
|
| 56 |
+
eidx = int(tr["entry_idx"])
|
| 57 |
+
lo = max(0, eidx - half)
|
| 58 |
+
hi = min(len(d), eidx + half)
|
| 59 |
+
sub = d.iloc[lo:hi].reset_index(drop=True)
|
| 60 |
+
x = np.arange(len(sub))
|
| 61 |
+
entry_x = eidx - lo
|
| 62 |
+
exit_x = int(tr["exit_idx"]) - lo
|
| 63 |
+
|
| 64 |
+
fig, ax = plt.subplots(figsize=(13, 6))
|
| 65 |
+
_draw_candles(ax, sub, x)
|
| 66 |
+
|
| 67 |
+
# order-block zone shaded across the visible window
|
| 68 |
+
ax.add_patch(mpatches.Rectangle(
|
| 69 |
+
(0, tr["zone_bottom"]), len(sub) - 1, tr["zone_top"] - tr["zone_bottom"],
|
| 70 |
+
color="#42a5f5" if tr["direction"] == "long" else "#ffa726",
|
| 71 |
+
alpha=0.18, linewidth=0))
|
| 72 |
+
ax.axhline(tr["zone_top"], color="#1e88e5", lw=0.8, ls=":")
|
| 73 |
+
ax.axhline(tr["zone_bottom"], color="#1e88e5", lw=0.8, ls=":")
|
| 74 |
+
|
| 75 |
+
# stop / target
|
| 76 |
+
ax.axhline(tr["stop"], color="#c62828", lw=0.9, ls="--", label="stop")
|
| 77 |
+
ax.axhline(tr["target"], color="#2e7d32", lw=0.9, ls="--", label="target")
|
| 78 |
+
|
| 79 |
+
# entry / exit markers
|
| 80 |
+
ax.scatter([entry_x], [tr["entry_price"]], marker="^" if tr["direction"] == "long" else "v",
|
| 81 |
+
color="black", s=90, zorder=5, label="entry")
|
| 82 |
+
if 0 <= exit_x < len(sub):
|
| 83 |
+
ax.scatter([exit_x], [tr["exit_price"]], marker="x", color="purple",
|
| 84 |
+
s=90, zorder=5, label="exit")
|
| 85 |
+
|
| 86 |
+
ax.set_title(
|
| 87 |
+
f"{tr['direction'].upper()} {tr['entry_time']} "
|
| 88 |
+
f"score={tr['zone_score']} reason={tr['exit_reason']} "
|
| 89 |
+
f"R={tr['r_multiple']} pnl={tr['pnl_points']}pts")
|
| 90 |
+
ax.set_xlabel("bar (relative)")
|
| 91 |
+
ax.set_ylabel("price")
|
| 92 |
+
ax.legend(loc="best", fontsize=8)
|
| 93 |
+
ax.grid(alpha=0.25)
|
| 94 |
+
fig.tight_layout()
|
| 95 |
+
p = out_dir / f"trade_{rank:02d}_{tr['direction']}_{pd.to_datetime(tr['entry_time']):%Y%m%d_%H%M}.png"
|
| 96 |
+
fig.savefig(p, dpi=100)
|
| 97 |
+
plt.close(fig)
|
| 98 |
+
paths.append(p)
|
| 99 |
+
return paths
|
ob_portfolio/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Intraday order-block *options portfolio* backtest over NSE F&O stocks.
|
| 2 |
+
|
| 3 |
+
Sibling of :mod:`ob_intraday`: it reuses that package's proven, unit-tested
|
| 4 |
+
detection (`ob_intraday.detector`) and pure signal rules (`ob_intraday.strategy`)
|
| 5 |
+
unchanged, and adds the multi-symbol data layer, the options execution model and
|
| 6 |
+
the cross-symbol portfolio engine that are the point of this project.
|
| 7 |
+
|
| 8 |
+
Relative imports throughout keep it from colliding with the repo's top-level
|
| 9 |
+
``config.py`` / ``strategy.py``.
|
| 10 |
+
"""
|
ob_portfolio/charts/trade_00_TATAMOTORS_long_20240731_0940.png
ADDED
|
ob_portfolio/charts/trade_01_ICICIBANK_short_20240909_0945.png
ADDED
|
ob_portfolio/charts/trade_02_INFY_long_20240925_0945.png
ADDED
|
ob_portfolio/charts/trade_03_BAJFINANCE_short_20241001_1230.png
ADDED
|
ob_portfolio/charts/trade_04_INFY_long_20241014_1330.png
ADDED
|
ob_portfolio/charts/trade_05_BAJFINANCE_short_20241017_1220.png
ADDED
|
ob_portfolio/charts/trade_06_RELIANCE_long_20250312_1150.png
ADDED
|
ob_portfolio/charts/trade_07_AXISBANK_short_20250430_1415.png
ADDED
|
ob_portfolio/charts/trade_08_HDFCBANK_long_20250508_1400.png
ADDED
|
ob_portfolio/charts/trade_09_HDFCBANK_short_20250708_0945.png
ADDED
|
ob_portfolio/charts/trade_10_BAJFINANCE_short_20250714_1310.png
ADDED
|