Quantor
Risk · 12 min read

The 14 risk gates we run before a LIVE bot starts trading your money

You hit Start LIVE bot in the Quantor dashboard. Before a single byte goes to the Binance order endpoint, 14 independent checks fire inside the api. Any one of them returning "no" aborts the start with a specific, machine-readable reason code, a human-readable explanation, and a JSON payload describing the exact state at the moment the gate fired. None of this is fast. All of it is by design.

Most retail bot products check one or two of these — plan-level authorisation and maybe an API-key presence flag. We check all 14, every single live start, every time. This post walks each one in the order they actually fire in BotStartRiskPolicyService.assertCanStart(), with the exact reason code the api emits and what it means.

The point isn't to make starting a bot hard. The point is that every time we do allow one to start, it's because fourteen independent signals all said "yes" — not because nobody thought to check.

Why a chain instead of a single decision

A single boolean check ("can this user start a live bot?") would be simpler. It would also be opaque. When the chain says "no", the user gets the specific gate that fired: EXCHANGE_KEYS_NOT_VERIFIED means something completely different from DANGEROUS_MARKET_REGIME, and a single boolean can't tell them apart. With 14 named gates, the failure mode itself is the diagnostic.

Order matters too. Cheap checks (symbol allowlist, plan permissions) run before expensive ones (subscription DB lookup, regime cache read, exchange-key decryption). If the symbol isn't BTCUSDT or ETHUSDT, we return that reason in ~10µs without touching anything else.

Gate 1 — SYMBOL_NOT_ALLOWED

The cheapest gate. The backend keeps an explicit allowlist of symbols we're willing to route automated orders for. The default is {BTCUSDT, ETHUSDT}; admins can extend it via the quantor.trading.symbolAllowlist config. If the requested symbol is anything else — including an alt the user could absolutely trade manually — the start is refused.

Why: most public retail bot failures are alt-coin specific (rug pulls, liquidity holes, exchange listing changes). Sticking to majors is the boring, defensible default; users who want something else can ask for it in writing and we'll think about it.

Gate 2 — PAPER_TRADING_NOT_ALLOWED

If the user is somehow on a plan whose paperTradingAllowed flag is false (very rare — every shipped plan includes PAPER), even paper starts are refused before we touch live-only checks. This is a defence-in-depth gate against plan-config mistakes: if we ever ship a plan that shouldn't allow trading at all, the user can't accidentally route paper orders either.

Gate 3 — DANGEROUS_MARKET_REGIME

The flagship Quantor gate, covered at length in the regime-detector post. The MarketRegimeService publishes a refreshed assessment (CALM / VOLATILE / DANGEROUS) every few minutes. If the current regime is DANGEROUS — high realised daily volatility or deep drawdown from peak — every live start is refused regardless of plan, allowlist, or user permission.

The reason text in the API response is the literal numeric assessment: "Daily volatility 4.2% and drawdown 16% both signal a crash window — live starts blocked." The user sees the specific evidence, not a generic "try again later".

Gate 4 — LIVE_TRADING_DISABLED

The runtime kill switch. A single Cloud Run env var (quantor.trading.live.enabled) controls whether any LIVE starts are allowed across the entire system. Flipping it off takes effect within seconds and propagates without a redeploy. Running bots already in flight aren't auto-killed but no new ones can start.

This is the gate we use during incident response, ahead of a contentious exchange listing event, or while shipping a non-trivial code change. The fact that it's a config flag, not a code branch, means it can be flipped by anyone with Cloud Run admin access in under a minute.

Gate 5 — LIVE_TRADING_USER_NOT_ALLOWLISTED

Beta gate. Until Quantor exits closed beta, even paying users have to be on a per-userId allowlist (quantor.trading.live.allowedUserIds) to send a single live order. The list is short, intentionally; we'd rather delay a few users than ship live trading prematurely.

This gate goes away when the product graduates from "controlled live access" to "anyone on Pro+ can flip live on". For now it stays.

Gate 6 — LIVE_MAX_NOTIONAL_REQUIRED

The live start request must include a positive notionalUsd field. Sending zero or omitting it doesn't fall back to "trade with whatever balance you have" — it fails the start. Explicit notional means an explicit per-bot cap on dollar exposure, and the user always sees the cap they consented to in the request URL.

Implementation detail: the field is required at the API layer, not just the UI layer. A user crafting a curl bypass still has to set it. There is no "we'll figure out the size for you" path anywhere in the chain.

Gate 7 — SUBSCRIPTION_REQUIRED

The user has to have an active, non-frozen subscription in our subscriptions table that grants live access. The SubscriptionPort.canTrade() check reads the latest subscription record and returns true only for ACTIVE rows whose plan supports trading. Expired, cancelled, or never-paid users hit this gate.

Gate 8 — ACCOUNT_FROZEN / PLAN_BOT_LIMIT_REACHED

Bot count limit. Every plan has a max-bots field (Starter=1, Pro=3, Teams=10) and a frozen-account flag set manually by admin during incident response. The SubscriptionAccessService.assertCanStart() check enforces both: a frozen account can't start any new bot, and a plan at its bot limit can't start one above the cap until an existing bot is stopped.

The risk code splits — ACCOUNT_FROZEN if the freeze is the cause, PLAN_BOT_LIMIT_REACHED if it's the count cap — so the user knows whether the answer is "talk to us" or "upgrade your plan".

Gate 9 — LIVE_TRADING_NOT_ALLOWED

Plan-level live permission. Starter is paper-only by design; Pro and Teams support live. If the user's resolved plan doesn't include live, the gate returns BlockingReasonCode.PLAN_DOES_NOT_ALLOW_LIVE_TRADING and the dashboard shows the "upgrade to Pro" CTA inline with the error.

Gate 10 — ADVANCED_LIVE_NOT_ALLOWED

Two LIVE sub-modes exist: LIVE_SAFE (EMA strategy only — the simplest, most-tested code path) and LIVE_ADVANCED (Breakout, TSMOM, Regime Pro). Even if the plan supports live, advanced strategies require the advancedStrategies flag on the plan. Pro+ and Teams; Starter and Pro Lite stay on EMA-only.

Why this is its own gate: a user accidentally selecting "TSMOM" on a Pro plan shouldn't get a confusing "LIVE_TRADING_NOT_ALLOWED" error — they should get "ADVANCED_LIVE_NOT_ALLOWED" with a hint about which strategy is available on their plan. The fine-grained reason code unlocks better UX.

Gate 11 — TELEGRAM_NOT_LINKED

No Telegram link, no live trading. The chain checks telegram_link for a confirmed account before proceeding. The link is required because a live bot needs a reliable out-of-band channel for alerts (regime flip, daily-loss reached, kill-switch flipped, key revoked at exchange). Email is not a substitute — Telegram delivery is faster and the user is more likely to see it.

Side benefit: linking TG also enables the /identity command that lets the user verify the running build's Ed25519 signature from inside their bot chat. The chain is consistent: every component that touches money has a verifiable out-of-band signal.

Gate 12 — STRATEGY_NOT_ALLOWED

Strategy code must match the execution mode:

  • LIVE_SAFE accepts: EMA
  • LIVE_ADVANCED accepts: EMA, BREAKOUT_V1, TSMOM_V1, REGIME_PRO_V1

Any other strategy code triggers the gate. The set is hard-coded in BotStartRiskPolicyService — we don't read it from config, because a misconfigured allowlist would silently enable a strategy in live mode that hasn't been validated.

Gate 13 — EXCHANGE_KEYS_NOT_VERIFIED

The user's Binance API key must exist and have its verified=true flag set in the DB. The flag is set only after the onboarding flow successfully called a read-only balance endpoint with the key — i.e. we observed it work end-to-end against the exchange. Copy-paste typos, expired keys, IP-allowlist mismatches all fail this gate.

Important: this gate doesn't check the scope of the key (whether withdraw is disabled). That's covered in our self-custody post: the exchange itself enforces the scope, and the safe scope is "trade-only" — verified by the user on the Binance side, which we can't fake from inside our infrastructure.

Gate 14 — MAX_NOTIONAL_USD_EXCEEDED / TRADING_DISABLED

The final gate — and the most quantitative. StartRiskEvaluator.canStart() runs the plan-specific risk limits against the user's requested notional:

  • Plan PRO: max notional $250 per bot, daily-loss limit 2%
  • Plan PRO_PLUS: max notional $5 000 per bot, daily-loss limit 7.5%
  • Plan ENTERPRISE: max notional $25 000 per bot, daily-loss limit 10%
  • Plan FREE: trading disabled (start refused regardless of request)

If the requested notional exceeds the plan ceiling, the gate returns the cap so the user can adjust. If the plan has tradingDisabled=true (FREE), the gate refuses with TRADING_DISABLED and no further evaluation.

How the chain looks from the outside

Every gate returns the same response shape — a ProductPolicyException rendered as JSON. The risk_code field is the discriminator, the message field is human text, and the extra fields describe the state that caused the refusal:

POST /api/v1/engine/start
{ "exchange": "BINANCE", "strategyId": "TSMOM_V1",
  "executionMode": "LIVE_ADVANCED", "notionalUsd": 6000,
  "symbol": "BTCUSDT" }

→ HTTP 403
{
  "error": "Live trading is disabled by runtime kill switch.",
  "code":  "RISK_POLICY_BLOCKED",
  "details": {
    "risk_code":              "LIVE_TRADING_DISABLED",
    "exchange":               "BINANCE",
    "requestedNotionalUsd":   "6000",
    "plan":                   "PRO_PLUS",
    "executionMode":          "LIVE_ADVANCED",
    "strategyId":             "TSMOM_V1"
  }
}

Any operator integrating Quantor via API can match on risk_code, surface the right UI, and even pre-flight the call: each gate is also exposed in previewLimits() which returns the user's current ceilings without attempting an actual start.

Why we publish this

Most retail bot products treat their risk policy as proprietary, then market on "robust risk management" without naming a single gate. The result is users can't tell whether the product checks 14 things or 1 — they have to take it on faith.

We don't want that contract. The codes are public. The order is public. The plan-specific ceilings are public. Every refusal in production carries enough machine-readable detail that the user can debug what happened. And the BotStartRiskPolicyService.java source file is — when the repo opens — the single source of truth for what "safe to start a LIVE bot" means inside Quantor.

If you can think of the 15th gate we should add, please tell us. The mailing address is at the bottom of every page.


For the algorithm behind gate 3 see the regime detector post; for gate 13 + the broader key-handling story see self-custody by construction. Build identity (separate cryptographic guarantee) is at signed builds with Ed25519. Questions and criticism go to quantorsaas@gmail.com.