An end-to-end, self-hosted algorithmic trading system for Indian markets. It runs unattended on a free-tier cloud VM, places orders through Zerodha Kite Connect, enforces hard risk limits that no strategy can override, and uses an LLM to journal every trade and propose its own parameter changes.
The design goal was one manual step per day — the 60-second Kite login that Zerodha does not permit automating. Everything else is scheduled.
Paper trading is the default.
PAPER_TRADING=truesimulates fills locally with full Indian charges. Live trading requires explicitly flipping it tofalse. Read the rollout plan before you do.
- What it actually does
- Architecture
- Quick start (local, paper mode)
- Deploy to a server
- Daily operation
- Monitoring
- Strategies
- Risk engine
- Backtesting
- The LLM feedback loop
- Configuration reference
- Project layout
- Testing and code quality
- Phased rollout
- Troubleshooting
- Cost
- Disclaimer
Trading
- Three implemented strategies (swing momentum, intraday opening-range breakout, stock futures momentum) plus stubs for options spreads and MCX trend.
- A paper adapter that simulates fills against live prices with slippage and the complete Indian retail charge stack — STT, GST, SEBI turnover fee, stamp duty, exchange transaction charges, and brokerage. Simulated P&L is comparable to a real contract note.
- An order manager with an explicit state machine, plus a reconciler that syncs local state against the broker every 30 seconds.
Risk
- Global limits hardcoded in Python (not YAML) so a config edit cannot loosen them: daily loss kill-switch, weekly loss cap, per-sleeve monthly drawdown, position count, and margin ceiling.
- A rolling 20-trade circuit breaker that automatically disables any strategy bleeding beyond 3% of its allocated capital.
- An "excluded holdings" filter so the bot never trades a symbol you already hold as a long-term investment in the same account.
Automation
- Nine systemd units covering pre-market, the intraday session, end-of-day, an MCX evening session, weekly reporting, monthly optimization, and daily cleanup.
- A watchdog daemon that observes process health and pages you on Telegram.
- A salvage path: if you authenticate late, a systemd path unit notices the token file change and starts whatever part of the session can still be recovered.
- Log rotation and artifact pruning so a 45 GB disk never fills.
Analysis
- FIFO round-trip P&L attribution that correctly credits the opening strategy even when a position is closed by the auto-square-off job.
- Mark-to-market on open positions, an equity curve with drawdown, and a live-vs-backtest drift monitor.
- Backtesting engines for both intraday and daily strategies, sharing the same charge model as live trading, plus a walk-forward optimizer.
- An LLM that writes a post-mortem for every closed trade and, weekly, proposes one narrow config change with supporting evidence.
┌──────────────────────┐
│ Telegram Notifier │◄──── alerts from everywhere
└──────────▲───────────┘
│
┌────────────────────────────────────┴──────────────────────────────────────┐
│ Orchestrators │
│ pre_market · intraday_loop · eod · mcx_evening · weekly_report │
│ (each a systemd timer + service) │
└───┬─────────────────┬──────────────────┬───────────────────┬──────────────┘
│ │ │ │
┌───▼──────────┐ ┌────▼─────────┐ ┌──────▼───────┐ ┌────────▼─────────┐
│ Strategies │ │ Risk Engine │ │ OMS │ │ Analytics │
│ │ │ │ │ │ │ │
│ swing_mom │►│ hardcoded │►│ OrderManager │ │ FIFO P&L │
│ intraday_orb │ │ limits │ │ Reconciler │ │ drift monitor │
│ stock_fut │ │ circuit brkr │ │ state machine│ │ equity curve │
│ (+ stubs) │ │ sleeve state │ │ │ │ LLM journals │
└──────────────┘ └──────────────┘ └──────┬───────┘ └────────┬─────────┘
│ │
┌────────────▼────────┐ ┌───────▼────────┐
│ Adapters │ │ DuckDB store │
│ KiteAdapter (live) │ │ orders, fills, │
│ PaperAdapter (sim) │ │ positions, │
└──────────┬──────────┘ │ daily_pnl, ... │
│ └────────────────┘
▼
Zerodha Kite Connect (REST + WebSocket)
▲
│
access_token.txt ◄─── auth.py (daily login)
Detailed module-by-module notes live in docs/ARCHITECTURE.md.
You can run the whole system on a laptop in paper mode. Nothing here touches real money.
- Python 3.11+
- A Zerodha account with Kite Connect API access. The API is a paid add-on (about ₹2000/month) and is separate from your trading account. Sign up at developers.kite.trade.
- An OpenAI API key for the journaling and reporting jobs.
- A Telegram bot for alerts.
git clone https://github.com/dhruv7539/zerodha-trading-bot.git
cd zerodha-trading-bot
python3.11 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install --upgrade pip
pip install -r requirements.txtAt developers.kite.trade/apps, create an app and set the Redirect URL to exactly:
http://127.0.0.1:5000/callback
This has to match character-for-character or the login callback will fail.
- Message @BotFather on Telegram, send
/newbot, and copy the token it gives you. - Send any message to your new bot so a chat exists.
- After step 4 below, run
python scripts/get_telegram_chat_id.pyto print your chat ID.
cp .env.example .envOpen .env and fill in every required value. The file documents each one inline. At minimum you need KITE_API_KEY, KITE_API_SECRET, KITE_USER_ID, OPENAI_API_KEY, TELEGRAM_BOT_TOKEN, and TELEGRAM_CHAT_ID. Leave PAPER_TRADING=true.
Then grab your chat ID:
python scripts/get_telegram_chat_id.py
# paste the result into TELEGRAM_CHAT_ID in .envKite access tokens expire every morning around 06:00 IST, so this is the daily ritual:
python auth.pyThis opens the Kite login page in your browser. Log in, and the callback writes access_token.txt (mode 0600, gitignored). The script exits once the token is saved.
If you already hold stocks in this Zerodha account, tell the bot to never trade them. Otherwise an intraday sleeve shorting RELIANCE would actually sell your long-term position.
python scripts/build_safe_universe.py --dry-run # preview
python scripts/build_safe_universe.py # write to config/universe.yamlThis reads your live CNC holdings and writes them into excluded_holdings in config/universe.yaml. Re-run it whenever your holdings change. Do not skip this step if you share the account with your investments.
python scripts/preflight.pyThis checks env vars, token freshness, a live Kite profile call, the OpenAI key format, and sends a Telegram heartbeat. You should get a Telegram message. If you do, the whole chain works.
Run the offline test suite too:
pytest -m "not live"python -m orchestrator.pre_market # instrument refresh, universe build, strategy pre-market hooks
python -m orchestrator.intraday_loop # the main session loop (09:15-15:30 IST)
python -m orchestrator.eod # reconcile, square off, compute P&L, write LLM journalsIn another terminal, watch it live:
python scripts/dashboard.pyThe bot is built to run unattended on a Linux VM. Oracle Cloud's Always Free tier is a good fit — an Ampere ARM instance with 1 OCPU and 1 GB RAM handles this comfortably and costs nothing.
Full walkthrough, from creating the cloud account to verifying the timers: docs/DEPLOYMENT.md.
The short version, once you have an Ubuntu 22.04 VM and can SSH to it:
export VM_IP=203.0.113.10
export SSH_KEY=~/.ssh/trading_bot
bash deploy/deploy_to_vm.shThat rsyncs the project to /opt/trading-bot, copies .env over separately with mode 600, and runs deploy/install.sh on the VM. The installer sets the system timezone to Asia/Kolkata, installs Python 3.11, builds the venv, creates runtime directories, installs all systemd units, configures logrotate and sudoers, and enables the timers.
Verify:
ssh -i ~/.ssh/trading_bot ubuntu@$VM_IP 'systemctl list-timers | grep -E "intraday|pre-market|eod"'| Unit | Schedule (IST) | What it does |
|---|---|---|
morning-alert |
Mon–Fri 08:55 | Telegram nudge to log in; polls 30 min for the token |
pre-market |
Mon–Fri 09:00 | Instrument refresh, universe build, strategy pre-market hooks |
intraday-loop |
Mon–Fri 09:15 | Main session: WebSocket ticks, bars, signals, reconciliation |
eod |
Mon–Fri 15:45 | Reconcile, square off, compute daily P&L, write LLM journals |
mcx-evening |
Mon–Fri 17:00 | MCX commodity session until 23:00 |
weekly-report |
Sun 18:00 | LLM weekly digest, equity curve PNG, drift report, config suggestion |
cleanup-artifacts |
Daily 20:00 | Prune old instrument parquets and journal directories |
walk-forward |
1st of month 02:00 | Re-optimize strategy parameters on rolling windows |
watchdog |
always on | Health checks, stale-token and missing-process alerts |
late-start.path |
on token change | Salvages the session if you authenticate late |
macOS users can schedule locally instead with python scripts/launchd/install_launchd.py install.
The only thing you must do each trading day is authenticate.
On a local machine:
python auth.pyOn a remote VM — the login callback is served on the VM's localhost, so tunnel port 5000 to it:
ssh -L 5000:localhost:5000 -i ~/.ssh/trading_bot ubuntu@$VM_IP
# then, inside that SSH session:
bash /opt/trading-bot/deploy/auth_remote.shOpen the printed URL in your local browser, log in, and the token lands on the VM. If you set VM_HOST in .env, the 08:55 Telegram reminder includes this exact command so you can copy-paste it from your phone.
If you miss the window: authenticate anyway. The late-start.path unit watches access_token.txt and, when it changes, starts the intraday loop if there's still session left (between 09:31 and 14:30) and runs the Monday swing rebalance if applicable. A late start is much better than a skipped day.
Zerodha requires an interactive login with 2FA for every session token. Scripting it with Selenium violates their terms of service and risks having your API access revoked. This project deliberately does not ship that. Sixty seconds a day is the cost of staying compliant.
A terminal UI that reads an atomic JSON snapshot written by the running orchestrator. It's read-only and never touches the DuckDB write lock.
python scripts/dashboard.py # refreshes every 2s
python scripts/dashboard.py --once # render once and exit
python scripts/dashboard.py --interval 5 # slower refreshAgainst a remote VM:
export VM_HOST=203.0.113.10
bash deploy/watch.shIt shows today's realized P&L, open positions with live mark-to-market, recent signals and fills, per-sleeve enable/disable state, token age, and writer health.
You get paged for: stale token at market open, circuit-breaker trips, risk-limit kill switches, crashed orchestrators, and the EOD and weekly summaries. Alerts are throttled and deduplicated — a stale token suppresses the redundant "process missing" alerts it would otherwise cause.
python scripts/sleeve.py status
python scripts/sleeve.py disable intraday_orb "underperforming, see W21 drift"
python scripts/sleeve.py enable intraday_orbState persists to data/store/sleeve_state.json. It's a JSON file rather than a DuckDB table specifically so you can flip a sleeve while the bot is running and holding the database write lock.
logs/pre_market.log logs/intraday_loop.log logs/eod.log
logs/mcx_evening.log logs/watchdog.log logs/weekly_report.log
run/*.pid # live PIDs, read by the watchdog
Rotated daily by logrotate with 14 days of compressed retention.
| Sleeve | Type | Status | Summary |
|---|---|---|---|
swing_momentum |
Multi-day CNC | Implemented, validated | Top-3 concentrated portfolio from a Nifty-100 universe, ranked by 60-day return, filtered on positive 20-day return and price above the 50-day SMA. 15% hard stop, Monday rebalance with a rank-6 hold band. |
intraday_orb |
Intraday MIS | Implemented, on probation | Opening-range breakout on the 09:15–09:30 range across the 8 most liquid F&O names. Volume-confirmed entries, target at 1.5× the range, optional regime filter gating on Nifty direction and an ATR volatility floor. |
stock_futures_momentum |
Short-hold | Implemented, unvalidated | 5-day momentum on liquid F&O underlyings. Two concurrent positions, 5% hard stop, 3-day minimum and 10-day maximum hold, Tuesday rebalance. Currently trades cash equivalent. |
options_spreads |
Defined-risk F&O | Stub | Intended for weekly NIFTY/BANKNIFTY defined-risk spreads. |
mcx_trend |
Commodity | Stub | Intended for CRUDEOIL and NATURALGAS evening trend-following. |
smallcase_drift, gold_etf, intl_etf, reit |
Monitors | Alert-only | Never place orders; emit rebalance and drift alerts. |
Every strategy inherits BaseStrategy and must route orders through self.risk_engine.check_and_place(...). There is no path from a strategy to the broker that bypasses the risk engine.
Parameter details and the reasoning behind each value are in docs/STRATEGIES.md.
Global limits are constants in risk/limits.py, deliberately not in YAML, so no config change can loosen them.
| Limit | Value |
|---|---|
| Daily loss kill-switch | ₹3,500 |
| Weekly loss limit | ₹7,000 |
| Monthly drawdown per sleeve | 25% → auto-disable |
| Max concurrent open positions | 8 |
| Total margin used cap | ₹70,000 |
| MIS auto-square-off | 15:15 IST |
| MCX new-entry cutoff / square-off | 22:30 / 23:00 IST |
| Earnings blackout (futures, options) | 3 trading days |
Circuit breaker, on 5-minute rolling windows:
| Symptom | Threshold |
|---|---|
| Order rejections | > 3 |
| WebSocket disconnects | ≥ 2 |
| Broker API errors | ≥ 5 |
Separately, a rolling 20-trade P&L check auto-disables any sleeve that loses more than 3% of its allocated capital over its last 20 trades.
These numbers are sized for a ₹1,00,000 account. Edit capital in config/settings.yaml and the constants in risk/limits.py to match your own account before going live.
Both engines reuse the live charge and slippage model, so backtested P&L is directly comparable to paper and live results. Bars are cached in a separate DuckDB file to avoid contending with the live database.
# Intraday opening-range breakout
python -m backtest.run_orb --start 2026-03-01 --end 2026-04-30
python -m backtest.run_orb --start 2026-03-01 --end 2026-04-30 --grid
python -m backtest.run_orb --start 2026-03-01 --end 2026-04-30 --regime-filter
# Swing momentum
python -m backtest.run_swing --start 2024-01-01 --end 2026-01-01
python -m backtest.run_swing --start 2024-01-01 --end 2026-01-01 --grid --export trades.csvBoth require a fresh access_token.txt, since historical bars come from the Kite API.
The monthly walk-forward optimizer runs grid searches over rolling train/test splits and writes results to pending_changes/:
python scripts/run_walkforward.py
python scripts/run_walkforward.py --orb-onlyA word of caution learned the hard way here: an early parameter change looked great on a 5-trade sample and was strictly worse across 84 trades. Treat anything under ~30 round-trips as noise, and prefer walk-forward results over single-window grids.
Four places call OpenAI, all unattended:
| Job | When | Output |
|---|---|---|
| News parser | Pre-market | Structured signals into the signals table |
| Trade journal | EOD, per closed round-trip | data/journal/YYYY-MM-DD/<strategy>__<symbol>.md |
| Weekly report | Sunday 18:00 | data/journal/weekly/<iso-week>.md + Telegram |
| Config review | Sunday 18:00 | pending_changes/<iso-week>.json |
The journal gets full trade context including direction, entry and exit prices, charges, and a granular exit reason parsed from the order tag (exit_stop vs exit_target vs auto_squareoff_mis), so the model can tell a stop-out from a target hit and reads short trades correctly.
The config reviewer reads recent journals, P&L, and the drift report, then proposes exactly one narrow parameter change with evidence and a confidence score. It writes a JSON file and a Telegram summary.
It never edits config/settings.yaml itself. Applying a suggestion is a deliberate human action. Given that an LLM reading a 16-trade sample confidently recommended disabling a strategy two weeks running — based on trades from before a filter change that had already invalidated them — that boundary matters.
The drift monitor compares live expectancy, win rate, and profit factor against each strategy's backtest_baseline. Set evaluated_since in that baseline when you materially change a strategy so pre-change trades stop polluting the comparison.
| File | Purpose | Edit? |
|---|---|---|
.env |
Secrets and the paper/live switch | Yes, required |
config/settings.yaml |
Capital allocation, per-strategy tunables, charges, session times, slippage | Yes |
config/universe.yaml |
Tradable symbol lists and your excluded holdings | Via build_safe_universe.py |
config/risk_limits.yaml |
Documentation mirror of the hard limits | No — reference only; the real values are in risk/limits.py |
The first thing to change is capital in config/settings.yaml. It ships sized for a ₹1,00,000 account split across sleeves.
Environment variables, all read via .env:
| Variable | Required | Default |
|---|---|---|
KITE_API_KEY, KITE_API_SECRET, KITE_USER_ID |
Yes | — |
OPENAI_API_KEY |
Yes | — |
OPENAI_MODEL |
No | gpt-5.5 |
OPENAI_REASONING_EFFORT |
No | low |
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID |
Yes | — |
PAPER_TRADING |
No | true |
VM_HOST, VM_USER, VM_SSH_KEY, REMOTE_PROJECT_ROOT |
No | used only in alert text |
.
├── auth.py # Daily Kite login (Flask callback on 127.0.0.1:5000)
├── adapters/ # KiteAdapter (live) + PaperAdapter (simulated fills)
├── orchestrator/ # 5 runnable entry points + shared bootstrap
├── strategies/
│ ├── base.py # BaseStrategy lifecycle hooks
│ ├── executes/ # Order-placing sleeves
│ └── monitors/ # Alert-only sleeves
├── risk/ # Hardcoded limits, circuit breaker, sizing, sleeve state
├── oms/ # Order state machine + broker reconciliation
├── data/ # DuckDB store, instrument cache, universe builder
├── analytics/ # FIFO P&L, drift, equity curve, journaler, LLM review
├── backtest/ # ORB + swing engines, shared metrics and data loaders
├── llm/ # OpenAI client, news parser, journal, weekly report
├── monitoring/ # Dashboard snapshot writer
├── notifications/ # Telegram alerter
├── scripts/ # Operational CLIs (preflight, dashboard, watchdog, ...)
├── deploy/ # systemd units, installer, logrotate, deploy scripts
├── config/ # settings.yaml, universe.yaml, risk_limits.yaml
├── docs/ # Architecture, deployment, strategy notes
└── tests/ # ~100 tests, offline by default
Runtime artifacts (all gitignored): access_token.txt, data/store/, data/journal/, pending_changes/, logs/, run/.
pytest -m "not live" # offline suite, no network, no credentials needed
pytest # includes live read-only Kite smoke tests
ruff check .
black .The live smoke tests in tests/smoke_test.py hit the real Kite API for a profile lookup, an instrument list, and an LTP query. They are read-only and never place orders, and they skip automatically when access_token.txt is missing. Use -m "not live" in CI.
Do not skip these. The system is only as safe as the discipline applied to turning it on.
| Phase | Window | What runs | At risk |
|---|---|---|---|
| 0 — wiring | Days 0–2 | Auth, instrument refresh, pre-market and intraday loop. Verify Telegram and the dashboard. | ₹0 |
| 1 — full paper | Weeks 1–4 | Every sleeve in paper. Confirm fill simulation, daily P&L, circuit-breaker behaviour, EOD reconciliation. | ₹0 |
| 2 — one live sleeve | Week 5 | One strategy live at 50% sizing, everything else still paper. | ≤ ₹15k |
| 3 — graduated expansion | Week 6+ | Add one sleeve at a time. Each needs 4+ weeks of clean paper P&L, and its first live week runs at half size. | Up to the ₹70k margin cap |
Before promoting any sleeve from paper to live:
- 4+ weeks of paper trades in
daily_pnl, aggregate flat or positive - Zero unexplained reconciliation discrepancies in the last 5 sessions
- You have received and acted on a real Telegram alert (do a circuit-breaker drill)
-
build_safe_universe.pyhas been run and your holdings are excluded - Sizing halved for the first live week
TokenException: Incorrect api_key or access_token — the token expired. Run python auth.py. Tokens die daily around 06:00 IST.
Login callback never fires — the redirect URL in your Kite app must be exactly http://127.0.0.1:5000/callback. On a remote VM you also need the tunnel: ssh -L 5000:localhost:5000 ....
IOException: Could not set lock on file ... trading.duckdb — DuckDB allows one writer. An orchestrator is running. Use a read-only connection for analysis, or wait for the session to end. The dashboard already does this correctly.
Dashboard shows P&L of ₹0.00 despite fills — P&L only materializes on closed round-trips. If a strategy holds positions across days, check open positions instead. To rebuild history: python scripts/backfill_pnl.py.
No Telegram messages — run python scripts/preflight.py. Most often the chat ID is wrong: send your bot a message first, then re-run python scripts/get_telegram_chat_id.py.
Strategy places no orders — check python scripts/sleeve.py status (it may be auto-disabled by the rolling-loss breaker), confirm the symbols aren't in excluded_holdings, and grep the logs for RISK DENY.
Systemd service fails immediately — journalctl -u <service> -n 50 --no-pager. Usually a stale token, since preflight is a dependency and exits non-zero.
| Item | Monthly |
|---|---|
| Zerodha Kite Connect API | ₹2,000 |
| Oracle Cloud Always Free VM | ₹0 |
| OpenAI API (~30 trades/week) | ₹250–400 |
| Total | ≈ ₹2,300 |
The API subscription dominates and is fixed regardless of how much you trade, which is worth weighing against your expected returns.
Issues and pull requests are welcome. Please keep the existing conventions: type hints on public functions, loguru rather than stdlib logging, no magic numbers in trading paths, and every new strategy inheriting BaseStrategy and routing through the risk engine. Run ruff check ., black ., and pytest -m "not live" before opening a PR.
This software is provided for educational purposes only. It is not financial advice.
Trading Indian equities, futures, options, and commodities carries substantial risk of loss. Algorithmic trading adds failure modes that manual trading does not have: bugs, stale data, connectivity loss, and unattended execution against a moving market.
The authors accept no liability for financial losses arising from use of this software. You are solely responsible for orders placed through your account.
Independently verify the charge, brokerage, and tax assumptions in config/settings.yaml against your own contract notes before treating simulated P&L as realistic. Run in paper mode for weeks. Start small. Never deploy capital you cannot afford to lose.
MIT — see LICENSE.