Quantor
Risk · 9 min read

The regime detector — what we kill live trades on, and why

A user opens the Quantor mini-app on the day BTC drops 14%, taps Start LIVE bot, and gets back a polite refusal:

{
  "error":   "Price is 16.3% below the recent peak — live starts blocked.",
  "code":    "RISK_POLICY_BLOCKED",
  "details": {
    "risk_code":          "DANGEROUS_MARKET_REGIME",
    "regime":             "DANGEROUS",
    "regime_reason_code": "DRAWDOWN_HIGH",
    "daily_volatility":   "0.0420",
    "drawdown_from_peak": "0.1631"
  }
}

The user is annoyed. They believe their strategy is good. They believe this is the moment to deploy capital — buy the dip, catch the rebound. We refused anyway. This post is about why we built that refusal, the exact 50-line classifier behind it, and the trade-off it represents.

What "regime" actually means here

Forget the textbook macro definition for a second. In a Quantor context, market regime is a single coarse label — CALM, VOLATILE, or DANGEROUS — that summarises how hostile the price environment is to an automated retail bot right now. It's not a forecast. It's not an alpha signal. It's a context tag the rest of the system consults before doing anything irreversible with real money.

Three buckets is on purpose. We don't ship 30-state HMMs or multi-factor classifiers. A retail user does not benefit from seeing "current state: cluster #14 in the latent space". They benefit from seeing "calm" / "elevated" / "stand back" — and from the system actually changing its behaviour when the label changes, not just displaying it.

Most "regime detection" in retail bot products is a chart label with no consequence. Ours is a chart label that costs you a click and possibly a position. That is the entire point.

The algorithm, in 50 lines

MarketRegimeDetector is a stateless pure function: input is an ordered list of OHLC candles, output is a RegimeAssessment record. The whole classifier rests on two retail-readable indicators:

  • Realized daily volatility — sample standard deviation of log(close[i] / close[i-1]) over the recent candle window. This catches "the market is choppy".
  • Drawdown from peak — current close vs. maximum close in the window, expressed as a positive fraction. This catches "we just crashed".

Each indicator is bucketed independently into CALM / VOLATILE / DANGEROUS via hard thresholds, and the worst of the two buckets wins. That's the entire classifier. No Kalman filter, no LSTM, no regime probability vector. Two numbers, four thresholds, one max().

// Default thresholds, tuned for daily BTC/ETH candles.
// Tunable per-instrument via the Thresholds record.
volVolatile      = 0.025   // 2.5% daily realized vol  → VOLATILE
volDangerous     = 0.050   // 5.0% daily realized vol  → DANGEROUS
drawdownVolatile = 0.08    // 8%  below 30-day peak    → VOLATILE
drawdownDangerous= 0.15    // 15% below 30-day peak    → DANGEROUS

These numbers are not magic. They are anchored on the historical daily distribution of BTC and ETH returns over the last seven years. ~5% realized daily vol is the upper quartile of crash-window months. ~15% drawdown from a 30-day peak is the threshold below which historical 30-day forward returns turn meaningfully right-skewed and historical 7-day forward variance explodes — i.e. it stops being a normal pullback and starts being a crash window. You can disagree with the exact cutoffs and tune them per symbol; the structure stays the same.

Why max() of the two indicators

Volatility-only and drawdown-only classifiers each fail in a predictable way:

  • Volatility-only misses the silent crash — a market that grinds down 20% over a week with below-average daily vol on the way. The path is calm, the level is wrong. Buying the dip here is the textbook failure mode.
  • Drawdown-only misses the volatility explosion before the move — a market that's chopping 6% per day at the same nominal level. The level looks fine, the path is a meat grinder. Putting a trend-following strategy in here gets whipsawed to death.

Taking the worst of the two catches both. It also intentionally over-classifies in the grey zone — if you're either choppy or down deep, you're at least VOLATILE; if you're choppy and down deep, you're DANGEROUS. The result is more false-positives (some good entries blocked) and fewer false-negatives (catastrophic entries during a 2020-03-12 style session). That asymmetry is the deliberate choice.

What flipping to DANGEROUS actually does

The classifier produces a RegimeAssessment every few minutes, pinned in memory and refreshed on a scheduler. The result is consumed in three places in the system, each one with a different consequence:

Consequences of regime = DANGEROUS
  • New LIVE bot starts: blocked. BotStartRiskPolicyService.assertCanStart() throws RISK_POLICY_BLOCKED with risk_code = DANGEROUS_MARKET_REGIME. The user sees the actual numeric reason text in the mini-app, not a generic "try again later".
  • Already-running LIVE bots: not auto-killed. A regime flip mid-day does not unwind open positions — that's the user's call. The bot continues to honour its own stop-loss and max-notional, just no new live activity is opened by Quantor's logic on top.
  • PAPER mode: unaffected. A hostile regime is a great time to test strategies on paper, not a reason to shut them off. Refusing PAPER would punish the user for doing the right thing.

This is the asymmetry that matters: the cost of refusing a good live entry is "the user is annoyed". The cost of allowing a bad live entry during a crash window is "the user loses real money on our recommendation". One of these is recoverable, the other isn't.

Live state, public

The current regime is not a private debug number. It's pinned at /api/v1/market/regime with the assessment object intact — current bucket, current volatility, current drawdown, reason code, sample size, timestamp. The mini-app reads it on dashboard load and shows the chip ("calm" / "volatile" / "stand back") with the matching reason text on hover.

You can also see the historical regime stream in RegimeHistoryRepository — every flip is persisted with a timestamp, the two underlying numbers, and the reason code. The audit chain on /security includes regime events in its append-only hashchain so a user can later answer the question "what was the published regime when my bot was refused?" and we cannot rewrite that answer.

What this catches

Historical regimes where DANGEROUS would have fired
  • 2020-03-12 (COVID crash). Realized daily vol blew past 12%, drawdown from prior peak hit 38% in 48 hours. The classifier would have been DANGEROUS for the entire window before, during, and for ~3 weeks after.
  • 2022-05 (Terra/Luna). BTC dropped from $40k to $26k over two weeks; ETH's drawdown from 30-day peak hit 37% on May 12. Both metrics deep in DANGEROUS.
  • 2022-11 (FTX collapse). Drawdown single-handedly tripped DANGEROUS in the 72-hour window where the exchange contagion was unwinding. Spot volatility looked almost normal — this is exactly the "silent crash" the drawdown component is built for.
  • 2024-08-05 (yen carry unwind). Single-day 21% realized vol, drawdown immediately past 15%. DANGEROUS the moment the candle closed.

None of these are exotic. Every one of them is a window where a retail user, looking at their backtest, would have been tempted to "let the bot keep running". And in every one, the historical forward 30-day distribution is a coin-flip at best.

What this does NOT catch

Failure modes you should know about
  • Idiosyncratic single-asset blow-ups. If your altcoin rugs while BTC and ETH are calm, the regime classifier — which reads BTC/ETH candles — stays CALM. We've kept the classifier scoped to majors deliberately; per-symbol regime is a feature on the roadmap but not in the shipping product.
  • Pre-crash conditions. The classifier reacts to the candle window it can see. It does not predict the next crash. If the crash hasn't started yet, we are CALM. There is no claim of foresight here.
  • Exchange-specific outage / liquidity holes. A flash-illiquidity event on Binance perp markets that does not move the spot close in our window won't trip the bucket. Mitigation: separate per-exchange health probes already in Pulse — those are independent of the regime classifier.
  • Slow grinds within thresholds. A market that drops 14% over a month at 2% daily vol is VOLATILE by drawdown, not DANGEROUS. You can still start LIVE bots — at reduced size if you're on Pro, with the user-visible warning chip on. This is intentional. We refuse the crash window, not the routine chop.

The honest framing: the detector is a circuit breaker for catastrophic entries, not a market-timing oracle. Treat it as the ground-floor risk gate, not as your edge.

Why we don't have a fancier classifier

It's tempting to ship something more sophisticated. ML practitioners reach for HMMs, GMMs, change-point detectors, regime-switching ARCH. We considered all of them. We shipped two numbers and a max(). Three reasons:

  • Explainability. When the system refuses to start a LIVE bot, the user has to see why in plain language: "daily vol 4.2%, drawdown 16% — live starts blocked". A 30-state HMM in latent space cannot produce that text. A two-indicator threshold can.
  • Tuning surface. Four numbers in a Thresholds record. A retail user with an opinion ("I think 5% drawdown is enough to stand back") can be served by a per-plan or per-user threshold override. A 30-state HMM cannot.
  • Failure mode on the input. When the candle stream is partial or stale, both indicators degrade predictably — vol goes to zero, drawdown goes to zero, and we explicitly return VOLATILE / INSUFFICIENT_DATA if sample size is below the minimum. A learned model would confidently emit a wrong label on incomplete data, which is exactly the failure we cannot afford here.

The classifier is one of those parts of the system where sophistication is a liability, not an asset. The right answer is the boring one.

The cost we accept

The regime guard will block some entries that, in hindsight, would have been profitable. The 2024-08-05 single-day crash recovered most of the loss within five days. A trend-following strategy on the long side at the close of that day would have done extremely well. We blocked it.

This is the trade-off we picked, in writing, on the public site: Quantor does not promise yield. We promise discipline. A bot that participates in a 30% drawdown because the math said the expected value was positive is not the product. The product is a bot whose operator can answer the question "what is the worst day I can have with this thing running?" with a number — and that number stays bounded because the gates fire before the worst day, not after it.

If you believe you can pick the rebound, you don't need Quantor. You need a discretionary account and a sharp eye. We're for the operator who would rather miss the rebound than catch the falling knife.

Inspect the current regime now

curl -s https://api.quantorsaas.app/api/v1/market/regime | jq .
# {
#   "regime":             "CALM",
#   "dailyVolatility":     0.0192,
#   "drawdownFromPeak":    0.0341,
#   "reasonCode":          "CALM",
#   "reasonText":          "Calm market — daily volatility 1.9%, drawdown 3.4%.",
#   "sampleSize":          30,
#   "computedAt":          "2026-05-11T18:55:00Z"
# }

You don't need an account. You don't need a key. You don't need to like our product. The regime is published the same way the build signature is — open, verifiable, with the underlying numbers attached so you can disagree with the cutoffs and run your own.


For the operational details — historical regime stream, audit chain, per-plan thresholds roadmap — see the Strategies and Security pages. The classifier source lives in quantor-domain/.../MarketRegimeDetector.java; the gate that consumes it lives in quantor-api/.../BotStartRiskPolicyService.java. Questions and criticism go to quantorsaas@gmail.com.