Context
A backtest's only job is to tell you the truth about a strategy, and most retail backtesters lie in subtle ways: they let a strategy act on the same bar's close it used to decide, ignore trading costs, drift on float money, and let an AI narrate figures it invented. TradePulse's entire thesis is honesty over flattering numbers— those lies are engineered to be structurally impossible, not merely discouraged.
Constraints
Free market data and a $0 stack, so no paid real-time feed. And a hard product constraint: a strategy you can't actually trade must be un-authorable — no acting on the signal bar, no zero-cost fills, no entry-only strategies with no exit or risk control.
System architecture
A modular monolith: a FastAPI API and an ARQ worker, TimescaleDB for bars, Redis for queues, and a Next.js builder. The keystone is the StrategySpec DSL — one canonical, declarative, non-Turing-complete spec shared by the builder, engine, paper trading, and the AI. Operands read only closed bars, so look-ahead is unrepresentable; exit, sizing, and risk limits are mandatory fields.
System map
Tap to inspect
- 01
Authors a StrategySpec through a typed client; exit, sizing, and risk limits are mandatory fields.
- 02
Validates the spec and orchestrates backtests and paper runs; background work runs on an ARQ worker.
- Instead of
- Celery (sync-first), or a full broker — Kafka / RabbitMQ
- Why
- The app is async-first, and Redis is already a hard dependency for sessions — so ARQ (asyncio-native, Redis-backed) adds no new infrastructure. The ingestion supervisor and paper-trading cron live on the worker, never in the API lifespan, so a web deploy never interrupts live feeds.
- Tradeoff
- Redis pub/sub is lossy by design — fine for transient quotes; real-money fills would need an outbox, which is deferred behind an interface seam.
One canonical, non-Turing-complete spec shared by builder, engine, paper, and AI. Operands read only closed bars — look-ahead is unrepresentable.
- Instead of
- Four separate specs (AI, engine, DB, UI) with no single owner
- Why
- The legacy had four ways to describe a strategy and no source of truth — the #1 integration risk. Now one Pydantic-v2 spec is the contract: owned by the engine, consumed by the AI, UI (zod via OpenAPI), and DB. Entry, exit, sizing, and risk are mandatory; a CI drift-guard fails if any generated artifact diverges.
- Tradeoff
- Stricter schema rejects some legacy entry-only strategies — deliberately, to force discipline.
- 03
Event-driven replay: decide on closed bar i, fill at bar i+1's open. Costs and slippage per fill.
- Instead of
- Simpler bar-forward replay with looser timing (lets the future leak in)
- Why
- Financial honesty is the product. Decisions are evaluated only after a bar closes; fills happen at the next bar's open; indicators are causal (bar i sees only bars 0..i). A canary test scales all future bars by 3× and asserts the past equity curve is byte-identical — so look-ahead is caught by CI, not trust.
- Tradeoff
- A more complex state machine than a naive loop — paid back in reproducibility. Default costs (2 bps commission + 1 bps slippage) are always on, so the numbers are honest, not flattering.
Position clamp, daily-loss kill-switch, consecutive-loss halt — fired before a position is taken, recorded as risk events.
Every result carries a spec hash, engine version, and data fingerprint, so any run can be re-derived.
- 04
Paper trading runs the exact backtest engine with close_at_end=False; a parity test guarantees they agree.
- Instead of
- A separate live/paper execution path from the backtester
- Why
- Two engines drift; the paper results would stop matching the backtest that sold the strategy. Instead paper trading calls the same engine.run() with close_at_end=False, and a parity test asserts identical final equity. Even the position-sizing calculator delegates to the engine's _size() rather than reimplementing it.
- Tradeoff
- The engine must stay pure and reusable — a constraint that keeps the design honest.
1-minute bars in a TimescaleDB hypertable when available; higher timeframes derived on the fly via time_bucket.
- Instead of
- Mandatory TimescaleDB, or pre-aggregating every timeframe at ingest
- Why
- Store only 1-minute bars; derive 5m/1h/1d on the fly with time_bucket — always fresh, zero staleness. The migration creates a hypertable only if the extension exists, and falls back to a normal Postgres table otherwise, so it runs on any free-tier Postgres (Neon, Supabase, RDS).
- Tradeoff
- On-the-fly aggregation costs a little CPU vs. pre-aggregated tables — worth it to support any timeframe without a migration.
NL→validated spec via a validate-repair loop; narrates only the numbers present, never invents, never auto-executes.
- Instead of
- Free-form LLM output that can invent numbers or place trades
- Why
- The copilot turns natural language into a StrategySpec through a validate-repair loop (up to 2 repairs) — the output is always a schema-valid spec or an honest refusal. When it narrates results it is instructed to use only the numbers provided, never invent, and always end with 'not financial advice'.
- Tradeoff
- Some NL inputs are rejected rather than guessed — the right failure mode for money.
Hover a node to preview · click one to see the decision behind it
Use the arrow keys, Home, or End to move between nodes. Press Enter or Space to pin a node's decision, and Escape to release it.
- Builder UI: Authors a StrategySpec through a typed client; exit, sizing, and risk limits are mandatory fields.
- FastAPI + ARQ: Validates the spec and orchestrates backtests and paper runs; background work runs on an ARQ worker. Why: The app is async-first, and Redis is already a hard dependency for sessions — so ARQ (asyncio-native, Redis-backed) adds no new infrastructure. The ingestion supervisor and paper-trading cron live on the worker, never in the API lifespan, so a web deploy never interrupts live feeds. Instead of: Celery (sync-first), or a full broker — Kafka / RabbitMQ. Tradeoff: Redis pub/sub is lossy by design — fine for transient quotes; real-money fills would need an outbox, which is deferred behind an interface seam.
- StrategySpec: One canonical, non-Turing-complete spec shared by builder, engine, paper, and AI. Operands read only closed bars — look-ahead is unrepresentable. Why: The legacy had four ways to describe a strategy and no source of truth — the #1 integration risk. Now one Pydantic-v2 spec is the contract: owned by the engine, consumed by the AI, UI (zod via OpenAPI), and DB. Entry, exit, sizing, and risk are mandatory; a CI drift-guard fails if any generated artifact diverges. Instead of: Four separate specs (AI, engine, DB, UI) with no single owner. Tradeoff: Stricter schema rejects some legacy entry-only strategies — deliberately, to force discipline.
- Backtest engine: Event-driven replay: decide on closed bar i, fill at bar i+1's open. Costs and slippage per fill. Why: Financial honesty is the product. Decisions are evaluated only after a bar closes; fills happen at the next bar's open; indicators are causal (bar i sees only bars 0..i). A canary test scales all future bars by 3× and asserts the past equity curve is byte-identical — so look-ahead is caught by CI, not trust. Instead of: Simpler bar-forward replay with looser timing (lets the future leak in). Tradeoff: A more complex state machine than a naive loop — paid back in reproducibility. Default costs (2 bps commission + 1 bps slippage) are always on, so the numbers are honest, not flattering.
- Risk limits: Position clamp, daily-loss kill-switch, consecutive-loss halt — fired before a position is taken, recorded as risk events.
- Reproducible result: Every result carries a spec hash, engine version, and data fingerprint, so any run can be re-derived.
- ARQ paper engine: Paper trading runs the exact backtest engine with close_at_end=False; a parity test guarantees they agree. Why: Two engines drift; the paper results would stop matching the backtest that sold the strategy. Instead paper trading calls the same engine.run() with close_at_end=False, and a parity test asserts identical final equity. Even the position-sizing calculator delegates to the engine's _size() rather than reimplementing it. Instead of: A separate live/paper execution path from the backtester. Tradeoff: The engine must stay pure and reusable — a constraint that keeps the design honest.
- TimescaleDB: 1-minute bars in a TimescaleDB hypertable when available; higher timeframes derived on the fly via time_bucket. Why: Store only 1-minute bars; derive 5m/1h/1d on the fly with time_bucket — always fresh, zero staleness. The migration creates a hypertable only if the extension exists, and falls back to a normal Postgres table otherwise, so it runs on any free-tier Postgres (Neon, Supabase, RDS). Instead of: Mandatory TimescaleDB, or pre-aggregating every timeframe at ingest. Tradeoff: On-the-fly aggregation costs a little CPU vs. pre-aggregated tables — worth it to support any timeframe without a migration.
- AI copilot: NL→validated spec via a validate-repair loop; narrates only the numbers present, never invents, never auto-executes. Why: The copilot turns natural language into a StrategySpec through a validate-repair loop (up to 2 repairs) — the output is always a schema-valid spec or an honest refusal. When it narrates results it is instructed to use only the numbers provided, never invent, and always end with 'not financial advice'. Instead of: Free-form LLM output that can invent numbers or place trades. Tradeoff: Some NL inputs are rejected rather than guessed — the right failure mode for money.
Data flow
A spec is validated (Pydantic v2), then replayed bar by bar: the engine decides on a closed bar i, applies risk limits, and fills at bar i+1's open with commission and slippage. The result carries its metrics, trades, risk events, and a reproducibility triple. The paper engine replays the same engine live with a single flag.
Key decisions
Look-ahead-free by construction, not convention
Instead of: fill at the signal bar's close, warn against look-ahead in docs
Indicators read only closed bars and warm up with min_periods (MACD even double-warms its signal EMA), so a strategy can't peek even if the author wants to.
Trade-off accepted — A bar of latency between signal and fill, and no 'just fill at close' mode outside tests.
Decimal money and costs-on by default
Instead of: float accounting, frictionless unless enabled
All accounting is Decimal; every fill pays commission (2 bps/side) and slippage (1 bps, adverse), and returns are shown net. A cash-conservation test asserts initial + Σ P&L equals final equity.
Trade-off accepted — More arithmetic discipline; frictionless mode exists only for tests.
Risk limits enforced at entry, not reported after
Instead of: compute risk stats post-hoc, no built-in limits
A position clamp, a daily-loss kill-switch, and a consecutive-loss halt fire before a position is taken and are recorded as risk events.
Trade-off accepted — Strategies can be halted mid-run and 'look worse' — which is the point.
Reproducibility as a first-class output
Instead of: store only the metrics
Every result carries a spec hash (canonical JSON), the engine version, and a data fingerprint, so any run can be audited and re-derived.
Trade-off accepted — A little bookkeeping per run.
A grounded AI copilot over a chatty one
Instead of: an LLM that narrates results freely
NL→strategy uses a validate-and-repair loop and returns a validated spec it never auto-executes; narration is instructed to use only the numbers present and never extrapolate.
Trade-off accepted — The copilot sometimes refuses rather than guesses — exactly what a financial tool should do.
What failed
The first indicators emitted finite values from bar zero — a subtle look-ahead, because an indicator that needs N bars shouldn't report a number before it has them. The fix was to warm them up with min_periods so they emit honest null gaps; MACD warms twice, because its signal line sits on the already-gapped MACD line.
Cause — indicators emitted finite values from bar 0 — a quiet look-ahead
Fix — warm up with min_periods (null gaps); MACD double-warms its signal EMA
Performance & cost
| Metric | Value | Method |
|---|---|---|
| correctness suite | canary + parity | test_lookahead_canary perturbs future bars 3× and proves equity through the cut bar is byte-identical; a parity test proves the paper engine reuses the backtest engine. |
| Sharpe risk-free rate | 0 (stated) | Annualized from per-bar returns; shown wherever Sharpe/Sortino appear. Max drawdown always shown. |
| default costs | 2 / 1 bps | 2 bps/side commission + 1 bps adverse slippage on every fill; frictionless mode exists only in tests. |
| data provenance | DELAYED | Prices polled ~30s and labeled DELAYED; every result carries a hypothetical-performance notice. |
Operations
Live ingestion and paper trading run on an ARQ worker; the paper engine is the backtest engine with close_at_end=False, guaranteed to agree by a parity test. The AI copilot runs on Gemini (free tier) or local Ollama behind one provider contract, with per-user (200K/day) and global (5M/day) token budgets.
What I'd do differently
Several things are deliberately notclaimed yet, and stating that is part of the honesty contract: a real-time consolidated feed, point-in-time survivorship-free fundamentals, an automatic in-sample/out-of-sample split, a Deflated-Sharpe / multiple-testing correction, and a buy-and-hold benchmark overlay. Those are the next things I'd build.
Evidence
The app is live and the engine, DSL, and test suite are in the repository linked above.
Changelog
Public per-ticker pages, a position-sizing calculator, and real paper-trading email alerts.
A public methodology page and a reality pass — cut anything the engine couldn't back.
Credible backtest report: an underwater drawdown curve and full trade CSV export.
Free-tier cloud deploy (Docker on Hugging Face Spaces + Neon) running on real live market data.
First build — a look-ahead-safe, cost-aware backtest engine with a grounded NL→strategy copilot.