ash001 commited on
Commit
db3ddf2
·
verified ·
1 Parent(s): 46df48c

Deploy from GitHub Actions to nse-bot-backend (part 3)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. ob_breaker_luxalgo_nse_fo_strategy.pine +396 -0
  2. ob_intraday/README.md +71 -0
  3. ob_intraday/__init__.py +19 -0
  4. ob_intraday/backtest.py +222 -0
  5. ob_intraday/config.py +75 -0
  6. ob_intraday/data/NIFTY_5min.csv +0 -0
  7. ob_intraday/data/NIFTY_5min_synthetic.csv +0 -0
  8. ob_intraday/data_download.py +335 -0
  9. ob_intraday/detector.py +305 -0
  10. ob_intraday/output/equity_curve.png +0 -0
  11. ob_intraday/output/monthly_pnl.csv +2 -0
  12. ob_intraday/output/sweep.csv +13 -0
  13. ob_intraday/output/trades.csv +2 -0
  14. ob_intraday/output/validation/trade_00_short_20240731_1005.png +0 -0
  15. ob_intraday/output/validation/trade_00_short_20260521_0955.png +0 -0
  16. ob_intraday/output/validation/trade_01_long_20240807_1420.png +0 -0
  17. ob_intraday/output/validation/trade_02_long_20240924_1200.png +0 -0
  18. ob_intraday/output/validation/trade_03_short_20241009_0940.png +0 -0
  19. ob_intraday/output/validation/trade_04_long_20241203_1200.png +0 -0
  20. ob_intraday/output/validation/trade_05_long_20250123_1210.png +0 -0
  21. ob_intraday/output/validation/trade_06_long_20250704_1320.png +0 -0
  22. ob_intraday/output/validation/trade_07_long_20250718_1230.png +0 -0
  23. ob_intraday/output/validation/trade_08_long_20250804_1130.png +0 -0
  24. ob_intraday/output/validation/trade_09_long_20250829_1115.png +0 -0
  25. ob_intraday/output/validation/trade_10_long_20250829_1240.png +0 -0
  26. ob_intraday/output/validation/trade_11_long_20251013_1025.png +0 -0
  27. ob_intraday/output/validation/trade_12_long_20251202_1250.png +0 -0
  28. ob_intraday/output/validation/trade_13_long_20251218_0955.png +0 -0
  29. ob_intraday/output/validation/trade_14_long_20251218_1105.png +0 -0
  30. ob_intraday/output/validation/trade_15_long_20260302_1045.png +0 -0
  31. ob_intraday/output/validation/trade_16_short_20260303_0940.png +0 -0
  32. ob_intraday/output/validation/trade_17_long_20260414_1055.png +0 -0
  33. ob_intraday/output/validation/trade_18_long_20260505_1250.png +0 -0
  34. ob_intraday/output/validation/trade_19_short_20260603_1220.png +0 -0
  35. ob_intraday/run.py +97 -0
  36. ob_intraday/strategy.py +85 -0
  37. ob_intraday/sweep.py +41 -0
  38. ob_intraday/validation.py +99 -0
  39. ob_portfolio/__init__.py +10 -0
  40. ob_portfolio/charts/trade_00_TATAMOTORS_long_20240731_0940.png +0 -0
  41. ob_portfolio/charts/trade_01_ICICIBANK_short_20240909_0945.png +0 -0
  42. ob_portfolio/charts/trade_02_INFY_long_20240925_0945.png +0 -0
  43. ob_portfolio/charts/trade_03_BAJFINANCE_short_20241001_1230.png +0 -0
  44. ob_portfolio/charts/trade_04_INFY_long_20241014_1330.png +0 -0
  45. ob_portfolio/charts/trade_05_BAJFINANCE_short_20241017_1220.png +0 -0
  46. ob_portfolio/charts/trade_06_RELIANCE_long_20250312_1150.png +0 -0
  47. ob_portfolio/charts/trade_07_AXISBANK_short_20250430_1415.png +0 -0
  48. ob_portfolio/charts/trade_08_HDFCBANK_long_20250508_1400.png +0 -0
  49. ob_portfolio/charts/trade_09_HDFCBANK_short_20250708_0945.png +0 -0
  50. 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