Oracle3
Oracle3: 예측시장 거래 엔진·MCP 서버
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A single-user algorithmic trading platform for Indian equity options. Signals are scraped from Telegram groups (MTProto userbot), executed via the Kotak Neo API, and monitored through a React dashboard. [README 설치·사용 부분 발췌] Quick StartREADME 전문 보기 ↗
A single-user algorithmic trading platform for Indian equity options.
Signals are scraped from Telegram groups (MTProto userbot), executed via the Kotak Neo API, and monitored through a React dashboard.
┌─────────────────────────────────────────────────────────┐
│ Telegram Groups (3rd-party, no bot access) │
└──────────────────────────┬──────────────────────────────┘
│ MTProto (grammers-client)
▼
┌─────────────────────────────────────────────────────────┐
│ telegram_ingester regex signal parser │
│ → TradeSignal → broadcast channel │
└──────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ trading_engine stateful OMS (50ms tick) │
│ WaitingForEntry → Active → Target1Hit → Closed │
│ FeeCalculator (STT, SEBI, stamp, GST) │
│ → DbWriteMessage → mpsc channel │
└──────────────┬────────────────────────┬─────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌────────────────────────────┐
│ kotak_client │ │ SQLite (WAL) │
│ REST: login + │ │ wallet / paper_trades / │
│ place_live_order │ │ system_logs / │
│ WebSocket: LTP feed │ │ trading_config │
│ (sfeed.*) │ └────────────────────────────┘
└──────────────────────┘ │
▼
┌───────────────────────┐
│ server (Axum :8080) │
│ GET /api/portfolio │
│ GET /api/settings │
│ POST /api/settings │
│ POST /api/webhook/.. │
│ GET /api/logs/stream│
└───────────┬───────────┘
│ SSE + REST
▼
┌───────────────────────┐
│ frontend (Vite/React)│
│ Settings bar │
│ P&L chart (recharts) │
│ Trade table │
│ Live log terminal │
└───────────────────────┘
| Tool | Minimum version | Install |
|---|---|---|
| Rust + Cargo | stable ≥ 1.80 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh |
| Node.js | ≥ 20 | nodejs.org |
| pnpm | ≥ 9 | npm i -g pnpm |
| SQLite | any | shipped with macOS/Linux |
git clone <repo-url> auto-trader
cd auto-trader
# type-check all 5 crates
cargo check
# run the server (SQLite auto-created at trades.db)
cargo run -p server
The server listens on http://0.0.0.0:8080.
cd frontend
pnpm install
pnpm dev # http://localhost:5173 — proxies /api → :8080
Open http://localhost:5173 in your browser.
cd frontend
pnpm build # outputs to frontend/dist/
The Axum server already serves ../frontend/dist as a static fallback, so after building you can access the dashboard directly at http://localhost:8080.
This repo now supports a split deployment model:
For the backend host:
cd auto-trader
cargo build --release -p server
cd kotak-bridge && npm install
Then run the backend from the repo root so the SQLite database, Telegram session file, and kotak-bridge/ folder all stay in a stable relative layout:
cd auto-trader
./target/release/server
Recommended VM notes:
8080, or put Nginx/Caddy in front and expose HTTPS on 443trades.db and session.json on persistent diskkotak-bridge/index.jsFor the Vercel frontend:
frontend/ directoryServer URL or IP:PORT field in the Kotak login panelExamples:
http://34.93.xx.xx:8080https://api.example.comThe backend reads environment variables from the process environment. It also
auto-loads a .env file from the working directory at startup (via
dotenvy) if one is present — variables already exported in the shell or a
systemd EnvironmentFile= still take priority. So any of the following work:
.env file next to trades.db (never commit this — it's gitignored), orsystemd unit with Environment= or EnvironmentFile=All backend date-sensitive logic is normalized to Indian Standard Time (IST, UTC+05:30).
That includes expiry-date interpretation, "today" checks, and persisted trade/log timestamps,
so behavior stays aligned with Indian markets even if the server is running in another timezone.
See .env.example for a ready-to-copy template (and
frontend/.env.example for the optional frontend
one). These are the supported variables:
# ── Sign-in (required) ────────────────────────────────────
# Gates the whole app — every /api/* route but /api/health, /api/version and
# the sign-in itself needs the bearer token a phone sign-in returns, signed
# with AUTH_SECRET. See "Signing In". Read at run time only, never built into
# the binary (release binaries are public).
AUTH_SECRET=change-me-too
ADMIN_PHONE=75xxxxxxxx # the owner's 10-digit number: the one admin
WA_SERVER_URL=https://wa.example.com # self-hosted WhatsApp server that sends the codes
WA_API_KEY=change-me
# ── SQLite ───────────────────────────────────────────────
DATABASE_URL=sqlite://trades.db # default
# Trading mode (PAPER/LIVE), max trade size, brokerage, and target/SL
# settings all live in the `trading_config` SQLite table, not env vars —
# edit them via the Settings bar in the UI or POST /api/settings.
# ── Telegram MTProto ingester (optional) ─────────────────
# Get these from https://my.telegram.org → API Development Tools
TELEGRAM_API_ID=12345678
TELEGRAM_API_HASH=abcdef1234567890abcdef1234567890
# Comma-separated chat IDs to listen to (use a negative number for groups)
TELEGRAM_CHAT_IDS=-1001234567890,-1009876543210
# ── Kotak Neo WebSocket ───────────────────────────────────
# Scrips to subscribe (pSymbol from scrip master, & separated)
KOTAK_SCRIPS=nse_cm|11536&nse_cm|1594
# ── Kotak Neo auto-login (optional) ───────────────────────
# Set all five to skip the manual Kotak login form. The server will:
# - log in at startup if no valid session is restored from the DB,
# - log in at 09:05 IST each trading day (pre-warm, so the Scrip Master is
# loaded before the 09:15 open), and retry at 09:15 if that failed.
# The 6-digit TOTP is generated from KOTAK_TOTP_SECRET (RFC 6238), so no
# human needs to read a code off an authenticator app each morning.
KOTAK_ACCESS_TOKEN=eyJhbGci... # API Dashboard access token
KOTAK_MOBILE_NUMBER=+91XXXXXXXXXX # registered mobile, with country code
KOTAK_UCC=Y4HAU # Unique Client Code
KOTAK_MPIN=123456 # 6-digit trading MPIN
KOTAK_TOTP_SECRET=JBSWY3DPEHPK3PXP # Base32 secret from TOTP registration (alias: KOTAK_TOTP_HASH)
# KOTAK_AUTO_LOGIN=false # set to disable unattended auto-login even if the above are set
Any of access_token / mobile_number / ucc / mpin / totp left blank
in a manual POST /api/auth/kotak request also falls back to these same env
vars, and a blank totp is generated from KOTAK_TOTP_SECRET — so the
Kotak login form still works with only some fields filled in. A dedicated
POST /api/auth/kotak/auto-login (no body) logs in using only the env vars.
Example shell startup:
export AUTH_SECRET=change-me-too
export ADMIN_PHONE=75xxxxxxxx
export WA_SERVER_URL=https://wa.example.com
export WA_API_KEY=change-me
export DATABASE_URL=sqlite:///home/ubuntu/auto-trader/trades.db
export TELEGRAM_API_ID=12345678
export TELEGRAM_API_HASH=abcdef1234567890abcdef1234567890
export TELEGRAM_CHAT_IDS=-1001234567890,-1009876543210
cd ~/auto-trader
./target/release/server
This section assumes:
Create a small VM (a free/burstable tier such as GCP e2-micro or Oracle Cloud's Always Free shapes is enough) and allow, in your cloud provider's console-level firewall (security list / network security group — this is separate from and in addition to the VM's own OS firewall):
8080 if you want to expose the Rust server directly80 and 443 if you will use Nginx/Caddy as a reverse proxyGotcha — check the VM's own OS firewall too, and check rule order. Several stock cloud images (Oracle Cloud's Ubuntu images in particular) ship with
iptablespre-configured to accept SSH and reject everything else by default. If you (or a setup script) append newACCEPTrules for8080/443after that catch-allREJECTrule, they are silently dead —iptablesmatches top to bottom, so the reject fires first and the port stays closed to the outside world even thoughiptables -Lshows an "allow" rule for it further down. This is easy to miss because the backend still answers fine on127.0.0.1from inside the VM (loopback traffic skips the rule), so the API looks healthy over SSH while every external caller — including your Vercel frontend — silently times out.Check with
sudo iptables -L INPUT -n --line-numbersand make sure yourACCEPTrules for the ports you need come before any blanketREJECT/DROPrule. If you need to reorder, insert the correct rules withsudo iptables -I INPUT <line-number-before-the-reject> ..., remove the old dead ones, then runsudo netfilter-persistent save(ifiptables-persistentis installed) so the fix survives a reboot. After any change, verify from outside the VM —curl http://<public-ip>:8080/api/healthfrom your own machine, not just from inside an SSH session.
Recommended baseline packages:
sudo apt update
sudo apt install -y curl git nginx
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
node -v
npm -v
Rust itself is only needed on the VM if you plan to build there; the recommended flow (below) downloads a prebuilt release binary instead, so cargo/rustup are optional.
cd ~
git clone <repo-url> auto-trader
cd auto-trader/kotak-bridge
npm install
cd ..
The backend binary itself does not need to be built on the VM — see step 4.
Important runtime note — PATH CONTRACT:
auto-trader/backend/ as its working directory, not the repo root — it resolves trades.db, session.json, ../frontend/dist, and ../kotak-bridge relative to the working directory it was started from. Starting it from anywhere else breaks those lookups. In production the binary itself also lives directly inside backend/ (see step 4), so in practice this means: cd ~/auto-trader/backend && ./the-binary.frontend/dist doesn't need to contain a real build in production, since the frontend is served by Vercel, not this server — the static-fallback route just won't have anything to serve if you hit the VM's IP directly, which is expected.If you are deploying from GitHub, point Vercel at the frontend/ directory.
Vercel settings:
Vitefrontendpnpm builddistNo frontend environment variable is required for the backend URL because the UI now asks for it and stores it in browser storage and a cookie.
Download the latest release binary built by CI (.github/workflows/release-server.yml) rather than building on the VM — this is a small VM that also runs the live 50ms trading-tick loop once it's live, and a cargo build --release here would compete with it for CPU/memory:
cd ~/auto-trader/backend
LATEST_JSON=$(curl -s https://api.github.com/repos/MrImmortal09/auto-trader/releases/latest)
DOWNLOAD_URL=$(echo "$LATEST_JSON" | grep -o '"browser_download_url": *"[^"]*"' | grep 'x86_64-unknown-linux-gnu' | head -n1 | cut -d '"' -f4)
curl -fsSL "$DOWNLOAD_URL" -o "$(basename "$DOWNLOAD_URL")"
chmod +x server-*-x86_64-unknown-linux-gnu
Put your env vars in ~/auto-trader/backend/.env (auto-loaded from the working directory at startup — see Environment Variables):
cat > ~/auto-trader/backend/.env <<'EOF'
AUTH_SECRET=change-me-too
ADMIN_PHONE=75xxxxxxxx
WA_SERVER_URL=https://wa.example.com
WA_API_KEY=change-me
TELEGRAM_API_ID=12345678
TELEGRAM_API_HASH=abcdef1234567890abcdef1234567890
TELEGRAM_CHAT_IDS=-1001234567890,-1009876543210
KOTAK_SCRIPS=nse_cm|11536&nse_cm|1594
EOF
PATH CONTRACT, again: start it from inside backend/, not the repo root — trades.db, .env, session.json, and the ../frontend/dist / ../kotak-bridge lookups are all relative to the working directory:
cd ~/auto-trader/backend
./server-*-x86_64-unknown-linux-gnu
Expected behavior:
0.0.0.0:8080session.jsonupdate.shThis project runs the backend in the foreground of a dedicated tmux pane rather than under systemd, so a live trading process is never silently backgrounded or auto-restarted mid-position without a human noticing:
tmux new-session -d -s 0
tmux send-keys -t 0:0 "cd ~/auto-trader/backend && ./server-*-x86_64-unknown-linux-gnu" C-m
tmux attach -t 0
Session 0, window 0 (i.e. pane 0:0) is what update.sh restarts by default — using the exact names above means it works with no edits. If you'd rather use a different session/window name, update TMUX_PANE at the top of backend/server/update.sh to match.
backend/server/update.sh (already in the repo) handles subsequent updates: it's what POST /api/update_server spawns, or you can run it by hand. It syncs origin/main, downloads the newest release binary, backs up the binary that's currently running, swaps it in, restarts the tmux pane, and health-checks /api/health — rolling back to the backup automatically if the new binary doesn't come up healthy. All output goes to /tmp/update.log on the VM.
If you'd rather run under systemd with auto-restart-on-crash instead of tmux, that's a reasonable alternative for a less hands-on setup — just be aware update.sh as written assumes tmux, so you'd need to adapt its stop/start steps to systemctl restart first.
Directly exposing :8080 works, but a reverse proxy is cleaner and lets you add TLS.
Example Nginx site:
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
proxy_buffering off;
}
}
Then enable it:
sudo tee /etc/nginx/sites-available/auto-trader >/dev/null <<'EOF'
server {
listen 80;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
proxy_buffering off;
}
}
EOF
sudo ln -s /etc/nginx/sites-available/auto-trader /etc/nginx/sites-enabled/auto-trader
sudo nginx -t
sudo systemctl reload nginx
If you have a domain, add HTTPS with Certbot afterward.
Client addresses behind the proxy. The backend keys its sign-in rate limits (the phone OTP's send and verify limits) on the last X-Forwarded-For hop, which $proxy_add_x_forwarded_for appends from nginx's own view of the client; earlier hops and any X-Real-IP are whatever the client sent and are ignored. Keep that proxy_set_header line. If the site sits behind Cloudflare, $remote_addr is a Cloudflare edge address unless nginx restores the visitor's, so add the real_ip directives to the http block (Cloudflare publishes its ranges at https://www.cloudflare.com/ips/):
set_real_ip_from 173.245.48.0/20; # one line per Cloudflare range, v4 and v6
real_ip_header CF-Connecting-IP;
After the Vercel site is live:
Change under the form and enter your backend address.http://PUBLIC_IP:8080 or your reverse-proxied domain such as https://api.example.com.Examples:
http://34.93.xx.xx:8080https://api.example.comFor Telegram:
For Kotak:
Connect.Before considering the deployment ready, verify:
curl http://127.0.0.1:8080/api/health returns JSON from inside the VM (over SSH)curl http://<public-ip>:8080/api/health returns JSON from your own machine, outside the VM — this is the check that would have caught the iptables-ordering gotcha from step 1; a pass over SSH alone does not prove the outside world can reach ittmux attach -t trader (or your session name) shows the server running and logging normally/api/logs/streamThese files should remain on persistent disk on the VM, inside backend/:
trades.db (+ -shm/-wal companion files)session.json.envbackend/server-<version>-x86_64-unknown-linux-gnukotak-bridge/node_modules/update.sh already keeps one previous binary as backend/.server_prev for rollback and prunes older ones — you don't need to manage binary versions by hand. If you ever move to a fresh VM, copy over trades.db*, session.json, and .env from backend/ before starting the server there.
The site (for example https://at.axiosiiitl.dev) opens on a sign-in page; nothing else loads, and the server answers nothing but /api/health, /api/version and the sign-in itself, until someone signs in. The redesign (v2) is at /v2/, one address per screen (/v2/desk, /v2/trade, /v2/market, /v2/settings, /v2/admin), and / opens it; the old dashboard (v1) is at /v1/ (/v1/execute, /v1/positions, …). The System screens have no button: open /v2/system or /v1/system (signed in as the admin).
WA_SERVER_URL / WA_API_KEY), valid for 10 minutes and for one sign-in. Only numbers on the allow-list (the Admin page) get a code. There is no passkey.admin-token.ADMIN_PHONE, and nobody else. That number is put on the allow-list as the admin at every start, and any other admin row is made a paper user. The admin's sign-in does everything: LIVE orders, Paper↔LIVE, the kill switch, Kotak and Telegram, settings, the allow-list, clearing the database and updating the server. It lasts 7 days./v1/ opens with the admin's sign-in, made on the main page in the same browser. Without it, v1 shows a link there.The server also stores the admin's code in a file, so you can still get in when WhatsApp is down:
Where: admin-otp.log in the server's working directory. On the VM that is ~/auto-trader/backend/admin-otp.log, beside trades.db. Read it over SSH:
cat ~/auto-trader/backend/admin-otp.log
# 2026-10-27 18:02:11 IST admin sign-in code 123456 for 75••••xxxx valid once, until 2026-10-27 18:12:11 IST
What: only the admin's latest code, in plain text. Users' codes are never written there. Every code, the admin's included, is also kept in trades.db (table otp_codes), but only as a keyed hash, so it can't be read back from the database.
Who can read it: the file is created with mode 600, so only the VM user that runs the server can read it. It is replaced by each new admin code and deleted when the admin signs in.
When WhatsApp fails or isn't set up, the admin's code still counts: the sign-in page says the code is on the server instead of refusing. A user's code is refused as before.
admin-tokenThe admin's code shares its limits with anyone who types the admin's number, so a stranger can burn every code (and the day's 30 guesses) before you get to use one. The server binary can then mint an admin sign-in itself, from a shell on the VM:
cd ~/auto-trader/backend
./server-<version>-x86_64-unknown-linux-gnu admin-token
# eyJhbGciOiJIUzI1NiIs… ← the token (stdout); the line after it says what to do
It reads AUTH_SECRET and ADMIN_PHONE from .env and the admin's row from trades.db in that directory (read only — the running server keeps running), prints a 7-day admin token and exits. On the site, open the browser console and run localStorage.setItem('at_user_token', '<token>'), then reload. Anyone who can run this already has the VM, so it opens nothing new.
The server writes its log to its terminal: on the VM, the tmux pane 0:0 it runs in (see Deployment Guide § 5).
tmux attach -t 0 # watch the live log; detach with Ctrl-b then d
update.sh keeps its own log at /tmp/update.log. The engine's events also reach the dashboard's log terminal and the system_logs table in trades.db. The admin's code is not in any of these logs: they only say Admin sign-in code also written on the server, so the code doesn't travel wherever the log is shipped (Grafana Alloy, for example). Only admin-otp.log holds it.
Kotak session tokens expire daily. The server persists its session (auth
token, sid, base URL) in the kotak_session SQLite table and restores it
automatically at startup if it's still from today — otherwise it needs a
fresh login, which happens one of two ways:
KOTAK_* auto-login env vars are set.KOTAK_ACCESS_TOKEN / KOTAK_MOBILE_NUMBER /
KOTAK_UCC / KOTAK_MPIN / KOTAK_TOTP_SECRET (see
Environment Variables) and the server logs in on
its own — at startup, at 09:05 IST (pre-warm, so the Scrip Master is loaded
before the bell), and again at 09:15 IST if still disconnected. No human
action required, including in LIVE mode.Either way, every step of the login is streamed to the dashboard's log
terminal (KOTAK_LOGIN_START → KOTAK_LOGIN_OK → KOTAK_WS_START →
KOTAK_CONNECTED → SCRIP_FETCH → SCRIP_FETCH_SUCCESS), so you can watch
an unattended login happen. Failures log KOTAK_LOGIN_FAILED in red. Secrets
are never logged — only a masked UCC and whether the TOTP was auto-generated.
Until a session exists, the WebSocket market-data feed will silently fail to connect (the position monitor still works in paper mode using entry_price as the assumed LTP).
On the first run with TELEGRAM_API_ID set, the ingester will prompt you interactively:
Telegram phone number (e.g. +91XXXXXXXXXX): +91XXXXXXXXXX
Login code (sent to your Telegram app): 12345
A session file session.db is created alongside the binary and reused automatically on subsequent runs.
If two-factor authentication is enabled you will also be asked for your 2FA password.
The current implementation stores the Telegram session in session.json in the working directory.
The Telegram parser recognises messages like:
BUY BHEL 425 CE ABOVE 8.25
TARGET :- 9.50 / 11.50
SL :- 5
JULY EXPIRY
| Field | Example | Notes |
|---|---|---|
| Action | BUY / SELL | Required |
| Instrument | BHEL | Underlying name |
| Strike | 425 | Options only |
| Option type | CE / PE | Options only |
| Entry condition | ABOVE / BELOW | LTP trigger |
| Entry price | 8.25 | Trigger price |
| Targets | 9.50 / 11.50 | /-separated, ordered |
| Stop loss | SL :- 5 | Initial SL |
| Expiry | JULY EXPIRY | Optional |
Equity signals (no strike/type) are also supported:
BUY RELIANCE ABOVE 2500
TGT 2600 / 2700
SL 2420
Options calls with an entry range instead of a trigger (BUY only):
BUY NIFTY NEXT WEEK 23000 STRIKE PUTS 140-145 SL 120 TARGET 175
BUY NIFTY NEXT WEEK 23000 STRIKE PUTS @ 125-130 SL 105 TARGET 165
BUY NIFTY 22300 CALLS CMP 100-102 SL 90 TARGET 125
CALL(S) / PUT(S) mean CE / PE; STRIKE and the @ / CMP / AT prefix are optional.NEXT WEEK = the weekly expiry falling in next calendar week (Mon–Sun) — NIFTY's Tuesday, SENSEX's Thursday. Other instruments have no weekly series, so NEXT WEEK calls on them are refused.BUY AGAIN 22300 CALLS CMP 100-102) is ignored — never applied as an SL update to the message it replies to.Index options only (index_options_only, off by default; PUT /api/v2/flags): when on, only CE/PE calls with a strike on NIFTY, BANKNIFTY, FINNIFTY, MIDCPNIFTY, SENSEX or BANKEX are traded, matched by exact name (NIFTY50 is not NIFTY). Stock options, equity calls and index calls with no strike (BUY NIFTY ABOVE 25000) are skipped and logged as SIGNAL_SKIPPED, from Telegram and from the webhook alike, including edits of such calls. Reply commands and positions already open are unaffected.
When replying directly to an original trade signal message in Telegram, the ingester recognises:
SL to 4.50, Move SL to 4, sl -> 2.5 — updates the active stop-loss for the position.exit at 610, exit @610, exit @ 610, exit 610, exit all at 610 — immediately forces the position to exit all remaining quantities at the specified price.WaitingForEntry ──LTP crosses entry──▶ Active
│
┌── SL hit ────────────┤
▼ ├── Target 1 (full exit) ──▶ Closed
Closed │
▲ └── Target 1 (partial) ──▶ Target1Hit
│ │
└──── SL (trailed) or Target 2 hit ───────────────────┘
On Target1Hit:
target_1_exit_pct % of the position(avg_buy_price + target_2) / 2Both are configured under Settings → Telegram Call Rules and apply in PAPER and LIVE.
Call validity (call_validity_minutes, default 120): a call that has not been bought within this many
minutes of arriving is dropped (CALL_EXPIRED). Only unbought calls: an entry already sent to the broker and every
open position are untouched. Cancelling a call from the dashboard still works any time. 0 = off (valid until the
15:39 no-entry cutoff).
Indicator sizing (indicator_sizing, off by default): when a call's entry triggers, the engine reads the last
closed VWAP + PCR check of its index — the headline badge on the Market Data cards. BANKNIFTY and SENSEX use their
own; NIFTY stands in for everything else (FINNIFTY, MIDCPNIFTY, BANKEX, stock options).
| Indicator vs call | Lots bought |
|---|---|
Strongly agrees: PCR and VWAP both on the call's side (+2 reading + CE, −2 reading + PE) | default lots × indicator_strong_agree_multiplier (1.5) |
Agrees: one of them on the call's side, the other neutral (+1 reading + CE, −1 reading + PE) | default lots × indicator_agree_multiplier (1.0) |
| NEUTRAL, or no fresh reading | default lots × indicator_neutral_multiplier (0.5) |
| Disagrees | none — the call keeps waiting (INDICATOR_WAIT) or is dropped (INDICATOR_SKIP), see below |
Lots round to the nearest whole lot with exact halves rounded down, never below 1 (1 lot × 1.5 stays 1 lot;
2 lots × 1.5 → 3). A manual qty override on a position is never scaled, but is still skipped on disagreement. A reading
older than its interval + 2 minutes (Upstox down, server just restarted) counts as no fresh reading. The indicator's
settings (interval, OI basis, strike range, PCR thresholds, VWAP band) are the same ones the Market Data page scores
with. Logic and tests: backend/shared_domain/src/entry_gate.rs.
The multipliers in brackets are the defaults of a new database. A database from before the strong-agree tier keeps
its saved indicator_agree_multiplier (1.5 unless it was changed), so a plain agree keeps buying at that size until
it is set to 1 under Settings.
Three switches refine it; each can be turned on or off at any time and only matters while indicator sizing is on:
Opening checks (indicator_early_checks, default on): the 09:16 check always counts as NEUTRAL. From 09:22
the latest closed 5-minute check (09:21, 09:26) is used until the first regular check closes (09:31 on 15 / 30 min,
10:01 on 60 min); after that the configured interval takes over. The Market Data cards still show the plain
interval reading, so they can differ from the engine before the first regular check; the Settings preview shows
what the engine uses.
Whenever a new trade signal arrives for an instrument (e.g., SENSEX 80000 PE), any existing open position for the same underlying instrument in the opposite direction (e.g., an active SENSEX CE position or opposite side equity trade) is automatically exited with the reason Opposite Signal Exit.
Every completed trade records its explicit exit reason (exit_reason column in SQLite) which is displayed in the UI (Trade History & Daily Reports modal):
SL Hit: Initial Stop-Loss triggeredTrailed SL Hit: Trailing Stop-Loss triggered after Target 1Target 1 Hit / Target 2 Hit: Target profit levels reachedClosed via Frontend: Manually closed using the dashboard Close buttonExit via Telegram Msg: Exited via a direct Telegram reply command (e.g., exit @ 610)Opposite Signal Exit: Automatically closed due to an opposite trade signal arrivingChart Stop Hit / Chart Target Hit: A scalp's stop or target set from the v2 charts was reached (the whole holding is sold)| Section | URL | Description |
|---|---|---|
| Settings bar | top | Edit brokerage, max trade, targets, PAPER/LIVE toggle. Changes persist to SQLite immediately. |
| P&L chart | middle | Recharts line chart of cumulative realised P&L. |
| Trade table | middle | Full history with gross, charges breakdown, and per-trade P&L. |
| Log terminal | bottom | Live SSE stream of engine events (entry, SL hit, target hit, config changes). |
| Daily Reports | /reports | Dedicated daily summary grouping trades by calendar date and Telegram signal ID. |
/reports)The Daily Reports page provides an aggregated view of trading performance by calendar day (in IST):
signal_id, making it easy to track net P&L per strategy or call. Trades without an ID appear under "Legacy Trades".i button): Clicking the i button next to any signal opens a modal showing:
| Method | Path | Description |
|---|---|---|
GET | /api/portfolio | Wallet balance + last 100 trades |
GET | /api/settings | Current TradingConfig as JSON |
GET | /api/market/indicator | Last closed VWAP + PCR check per index and the CE / PE sizing verdict a call triggering now would get |
POST | /api/settings | Update TradingConfig (persisted to DB + in-memory) |
POST | /api/webhook/telegram | Inject a TradeSignal JSON manually (422 for a non-index call while index_options_only is on) |
GET | /api/logs/stream | SSE stream of engine log events |
GET | /api/v2/flags | v2 engine switches: {"index_options_only": false, "chart_stops": false} |
PUT | /api/v2/flags | Change only the switches named in the body; saved in their own table, so a v1 settings save never resets them |
PATCH | /api/v2/positions/:id | A scalp's chart stop and target: each {price, trigger} with trigger premium (the option's price) or index (its index — a CE's stop fires as the index falls, a PE's as it rises; targets the other way). Left out keeps a level, null clears it. Reaching either sells the whole holding; a level already crossed sells on the next tick (sells_now). New levels need chart_stops on; ones already set stay until the position closes |
GET | /api/v2/history?range=&group=&mode= | The v2 Desk's history: trades grouped into calls (one per Telegram call or scalp, with its legs and original message) closed in range (today, 7d default, 30d, all), with KPIs (net, calls, win rate, profit factor, average per call, max drawdown), the cumulative P&L curve, P&L grouped by (default), or , and trading days. A call counts once fully sold, on the day of its last sell. Rows from before channels were stored show as "Unknown channel" |
auto-trader/
├── Cargo.toml workspace root
├── backend/
│ ├── shared_domain/ domain types (TradeSignal, TradingConfig, etc.)
│ ├── kotak_client/ Kotak Neo REST + WebSocket client
│ ├── telegram_ingester/ MTProto userbot + regex signal parser
│ ├── trading_engine/ stateful OMS + fee calculator
│ └── server/ Axum HTTP server + SQLite writer
├── frontend/ Vite + React 19 + Tailwind v4 dashboard
└── kotak-api-docs/ local copy of official Kotak API docs
All paper trades compute Kotak Neo charges automatically:
| Charge | Rule |
|---|---|
| Brokerage | Flat ₹20 per leg (configurable) |
| SEBI fee | 0.0001% of turnover |
| Exchange transaction charge | Options: 0.03503% of premium (NSE), 0.0325% (BSE: SENSEX, BANKEX) · Equity: 0.00297% (NSE), 0.00375% (BSE) |
| STT (options) | 0.15% of premium, SELL side only (since 1 April 2026) |
| STT (equity intraday) | 0.025% on SELL side only |
| Stamp duty | 0.003% on BUY side only |
| GST | 18% × (brokerage + SEBI + exchange charge) |
Wait on disagreement (indicator_wait_on_disagree, default on): a call that triggers against the indicator is
neither bought nor dropped. It is re-read every tick and bought once the indicator turns neutral or agrees while
the price is still in the buy range. It still expires with call validity and the 15:39 cutoff. Off: the call is
dropped on the spot.
Aggressive top-up (indicator_topup, off by default): a call bought at the reduced NEUTRAL size is topped
up if the indicator later agrees while the price is back in the buy range. Which reading does it, and up to which
size, is the Top-Up Rule (indicator_topup_mode); sizes are default lots × the multipliers above:
| Rule | Tops up on | Up to | Top-ups per call |
|---|---|---|---|
strong_to_agree (default) | strong agree only | the agree size | 1 |
strong_to_strong | strong agree only | the strong-agree size | 1 |
tier | any agreement | that reading's own size | 1 |
step | any agreement | that reading's own size, then once more to the strong-agree size when the reading turns strong; a call bought at the agree size steps up too | up to 2 |
With 2 default lots (1 bought at neutral): the default rule adds 1 on a strong agree and holds 2; strong_to_strong
adds 2 and holds 3. Under every rule: only before target 1, while the stop is still at the
signal level and the holding is exactly what the last buy left; never past call validity or 15:39; never on a
manual qty override. A partly filled top-up ends it, and both steps of step share the three-send cap. Added lots
share the position's stop-loss and targets, and the average buy price is
re-weighted. The indicator only ever adds: a reading that turns against an open position changes nothing. In LIVE
only the broker's filled quantity is added to the holding; a top-up send with no broker verdict is not tracked
and is flagged loudly instead, and a top-up that fills after the position was exited is sold straight back
(TOPUP_AFTER_EXIT).
channelindexcepeGET | /api/execute/stream?index=&expiry=¢re= | SSE, one frame every 250 ms: every index's spot, index's option ladder for expiry (YYYY-MM-DD, the nearest when left out; an unlisted one is a board error; the board lists expiries) with live prices for the strikes around centre (ATM when left out; the board says the window covered), and the open positions |
POST | /api/execute/buy | {trading_symbol, lots}: one tap, a market buy of any listed index option (any strike, any expiry from today) through the engine, or an add to the manual position already on that contract. Refused outside 09:15–15:30 IST, on a stale feed, without a price for the contract in the last 10 s, with the kill switch on, or over 10 lots |
GET | /api/v2/expiries?index= | Every expiry the index's options are listed for from today, earliest first, each with its lot size |
GET | /api/v2/chain?index=&expiry= | One expiry's strikes (the nearest by default), each side with its trading symbol and delta (Upstox's for the expiry it polls, else an assumed ±0.50) |
GET | /api/v2/candles?symbol=&tf=&from=&to= | Chart bars, oldest first. symbol: an index (NIFTY), its nearest future (NIFTY:FUT) or an option (NIFTY:2026-10-27:22750:CE); tf: 1m, 5m (default) or 15m; from/to: IST days (default: the last five). Past days come from Upstox; today's are built from live ticks and replaced minute by minute with Upstox's official candles, which carry the volume |
GET | /api/v2/candles/stream?symbols=&tf= | SSE: every 250 ms, the bars that changed — the forming bar, and a closed bar again when Upstox's official minutes correct it. Up to 6 comma-separated symbols |
GET | /api/v2/me/desk | A user's own paper Desk, in /api/v2/desk's shape: wallet (₹10,00,000 to start), today's figures, open positions and the calls closed today. /api/v2/me/* is for a user's phone sign-in only (the admin gets 403) |
GET | /api/v2/me/history?range=&group= · /me/marks · /me/positions · /me/activity | The user's closed calls (/api/v2/history's shape), today's fills for the charts, open positions, and the last 100 things that happened to them (in memory) |
POST | /api/v2/me/buy | {trading_symbol, lots}: a paper buy, filled at once at the live price — the same checks as /api/execute/buy but the kill switch, plus 10 open contracts per user, 40 across all users, and no buy of a contract being squared off. Charges are Kotak's with ₹20 brokerage a leg; the wallet never goes below zero |
POST | /api/v2/me/positions/:id/sell · /close · /api/v2/me/exit-all | {lots} / all of one / all of them, at the live price (from the last 10 s), 09:15–15:30 IST. Never more than is held |
PATCH | /api/v2/me/positions/:id | Chart stop and target, as /api/v2/positions/:id (not gated by chart_stops). Checked every 500 ms; a crossed level sells everything at a fresh price. An expiring position is sold from 15:29:30 IST |
POST | /api/v2/me/reset | Start over with ₹10,00,000 and an empty history (only when nothing is open) |