Turning plain-English trading strategies into runnable backtests
Caligo turns a strategy a trader describes in plain English, “buy when RSI is oversold and price reclaims the 50-day,” into a rigorous, reproducible backtest, with no code required. The interesting engineering is not the model prompt. It is the boundary between a language model’s free-text output and a deterministic engine that has to run the same way every time.
This post walks through that boundary as it is actually built: how the model’s output is constrained to a fixed vocabulary, how a free-text sentence becomes a typed plan, what happens the moment the plan crosses into the engine, and how the system spends most of its effort trying to disprove its own best-looking result.
Never let the model touch the engine directly
The temptation with an LLM is to let it emit something executable. That is exactly what you must not do. In Caligo the AI agent’s only job is to map an idea onto a fixed library of building blocks (formulas, indicators, entry and exit conditions) and produce a structured plan object, never code and never raw parameters the engine trusts blindly.
The mechanism that enforces this is not prompting discipline; it is the API surface. The model is never asked for prose that we then parse. It is handed a single tool and forced to call it:
# services/ai/llm_provider.py: AnthropicProvider.parse_structured
response = self.client.messages.create(
model=self.parse_model,
max_tokens=4096,
system=system or "You are a precise data extraction assistant.",
messages=[{"role": "user", "content": prompt}],
tools=[tool_def],
tool_choice={"type": "tool", "name": tool_def["name"]}, # forced, not optional
)
for block in response.content:
if block.type == "tool_use":
return block.input # a dict shaped by our JSON schema
The model cannot answer in free text. tool_choice pins it to one tool whose
input_schema we defined, so the only thing it can return is a JSON object that fits a
shape we control. The same interface is implemented for OpenAI via function calling, and
which provider runs is a single env var (AI_PROVIDER) resolved behind an abstract
LLMProvider. The rest of the system never knows or cares which model produced the plan.
There is a second, quieter guarantee here. If no API key is configured, get_llm_provider()
returns None and the parser falls back to a deterministic regex path that produces the
same plan object. The LLM is an ergonomics layer over a schema, not a load-bearing
dependency. Swap it out and the engine downstream is unchanged.
A fixed vocabulary the model is allowed to speak
Constraining the format is not enough; you also have to constrain the content. A model that returns well-formed JSON referencing an indicator that doesn’t exist is still garbage in. So the building blocks the AI is allowed to reference live in one place, a capability registry that is the single source of truth for what the AI layer can express:
# services/ai/capability_registry.py
@dataclass(frozen=True)
class FormulaCapability:
name: str # canonical, e.g. 'rsi'
aliases: Tuple[str, ...] # natural-language aliases the parser accepts
default_config: Dict[str, Any] # e.g. {"period": 14, "source": "close"}
numeric_param_key: Optional[str] = None
is_volume_based: bool = False
# support-level flags: a formula can be known but not yet runnable
regex_supported: bool = True
llm_supported: bool = True
frontend_editable: bool = True
execution_supported: bool = True
Everything downstream (the tool schema handed to the model, the regex parser’s alias
table, the validators, and the frontend’s picker metadata) is derived from this registry
rather than maintaining its own parallel list of constants. That matters more than it
looks. It means there is exactly one place where “the system knows about MACD” is true,
and the execution_supported flag lets a formula be describable before it is runnable
without the two ever drifting out of sync. Today the allow-list is a closed set of around
eighteen technical indicators (RSI, the moving-average family, MACD, Bollinger Bands, ATR,
ADX, OBV, VWAP, the stochastics, and so on) plus a handful of derived price columns. The
model cannot reference anything outside it, and validation rejects the plan if it tries.
The system prompt goes a step further and teaches the model what it is not allowed to
express. Requests for all-time-high breakouts, delayed entries, chart patterns like
engulfing candles, or fundamentals come back with is_valid=false and a plain-English
reason, rather than a plausible-looking plan the engine would silently mishandle. Refusing
cleanly is a feature; guessing is the failure mode you are trying to design out.
The output of parsing is a plain dataclass, an AnalysisPlan, holding the fully resolved
idea: the formula and its config, the condition type (above, below, crosses_above,
between_formulas, …), thresholds, timeframe, lookback window, trade direction, and a
complete exit specification (timed hold, fixed or ATR-based stops, take-profit, trailing
stops). Multi-condition strategies attach a list of ConditionSpecs that are AND-combined.
Nothing in that object is executable. It is a description, and every field is a value the
engine already knows how to interpret.
From a plan to entities, deterministically
The plan is not the backtest. An orchestrator consumes the AnalysisPlan and materialises
the concrete entities the engine works with, resolving the ticker, creating or reusing a
factor (a formula plus config plus a condition), assembling those into a strategy,
and recording a backtest. Reuse is deliberate: if the same factor already exists it is
returned rather than duplicated, so “RSI(14) below 30” is one object in the system no
matter how many different sentences produced it.
Past this line there is no AI left in the system. Everything from here is a Python engine computing indicators over institutional-grade market data (Polygon.io, cached in PostgreSQL behind a coverage-aware metadata layer so a given symbol-and-timeframe is only fetched once) and running a backtest loop that is identical for every user. The model proposes; the type system and registry dispose.
The deterministic core
Downstream of the plan the engine runs the same four steps every time, and the orchestrator says so in one line: fetch data → calculate factors → evaluate strategy → calculate performance.
The first two steps are pure pandas. A formula is a named chain of operations. RSI, for instance, is ten chained operations (difference, clip gains and losses, exponential moving averages of each, ratio, rescale), and a factor is that formula bound to a config and materialised as a column over one symbol’s bars. The strategy’s condition is then compiled once into an evaluator and run bar by bar, in order, producing the set of row indices where it matched. Those matches are the entry signals. Exit conditions run as a second identical pass. There is nothing probabilistic anywhere in this: pure functions over cached, ordered OHLCV, evaluators keyed by their config hash, same inputs, same signals.
The final step, trade simulation, walks those matched indices in order and simulates each trade under an explicit, fixed set of rules:
# services/performance_calculator.py: calculate_performance (abridged)
for match_idx in match_indices:
# overlap policy is a config value, not a heuristic:
# 'skip_if_in_position' | 'close_and_reenter' | 'allow_all'
if overlap_handling != 'allow_all' and active_exit_index >= 0:
if new_entry_index <= active_exit_index:
... # record a skipped trade with a reason and move on
trade = self._simulate_trade(...) # entry/exit by index, priced off the bars
When a trade can close for several reasons at once, the tie is broken by a fixed
priority, not by whichever branch happens to run first: stop-loss > take-profit >
trailing stop > timeout > signal exit. Every trade carries its exit_reason, every
result is reproducible, and the same inputs always produce the same equity curve. That
determinism is what makes the numbers downstream mean anything, and it is the entire
reason the AI is kept on the far side of the boundary.
Design against the demo, not for it
Most retail backtesting fails the same way: a single equity curve that looks incredible because it is overfit. A responsible product has to actively fight its own best-looking output. Caligo does this in layers, and none of them involve the AI.
Tier-1 gates are cheap sanity filters applied to every generated result as it streams in. They flag too few trades to mean anything, drawdown past a hard floor, a suspiciously high CAGR that smells of curve-fitting, or a win rate below a meaningful threshold:
# services/ai/robustness_gates.py (thresholds tuned in practice, values elided here)
MIN_TRADE_COUNT = ... # too few trades to be meaningful
MAX_DRAWDOWN_PCT = ... # a hard floor on drawdown
MAX_CAGR_PCT = ... # above this, almost certainly overfit
MIN_WIN_RATE_PCT = ... # below this, not a real edge
Tier-2 scoring goes deeper without re-running anything, reusing the equity curve and variant data already computed. It produces a composite 0–100 robustness score from four dimensions, each designed to catch a different way a result can be a fluke:
- Variant consistency. Run the strategy across a spread of hold periods and exit styles, and for its top threshold-based candidates re-run the backtest at the neighbouring thresholds on either side, then measure the coefficient of variation of CAGR and Sharpe across all of them. A real edge survives small changes; an overfit one falls apart the moment you nudge it.
- Regime consistency. Split the equity curve in half and check that both halves pull their weight. A strategy that made all its money in one six-month window is not a strategy.
- Rolling stability. Slide a window across the trade sequence and count how many windows were profitable.
- Equity smoothness. Return-to-drawdown ratio and profit factor, weighted by trade count so a smooth curve built on eight trades doesn’t score like one built on eighty.
The composite is a weighted average, and if any Tier-1 gate failed it is multiplied down by a penalty so a headline number can never launder a broken result.
Monte Carlo closes the loop on the “was this luck?” question directly. It takes the strategy’s realized trades and resamples them thousands of times (shuffle or bootstrap, up to ten thousand iterations), replaying the equity simulation each time to produce percentile distributions of CAGR, drawdown, Sharpe and final equity, plus explicit tail-risk numbers like the probability of loss and the probability of ruin. Crucially it does not re-run the match-and-backtest engine per iteration; it resamples the trade returns with vectorised numpy (a single cumulative product per iteration), which is what makes ten thousand iterations cheap enough to run inline. The one stochastic step in the whole system is seeded, so even the randomness is reproducible.
The AI stays in the loop only to explain these results, never to bless them. That inversion is the whole product ethic: the model’s job is to lower the barrier to a real answer, not to manufacture a flattering one.
Why the boundary is the product
Collapsing “learn to code, source data, build a backtest loop” down to a single conversation is only valuable if the answer at the end is trustworthy. Put the creativity in the language layer and the rigor in a deterministic core, and you get both: anyone can ask, and what comes back is something a quant would actually stand behind.
The architecture is really just that idea enforced at every step. The model is boxed into a forced tool call. The tool’s vocabulary is derived from one registry. The plan is an inert dataclass. The engine that runs it contains no AI at all and breaks every tie the same way. And the results are put through gates, scored, and Monte-Carlo’d before anyone sees them.
When AI output feeds a system where being wrong costs real money, the schema at the boundary is not boilerplate. It is the feature.