Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,35 @@ 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) |
| ₹2,500 | Lock ₹500 profit |
| ₹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
Expand Down
22 changes: 22 additions & 0 deletions __tests__/helpers/marketData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
22 changes: 22 additions & 0 deletions __tests__/jobs/priceMonitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
2 changes: 2 additions & 0 deletions __tests__/store/tradeStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
15 changes: 13 additions & 2 deletions src/helpers/marketData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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));
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/jobs/priceMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export async function runPriceMonitor(): Promise<void> {
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;

Expand Down
91 changes: 78 additions & 13 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
logger.info('Starting ORB Algo...');

Expand All @@ -21,7 +41,15 @@ async function bootstrap(): Promise<void> {
// 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',
Expand All @@ -40,13 +68,32 @@ async function bootstrap(): Promise<void> {

// 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<void> => {
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<void> => {
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<void> => {
if (isKilled()) return;
const { isTradingDay: trading } = await isTradingDay();
if (trading) await runPriceMonitor();
})();
Expand All @@ -56,13 +103,32 @@ async function bootstrap(): Promise<void> {

// 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<void> => {
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<void> => {
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<void> => {
if (isKilled()) return;
const { isTradingDay: trading } = await isTradingDay();
if (trading) await runTradeMonitor();
})();
Expand All @@ -79,8 +145,7 @@ async function bootstrap(): Promise<void> {
return;
}

await login();
await downloadScripMaster();
await dailyInit();

logger.info('ORB Algo initialized successfully');

Expand Down
4 changes: 4 additions & 0 deletions src/store/tradeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ class TradeStore {
return this.watchList;
}

clearWatchList(): void {
this.watchList = [];
}

setActiveTrade(trade: ActiveTrade | null): void {
this.activeTrade = trade;
}
Expand Down
Loading