diff --git a/README.md b/README.md index 6f8da40..0f74d75 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,27 @@ ORB is a momentum-based intraday options strategy. After the market opens and se ## How This Algo Works — Step by Step -1. **10:30 AM IST — Morning Scan** +1. **09:00 AM IST — Daily Initialization** + The bot automatically logs into Angel One, downloads the latest scrip master data, and clears any state from the previous day. This ensures fresh session tokens and instruments for the new trading day. + +2. **10:30 AM IST — Morning Scan** Fetch all 50 Nifty 50 stocks via Angel One API. Compute % change from previous close. Pick top 5 gainers and top 5 losers. -2. **Resistance and Support Identification** +3. **Resistance and Support Identification** For each of the 10 stocks, fetch the monthly options chain and the morning's candle data (from 9:15 AM to 10:30 AM). - **Resistance (Gainers):** The higher of (Maximum Call OI strike above spot) and (Morning High). - **Support (Losers):** The lower of (Maximum Put OI strike below spot) and (Morning Low). This dual-check ensures we only enter trades when both price action and heavy OI levels are cleared. -3. **Every 5 Minutes — Price Monitoring** +4. **10:35 AM IST to 03:25 PM IST — Price Monitoring** Poll the spot price of all 10 stocks every 5 minutes. Wait for price to breach the identified level and *sustain* beyond it for 5 continuous minutes (not just a wick — actual price sustain). -4. **Trade Entry (first confirmed breakout wins, one trade per day)** +5. **Trade Entry (first confirmed breakout wins, one trade per day)** - Buy the Call/Put at the breakout strike (1 lot) - Sell a hedge option at a strike where premium ≈ 1/4th of bought premium (reduces cost of trade) - Place a hard stoploss order on the exchange: max loss ₹3,000 -5. **Trailing Stoploss** +6. **Trailing Stoploss** | Profit Milestone | SL Moves To | |------------------|---------------------| | ₹2,000 | Entry cost (risk-free) | @@ -33,7 +36,7 @@ ORB is a momentum-based intraday options strategy. After the market opens and se | ₹3,000 | Lock ₹1,000 profit | | Every +₹500 | Lock previous ₹500 | -6. **Exit** +7. **Exit** Trade exits when: SL is hit, trailing SL is hit, or 3:20 PM EOD square-off — whichever comes first. No second trade that day. ## Resilience & Efficiency diff --git a/__tests__/helpers/marketData.test.ts b/__tests__/helpers/marketData.test.ts index 9e1ad6d..d45bbf6 100644 --- a/__tests__/helpers/marketData.test.ts +++ b/__tests__/helpers/marketData.test.ts @@ -98,6 +98,28 @@ describe('marketData', () => { expect(logger.error).toHaveBeenCalled(); }); + it('should handle token not found in scrip master', async () => { + (scripMasterStore.getScrips as jest.Mock).mockReturnValue([]); + const promise = getTopMovers(); + await jest.runAllTimersAsync(); + const { gainers } = await promise; + expect(gainers).toHaveLength(0); + }); + + it('should skip invalid LTP in getTopMovers', async () => { + (scripMasterStore.getScrips as jest.Mock).mockReturnValue([ + { symbol: 'S1-EQ', token: '25', exch_seg: 'NSE', name: 'S1' }, + ]); + (api.post as jest.Mock).mockResolvedValue({ data: { ltp: 0 } }); + const promise = getTopMovers(); + await jest.runAllTimersAsync(); + const { gainers } = await promise; + expect(gainers).toHaveLength(0); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Received invalid LTP for S1-EQ: 0'), + ); + }); + it('should return empty if scrips master is empty', async () => { (scripMasterStore.getScrips as jest.Mock).mockReturnValue([]); const { gainers } = await getTopMovers(); diff --git a/__tests__/jobs/priceMonitor.test.ts b/__tests__/jobs/priceMonitor.test.ts index a75096e..927b8d5 100644 --- a/__tests__/jobs/priceMonitor.test.ts +++ b/__tests__/jobs/priceMonitor.test.ts @@ -210,4 +210,26 @@ describe('priceMonitor', () => { ), ); }); + + it('should skip if LTP is 0 or less', async () => { + const mockStock: WatchStock = { + symbol: 'RELIANCE', + symbolToken: '2885', + ltp: 2500, + side: 'CALL', + watchLevel: 2550, + breachStartTime: null, + }; + (tradeStore.getWatchList as jest.Mock).mockReturnValue([mockStock]); + (marketData.getLtp as jest.Mock).mockResolvedValue({ ltp: 0 }); + + const promise = runPriceMonitor(); + jest.runAllTimers(); + await promise; + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Received 0 LTP for RELIANCE'), + ); + expect(tradeStore.updateWatchStock).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/store/tradeStore.test.ts b/__tests__/store/tradeStore.test.ts index f2bcbb5..15d4fd1 100644 --- a/__tests__/store/tradeStore.test.ts +++ b/__tests__/store/tradeStore.test.ts @@ -18,6 +18,8 @@ describe('tradeStore', () => { tradeStore.setWatchList([mockStock]); expect(tradeStore.getWatchList()).toHaveLength(1); expect(tradeStore.getWatchList()[0].symbol).toBe('SBIN'); + tradeStore.clearWatchList(); + expect(tradeStore.getWatchList()).toHaveLength(0); }); it('should update watch stock', () => { diff --git a/src/helpers/marketData.ts b/src/helpers/marketData.ts index 9afd4fd..c934b22 100644 --- a/src/helpers/marketData.ts +++ b/src/helpers/marketData.ts @@ -70,10 +70,15 @@ export async function getTopMovers(): Promise<{ const tokens = NIFTY_50_TOKENS; const stocks: Stock[] = []; + logger.info(`Fetching LTP for ${tokens.length} Nifty 50 tokens...`); + for (const token of tokens) { try { const scrip = scrips.find(s => s.token === token && s.exch_seg === 'NSE'); - if (!scrip) continue; + if (!scrip) { + logger.debug(`Token ${token} not found in scrip master`); + continue; + } const payload = { exchange: 'NSE', tradingsymbol: scrip.symbol, @@ -84,7 +89,7 @@ export async function getTopMovers(): Promise<{ payload, ); const item = response.data; - if (item) { + if (item && item.ltp > 0) { const ltp = item.ltp; const close = item.close; const changePercent = close !== 0 ? ((ltp - close) / close) * 100 : 0; @@ -95,6 +100,10 @@ export async function getTopMovers(): Promise<{ ltp, changePercent, }); + } else { + logger.warn( + `Received invalid LTP for ${scrip.symbol}: ${item?.ltp || 0}`, + ); } // Rate limit: 3 requests per second await new Promise(resolve => setTimeout(resolve, 350)); @@ -105,6 +114,8 @@ export async function getTopMovers(): Promise<{ } } + logger.info(`Fetched data for ${stocks.length} stocks`); + const sorted = [...stocks].sort((a, b) => b.changePercent - a.changePercent); const gainers = sorted.filter(s => s.changePercent > 0).slice(0, 5); const losers = sorted diff --git a/src/jobs/priceMonitor.ts b/src/jobs/priceMonitor.ts index 2648c29..7b82399 100644 --- a/src/jobs/priceMonitor.ts +++ b/src/jobs/priceMonitor.ts @@ -13,6 +13,12 @@ export async function runPriceMonitor(): Promise { for (const stock of watchList) { try { const { ltp } = await getLtp(stock.symbol, stock.symbolToken); + + if (ltp <= 0) { + logger.warn(`Received 0 LTP for ${stock.symbol}. Skipping.`); + continue; + } + const isBreached = stock.side === 'CALL' ? ltp > stock.watchLevel : ltp < stock.watchLevel; diff --git a/src/main.ts b/src/main.ts index f3f0015..c9e41b7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,8 +9,28 @@ import { startServer } from './server.js'; import { logger } from './helpers/logger.js'; import { sendNotification } from './notifier.js'; import { startTelegramListener, isKilled } from './helpers/telegramListener.js'; +import { tradeStore } from './store/tradeStore.js'; import moment from 'moment-timezone'; +async function dailyInit(): Promise { + try { + const { isTradingDay: trading, reason } = await isTradingDay(); + if (!trading) { + logger.info(`Today is not a trading day: ${reason}`); + return; + } + await login(); + await downloadScripMaster(); + tradeStore.clearWatchList(); + tradeStore.setActiveTrade(null); + logger.info('Daily initialization successful'); + } catch (error) { + logger.error( + `Daily initialization failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + async function bootstrap(): Promise { logger.info('Starting ORB Algo...'); @@ -21,7 +41,15 @@ async function bootstrap(): Promise { // Start Telegram listener to handle commands (/killorb, /paperorb, /resumeorb) void startTelegramListener(); - // Register Cron Jobs (Even on holidays, to keep process alive) + // Daily Initialization: 9:00 AM IST (Mon-Fri) + cron.schedule( + '0 9 * * 1-5', + () => { + void dailyInit(); + }, + { timezone: 'Asia/Kolkata' }, + ); + // Morning Scanner: 10:30 AM IST (Mon-Fri) cron.schedule( '30 10 * * 1-5', @@ -40,13 +68,32 @@ async function bootstrap(): Promise { // Price Monitor: Every 5 mins between 10:35 AM and 3:25 PM cron.schedule( - '*/5 10-15 * * 1-5', + '35-55/5 10 * * 1-5', () => { void (async (): Promise => { - if (isKilled()) { - logger.warn('Price Monitor skipped: Algo is in KILLED state.'); - return; - } + if (isKilled()) return; + const { isTradingDay: trading } = await isTradingDay(); + if (trading) await runPriceMonitor(); + })(); + }, + { timezone: 'Asia/Kolkata' }, + ); + cron.schedule( + '*/5 11-14 * * 1-5', + () => { + void (async (): Promise => { + if (isKilled()) return; + const { isTradingDay: trading } = await isTradingDay(); + if (trading) await runPriceMonitor(); + })(); + }, + { timezone: 'Asia/Kolkata' }, + ); + cron.schedule( + '0-25/5 15 * * 1-5', + () => { + void (async (): Promise => { + if (isKilled()) return; const { isTradingDay: trading } = await isTradingDay(); if (trading) await runPriceMonitor(); })(); @@ -56,13 +103,32 @@ async function bootstrap(): Promise { // Trade Monitor: Every 5 mins between 10:35 AM and 3:25 PM cron.schedule( - '*/5 10-15 * * 1-5', + '35-55/5 10 * * 1-5', () => { void (async (): Promise => { - if (isKilled()) { - logger.warn('Trade Monitor skipped: Algo is in KILLED state.'); - return; - } + if (isKilled()) return; + const { isTradingDay: trading } = await isTradingDay(); + if (trading) await runTradeMonitor(); + })(); + }, + { timezone: 'Asia/Kolkata' }, + ); + cron.schedule( + '*/5 11-14 * * 1-5', + () => { + void (async (): Promise => { + if (isKilled()) return; + const { isTradingDay: trading } = await isTradingDay(); + if (trading) await runTradeMonitor(); + })(); + }, + { timezone: 'Asia/Kolkata' }, + ); + cron.schedule( + '0-25/5 15 * * 1-5', + () => { + void (async (): Promise => { + if (isKilled()) return; const { isTradingDay: trading } = await isTradingDay(); if (trading) await runTradeMonitor(); })(); @@ -79,8 +145,7 @@ async function bootstrap(): Promise { return; } - await login(); - await downloadScripMaster(); + await dailyInit(); logger.info('ORB Algo initialized successfully'); diff --git a/src/store/tradeStore.ts b/src/store/tradeStore.ts index a221d4f..d7db4cd 100644 --- a/src/store/tradeStore.ts +++ b/src/store/tradeStore.ts @@ -33,6 +33,10 @@ class TradeStore { return this.watchList; } + clearWatchList(): void { + this.watchList = []; + } + setActiveTrade(trade: ActiveTrade | null): void { this.activeTrade = trade; }