const express = require("express"); const path = require("path"); const BreezeConnect = require("breezeconnect").BreezeConnect; try { require("dotenv").config(); } catch (_) {} const app = express(); const PORT = Number(process.env.PORT || 7860); app.use(express.json({ limit: "1mb" })); app.use(express.static(path.join(__dirname, "public"))); const BREEZE_API_KEY = process.env.BREEZE_API_KEY || ""; const BREEZE_API_SECRET = process.env.BREEZE_API_SECRET || ""; let breeze = null; let connected = false; let customer = null; let lastError = null; // This Space is designed for a single owner/user. The Breeze session is kept // in server memory only and is cleared when the Space restarts or logout is used. function clearSession() { breeze = null; connected = false; customer = null; } function isoAt(dateStr, hhmm, endOfMinute = false) { const [h, m] = hhmm.split(":").map(Number); const ms = endOfMinute ? 59_000 : 0; return new Date(Date.UTC( Number(dateStr.slice(0, 4)), Number(dateStr.slice(5, 7)) - 1, Number(dateStr.slice(8, 10)), h, m, 0, ms )).toISOString(); } function addDays(dateStr, n) { const d = new Date(`${dateStr}T00:00:00Z`); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10); } function weekday(dateStr) { return new Date(`${dateStr}T00:00:00Z`).getUTCDay(); } function isWeekday(dateStr) { const w = weekday(dateStr); return w >= 1 && w <= 5; } function isEntryDay(dateStr) { const w = weekday(dateStr); return w >= 1 && w <= 4; } function getNextCalendarTradingDay(dateStr) { for (let i = 1; i <= 8; i++) { const d = addDays(dateStr, i); if (isWeekday(d)) return d; } return null; } // Retained from the supplied strategy. // Mon -> Tue, Tue -> next Tue, Wed -> next Tue, Thu -> next Tue. function getStrategyExpiry(dateStr) { const w = weekday(dateStr); const days = { 1: 1, 2: 7, 3: 6, 4: 5 }[w]; return days == null ? null : addDays(dateStr, days); } function roundATM(spot) { return Math.round(Number(spot) / 50) * 50; } function unwrapSuccess(resp) { if (!resp) return []; if (Array.isArray(resp)) return resp; if (Array.isArray(resp.Success)) return resp.Success; if (Array.isArray(resp.success)) return resp.success; if (Array.isArray(resp.data)) return resp.data; if (resp.Success && typeof resp.Success === "object") return [resp.Success]; return []; } function responseError(resp) { if (!resp) return "Empty Breeze response"; if (resp.Error) return String(resp.Error); if (resp.error) return String(resp.error); if (resp.Message) return String(resp.Message); if (resp.message) return String(resp.message); return null; } function normalizeBars(resp) { return unwrapSuccess(resp) .map(x => ({ datetime: x.datetime || x.dateTime || x.timestamp, open: Number(x.open), high: Number(x.high), low: Number(x.low), close: Number(x.close), volume: Number(x.volume || 0), open_interest: Number(x.open_interest || 0) })) .filter(x => x.datetime && Number.isFinite(x.open) && Number.isFinite(x.high) && Number.isFinite(x.low) && Number.isFinite(x.close) ) .sort((a, b) => String(a.datetime).localeCompare(String(b.datetime))); } function localTimeFromBreeze(dt) { // Breeze examples return "YYYY-MM-DD HH:mm:ss" in IST for historical candles. const s = String(dt); const m = s.match(/(\d{2}):(\d{2}):(\d{2})$/); return m ? Number(m[1]) * 60 + Number(m[2]) : null; } async function breezeHistorical(params) { if (!breeze || !connected) { throw new Error("Breeze is not connected."); } const resp = await breeze.getHistoricalDatav2(params); const err = responseError(resp); if (err) throw new Error(err); const bars = normalizeBars(resp); if (!bars.length) { throw new Error("Breeze returned no historical candles for the requested contract/date."); } return bars; } async function getNiftySpotAt1515(dateStr) { const bars = await breezeHistorical({ interval: "1minute", fromDate: isoAt(dateStr, "15:15"), toDate: isoAt(dateStr, "15:15", true), stockCode: "NIFTY", exchangeCode: "NSE", productType: "cash" }); const bar = bars.find(b => localTimeFromBreeze(b.datetime) === 15 * 60 + 15) || bars[bars.length - 1]; return { price: bar.close, bar }; } async function getOptionBars(dateStr, expiryStr, right, strike, fromTime, toTime) { return breezeHistorical({ interval: "1minute", fromDate: isoAt(dateStr, fromTime), toDate: isoAt(dateStr, toTime, true), stockCode: "NIFTY", exchangeCode: "NFO", productType: "options", expiryDate: isoAt(expiryStr, "07:00"), right: right.toLowerCase(), strikePrice: String(strike) }); } function simulatePosition(entryPrice, bars, targetPts, slPts, squareOffMin) { const target = entryPrice + targetPts; const sl = entryPrice - slPts; for (const b of bars) { const min = localTimeFromBreeze(b.datetime); if (min == null || min < 9 * 60 + 15) continue; if (min > squareOffMin) break; // Preserve the supplied strategy's conservative same-candle rule: // SL is evaluated before target. if (b.low <= sl) { return { exitPrice: sl, exitMin: min, reason: "SL" }; } if (b.high >= target) { return { exitPrice: target, exitMin: min, reason: "TARGET" }; } } const usable = bars.filter(b => { const m = localTimeFromBreeze(b.datetime); return m != null && m <= squareOffMin; }); if (!usable.length) { return { exitPrice: entryPrice, exitMin: squareOffMin, reason: "NO_NEXT_DAY_DATA" }; } const last = usable[usable.length - 1]; return { exitPrice: last.close, exitMin: localTimeFromBreeze(last.datetime), reason: "NEXT_DAY_SQUAREOFF" }; } function minutesToHHMM(min) { const h = Math.floor(min / 60); const m = min % 60; return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; } async function runRealBacktest(cfg, progressCb = () => {}) { const start = cfg.start; const end = cfg.end; const targetPts = Number(cfg.targetPts); const slPts = Number(cfg.slPts); const brokerage = Number(cfg.brokerage); const sqOffMin = Number(cfg.sqOffMin); if (![targetPts, slPts, brokerage, sqOffMin].every(Number.isFinite)) { throw new Error("Invalid backtest parameters."); } if (start > end) throw new Error("Start date cannot be after end date."); const entryDates = []; for (let d = start; d <= end; d = addDays(d, 1)) { if (isEntryDay(d)) entryDates.push(d); } const trades = []; const daily = []; for (let i = 0; i < entryDates.length; i++) { const entryDate = entryDates[i]; progressCb({ current: i + 1, total: entryDates.length, message: `FETCHING REAL DATA ${i + 1}/${entryDates.length} ยท ${entryDate}` }); const nextDay = getNextCalendarTradingDay(entryDate); if (!nextDay) continue; const expiry = getStrategyExpiry(entryDate); if (!expiry) continue; // Real NIFTY cash price at 15:15 determines ATM. const spotResult = await getNiftySpotAt1515(entryDate); const atm = roundATM(spotResult.price); let dayPnl = 0; for (const right of ["call", "put"]) { // Real entry premium at 15:15. const entryBars = await getOptionBars( entryDate, expiry, right, atm, "15:15", "15:15" ); const entryBar = entryBars.find(b => localTimeFromBreeze(b.datetime) === 15 * 60 + 15) || entryBars[entryBars.length - 1]; if (!entryBar) { throw new Error(`No ${right.toUpperCase()} entry candle for ${entryDate} ${atm} ${expiry}.`); } const entryPrice = entryBar.close; // Real next-trading-day option bars. const nextBars = await getOptionBars( nextDay, expiry, right, atm, "09:15", minutesToHHMM(sqOffMin) ); const exit = simulatePosition( entryPrice, nextBars, targetPts, slPts, sqOffMin ); const qty = 65; const gross = (exit.exitPrice - entryPrice) * qty; const net = gross - brokerage; dayPnl += net; trades.push({ entryDate, entryTime: "15:15", exitDate: nextDay, exitTime: minutesToHHMM(exit.exitMin), dayHeld: 1, right: right.toUpperCase() === "CALL" ? "CE" : "PE", strike: atm, expiry, lots: 1, qty, entry: entryPrice, exit: exit.exitPrice, gross, brokerage, net, reason: exit.reason, spotAtEntry: spotResult.price }); } daily.push({ entryDate, entryTime: "15:15", exitDate: nextDay, exitTime: minutesToHHMM(sqOffMin), pnl: dayPnl }); } let cum = 0; for (const d of daily) { cum += d.pnl; d.cum = cum; d.result = d.pnl > 0 ? "PROFIT" : d.pnl < 0 ? "LOSS" : "FLAT"; } return { trades, daily }; } function calculateMetrics(trades) { const pnl = trades.map(t => Number(t.net)); if (!pnl.length) { return { trades: 0, wins: 0, losses: 0, winRate: 0, grossProfit: 0, grossLoss: 0, brokerage: 0, netPnl: 0, avgTrade: 0, avgWin: 0, avgLoss: 0, maxProfit: 0, maxLoss: 0, profitFactor: 0, maxDrawdown: 0, expectancy: 0 }; } const wins = pnl.filter(v => v > 0); const losses = pnl.filter(v => v < 0); const grossProfit = wins.reduce((a, b) => a + b, 0); const grossLoss = Math.abs(losses.reduce((a, b) => a + b, 0)); const netPnl = pnl.reduce((a, b) => a + b, 0); let eq = 0; let peak = 0; let dd = 0; for (const v of pnl) { eq += v; peak = Math.max(peak, eq); dd = Math.min(dd, eq - peak); } return { trades: pnl.length, wins: wins.length, losses: losses.length, winRate: wins.length / pnl.length * 100, grossProfit, grossLoss, brokerage: trades.reduce((a, t) => a + Number(t.brokerage || 0), 0), netPnl, avgTrade: netPnl / pnl.length, avgWin: wins.length ? grossProfit / wins.length : 0, avgLoss: losses.length ? -grossLoss / losses.length : 0, maxProfit: Math.max(...pnl), maxLoss: Math.min(...pnl), profitFactor: grossLoss > 0 ? grossProfit / grossLoss : null, maxDrawdown: dd, expectancy: netPnl / pnl.length }; } app.get("/api/health", (req, res) => { res.json({ ok: true, service: "selva-option-backtest", port: PORT, breezeConfigured: Boolean(BREEZE_API_KEY && BREEZE_API_SECRET), connected }); }); app.get("/api/status", (req, res) => { res.json({ connected, customer, breezeConfigured: Boolean(BREEZE_API_KEY && BREEZE_API_SECRET), lastError }); }); app.get("/api/login-url", (req, res) => { if (!BREEZE_API_KEY) { return res.status(500).json({ error: "BREEZE_API_KEY is missing in Hugging Face Secrets." }); } const url = `https://api.icicidirect.com/apiuser/login?api_key=${encodeURIComponent(BREEZE_API_KEY)}`; res.json({ url }); }); app.post("/api/connect", async (req, res) => { try { if (!BREEZE_API_KEY || !BREEZE_API_SECRET) { return res.status(500).json({ error: "BREEZE_API_KEY / BREEZE_API_SECRET are not configured in Hugging Face Secrets." }); } const apiSession = String(req.body?.apiSession || "").trim(); if (!apiSession) { return res.status(400).json({ error: "API Session is required." }); } const bc = new BreezeConnect({ appKey: BREEZE_API_KEY }); const sessionResp = await bc.generateSession(BREEZE_API_SECRET, apiSession); const sessionErr = responseError(sessionResp); if (sessionErr) { clearSession(); lastError = sessionErr; return res.status(401).json({ error: sessionErr }); } // generateSession mutates the Breeze client with the access token/session. let details = null; try { const detailResp = await bc.getCustomerDetails(apiSession); details = unwrapSuccess(detailResp)[0] || null; const detailErr = responseError(detailResp); if (detailErr) { // Session itself is still valid; do not fail solely because details call // is unavailable in a particular SDK version. lastError = detailErr; } } catch (e) { lastError = e.message; } breeze = bc; connected = true; customer = details; lastError = null; res.json({ ok: true, connected: true, customer }); } catch (e) { clearSession(); lastError = e.message; res.status(500).json({ error: e.message }); } }); app.post("/api/logout", (req, res) => { clearSession(); lastError = null; res.json({ ok: true }); }); app.post("/api/backtest", async (req, res) => { if (!connected || !breeze) { return res.status(401).json({ error: "Connect Breeze before running the backtest." }); } const cfg = { start: String(req.body?.start || ""), end: String(req.body?.end || ""), targetPts: Number(req.body?.targetPts ?? 20), slPts: Number(req.body?.slPts ?? 10), brokerage: Number(req.body?.brokerage ?? 40), sqOffMin: (() => { const s = String(req.body?.sqOff || "15:10"); const [h, m] = s.split(":").map(Number); return h * 60 + m; })() }; try { lastError = null; const result = await runRealBacktest(cfg); res.json({ ok: true, source: "ICICI DIRECT BREEZE HISTORICAL DATA", strategy: { entry: "15:15", legs: ["ATM CE", "ATM PE"], qty: 65, targetPts: cfg.targetPts, slPts: cfg.slPts, squareOff: String(req.body?.sqOff || "15:10") }, ...result, metrics: calculateMetrics(result.trades) }); } catch (e) { lastError = e.message; res.status(500).json({ error: e.message }); } }); app.get("*", (req, res) => { res.sendFile(path.join(__dirname, "public", "index.html")); }); app.listen(PORT, "0.0.0.0", () => { console.log(`Selva Option Backtest listening on 0.0.0.0:${PORT}`); });