Project / Coil

A screener for 2,300 stocks, and a backtest that says its pattern doesn’t work.

Coil finds NSE stocks building a Volatility Contraction Pattern. It is paired with a ten-year controlled backtest that asked whether the pattern’s timing beats arbitrary timing, and the answer, reported in full, is no.

  • Data analysis
  • Backtesting
  • FastAPI
  • Negative result

The study

From a chart pattern to a verdict.

Does a famous chart pattern beat buying on a random day?

01 / 05 Pattern / contraction Schematic

  1. Stage 01 — Pattern

    Each pullback shallower than the last.

    A Volatility Contraction Pattern is Mark Minervini’s setup: inside a base, every pullback is smaller than the one before and volume dries up, until price breaks out through the pivot. Coil finds the swings in the last ~130 sessions, requires each contraction to be smaller than the one before, and checks that volume in the last one is below the base average.

  2. Stage 02 — Scan

    Two thousand stocks, narrowed to a handful.

    Conditions live in YAML rather than code and run in tiers: technical checks on cached prices for every stock, fundamentals only for the survivors, ownership filings only for what is left. A typical run goes 2,301 → 30 → 20 → 7, so only about thirty stocks ever need a network call.

  3. Stage 03 — Replay

    Every past signal, rebuilt from what was known then.

    Ten years of daily bars, with the screen replayed every fifth session and every metric computed from past bars only. That produced 2,657 signals across 401 stocks, each traded by the same simulator the live app uses.

  4. Stage 04 — Control

    Same stock, same risk, random date.

    A long-hold rule over a bull decade looks profitable whether or not the pattern did anything. So every signal is compared with a random entry in the same stock, at the same stop distance, inside the same window. Only the timing differs.

  5. Stage 05 — Result

    The random entries won.

    Eight positions at a time, real costs deducted, survivorship controlled: the pattern returned 11.0% a year with a 74% drawdown. Random entries in the same stocks returned 22.3% with a 56% drawdown, on almost the same number of trades.

Schematic, generated in-browser. The last stage’s bars are drawn to the reported portfolio CAGRs; the other stages show the shape of each step, not measured data.

Portfolio level, 2016–2026: eight concurrent positions, brokerage, STT, GST and slippage deducted, point-in-time top-500 universe. VCP signals were ranked by relative strength whenever slots ran short, an advantage the random control does not get.
Measure VCP signals Random entries
CAGR+11.0%+22.3%
Sharpe0.500.88
Max drawdown−74.0%−55.6%
Trades9794

No demonstrable edge.

The first portfolio run, on today’s Nifty 500, gave the random control 23.8% a year against the pattern’s 11.0%, but on fewer trades (58 against 97). Rebuilding the universe point in time, so a stock only counts on dates when it was actually among the 500 most traded, matched the trade counts and the result held. Per trade, the numbers sit close to breakeven and are noisy; under a realistic capital constraint the pattern-selected portfolio loses on return, risk-adjusted return and drawdown.

One result points the other way. With the exit rule held fixed, a VCP entry beats its random control by roughly twice as much as a plain 20-day-high breakout does. So the contraction carries some information, just not enough to survive limited capital.

The screener checks roughly 2,300 listed NSE stocks against three layers of evidence: technical (trend template, ATR squeeze, contraction detection), fundamental (market cap, quarterly profit and sales growth, result dates), and ownership (quarterly FII, DII and promoter holdings parsed from NSE filings).

The screener is the smaller half of the project. The larger half is the backtest that tried to falsify it, and the engineering needed to make that backtest trustworthy: point-in-time signal replay, a single shared trade simulator, real transaction costs, and a portfolio-level constraint.

Core question

Does VCP timing beat arbitrary timing in the same stocks at the same risk?

Answer

No demonstrable edge. Under a realistic capital constraint the signals do worse than random entries.

2,300 NSE stocks scanned ~15,000 lines of Python 400+ tests 10 years of daily bars 2,657 replayed signals

A vcp/ package holds detection, indicators, fundamentals, sector flow and the backtest, with the trade rules in a pure domain/ layer that touches no database, network or clock. Prices, fundamentals and filings are cached in SQLite. A FastAPI layer serves it, and a dependency-free JavaScript frontend renders the screener, the detail drawer and the sector views, with TradingView’s lightweight-charts vendored locally and the VCP structure drawn on top: each contraction shaded, measured and numbered.

The frontend is hosted on Vercel and the API on Render’s free plan. A nightly GitHub Actions job refreshes prices for every stock, runs the screens, and publishes the cache as a snapshot the API loads at startup.

The organising constraint is that what gets backtested has to be literally what gets traded. Detection, simulation and evaluation are kept as three lanes, so a change to the strategy cannot silently mean one thing in the live screen and another in the backtest.

Coil Architecture detection -> simulation -> evaluation

Detection

  1. Universe + prices ~2,300 NSE names, cached daily bars
  2. Pattern engine trend template, ATR squeeze, contractions
  3. Evidence layers fundamentals, ownership, sector flow

Simulation

  1. Point-in-time replay every metric from past bars only
  2. Trade simulator pivot entry, stop, target, horizon
  3. Cost model brokerage, STT, GST, ATR slippage

Evaluation

  1. Matched control same stock, same risk, random date
  2. Portfolio simulator 8 concurrent slots, event driven
  3. Robustness sweeps sub-universes, year by year, survivorship

Slicing the signal set by sub-universe was meant to find the conditions under which the pattern works. It found the opposite: the three conditions the screen relies on most are the worst performers in the set.

  • RS ≥ 90 The single most heavily weighted filter in the config. Mean yearly edge −1.94 over matched random entries, positive in one year out of eight.
  • Tight final base (≤ 5%) The defining feature of the pattern itself. Mean yearly edge −0.64 across 2,031 signals.
  • Index above its 200 DMA Mean yearly edge −0.71, independently reproducing the regime-filter result found earlier in the study.
  • The apparent inversions RS < 70 and thin names score positive, but these are small unstable samples over fewer years—the kind of result that appears when you slice a noisy dataset eleven ways. The honest reading is that the filters carry no reliable directional information at all.

Four mistakes, each found and fixed while building this. Select a node to read the case.

Control Three simulators Trailing stop Lost script

A control I forgot to match

Assumption
Comparing the screener’s signals against random entries was a fair control.
What broke
Controls drawn from the whole decade, compared against signals from one sub-period, measured the difference between two market regimes rather than the pattern.
What changed
Controls are matched on stock, stop distance and window, and every reported number uses them.
Status
Corrected.

Three copies of one trade

Assumption
The backtest, the study and the live app could each carry their own trade logic.
What broke
Entry, stop and exit rules existed as three separate copies with nothing checking that they agreed, so the next edit could quietly make two backtests model different things.
What changed
One pure trade simulator, vcp/domain/execution.py, now serves the study, the portfolio backtest and the live app.
Status
Corrected.

The app ran a rule the study rejected

Assumption
The live app’s stop rule matched the one the study had tested.
What broke
The app trailed its stop on a 20-day EMA after 1R, while the study had found trailing made results worse: it sold winners during normal pullbacks.
What changed
The default no longer trails, the live and backtested stops call the same function, and the strategy is recorded as data in one YAML file.
Status
Corrected.

A number I couldn’t reproduce

Assumption
A figure written down was a figure I could stand behind.
What broke
Several figures from the first pass did not reproduce exactly, because the script that produced them was never saved.
What changed
Every follow-up ships as a named script with its command line recorded, and the write-up marks which figures are current.
Status
Corrected.

This project is the reason I care as much about evaluation as about models. A screener that produces confident-looking candidates is easy. A control that shows those candidates are no better than arbitrary ones is harder, and far more useful.

The cost analysis made the point sharply: transaction costs tax the real signals and the random control almost identically, so they wash out of the edge metric entirely while mattering enormously to absolute returns. Anyone reading only the edge number would have missed it.

Reporting a negative result about your own system is the whole point. The write-up records what was tested and how, so the conclusion can be re-run and checked rather than taken on faith.

  • This tests one mechanical implementation, not the idea; a discretionary trader reads base quality, sector context, earnings and news.
  • Delisted companies are still absent, since the price history only covers symbols listed today.
  • No position-level management: real trading scales in and out, sizes by conviction, and abandons setups that stop acting right.
  • Year buckets hold 150 to 440 trades each—enough to be suggestive, not enough to be conclusive per year.
  • The generic-breakout comparison is the one finding pointing the pattern’s way, and it has not yet been run at the portfolio level.