Yash Arya, technical lead at EdgeTest
How EdgeTest works, and how I check it is right.
A backtesting, market-data and paper-trading platform for NSE equities and index options. These notes cover the data, the engines, the cost model, and the bugs I found by testing my own work. Counts were measured from the codebase on 11 Oct 2026.
46K
lines of Python across 121 backend modules, excluding tests
57K
lines of TypeScript in the frontend
72
tables in the production TimescaleDB database
1,859
tests the suite collects, before four database-dependent files
v9
engine version, bumped whenever simulation semantics change
16 / 16
published payoff identities reproduced through the full engine
Architecture
One request path from the browser to the database, with the engine logic in the API and the workers, and the same database serving the live pages, the backtests and the nightly pipeline.
Cloudflare
The only route to the origin. nginx restores the visitor's real IP and refuses anything that did not come through it.
nginx
Reverse proxy in front of the API.
Next.js 14 frontend
React 18, TypeScript, Tailwind, lightweight-charts for price and equity curves.
FastAPI service
Python on gunicorn workers behind nginx, Pydantic schemas. The strategy language is validated here before anything runs.
Celery workers
Backtests, nightly data pollers, daily snapshots, the 08:15 IST signal email.
Redis
Task broker, price cache and the shared snapshot, so no two workers disagree.
TimescaleDB on PostgreSQL
72 tables. Hypertables for bars and F&O rows, planning 12.8x faster after re-chunking.
What runs when
08:15 IST, weekdays
Signal digest email, pre-rendered the evening before
20:00 IST, weekdays
Daily snapshots of Market Today, Options Today and Bulk Deals, plus 215 F&O stocks (ATM implied vol, realized vol, PCR, max pain, expected move)
20:30 IST, nightly
Cache warm for all ~210 F&O stock pages, so the first visitor never waits
Nightly
Index and options data pollers with "not published yet" handling and day-level gap detection
Data
Backtests are only as honest as their data. The platform holds the series below, refreshed through the last completed session, and handles the awkward parts of Indian market history explicitly.
| Dataset | Rows | Range | Coverage and notes |
|---|---|---|---|
| Equity, daily, split and bonus adjusted (core set) | ~2.0M | 1991-01-02 to 2026-10-06 | 494 symbols traded on the latest day; about 340 have 15+ years |
| Equity, daily, every listed stock | 5.49M | 2016-01-01 to 2026-10-06 | 3,430 symbols traded on the latest day; 1,017 split events applied on read |
| ETFs, REITs and InvITs, daily | 254K | 2016-08-26 to 2026-10-06 | 376 instruments: 348 ETFs, 22 InvITs, 6 REITs |
| F&O end of day | 30.6M | 2016-01-01 to 2026-10-06 | 13.9M index option rows and 16.4M stock option rows (stock options from 2024) |
| Implied volatility and Greeks | 6.55M | NIFTY from 2016-01-01 | Black-Scholes, implied vol solved with Brent's method, computed in-house and recomputed nightly |
| NIFTY options, 1-minute | ~58.2M | 2024-01-01 to 2025-09-30 | 435 sessions, 18,066 contracts, 93 expiries |
| BANKNIFTY, FINNIFTY, MIDCPNIFTY options, 1-minute | ~64.8M | 2024-01 to 2025-09 | 33,146 contracts |
| Equities, 1-minute | ~72.1M | 2024-01-01 to 2025-09-30 | 477 symbols |
| Fundamentals, quarterly | 41,146 | 2017-03-31 to 2026-06-30 | 2,308 companies; 36 point-in-time metrics derived from these |
| Corporate actions | 23,011 | ex-dates 2016 to 2026 | 2,542 symbols, including announced future events |
| India VIX | 2,664 | 2016-01-01 to 2026-10-06 | Used for entry filters on options strategies |
Equity, daily, split and bonus adjusted (core set)
- Rows
- ~2.0M
- Range
- 1991-01-02 to 2026-10-06
- Coverage and notes
- 494 symbols traded on the latest day; about 340 have 15+ years
Equity, daily, every listed stock
- Rows
- 5.49M
- Range
- 2016-01-01 to 2026-10-06
- Coverage and notes
- 3,430 symbols traded on the latest day; 1,017 split events applied on read
ETFs, REITs and InvITs, daily
- Rows
- 254K
- Range
- 2016-08-26 to 2026-10-06
- Coverage and notes
- 376 instruments: 348 ETFs, 22 InvITs, 6 REITs
F&O end of day
- Rows
- 30.6M
- Range
- 2016-01-01 to 2026-10-06
- Coverage and notes
- 13.9M index option rows and 16.4M stock option rows (stock options from 2024)
Implied volatility and Greeks
- Rows
- 6.55M
- Range
- NIFTY from 2016-01-01
- Coverage and notes
- Black-Scholes, implied vol solved with Brent's method, computed in-house and recomputed nightly
NIFTY options, 1-minute
- Rows
- ~58.2M
- Range
- 2024-01-01 to 2025-09-30
- Coverage and notes
- 435 sessions, 18,066 contracts, 93 expiries
BANKNIFTY, FINNIFTY, MIDCPNIFTY options, 1-minute
- Rows
- ~64.8M
- Range
- 2024-01 to 2025-09
- Coverage and notes
- 33,146 contracts
Equities, 1-minute
- Rows
- ~72.1M
- Range
- 2024-01-01 to 2025-09-30
- Coverage and notes
- 477 symbols
Fundamentals, quarterly
- Rows
- 41,146
- Range
- 2017-03-31 to 2026-06-30
- Coverage and notes
- 2,308 companies; 36 point-in-time metrics derived from these
Corporate actions
- Rows
- 23,011
- Range
- ex-dates 2016 to 2026
- Coverage and notes
- 2,542 symbols, including announced future events
India VIX
- Rows
- 2,664
- Range
- 2016-01-01 to 2026-10-06
- Coverage and notes
- Used for entry filters on options strategies
The three 1-minute sets add up to the 195M minute bars on the resume (58.2M + 64.8M + 72.1M).
Data-quality rules
Lot sizes are resolved per contract
NIFTY went 75, 50, 25, 75, 65 over the period and BANKNIFTY has eight regimes. The exchange's own data is trusted only when it is internally consistent, and grandfathered contracts keep the old lot. Pinned at 21 circular boundaries.
Phantom expiries are excluded
63 re-dated expiries are removed by a liveness test (a contract has to actually trade). Option and futures monthlies can differ: in April 2025 the option expiry was the 30th and the future's the 24th. September 2025 carried a futures expiry that had stopped trading on 31 July. Both are filtered the same way.
Weekly eras are per index
NIFTY weeklies start 2019-02-14. BANKNIFTY ran 2016-06-02 to 2024-11-13. FINNIFTY ran 2021-01-14 to 2024-11-19. NIFTYNXT50 never had weeklies. Outside a weekly era the engine trades the monthly contract and labels it per trade.
Corporate actions sit behind an approval gate
Adjustments are applied only after a human approves them. For the all-market data, splits and bonuses are applied on read (1,017 events), so the stored prices stay as the exchange printed them.
Fundamentals are point-in-time, by filing date
A stock is judged on what had been published by the decision day. Eligibility is re-decided every quarter using only filings whose filing_date falls on or before that day, and a stock's entry signals are masked on days it was not eligible. 98.3% of rows carry a real filing date, and the lag from period end to filing is 34 to 51 days in every year from 2018, which is the genuine reporting rhythm.
The point-in-time limit is stated
Filing coverage only becomes dense in 2021 (1,494 filings against 52 in 2020), and a trailing-twelve-month metric needs four consecutive quarters. So an honest filter serves backtests from about 2022. Earlier windows still run, with a warning shown to the user instead of a silently degraded answer.
Limits are disclosed, not hidden
Index constituents are current members only, so survivorship bias is stated on the results page itself. Coverage limits sit next to the numbers they affect.
Equity engine
The rules that decide what a stock backtest does, and, just as important, what it refuses to do.
No look-ahead
A condition seen at the close of day T fills at the open of day T+1.
Chronology decides precedence
A stop that fires during day T wins because it happened first. A pending signal exit fills at the next open before that bar's stop.
Gap-through stops fill at the open
If price gaps through a stop or target, the fill is the open, not the level you hoped for.
A real cash-and-holdings portfolio
It cannot overdraw, and the curve is a daily mark-to-market, so drawdown is bounded by construction. Signals dropped for want of cash, and positions opened smaller than asked, are counted and reported.
Liquidity decides who gets a slot
When more signals fire than there are slots, candidates are ranked most liquid first, by a 20-day trailing average of rupee turnover shifted one bar.
Open positions are not dressed up
Positions still open at the end close at the last price, are badged Still Open at End, and are charged no sell-side cost they never paid.
Rules are a small language
Any of 62 indicators, 7 operators (crosses_above, price_above_indicator and so on), left-to-right AND/OR, 15 tunable sub-parameters, and the same language on the exit side. A strategy with a stop but no way to close on profit is refused.
Ranked baskets
Hold the best N of a universe, re-decided weekly to quarterly, using 7 price and 19 fundamental factors with percentile ranking. 65 universes plus 22 industries.
Options engine
24 templates plus a leg builder, over ten years of exchange data on five index underlyings, with a daily mode and a 1-minute mode.
Eleven ways to choose a strike
| Mode | Meaning |
|---|---|
| strike_type | ATM, or N strike steps out |
| pct_of_atm | Offset as a percent of spot |
| premium_closest | Strike whose premium is nearest a target |
| premium_gte | Cheapest strike with premium at least X |
| premium_lte | Dearest strike with premium at most X |
| premium_range | Nearest the midpoint of a premium band |
| delta_closest | |delta| nearest a target |
| delta_range | |delta| inside a band |
| straddle_width | Offset as N times the ATM straddle premium |
| atm_straddle_pct | Offset as a percent of the ATM straddle premium |
| synthetic_future | ATM taken from the put-call-parity synthetic future |
strike_type
- Meaning
- ATM, or N strike steps out
pct_of_atm
- Meaning
- Offset as a percent of spot
premium_closest
- Meaning
- Strike whose premium is nearest a target
premium_gte
- Meaning
- Cheapest strike with premium at least X
premium_lte
- Meaning
- Dearest strike with premium at most X
premium_range
- Meaning
- Nearest the midpoint of a premium band
delta_closest
- Meaning
- |delta| nearest a target
delta_range
- Meaning
- |delta| inside a band
straddle_width
- Meaning
- Offset as N times the ATM straddle premium
atm_straddle_pct
- Meaning
- Offset as a percent of the ATM straddle premium
synthetic_future
- Meaning
- ATM taken from the put-call-parity synthetic future
Up to 10 legs, each its own risk unit
Buy or sell, call or put or a futures leg, strike rule, lots, and an expiry slot (current, next, or nearest monthly, which is what makes calendars, diagonals and covered calls on weeklies possible). Each leg can carry its own take-profit, stop multiple and trailing stop.
Exits that compose
Take profit, stop as a multiple of premium, max hold, close N days before expiry, overall rupee stop and target, square-off complete or partial, move survivors to break-even, lock profit with an optional ratchet, and re-entry with four trigger types.
Settlement against the true cash close
Hold-to-expiry settles at intrinsic value against the cash index close. The futures price was used earlier and was off by 55 points on average. Calendars and diagonals settle the expiring leg at intrinsic and sell the far leg at that day's market price.
Margin gates entry
A cycle the account cannot fund is not taken, and the count of skipped cycles is reported. Entry filters include an India VIX band and a 52-week ATM-weighted IV rank band.
Undefined payoffs are withheld
Payoff curves for two-expiry structures are not defined, so the report withholds them and says why, instead of drawing a plausible-looking line.
Stressed days still trade
If an exact strike did not trade on the entry day, the engine uses the nearest strike that did (within 2 steps) instead of dropping the cycle. This stopped months like March 2020 vanishing from backtests.
Minute resolution
Entry triggers
Time, opening-range breakout, a percent move from the entry-time level, or an indicator signal on minute bars, evaluated on the index or on the ATM straddle's own premium.
Exit on signal flip
Close the first minute the entry signal stops being true, for example the SuperTrend-on-straddle exit.
Measured fills, not bar prices
The default fill crosses the bid-ask spread measured for that premium band, days to expiry and time of day (546 measured estimates). The optimistic bar-price fill is available but is not the default.
Speed
A 1-year minute straddle run went from 188 s to 25.5 s, byte-identical on 12 setups.
Costs and margin
Every charge is itemised, dated and checked. Assumptions such as slippage are kept apart from the charges the exchange and broker actually levy.
- BrokerageRs 480.00
- STTRs 86.00
- Exchange chargesRs 40.62
- SEBI feesRs 0.11
- Stamp dutyRs 2.00
- GSTRs 93.73
TotalRs 702.46
Equity delivery
Brokerage Rs 0. STT on both sides. Exchange charge, dated (revised four times since 2021). SEBI fee of Rs 10 per crore. Stamp duty on the buy only. GST at 18% on brokerage, exchange and SEBI, never on STT or stamp. DP charge of Rs 15.34 per sale, which dominates small positions.
Options
Brokerage Rs 20 per order, and each leg is an order. STT on the sale, dated (0.0625%, then 0.10%, then 0.15%). STT on exercise for bought legs that finish in the money. Exchange charge on premium turnover (0.0495%, then 0.03503%, then 0.03553%). A leg that lapses worthless is charged nothing on exit.
Futures legs
Rs 20 or 0.03%, whichever is lower. STT on contract value across four regimes (0.01%, 0.0125%, 0.02%, 0.05%). Exchange fee about 20 times smaller than options. Stamp 0.002%.
Slippage assumptions
| Instrument | Assumption |
|---|---|
| Equity, Nifty 50 names | 0.07% per side |
| Equity, everything else | 0.20% per side |
| ETFs, REITs, InvITs | By measured liquidity in the tested window: 0.07% above about Rs 100 cr daily turnover, 0.20% above Rs 1 cr, 0.50% below |
| Options | By |delta|: ATM 0.10%, near-OTM 0.20%, far-OTM 0.50%. On minute runs the measured spread replaces this percentage, never adds to it |
| Futures | 0.002% per side |
Equity, Nifty 50 names
- Assumption
- 0.07% per side
Equity, everything else
- Assumption
- 0.20% per side
ETFs, REITs, InvITs
- Assumption
- By measured liquidity in the tested window: 0.07% above about Rs 100 cr daily turnover, 0.20% above Rs 1 cr, 0.50% below
Options
- Assumption
- By |delta|: ATM 0.10%, near-OTM 0.20%, far-OTM 0.50%. On minute runs the measured spread replaces this percentage, never adds to it
Futures
- Assumption
- 0.002% per side
Margin
Calibrated on a broker's own calculator
SPAN plus exposure, measured against Zerodha's margin calculator twice. Defined-risk structures were 11 to 15 times too light before the rebuild and are now within about 7%.
Futures margin measured, not guessed
11.34% of notional: 9.34% SPAN plus 2.00% exposure.
Charges and assumptions stay apart
Every trade carries statutory, brokerage and slippage costs separately, so the report shows what was paid and what was assumed.
Reproducibility
Run the same strategy twice and you should get the same answer, with a receipt that proves it. Most backtesting tools cannot say that.
Pin
A run's window ends at the last completed session in the data, never at "today". A run at 11 am and one at 9 pm cannot span different data through no fault of the user. Historical end dates pass through untouched; the pin only ever clamps down, so it can never invent bars.
Fingerprint
A hash over the resolved inputs that determine the answer: engine version, cost-model version, data as-of date, resolved window, universe hash and config hash. It covers inputs only, never outputs, so the same fingerprint with different metrics is a bug alarm instead of noise.
Replay
Any report can be re-run exactly, and returns a verdict of reproduced, or exactly which input moved.
Byte-identical regression
Changes that should not move results are proven not to. In one release nine options shapes over three years were byte-identical to the previous engine, equity results were byte-identical on four shapes after the slippage change, and the minute-run speedup was byte-identical on 12 setups.
A boundary that is easy to get wrong
The equity loader is half-open (the session date is excluded) and the options path is inclusive. One function, engine_end(), is the only place that difference is expressed, so callers ask by mode instead of reasoning about it.
Bugs found by checking
The most useful thing a quant developer can show is how they catch their own mistakes. These were found by testing against independent references, not by reading the code.
01
A covered call with the wrong sign
Sixteen named strategies were run through the full engine on real prices and checked against the payoff identity a broker publishes for each. The first pass was 15 of 16. A weekly covered call reported a max profit of -51,286 where the published identity gives +9,964. Three places decided "does this structure span two expiries" by hand-testing one slot name; adding a third slot satisfied none of them. The fix is one predicate used everywhere, plus a test that fails if anyone writes the check by hand again. Code review had not caught it. Running the formula did.
02
The same strategy, different answers
The default end date was evaluated once, at import time, so any run that did not name an end date was silently cut off at the API worker's boot date. Two workers restarted on different days gave different answers to the same request. The frontend also sent a UTC date, so between 00:00 and 05:30 IST it asked for yesterday. This is what the pin and fingerprint above exist to prevent.
03
A breakout that could never fire
The Donchian channel had no one-bar shift, so it contained today's own high and "close above the upper band" was impossible: 0 fires in 12,275 bar-observations, which made three shipped breakout presets dead. After the fix it fires 198 times on RELIANCE alone. The same audit corrected 27 of the 62 indicators.
04
Indicators that started trading too early
Several indicators emitted values from bar 0, so a "200-period" line was computed from 3 bars. Cold-start error reached 233% on ADX and 2,692% on TRIX. Because the engine loads no warm-up, bar 1 is a live trading decision: 15 of 218 trades on a measured ADX strategy entered before ADX was warm, six of them on the second bar. A +DI reading of 148.5 (a percentage above 100) came from numerator and denominator seeded from different bars. RSI returned NaN at exactly 100, so "RSI above 70" was false in the most overbought state possible.
05
A test suite that could not fail
Mutation testing removed the %D smoothing from the stochastic oscillator entirely, and all 641 tests still passed. The indicator library had no arithmetic tests at all. It does now, and the practice stuck: new guards are mutation-checked, and several test drafts that turned out to be vacuous were thrown away.
06
A 2016 entry chosen with 2026 financials
The first fundamental filter took each company's latest filing and applied it across the whole backtest window, which is the strongest form of look-ahead there is: "which companies screen as cheap and profitable today" is very close to "which companies did well", so testing whether they did well is nearly circular. The fix re-decides eligibility every quarter from filings published by that day. A column stamped during a backfill would have shown filings bunched in 2026 with absurd lags, which is how the four unusable 2017 rows were spotted.
07
Two devices, two answers
Production runs two gunicorn workers, each with its own module-level cache and its own refresh clock, and nginx load-balances with no session affinity. Forty requests to one endpoint returned two distinct as_of timestamps, split 26 and 14, which is how a laptop and a phone showed different numbers for the same page. One shared snapshot in Redis fixed it: 2 distinct snapshots before, 1 after.
08
Days the exchange was shut
The options equity curve was built on every business day. Measured on a 2024 to 2025 run, 30 of 523 points (5.7%) were exchange holidays, each a flat zero-return day that never happened, which understated volatility and moved Sharpe. The curve now uses real trading sessions.
09
Margin that was 11 to 15 times too light
The first margin gate understated defined-risk structures against the broker's own SPAN calculator. Rebuilding it and measuring against that calculator brought the error to about 7%.
AI layer
Plain English in, validated strategy out, with a hard limit on what the model can cost and what it can say.
Language in, validated rules out
A forced tool call turns a sentence into the strategy schema, so the model returns a structured config validated by the same Pydantic schema as a hand-built strategy. A mode missing its inputs, or an impossible band, is refused with a plain sentence and never silently defaulted. The tool definition is sorted so it is byte-stable across restarts and the prompt cache is not invalidated.
Prompt caching, accounted for
Prompts use cache_control with 5-minute and 1-hour TTLs, and the spend ledger prices cache writes (1.25x and 2x) and cache reads (0.1x) separately. An earlier version left the cached route unrecorded, so spend was understated and the gate saw only a fraction of the bill; that was found and fixed.
A spend gate, not just a ledger
A daily ceiling for the whole platform and another per signed-in user turn the ledger into a gate. Sums are cached for a minute, so the worst case is a small overshoot, not a runaway bill.
Bounded by design
No recommendations, no price targets and no promised returns, in any phrasing. The product is analysis only: no broker connection and no order routing.
Engine versions
ENGINE_VERSION is part of every run's fingerprint. Bumping it is the honest way to say that results from before that point are not comparable.
| Version | What changed |
|---|---|
| v5 | An end-of-window position is no longer charged a sell-side cost it never paid, so the equity trade log now sums exactly to the reported final value. |
| v6 | The options equity curve is built on real trading sessions instead of every business day (see holidays above). Sharpe moves; trades, P&L, CAGR and drawdown depth do not. |
| v7 | The indicator audit: 27 of 62 indicators corrected across warm-up, the Donchian shift, Wilder smoothing consistency and canonical bounds. |
| v8 | Instrument slippage is tiered by measured liquidity instead of one rate for every ETF, InvIT and REIT. Equity results proven byte-identical on four shapes. |
| v9 | An options leg whose exact strike did not trade now uses the nearest strike that did. Measured on 6 shapes and 845 trades from 2016 to 2026: no existing trade changed, and previously skipped cycles (3 of 126 on a monthly 5% OTM put-write, including 12 Mar 2020) are now filled. |
v5
- What changed
- An end-of-window position is no longer charged a sell-side cost it never paid, so the equity trade log now sums exactly to the reported final value.
v6
- What changed
- The options equity curve is built on real trading sessions instead of every business day (see holidays above). Sharpe moves; trades, P&L, CAGR and drawdown depth do not.
v7
- What changed
- The indicator audit: 27 of 62 indicators corrected across warm-up, the Donchian shift, Wilder smoothing consistency and canonical bounds.
v8
- What changed
- Instrument slippage is tiered by measured liquidity instead of one rate for every ETF, InvIT and REIT. Equity results proven byte-identical on four shapes.
v9
- What changed
- An options leg whose exact strike did not trade now uses the nearest strike that did. Measured on 6 shapes and 845 trades from 2016 to 2026: no existing trade changed, and previously skipped cycles (3 of 126 on a monthly 5% OTM put-write, including 12 Mar 2020) are now filled.
Want the full detail, or to talk through any of it?
