MarketGenie
AI-assisted intraday trading for Indian index options — a news-first edge, strict multi-level risk control, and a paper-gated path to live capital.
No sections match — try another term.
01Overview
Build a sound system first. The returns target is derived from measured paper-trading expectancy, never assumed. Success is defined by gates, not income.
MarketGenie runs a full Indian-market day unattended (except one daily token-approval tap), trading Nifty / Bank Nifty / Sensex options on the Upstox API. It pairs an LLM-driven news edge with a deterministic risk engine and an execution router that swaps between a paper fill-simulator, the Upstox sandbox, and live order placement by config alone.
Goal frame
Positive expectancy after costs over ≥80 trades / ≥6 weeks, then live capital starting at 20%.
Edge
News-first: announcements scored by a two-tier LLM, stress-tested by a bull/bear/judge debate before money moves.
Safety
Three-level kill switch, fractional Kelly sizing, an always-on hedge overlay, and a hard 15:12 square-off.
Four success gates (design objective)
- System runs a full market day unattended (except the daily token tap).
- Paper trading shows positive expectancy after costs over ≥80 trades / ≥6 weeks.
- Risk engine demonstrably halts trading — in drill and in anger.
- Only then: live capital, starting at 20%.
02Architecture
Three temporal phases, one execution router with four backends. Data flows top-to-bottom; the router is the single seam between strategy and broker. Gate ladder: shadow (record only, no orders) → paper (fill simulator) → sandbox (Upstox sandbox API, no fills) → live (real orders; requires NT_ALLOW_LIVE=1 env — never settable from YAML, so the self-reflection loop cannot enable live trading).
Cross-cutting design rules
Identical code path to live up to the router. Fill simulation is pessimistic — crosses the spread, adds slippage.
Lot sizes, expiries, freeze qtys, margins read from the instrument master at startup. Zero hardcoded contract math.
Any poller can die without downing the system; the combiner treats a missing source as reduced confidence, not failure.
No token → full data + signal + paper pipeline still runs. Only live orders are unavailable.
No component touches a vendor SDK. Anthropic→OpenAI→Gemini→Ollama is a config change.
Telegram notification on every order in live mode.
The shared execution seam
trade_signal() is the single entry-to-flat path for any signal: kill-check → risk plan → entry → monitor → exit. Two callers reuse it, so the gap strategy and announcement strategy share one tested core.
run_day() ──┐ derives S2 gap signal, then delegates
IntradaySession() ──┼─► trade_signal(cfg, sig, spot=…, sources=…, …)
│ kill-check → RiskEngine.plan → execute → PositionMonitor → exit
replay (SimClock) ──┘ same loop, archived store instead of live feed
03Data flow
Two flows matter most: how a single announcement becomes (or doesn't become) a trade, and how the daemon orchestrates a whole day.
Signal pipeline — announcement → trade
Daemon day-cycle
non-trading day → sleep to next 08:30
trading day:
08:45 start capture ─┐
09:10 start trade ────┼─ run concurrently (asyncio.gather), each fault-isolated
(gap + intraday)│ capture fills the store the IntradaySession polls
14:30 last new intraday entry (ops.intraday_until)
15:12 hard square-off
15:35 capture ends
15:45 EOD → render report + refine proposal → Telegram
every loop: touch var/heartbeat (healthcheck input; stale >5min = unhealthy)
The same IntradaySession loop serves live and replay. In replay, RecordedSources + SimClock serve an archived day deterministically — clock-bounded so no future data leaks into a decision. This symmetry is the M6 integration-validation guarantee.
04Decision flow
How a signal becomes a trade — or gets blocked. Two views: follow one signal through every gate, or watch a whole trading day on the clock. Pick a scenario to see the accept and block branches the code actually takes.
decisions audit table with its full rationale (M8b).05Strategy portfolio
| ID | Strategy | Status | Mechanism |
|---|---|---|---|
| S2 | GIFT-gap momentum | Live | GIFT Nifty gap >0.6% vs prior NSE close → directional ATM option buy at open |
| S1 | News-catalyst options | Live | Announcement → LLM → debate → plan; pre-open reference, spread filter, limit orders |
| S3 | 0DTE expiry momentum | Live (paper) | NIFTY weekly-expiry day (Tue) + time window + technical_signal → ATM today-expiry call/put; one entry/day; target/stop; 15:00 square-off |
| S4 | VIX-spike momentum | Live (paper) | India VIX ≥ spike_pct% above prior close → BUY_PE; latched, re-arm, daily entry cap |
| S5 | FII mega-flow | Bias filter | T-1 EOD data → combiner directional weight, not a standalone trigger |
| H1 | Hedge overlay | Always-on | Risk-layer enforced by the engine — see below |
S3 and S4 are enabled by default in config/default.yaml (paper mode — no live orders); toggle s3.enabled / s4.enabled. See Execution modes for how mode gates order placement, and Configure for every knob.
▶H1 — Hedge overlay (enforced, not optional)
- Defined-risk by default — entries above ₹15K premium are debit spreads (buy ATM, sell 1–2 OTM), not naked longs.
- Portfolio delta cap — net delta bounded ±2 lot-equivalents; breach forces opposite-delta or auto-hedge.
- Event-day protection — RBI/budget/Fed days: protective leg or size halves.
- L2 hedge-then-halt — on L2 trigger, convert positions to defined-risk before halting new entries.
- Loss-narrowing adjustment — down 20% but thesis intact → roll the short leg, journal-logged.
Overlay cost/benefit is measured, not assumed: the journal carries a pnl_unhedged_counterfactual column (what the same-size naked long would have realized) on every spread close.
06Risk engine & kill switches
Fractional Kelly sizer — kelly_fraction (0.25) × edge, capped at risk_per_trade_pct (1.5% of capital). Live win-rate/payoff from a rolling 30-day journal window; priors until 30 trades exist. Lot size and margin are runtime lookups.
| Level | Trigger | Action |
|---|---|---|
| L1 · trade | Stop-loss hit | Close the single position |
| L2 · session | Daily drawdown ≥2.5% or 3 consecutive losses | Hedge open positions → no new entries (latches for the day) |
| L3 · emergency | /halt sentinel or API-error storm (5/60s) | Cancel all → flatten → stop |
The monitor polls the halt sentinel on a 1s tick, so /halt flattens paper positions in <5s (drill-measured 0.51s). /resume clears the file, but L2/L3 latches stay latched for the session.
07Execution modes
One pipeline, four interchangeable execution backends. trading.mode (or run --mode) picks the backend, and orchestrator.run_cli resolves it through make_router() before any signal is evaluated — so an unknown or unsafe mode is rejected at the entry gate, never mid-trade.
| Mode | What it does | Orders | Requires |
|---|---|---|---|
| shadow | Full pipeline → decision → trade recorded tagged shadow. Validates news / data-source / decision quality on live data with zero execution. | None, anywhere | — |
| paper | Pessimistic fill simulator vs live bid/ask (crosses the spread, adds slippage). The campaign mode. | Simulated | — |
| sandbox | Routes real V3 order payloads to api-sandbox.upstox.com for shape validation; no fills exist there. CI / pre-live check. | Validated, no fills | UPSTOX_SANDBOX_TOKEN |
| live | Places REAL orders via Place Order V3. Every order is Telegram-alerted. | Real | trading.mode=live + NT_ALLOW_LIVE=1 + usable live token |
NT_ALLOW_LIVE is an environment-only gate — never settable from config/default.yaml. The self-reflection loop can edit YAML and code via PR but cannot set an env var, so no automated change can turn live trading on. Live needs all three — config mode live, the env flag, and a usable token; miss any one and make_router degrades to a non-placing mode.make_router(mode, …) is the single factory; an unrecognised mode resolves to UNKNOWN_MODE and degrades safely (no orders placed). Live fill reconciliation and position monitoring against real fills is M7 — not yet wired; see Roadmap.
08Install
Requirements: Python ≥3.12, git. Docker only for containerized deploy.
git clone <repo-url> marketgenie && cd neurotrade
python3.12 -m venv .venv
.venv/bin/pip install -e ".[dev,fyers]"
▶aiohttp pin trap (read this)
fyers-apiv3 pins aiohttp==3.9.3, which breaks LiteLLM (ConnectionTimeoutError). After any pip install into the venv, re-pin — the Dockerfile does this automatically:
.venv/bin/pip install 'aiohttp>=3.10'
Verify:
.venv/bin/python -m pytest tests/ -q # 541 passed
.venv/bin/ruff check src tests # clean
.venv/bin/python -m marketgenie token-status
09Deploy
Primary target. Two services share one var/ volume: daemon (capture ∥ trade → EOD, heartbeat healthcheck) and bot (Telegram long-poll). restart: unless-stopped survives reboot into data-only mode.
cp .env.example .env # fill secrets
docker compose up -d --build
Also starts Prometheus (:9090) and Grafana (:3000, anonymous viewer) — see Monitoring.
One Deployment, two containers sharing one RWO PVC at /app/var. Daemon exposes containerPort: 9100 (metrics); pod has prometheus.io/scrape: "true" annotation.
helm install nt deploy/helm/neurotrade \
--set secret.data.UPSTOX_API_KEY=... \
--set secret.data.TELEGRAM_BOT_TOKEN=...
Fresh Debian/Ubuntu VPS → running system.
cd deploy/ansible
cp inventory.example.ini inventory.ini # fill VPS host
cp vars.example.yml vars.yml # fill secrets + repo url
ansible-playbook -i inventory.ini provision.yml -e @vars.yml # provision
ansible-playbook -i inventory.ini deploy.yml -e @vars.yml # update
The Upstox access token expires 03:30 IST daily with no headless renewal. Each morning, run the OAuth login through an SSH port-forward so the browser stays on your laptop:
scripts/token_ritual.sh user@vps-host
The token lands in the VPS var/ volume. Without it, the system runs data-only by design.
10Use — CLI
python -m marketgenie <command>
| Command | Purpose |
|---|---|
login | Interactive Upstox OAuth; saves the daily token |
token-status | Print token usability |
instruments | Refresh the instrument-master cache |
run [--mode] | Run one trading day (gap + intraday); mode shadow·paper·sandbox·live (see §07) |
daemon | Day-cycle supervisor — the production process |
capture [--until] | Data-layer session capture only |
report [--date] | EOD report from the journal |
bot | Telegram command bot (long-poll) |
replay [--date --mock --limit] | Replay a day through the M3 signal layer |
replay-day [--date --mock] | Full-day replay through every layer |
drill --scenario spread|l2|l3 | Kill-switch / hedge drills (paper, no network) |
sandbox-check | Validate V3 order payloads against the sandbox |
db upgrade | Apply Alembic migrations (Postgres / fresh DB) — see Storage |
reflect [--dry-run] | EOD self-reflection agent → change-set PR (opt-in) — see §13 |
campaign --since [--until --audit-fills] | Graduation-gate metrics + fill spot-audit |
analyze <ticker> | Analyze a stock using news, technicals, and analyst consensus framework |
Telegram bot (configured chat only): /halt /resume /status /pnl /signals [ticker] (shows signals or analyzes ticker), /weights.
11Monitoring
docker compose up starts Prometheus (:9090) and Grafana (:3000, anonymous viewer) with two auto-provisioned dashboards. No extra config needed.
Metric scrape endpoints
/metrics is exposed in-process via prometheus_client. Both processes are scraped:
Trade path — signals, executions, kill-switch states, position counts, pipeline latency, feed heartbeat. Helm containerPort: 9100 + prometheus.io/scrape: "true".
Telegram command bot — command counts, latency. Lightweight; same scrape interval.
Three metric families
Timing and throughput — p50/p95 latency, feed health, order round-trip.
Business events — signals, trades, blocks. Labels carry strategy, direction, reason.
Live config values as gauges — plot actual vs limit side-by-side in Grafana.
Two Grafana dashboards
Trade/signal counts, blocked-entry reason breakdown, kill-switch state, position lifecycle, LLM latency and spend, VIX regime.
Signal-pipeline p50/p95 latency, feed heartbeat age, capture throughput, order round-trip time.
Grafana annotations from the journal
Each journal write triggers a Grafana annotation via the Annotations API:
Green annotation — signal passed all gates, order placed (paper or live), journal row written.
Red annotation — one or more signals cleared the combiner gate but were stopped by the risk engine.
blocked_entries — "could-have-traded" concept
The journal column blocked_entries counts signals that passed the ≥65% combiner gate but were blocked downstream by the risk engine or a kill switch. This is the "could-have-traded" signal: a non-zero value doesn't mean bad performance — it means the guard rails were engaged. Tracking it lets you distinguish a quiet market (no signals) from a gated market (signals, but blocked).
Gate reason codes recorded in nt_blocked_total{reason}:
Observability data-flow
12Admin console & decision audit
A read-mostly operator console (app/admin_api.py, Material 3) over the journal, plus a fault-isolated decision-audit trail. Every signal — traded or blocked — is written to the decisions table with its full rationale, so nothing the system decides is invisible.
Console (M8c)
Live kill level, day PnL, open positions and consecutive losses — fed by app/status.py writing var/status.json.
Browse the decisions, trades and signals tables with reason codes and the LLM rationale behind each call.
halt · resume · clear-latch — the same kill-switch surface as the Telegram bot, behind NT_ADMIN_TOKEN.
Decision audit (M8b)
journal/audit.py safe_log_decision() never raises — an audit-write failure cannot break the trade path. A passing signal records entered; an entry that unwinds mid-flight records abort; a rejected one records why:
size_delta_gate covers the size / delta-cap / max-positions rejections. Mode & token gating from the execution router surfaces as an upper-case block reason — LIVE_NOT_ENABLED, UNKNOWN_MODE, TOKEN_ABSENT, SANDBOX_TOKEN_ABSENT — on the same nt_blocked_total{reason} metric the dashboards aggregate.
13Self-reflection loop
Opt-in (reflect.enabled). The system proposes its own improvements — but a human and CI are always between a proposal and production.
At EOD the reflect agent (app/reflect.py) reviews the decision audit and the day's trades, then emits a validated change-set — small, evidence-backed edits to config knobs, code, or docs. Deterministic code (app/reflect_apply.py) applies it to a reflect/<date> branch and opens a Pull Request.
Market-grounded feedback
app/trade_review.py scores closed trades against what the market actually did afterwards and feeds that review back into the reflection prompt — so proposals are grounded in realised outcomes, not the model's priors alone.
Guard rails
- Never auto-merges — requires green CI and your approval.
- Protected paths (
.github/,tests/, risk & execution code) get a ⚠ HIGH-RISK banner on the PR. - Cannot enable live trading —
NT_ALLOW_LIVEis env-only (see Execution modes), outside the agent's reach. - Requires the
REFLECT_PATsecret. Dry run:docker compose run --rm daemon reflect --dry-run.
14Guides
Ten task-focused walkthroughs — four lifecycle (setup → live), six per-strategy playbooks. Each follows the same shape: what the app does → the knobs that steer it → run / observe → verify. The system trades autonomously; these show how to tune and watch it, not how to hand-place trades.
Run your first paper-trade campaign
The default, capital-free path. Everything runs; orders fill against a pessimistic simulator instead of the exchange.
- Docker + Docker Compose (or Python ≥3.12 for the non-compose path).
- The repo cloned. An Upstox account (free), an Anthropic API account, a Telegram account.
| Secret | Where to get it | Goes in |
|---|---|---|
UPSTOX_API_KEYUPSTOX_API_SECRET | Upstox developer console → create an app → set redirect URI to http://localhost:8721/callback. Copy the API key + secret. | .env |
ANTHROPIC_API_KEY | console.anthropic.com → API keys → create key. (Swap to OpenAI/Gemini/Ollama later by editing llm.providers — no key change needed if self-hosted.) | .env |
TELEGRAM_BOT_TOKEN | Telegram → message @BotFather → /newbot → copy the token. | .env |
TELEGRAM_CHAT_ID | Message your new bot once, then open https://api.telegram.org/bot<TOKEN>/getUpdates and read chat.id (or use @userinfobot). The bot serves only this chat. | .env |
cp .env.example .env # paste the four secrets above
docker compose up -d --build # daemon + bot + Prometheus + Grafana
# one-time + each trading morning: approve the Upstox token
docker compose run --rm --service-ports daemon login
# (on a remote VPS, use scripts/token_ritual.sh user@vps-host instead — it
# SSH-forwards localhost:8721 so the OAuth browser stays on your laptop)
docker compose run --rm daemon token-status # expect: usable
docker compose run --rm daemon instruments # cache the instrument master
# the daemon already runs the day cycle; to trigger a single day manually:
docker compose run --rm daemon run --mode paper
# after a few trading days, score the campaign:
docker compose run --rm daemon campaign --since 2026-06-14
token-statusprints usable.- Paper fills appear in the SQLite journal; the Telegram EOD report arrives ~15:45 IST.
campaignprints expectancy + graduation-gate status. Grafana on:3000shows live panels (Monitoring).
Promote a paper campaign to live capital
campaign has passed its graduation gates. Confirm the risk config (kelly_fraction 0.25, risk_per_trade_pct 1.5, daily_drawdown_pct 2.5) and that kill switches arm in a drill first.- A graduated paper campaign. A live-enabled Upstox trading account.
- A VPS with a registered static IP — SEBI algo rules require order APIs to call from an allowlisted IP. A laptop can read data + paper-trade but cannot place live orders.
| Setting | Where to get it | Goes in |
|---|---|---|
| Static IP allowlist | Your VPS's fixed outbound IP → register it in the Upstox app/developer settings. | broker portal |
X-Algo-Name | The algo identity required on live Place Order V3 — set per your broker's algo registration. | execution config |
| Live mode enabled | Upstox app switched from sandbox to live trading. | broker portal |
Live trading reuses the same .env secrets as paper — no new keys.
# 1. validate V3 order payload shapes against the sandbox first
docker compose run --rm daemon sandbox-check
# 2. arm + prove the kill switches in paper before risking capital
docker compose run --rm daemon drill --scenario l2
docker compose run --rm daemon drill --scenario l3
# 3. set trading.mode: live in config/default.yaml, redeploy, then:
docker compose run --rm daemon run --mode live
sandbox-checkpasses (payloads valid).- Drills show L2/L3 halting new entries. Live order rate stays ≤ 10 orders/s.
- Hard square-off fires at 15:12 IST; no entries after 14:30.
Run in data-only / research mode
Gather and replay market data without placing any orders — useful before you have a usable token, or for offline signal research. This is the system's default degraded mode (no usable Upstox token ⇒ data-only).
- Nothing required to capture. To replay with the real LLM, set
ANTHROPIC_API_KEY; otherwise use--mock(canned LLM, fully offline).
| Setting | Where to get it | Goes in |
|---|---|---|
UPSTOX_SANDBOX_TOKEN (optional) | Upstox portal → a separate 30-day sandbox token, for sandbox-check payload validation. Not needed for capture/replay. | .env |
# capture a live session's news/FII/VIX/chain to the archive
docker compose run --rm daemon capture --until 15:35
# replay a captured day through the M3 signal layer (offline, no orders)
docker compose run --rm daemon replay --date 2026-06-14 --mock --limit 25
# or replay a full day through every layer end-to-end
docker compose run --rm daemon replay-day --date 2026-06-14 --mock
- Capture writes session files to the archive DB.
- Replay prints per-announcement signal decisions (SKIP / gate / VETO outcomes) and places no orders.
Daily operation & tuning
The one daily human task, reading results, and adjusting source weights.
- A deployed instance (paper or live). SSH access to the VPS (
user@vps-host) for the remote token ritual.
No new tokens — reuse the deployed .env. You need only SSH access to the host running the daemon.
# every morning — the Upstox token expires 03:30 IST with no headless renewal.
# this forwards localhost:8721 over SSH so the OAuth browser stays on your laptop:
scripts/token_ritual.sh user@vps-host
# read the day's results (also auto-sent to Telegram at 15:45):
docker compose run --rm daemon report --date 2026-06-14
# tune: the refine agent proposes source-weight changes post-market (never auto-applied).
# review the proposal, then edit the individual weights under signals: in
# config/default.yaml — weight_news / weight_technical / weight_fii — and redeploy.
- After the ritual,
token-statusis usable for the day. - Telegram bot answers
/status,/pnl,/weights. Grafana panels (daemon:9100, bot:9101) stay live. - Applied weight changes show up in the next day's combiner output.
Operate the gap-momentum strategy (S2 · live)
S2 reads the overnight GIFT Nifty gap versus the prior NSE close. If the absolute gap clears the threshold, the app buys a directional ATM option at the open and manages it to a fixed target/stop. You don't place it — you set the threshold and exits, then watch.
config/default.yaml under trading:)| Knob | Default | Effect / how to dial |
|---|---|---|
gap_threshold_pct | 0.6 | Minimum |GIFT gap| to fire. Lower → more trades, more noise; raise → fewer, higher-conviction opens. |
target_pct | 0.25 | Exit at +25% premium. Raise for fatter winners (lower hit-rate), lower to bank quicker. |
stop_pct | 0.30 | Exit at −30% premium. Tighten to cut losers faster; widen to ride volatility. |
# dry-run a paper day so you can see the gap decision
docker compose run --rm daemon run --mode paper
# read the day's outcome (also sent to Telegram ~15:45 IST)
docker compose run --rm daemon report --date 2026-06-14
# or ask the bot intraday: /signals /pnl /status
- On a day with no qualifying gap: a decision with
reason_code = no_gap_signal— the threshold filtered it out. - On a fired day: a decision with
reason_code = enteredat the open, then an EXIT decision (target/stop) later. - Tune one knob, re-run the same day, and watch the decision flip — that's the feedback loop.
campaign before promoting (see the Go live tab).Operate the news-catalyst strategy (S1 · live)
S1 reacts to NSE/BSE announcements: an LLM analyses the news, a debate layer cross-checks it, and a combiner blends news + technical + FII signals into one confidence score. Above the gate it trades a plan (with a debit-spread filter and limit orders). You steer it by weighting the inputs and the gate.
signals:, and llm:, in config/default.yaml)| Knob | Default | Effect / how to dial |
|---|---|---|
combiner_gate | 0.65 | Min blended confidence to trade. Raise to be more selective; lower to act on weaker catalysts. |
weight_news / weight_technical / weight_fii | 0.55 / 0.30 / 0.15 | Blend weights (should sum to 1.0). Tilt toward weight_news for a stronger headline edge; toward technical for chart-led days. |
immediate_skip_confidence | 0.80 | An IMMEDIATE-tier catalyst above this skips the debate step for speed. |
fii_bias_threshold_cr | 500 | |Net FII flow| (₹cr) beyond which the FII input takes a direction. |
llm.providers.news_fast / news_deep | haiku-4-5 / sonnet-4-6 | Fast triage vs deep analysis models. Swap providers (OpenAI/Gemini/Ollama) here without touching code. |
# replay a captured announcement day through the signal layer (offline, no orders)
docker compose run --rm daemon replay --date 2026-06-14 --mock --limit 25
# the refine agent proposes weight changes post-market (never auto-applied):
docker compose run --rm daemon report --date 2026-06-14 # /weights on the bot
- A catalyst below the gate → decision with
reason_code = below_gate. - A catalyst the debate layer rejects →
reason_code = vetoed_debate(with the verdict recorded). - A trade →
reason_code = entered; the decision row carries the news tier + blended confidence.
Operate the hedge & spread overlay (H1 · always-on)
H1 is a risk overlay, not a standalone strategy: it automatically converts an expensive naked option into a defined-risk debit spread, caps net portfolio delta, and halves size on event days. It applies to whatever S1/S2 trades.
risk: in config/default.yaml)| Knob | Default | Effect / how to dial |
|---|---|---|
premium_threshold_inr | 15000 | Above this entry premium, buy a debit spread (buy ATM, sell 1–2 strikes OTM) instead of a naked option. Lower → spreads kick in sooner (cheaper, capped upside). |
delta_cap_lots | 2.0 | Max net directional exposure in lot-equivalents. Lower for a more market-neutral book. |
event_size_factor | 0.5 | Size multiplier on known event days (results/policy). 0.5 = half size. Lower to de-risk events further. |
# prove the hedge/spread + kill behaviour in a paper drill (no capital)
docker compose run --rm daemon drill --scenario l2
# watch a real day choose naked vs spread by premium
docker compose run --rm daemon run --mode paper
docker compose run --rm daemon report --date 2026-06-14
- High-premium entry → the decision's plan shows
kind = SPREAD(vsNAKED). - An entry blocked for breaching the delta cap →
reason_code = size_delta_gate. - On an event day, the logged lot size is the halved figure.
Tune position sizing — VIX regime & Kelly (risk engine)
Sizing is deterministic: a fractional-Kelly sizer scales each trade by measured edge and capital-at-risk, the VIX regime bands adjust posture, and three hard limits trip the L2 session kill. This is how you set the overall aggression of the book.
risk: and signals: in config/default.yaml)| Knob | Default | Effect / how to dial |
|---|---|---|
kelly_fraction | 0.25 | Fraction of full Kelly used. Raise for bigger size (more variance); lower to be conservative. Quarter-Kelly is the cautious default. |
risk_per_trade_pct | 1.5 | Max capital risked per trade (% of capital_inr). The sizer never exceeds this. |
daily_drawdown_pct | 2.5 | Realised daily loss that latches the L2 kill (blocks new entries). Lower for a tighter daily stop. |
max_consecutive_losses | 3 | Losing streak that latches L2. Lower to stop sooner on a bad run. |
max_open_positions | 3 | Concurrent open positions cap. |
vix_calm / vix_elevated / vix_panic | 13 / 17 / 22 | India-VIX band edges that set the volatility regime feeding sizing/posture. |
# prove the L2 / L3 kill switches latch in paper before trusting the limits
docker compose run --rm daemon drill --scenario l2
docker compose run --rm daemon drill --scenario l3
# bot: /status shows kill level + halt; admin console shows it live
- Hitting the drawdown or loss-streak limit → decisions with
reason_code = kill_l2(andkill_l3on emergency/halt). - The admin console Overview shows the current kill level, day PnL, open positions, and consecutive losses — the live view of these limits.
kelly_fraction or risk_per_trade_pct increases real drawdown. Re-run a paper campaign and confirm expectancy holds before going live.Operate the 0DTE expiry scalp (S3 · live in paper)
On a NIFTY weekly-expiry day, inside the configured window, S3 reads technical_signal and buys an ATM today-expiry call or put in the signal's direction — a momentum scalp into the theta-rich close. One entry per expiry day (latched), squared off before settlement. Enabled by default in paper (s3.enabled: true).
s3: in config/default.yaml)| Knob | Default | Effect |
|---|---|---|
enabled | true | Toggle S3. Paper-only until live graduation. |
window_start / window_end | 09:45 / 13:00 | Intraday window in which S3 may fire on an expiry day. |
target_pct / stop_pct | 0.40 / 0.30 | Take-profit / stop on premium; fall back to global trading.* when unset. |
exit_by | 15:00 | Hard square-off before ~15:30 settlement. |
# on a NIFTY expiry day, run a paper day and watch for the S3 entry
docker compose run --rm daemon run --mode paper
docker compose run --rm daemon report --date 2026-06-16
- Non-expiry day → S3 never fires (idle).
- Expiry day, inside the window, with a confirming technical signal → one
entereddecision tagged S3, then an EXIT byexit_by. - A second qualifying signal the same day is suppressed (once-per-day latch).
Operate the VIX-spike momentum strategy (S4 · live in paper)
S4 watches India VIX intraday. When VIX jumps spike_pct% or more above the prior close, it reads the move as a volatility / down-momentum impulse and buys a put (BUY_PE). The trigger latches so one spike fires once; it re-arms when VIX settles back, bounded by a daily entry cap. Enabled by default in paper (s4.enabled: true).
s4: in config/default.yaml)| Knob | Default | Effect |
|---|---|---|
enabled | true | Toggle S4. Paper-only until live graduation. |
spike_pct | 10.0 | % jump in India VIX above prior close that arms a BUY_PE entry. Raise to react only to sharper spikes. |
max_entries | 2 | Daily cap on S4 entries. |
target_pct / stop_pct / exit_by | fallback | Unset by default → inherit the global trading exits. |
docker compose run --rm daemon run --mode paper
docker compose run --rm daemon report --date 2026-06-16
# bot: /signals shows the live VIX-spike trigger state
- Calm VIX day → S4 idle.
- VIX crosses the spike threshold → one
entereddecision tagged S4 (BUY_PE); the trigger latches. - Further spikes the same day are bounded by
max_entries; the latch re-arms only after VIX settles.
15Configure
Configuration is config/default.yaml → pydantic Settings. Secrets always come from environment variables, never the file.
Secrets (env / .env)
UPSTOX_API_KEY=... UPSTOX_API_SECRET=...
FYERS_CLIENT_ID=... FYERS_SECRET_KEY=... FYERS_PIN=...
ANTHROPIC_API_KEY=... TAVILY_API_KEY=... # Tavily = web-research (opt-in)
TELEGRAM_BOT_TOKEN=... TELEGRAM_CHAT_ID=... # bot serves ONLY this chat
UPSTOX_SANDBOX_TOKEN=... # separate 30-day portal token
NT_ADMIN_TOKEN=... REFLECT_PAT=... # admin console · self-reflection PRs
NT_BASE_DOMAIN=... CLOUDFLARE_API_TOKEN=... # Caddy reverse proxy (subdomain TLS) — opt-in
# NT_ALLOW_LIVE=1 → env-only live-trading gate (NEVER in YAML — see §07)
Where to get each value — provider setup
Every value below is obtained manually from the provider, then pasted into .env. The When needed column tells you which are mandatory vs opt-in.
| Provider · value(s) | Where to get it | When needed |
|---|---|---|
UpstoxUPSTOX_API_KEYUPSTOX_API_SECRET | Upstox developer console → create an app → set redirect URI http://localhost:8721/callback → copy the API key + API secret. The OAuth access token is minted at runtime by docker compose run --rm --service-ports daemon login (not stored in .env). | Always (data + paper/live) |
Upstox sandboxUPSTOX_SANDBOX_TOKEN | Upstox portal → generate a separate 30-day sandbox token. | Only for sandbox-check |
FyersFYERS_CLIENT_IDFYERS_SECRET_KEYFYERS_PIN | myapi.fyers.in → Dashboard → Create App (App name + Redirect URL) → copy App ID (→ FYERS_CLIENT_ID, e.g. L9NY305RTW-100) + Secret ID (→ FYERS_SECRET_KEY). FYERS_PIN is your Fyers trading/login PIN. | If using the Fyers feed/capture |
TelegramTELEGRAM_BOT_TOKENTELEGRAM_CHAT_ID | Message @BotFather → /newbot → copy the token. Then message your bot once and read chat.id from https://api.telegram.org/bot<TOKEN>/getUpdates (or use @userinfobot). The bot serves only this chat. | Bot reports / EOD |
Anthropic (default LLM)ANTHROPIC_API_KEY | console.anthropic.com → Settings → API keys → Create key. Prefix sk-ant-. (Comma-separate multiple keys via MARKETGENIE_LLM_KEYS_ANTHROPIC for rotation.) | Real-LLM runs (else --mock) |
OpenAI (alt LLM)MARKETGENIE_LLM_KEYS_OPENAI | platform.openai.com → API keys → Create new secret key. Prefix sk-. | Only if a role routes to openai |
Tavily (web research)TAVILY_API_KEY | app.tavily.com → sign in → copy an API key from the dashboard. Prefix tvly-; 1,000 free credits/month, no card. | Only if research.enabled=true |
GitHub (self-reflection PRs)REFLECT_PAT | github.com → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate → scope to this repo → Repository permissions: Contents: Read and write + Pull requests: Read and write. Prefix github_pat_. | Only if reflect.enabled |
Cloudflare (Caddy proxy)CLOUDFLARE_API_TOKENNT_BASE_DOMAIN (user-defined) | dash.cloudflare.com → My Profile → API Tokens → Create Token → permissions Zone → DNS → Edit + Zone → Zone → Read, scoped to your zone (e.g. abysmallab.in). Set NT_BASE_DOMAIN to the base domain. Also create DNS A records grafana + admin → your VPS IP. | Only for the reverse proxy |
Admin console (self-generated)NT_ADMIN_TOKEN | No provider — generate one: openssl rand -hex 32. The admin API refuses to start if admin.enabled and this is empty. | If admin API enabled |
Sections & defaults
| Section | Key defaults |
|---|---|
trading | mode: paper · capital_inr: 1500000 · gap_threshold_pct: 0.6 |
llm | per-role {provider, model} · daily_budget_inr: 100 |
signals | combiner_gate: 0.65 · top_k_premarket: 5 · immediate_skip_confidence: 0.80 · weights news .55 / tech .30 / fii .15 |
risk | kelly_fraction: 0.25 · risk_per_trade_pct: 1.5 · daily_drawdown_pct: 2.5 · premium_threshold_inr: 15000 · history_days: 30 |
execution | trail_activate_pct: 0.10 · trail_pct: 0.15 · squareoff: 15:12 · orders_per_sec: 10 |
ops | capture_start: 08:45 · trade_start: 09:10 · intraday_until: 14:30 · eod_at: 15:45 |
obs | enabled: true · daemon_port: 9100 · bot_port: 9101 |
s3 · s4 | 0DTE + VIX-spike strategies — enabled: true (paper). Keys below. |
news_feed · research · reflect | All enabled: false by default — see Intel & Self-reflection. |
journal · marketdata | db_path + optional url (Postgres) — see Storage |
LLM provider routing
llm:
providers:
news_fast: {provider: anthropic, model: claude-haiku-4-5}
news_deep: {provider: anthropic, model: claude-sonnet-4-6}
debate_bull: {provider: anthropic, model: claude-haiku-4-5}
debate_bear: {provider: anthropic, model: claude-haiku-4-5}
debate_judge: {provider: anthropic, model: claude-sonnet-4-6}
refine: {provider: anthropic, model: claude-haiku-4-5}
reflect: {provider: anthropic, model: claude-sonnet-4-6}
# research: {provider: anthropic, model: claude-sonnet-4-6} # add when enabling research
daily_budget_inr: 100
Swap any role to OpenAI / Gemini / Ollama by changing provider + model — no code change. Override any of this from the environment without editing the repo — see Intel → LLM env routing.
S3 / S4 strategy knobs
s3: # 0DTE expiry-day momentum (NIFTY weekly expiry only)
enabled: true
window_start: "09:45"
window_end: "13:00"
target_pct: 0.40
stop_pct: 0.30
exit_by: "15:00" # hard square-off before ~15:30 settlement
s4: # VIX-spike momentum → BUY_PE
enabled: true
spike_pct: 10.0 # % jump in India VIX vs prior close that arms an entry
max_entries: 2 # daily cap (latches per spike, re-arms when VIX settles)
S4's target_pct/stop_pct/exit_by are unset by default and fall back to the global trading exits; each s3.* exit key likewise falls back when unset.
16Intel — news, research & LLM routing
Three opt-in-OFF units that widen and harden the information edge. All default disabled; enable per unit in config/env.
news_feed — RSS poller. Items normalise to NewsItem and are scored by the existing two-tier NewsAgent. Enable: news_feed.enabled: true + feeds: [...].
research — pre-open web-search grounding (Tavily). Runs once pre-market; the pipeline reads cached ResearchFindings via the evidence bundle — no live intraday search. Budget-gated + fault-isolated. max_searches: 3; needs TAVILY_API_KEY.
Env-only overrides + a credential pool — change models or keys without editing the repo. Precedence: per-role env > global env > YAML.
LLM env overrides & credential pool
The CredentialPool rotates comma-separated keys round-robin and fails over to the next provider when a provider's keys are exhausted; on full exhaustion the pipeline degrades to raw confidence rather than failing.
# Global kill-switch (all roles → one model):
MARKETGENIE_LLM_PROVIDER=anthropic
MARKETGENIE_LLM_MODEL=claude-haiku-4-5
# Per-role escape (role uppercased, hyphens → underscores):
MARKETGENIE_LLM_NEWS_DEEP_PROVIDER=anthropic
MARKETGENIE_LLM_NEWS_DEEP_MODEL=claude-opus-4-8
# Credential pool — comma-separated keys; round-robin, fail over on exhaustion:
MARKETGENIE_LLM_KEYS_ANTHROPIC=key1,key2
MARKETGENIE_LLM_KEYS_OPENAI=key1
# Cross-provider fallback order when all keys for a provider are exhausted:
MARKETGENIE_LLM_FALLBACK=anthropic,openai
Spend stays bounded by llm.daily_budget_inr. The YAML route blocks live in Configure.
17Storage
Two stores — the journal (signals, trades, positions, debates, decisions) and marketdata (ticks, candles, snapshots, events). Both run on SQLAlchemy Core; the dialect is chosen by URL, so the same code serves SQLite and Postgres.
| Store | Default | Postgres |
|---|---|---|
journal | SQLite (WAL) at var/journal.db | set journal.url or MARKETGENIE_JOURNAL_DB_URL |
marketdata | SQLite (WAL) at var/marketdata.db | set marketdata.url or MARKETGENIE_MARKETDATA_DB_URL |
# point at Postgres, then create the schema:
export MARKETGENIE_JOURNAL_DB_URL=postgresql+psycopg://user:pass@host/db
python -m marketgenie db upgrade # Alembic migrations on a fresh database
storage/factory.py picks the store from config; storage/engine.py builds a dialect-correct engine (SQLite WAL vs a Postgres pool); storage/schema.py defines the Core tables. SQLite needs zero setup and is the default for paper campaigns; Postgres is for durable, multi-process or cloud deploys.
18Module explorer
A curated map of the system's Python modules across 12 architectural layers — app, data, signals, risk, execution, llm, broker, market, journal, storage, research and obs. Filter by layer or search above.
19Tune
All knobs live in config/default.yaml — edit + restart, no redeploy logic.
| Goal | Knob | Effect |
|---|---|---|
| More / fewer signals | signals.combiner_gate | Lower → more trades, lower avg confidence |
| Reweight sources | weight_news / weight_technical / weight_fii | Set from measured hit-rates (refine agent) |
| Position size | risk.kelly_fraction · risk_per_trade_pct | Lower = more conservative |
| Naked vs spread | risk.premium_threshold_inr | Above → debit spread (H1-1) |
| Drawdown halt | daily_drawdown_pct · max_consecutive_losses | L2 trip thresholds |
| Trailing exit | trail_activate_pct · trail_pct | Arm point and trail width |
| Entry/exit times | execution.squareoff · ops.intraday_until | Last exit / last entry |
| LLM cost | llm.daily_budget_inr | Exhausted → fall back to raw confidence |
Weight tuning is evidence-driven: the refine agent scores each source's hit-rate from the journal and proposes new weights after each session. Proposals are never auto-applied — review via /weights or the EOD report, then edit config manually.
20Testing & validation
.venv/bin/python -m pytest tests/ -q # 541 passed, 2 skipped, 6 deselected (live)
.venv/bin/python -m pytest tests/integration/ -q # cross-component + full-day E2E
.venv/bin/python -m pytest -m live # network tests (needs token)
.venv/bin/ruff check src tests
- TDD throughout — every feature shipped failing-test-first.
- tests/integration/ is the M6 entry-condition gate: a synthetic full session through pollers → agents → debate → combiner → risk → paper fills → journal, output asserted.
- replay-day --mock runs the same path on canned LLM output, gating on journal consistency (all positions closed, no orphaned legs, failures surfaced).
- Drills exercise the kill switches in isolation, no network.
- Fault-isolation test —
test_server_bind_failure_does_not_raise: verifies that a second metrics server on an occupied port logs and never raises, keeping the trade path safe.
21Roadmap & milestones
M0 · Foundations
Repo, config, LLM abstraction, Upstox OAuth, instrument master, calendars.
M1 · Vertical slice
S2 gap signal → paper fill → journal → EOD report.
M2 · Data layer
MarketFeed + failover, pollers, candle archive, capture.
M3 · Signal layer
Technical + news agents, debate layer, combiner, VIX filter.
M4 · Risk + execution
Kelly sizer, H1 overlay, kill switches, order manager, monitor.
M5 · Ops + deployment
Telegram, daemon, Dockerfile, Compose, Helm, Ansible.
M6 · Validation (entry complete)
Integration E2E + campaign tooling. Daemon trades S1 + S2 live.
Observability · metrics + dashboards
prometheus_client, three metric families, two Grafana dashboards, journal annotations, Helm scrape port, fault-isolation test.
Execution modes
shadow / paper / sandbox / live router via make_router; env-only live gate (NT_ALLOW_LIVE).
Intel · news + research + LLM env
RSS news feed, pre-open web-search grounding, env LLM overrides + credential-pool rotation/failover.
S3 + S4 strategies
0DTE expiry momentum + VIX-spike momentum; opt-in, enabled in paper.
M8a · Storage abstraction
SQLAlchemy Core journal + marketdata; SQLite WAL default, Postgres via URL + Alembic.
M8b · Decision audit
Fault-isolated decisions table — every trade and block logged with reason code + rationale.
M8c · Admin console
Material 3 operator console: status, audit/trades/signals, halt/resume/clear-latch.
M8d · Self-reflection loop
EOD agent proposes config/code/doc changes via PR; CI + human gated; market-grounded.
M7 · Live graduation
20% capital, every order alerted, 2-week manual review — gated on prerequisites.
M6 → M7 graduation gates (measured by campaign)
- Positive expectancy after costs over ≥80 trades / ≥6 weeks
- Max daily drawdown ≤2.5% · zero L3 events from system defects
- News→order p95 <20s · paper fills spot-audited against market prints
- AlgoTest backtests don't contradict paper
UPSTOX_SANDBOX_TOKEN · Telegram tokens · Mumbai VPS with registered static IP · verified 2026 NSE holiday list.22Verified market facts
Every load-bearing market/API claim was re-verified 2026-06-10 against primary sources (SEBI/NSE circulars, live API probes, instrument masters).
Nifty 65, Bank Nifty 30 (changed 3× since Nov-2024). Always a runtime lookup, never hardcoded.
Only two exist: Nifty (NSE, Tuesday), Sensex (BSE, Thursday). NSE↔BSE swapped 1-Sep-2025.
Short ATM Nifty ≈ ₹1.74L; +2% ELM on expiry-day shorts. ₹15–25L → ~5–8 short lots → selling defaults to spreads.
Registered static IP, X-Algo-Name header, ≤10 orders/sec. Live orders require a Mumbai VPS.
Only 7 order-payload APIs; no market data, no fills. Paper trading is our own fill simulator vs live quotes.
Expires 03:30 IST daily; no headless renewal → one human approval tap each morning.