An intraday trading stack where strategy proposes, policy approves, execution fills, and portfolio reconciles — with the measurement and gating needed to decide whether any of it should be trading real money.
Status, stated plainly. The pipeline runs end to end against a paper broker and a real-time data feed. It has never been validated against live market data in this repository's CI, and no strategy here has demonstrated an edge — the tooling to test that claim is built (see Is the Edge Real, or Fitted?), and running it is your job before any money is involved. The defaults are deliberately conservative: nothing trades until it is registered and promoted.
| If you want to… | Read |
|---|---|
| Run it locally on delayed data | Running in Demo Mode |
| Trade on real-time intraday bars | Real-Time Intraday Trading |
| Know whether the strategy makes money | Does the Strategy Actually Work? |
| Know whether that result is real or fitted | Is the Edge Real, or Fitted? |
| Run more than one strategy | Strategies and the Portfolio |
| Control what is allowed to trade | The Strategy Lifecycle |
| See what an order actually cost | Execution Quality |
| Understand why a trade lost | Explaining Trades After the Fact |
| Go live | Enabling Live Mode |
Operational procedures — what to do when something breaks — are in RUNBOOK.md.
┌─────────────────────────────────────────────────────────────────┐
│ Autonomy Orchestrator :8007 │
│ scheduler → signals → risk → policy → ROSTER → execute │
└──────┬──────────────┬────────────┬──────────┬──────────┬────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
Risk Engine Policy Gate Strategy Execution Audit Logger
(sizing, (hard rules, Lifecycle Service :8006
drawdown, sector conc, (roster: :8002 (append-only)
PDT, sector) event block) may this (marketable
│ sleeve limit+IOC)
│ trade?) │
│ │ ▼
│ │ Broker (eToro/Paper)
│ │
┌────────┴─────────┐ └──▶ blocked ⇒ recorded, not dropped
▼ ▼
Notification :8009 Approval Gateway :8010
(webhook, tiered) (PENDING/APPROVE/REJECT)
── Data and evidence ──────────────────────────────────────────────
Strategy Service :8003 (signals, TA, ADX, patterns)
Portfolio Service :8004 (positions, NAV)
Research Service :8005 (AI research summaries)
Sentiment Aggregator :8008 (NewsAPI, AlphaVantage)
Backtest Service :8011 (backtest, walk-forward, sensitivity, portfolio)
Dashboard :8080 (kill switch UI, approvals, stats)
Journal (journal.db) every bar, price and decision, point-in-time
Roster (lifecycle.json) which sleeves may trade, and why
The roster is the addition that matters most: no (strategy, symbol) sleeve places an order until it has earned the state that permits it, and evidence the system recorded itself is what earns it.
| Service | Port | Purpose |
|---|---|---|
| policy-service | 8001 | Policy evaluation (hard rules gate) |
| execution-service | 8002 | Order routing to broker |
| strategy-service | 8003 | Signal generation (TA, ADX, patterns, volume) |
| portfolio-service | 8004 | Position tracking and NAV |
| research-service | 8005 | AI-powered research summaries |
| audit-logger | 8006 | Append-only audit trail (SQLite) |
| autonomy-orchestrator | 8007 | Main loop scheduler and decision engine |
| sentiment-aggregator | 8008 | News/sentiment scoring |
| notification-service | 8009 | Webhook notifications (tiered) |
| approval-gateway | 8010 | Human approval flow (PENDING/APPROVE/REJECT) |
| backtest-service | 8011 | Backtesting, walk-forward, parameter sensitivity |
| dashboard | 8080 | Read-only local operational view; mutations use authenticated APIs |
| Variable | Required | Description |
|---|---|---|
INTERNAL_API_KEY |
Yes (prod) | Shared secret for service-to-service auth |
ADMIN_API_KEY |
Yes (prod) | Extra key for kill switch / live mode endpoints |
ETORO_API_KEY |
Yes | eToro public API key |
ETORO_USER_KEY |
Yes | eToro user key |
ETORO_DEMO |
No | Set true for eToro demo account (default true) |
ANTHROPIC_API_KEY |
Yes | For AI research summaries |
NEWSAPI_KEY |
No | NewsAPI.org key for sentiment |
ALPHAVANTAGE_KEY |
No | AlphaVantage key for sentiment |
WEBHOOK_URL |
No | Slack/Discord/custom webhook for notifications |
WEBHOOK_ALLOWED_HOSTS |
With webhook | Exact hosts permitted for HTTPS notification egress |
BROKER |
No | etoro or paper (default paper) |
WORKER_ENABLED |
No | true to enable strategy worker polling |
ORCHESTRATOR_INTERVAL_MINUTES |
No | Cycle interval (default 5) |
STOP_LOSS_PCT |
No | Stop loss % (default 0.03 = 3%) |
TAKE_PROFIT_PCT |
No | Take profit % (default 0.06 = 6%) |
MAX_HOLD_HOURS |
No | Max position hold time in hours (default 48) |
VOLUME_CONFIRM_ENABLED |
No | Require above-avg volume for BUY (default true) |
STRATEGY_WATCHLIST |
No | Comma-separated symbols to trade |
LEARNING_ENABLED |
No | Schedule bounded offline learning for paper sleeves |
LEARNING_INTERVAL_SECONDS |
No | Learning cadence; minimum one hour |
LEARNING_MIN_PAPER_TRADES |
No | Closed paper round trips required before evaluation |
LEARNING_STRATEGY_REGISTRY_PATH |
No | Immutable champion artifact directory |
| Variable | Default | Description |
|---|---|---|
MARKET_DATA_TIMEFRAME |
daily |
Set to intraday to trade on intraday bars |
INTRADAY_MINUTES |
15 |
Bar size. Yahoo serves 1, 2, 5, 15, 30, 60, 90 |
INTRADAY_LOOKBACK_DAYS |
5 |
Bar history for indicators (Yahoo caps 1m at 7 days) |
MARKET_DATA_PROVIDER |
auto | yahoo forces Yahoo; blank uses Alpaca when keys are set |
STREAMING_ENABLED |
false |
Real-time websocket bar stream (Alpaca only) |
STREAM_SYMBOLS |
allowlist | Symbols to stream |
STREAM_SYMBOL_LIMIT |
30 |
Cap on concurrent subscriptions (free IEX feed) |
ALPACA_FEED |
iex |
sip on a paid Alpaca data plan |
MAX_PRICE_AGE_SECONDS |
120 |
Older prices are treated as unusable |
ORCHESTRATOR_INTERVAL_SECONDS |
— | Cycle cadence; overrides the minutes setting |
STOP_LOSS_CHECK_INTERVAL_MINUTES |
1 intraday / 5 daily | Stop-loss poll interval |
TAKE_PROFIT_CHECK_INTERVAL_MINUTES |
1 intraday / 5 daily | Take-profit poll interval |
PAPER_STARTING_CASH |
100000 |
Paper broker opening cash |
PAPER_SLIPPAGE_BPS |
2 |
Simulated slippage, always against the trader |
PAPER_STATE_PATH |
./paper-broker-state.json |
Paper position ledger |
| Variable | Default | Description |
|---|---|---|
USE_LIMIT_ORDERS |
true |
Send marketable limit + IOC instead of market orders |
LIMIT_TOLERANCE_BPS |
10 |
How far through the touch the limit is priced |
MAX_ADV_PARTICIPATION |
0.01 |
Cap an order at this share of average daily volume (0 disables) |
Added by the live-paper-run hardening (docs/hardening/LIVE-PAPER-RUN-2026-08-31.md
has the findings each of these closes):
| Variable | Default | Description |
|---|---|---|
EXECUTION_MAX_POSITION_QTY |
EXECUTION_MAX_QTY (1000) |
Per-sleeve position cap, enforced server-side in execution-service from its own fill journal. Reduce-only orders are exempt; an unreadable journal refuses entries (unknowable is not flat) |
EARNINGS_GATE_FAIL_CLOSED |
false |
true treats an unanswerable earnings calendar as a blackout (entries refused); the default fails open and warns. Any other value is refused on the first call |
STOP_LOSS_STATE_PATH |
./stop-loss-state.json |
Tracked stops persist here and are restored on orchestrator restart |
TAKE_PROFIT_STATE_PATH |
./take-profit-state.json |
Same, for take-profit targets |
LOG_LEVEL |
INFO |
Orchestrator log level. Below this the risk monitors' registrations are invisible |
MARKET_DATA_PROVIDER=file + MARKET_DATA_FILE_DIR |
— | File-drop market data for egress-restricted or replay deployments: <SYM>_1d.json / <SYM>_<N>m.json bars, quotes.json quotes (ts or timestamp key); fail-closed on missing or undated data |
VETO_MIN_ARCHIVED_BARS, VETO_MAX_STALE_MINUTES, VETO_WINDOW_HOURS, VETO_EXPECTED_INTERVAL_MINUTES |
intraday-tuned | The risk veto's data-sufficiency thresholds. Defaults suit 15m bars; a daily-cadence archive needs these raised, and scripts/specialist_report.py --timeframe 1d to read the right slice |
Paper fills price through get_fresh_price — the provider is consulted unless
the cache is seconds old, so a fill can never price from a snapshot the ticker
would tolerate but the market has left. The point-in-time journal also archives
every computed sentiment score, generated research report and fetched headline
(append-only), which is what backs all five specialist roles.
- Log into your eToro account at etoro.com
- Go to Settings → API (or developer.etoro.com)
- Create an API key pair — copy the public key and user key
- Set
ETORO_API_KEYandETORO_USER_KEYin your.env - Keep
ETORO_DEMO=trueuntil you are ready for live trading
cp .env.example .env
# Edit .env — at minimum set ANTHROPIC_API_KEY and ETORO_API_KEY/USER_KEY
# Leave ETORO_DEMO=true and BROKER=paper (or etoro with demo=true)
# Install dependencies
uv sync
# Start all services (each in its own terminal or use a process manager)
uv run uvicorn policy_service.main:app --port 8001
uv run uvicorn execution_service.main:app --port 8002
uv run uvicorn strategy_service.main:app --port 8003
uv run uvicorn portfolio_service.main:app --port 8004
uv run uvicorn research_service.main:app --port 8005
uv run uvicorn audit_logger.main:app --port 8006
uv run uvicorn autonomy_orchestrator.main:app --port 8007
uv run uvicorn sentiment_aggregator.main:app --port 8008
uv run uvicorn notification_service.main:app --port 8009
uv run uvicorn approval_gateway.main:app --port 8010
uv run uvicorn backtest_service.main:app --port 8011
# Open dashboard
open apps/dashboard/index.htmlTwo data paths are supported. Both run the same trading loop.
| Alpaca | Yahoo | |
|---|---|---|
| API key | free account required | none |
| Intraday bars | real-time | delayed, typically ~15 min |
| Websocket stream | yes | no |
| Market calendar | authoritative (holidays, half-days) | weekday heuristic only |
| Paper fills | real Alpaca paper account | local simulator |
MARKET_DATA_PROVIDER=yahoo
MARKET_DATA_TIMEFRAME=intraday
INTRADAY_MINUTES=15
BROKER=paperYahoo's intraday feed is delayed, so this is intraday but not truly real-time. It is the right setting for validating the pipeline before committing to a data provider. Prices are resolved by polling.
ALPACA_API_KEY=...
ALPACA_SECRET_KEY=...
ALPACA_PAPER=true
MARKET_DATA_PROVIDER= # blank — auto-selects Alpaca
MARKET_DATA_TIMEFRAME=intraday
INTRADAY_MINUTES=5
STREAMING_ENABLED=true
BROKER=alpacaKeys come from the Alpaca dashboard (alpaca.markets → Paper Trading → API
Keys). With STREAMING_ENABLED=true the orchestrator subscribes to 1-minute
bars over a websocket and serves prices from an in-memory cache, so stops are
evaluated against a price that is seconds old rather than one HTTP call away.
Worth knowing before you pick a provider. Yahoo's intraday feed is delayed by
roughly 15 minutes, while policy-service rejects any decision built on data
older than POLICY_MAX_DATA_AGE_SECONDS (default 30 seconds). Under
defaults, the Yahoo path therefore places zero live orders — every signal is
rejected as stale_data. That is the system failing closed correctly, not a
bug.
Three ways forward:
| What you get | |
|---|---|
| Use Alpaca | Real-time prices; the default limit works as intended |
Raise POLICY_MAX_DATA_AGE_SECONDS |
Trading on ~15-minute-old prices. Defensible on a 15-minute bar strategy, indefensible on a 1-minute one. Set it deliberately, not to make a warning go away |
| Use Yahoo for backtesting only | Historical bars have no freshness requirement, so run_backtest.py works fine on Yahoo regardless |
scripts/verify_intraday.py checks freshness against the policy limit and
fails when it is exceeded, so this shows up before you start rather than as a
run of rejections in the audit log.
Unit tests cannot prove that this host can reach a data provider. Run the preflight on the machine that will trade:
uv run python scripts/verify_intraday.py
uv run python scripts/verify_intraday.py --symbols AAPL,MSFT --stream 30It checks configuration, the market session, that intraday bars really arrive at the configured resolution, and that prices are fresh enough for the policy service to accept. It exits non-zero if anything fails.
curl http://localhost:8007/v1/orchestrator/realtimeReports the resolution the loop is actually running at: timeframe, provider,
cycle and risk-check cadence, stream state, and the age of every cached price.
Check this first if trades are being rejected — a degrading to DAILY bars
error in the orchestrator log means intraday data could not be fetched and the
strategy is no longer running at intraday resolution.
Each price lookup tries three tiers, freshest first:
- the websocket stream's in-memory cache (sub-second, Alpaca only)
- the provider's latest-trade endpoint (one HTTP call)
- the close of the most recent bar
Every result carries a timestamp. If no tier can supply a price, the strategy reports the data as stale rather than fresh, and the policy service rejects the trade. The system fails closed: no price means no order.
The live loop will trade a strategy with no edge just as happily as a good one. Backtest before trusting it with money.
# 60 days of 15-minute bars across three symbols, with realistic costs
uv run python scripts/run_backtest.py --symbols AAPL,MSFT,NVDA
# how much cost does the edge survive?
uv run python scripts/run_backtest.py --symbols AAPL --sweepThe report separates gross return from net, because on an intraday strategy the gap between them is usually the whole story:
Net return +2.14%
Gross (no costs) +8.90%
Cost drag -6.76% ($6,760, 76% of gross)
Costs default to 5bps spread + 1bps slippage + zero commission — roughly a
liquid US large-cap at a commission-free broker. Set them to match your broker
and your symbols. A wider spread is the fastest way for an intraday strategy
to stop working, and --sweep shows exactly where it breaks:
spread return trades
0.0bps 8.90% 84
5.0bps 2.14% 84
10.0bps -4.61% 84 <- edge gone
If the strategy is unprofitable at your real costs, no amount of faster execution will fix that. Fix the strategy or stop.
- It runs one symbol at a time with no portfolio interaction.
- It assumes every order fills at the next bar's open. Real market orders in fast markets do worse.
- Past results do not predict future returns. A strategy tuned until a backtest looks good is a strategy fitted to history — which is what the next section is for.
A backtest over the whole history answers "what would have happened?". It cannot answer "would it happen again?", and it is trivially gamed: try enough parameter combinations and one of them fits the sample.
This is not a subtle risk. The suite contains a test that builds 200 strategies out of pure random noise, picks the best one, and checks the numbers it produces:
best of 200 noise runs: annualised Sharpe 10.45
naive confidence: 99.8%
deflated Sharpe ratio: 0.53 <- a coin flip
Nothing was there. The first two numbers would still get a strategy funded.
Two commands exist to catch this.
uv run python scripts/run_backtest.py --symbols AAPL --walk-forwardThe data is split into sequential folds. For each fold, every configuration in the grid is scored on the bars before the test window, the best one is selected, and only then is it run on the test window. The out-of-sample segments are stitched together and reported; the in-sample figures appear for one reason, which is to show the size of the drop.
fold 1 2025-05-07 -> 2025-05-10 ema10/60 rsi40-70 macd>0
in-sample 3.66 out-of-sample -0.22 return -0.35% (5 trades)
fold 2 2025-05-10 -> 2025-05-14 ema10/60 rsi40-70 macd>0
in-sample 1.97 out-of-sample 3.26 return +3.71% (3 trades)
Out-of-sample Sharpe 5.20
In-sample Sharpe (mean) 2.75
Degradation -2.44
[PASS] Out-of-sample profitable +20.78%
[FAIL] Survives the search deflated Sharpe ratio 0.949
[PASS] Folds agree on parameters 67% picked the same configuration
The three checks, and why each is there:
| Check | What it catches |
|---|---|
| Out-of-sample profitable | The strategy only worked on the data it was tuned on |
| Survives the search | The winner is what a search of this size finds in noise |
| Folds agree on parameters | Each fold's "optimal" settings are a property of its window |
Read sharpe_degradation first. A strategy scoring 2.5 in-sample and 0.1
out-of-sample has not found an edge; it has memorised a sample. A negative
degradation means out-of-sample beat in-sample, which is luck rather than
vindication — with few folds it happens often.
Two design points worth knowing, because they are where leakage would hide:
- The embargo. Bars immediately before each test window are dropped from training. Adjacent bars carry nearly the same information — an EMA at the boundary is largely built from bars about to become test data — so optimising right up to the edge is close to optimising on the test set. Defaults to the indicator warm-up length.
- Warm-up is not leakage. The test segment computes its indicators from the bars preceding it. That is what live trading does; leakage would be using data from after the decision. Trades and equity are counted only from the test window's first bar.
The probability that the result beats what the best of N random configurations would produce by luck alone. Below 0.95 it does not.
It rises with sample length and falls with the number of configurations tried and how much they differ from each other — so a wider grid is not a more thorough search, it is a more expensive one. It also corrects for skew and kurtosis, which matters because "wins small and often, loses catastrophically" has a flattering Sharpe and a real risk of ruin.
What is compared, precisely: the out-of-sample record is tested against a benchmark built from the spread of the configurations' in-sample Sharpe ratios. The textbook version tests the in-sample winner against that benchmark; holding the out-of-sample record to it is the stricter of the two. Worth knowing so the number is not read as the canonical statistic.
The caveat no formula can fix: the trial count is the configurations this run evaluated. Every parameter you tried by hand beforehand, every strategy variant abandoned along the way, and every symbol swapped in and out is also a trial, and none of them are counted. The true multiple-testing burden is higher than what is reported. Treat the number as an upper bound on your confidence, not a measurement of it.
uv run python scripts/run_backtest.py --symbols AAPL --sensitivityScores every configuration in the grid and looks at the shape of the surface rather than its peak. A real effect is a plateau — the neighbours of the best configuration work nearly as well. A fitted one is a spike, alone above configurations that lose money.
Profitable configurations 81/81 (100%)
Neighbours of the best 4.35 Sharpe vs its 4.81 (90% retained)
[PASS] Plateau, not a spike the result survives one step in any parameter
A plateau is necessary but not sufficient. In a sample that happened to
trend, every momentum configuration profits and they form a plateau together —
this is reproducible on synthetic random-walk data. Sensitivity is in-sample by
design and reads as a companion to --walk-forward, never as a substitute.
curl -X POST http://localhost:8011/backtest/walk-forward \
-H "Content-Type: application/json" \
-d '{"symbol":"AAPL","timeframe":"intraday","period_days":59,"n_splits":4}'
curl -X POST http://localhost:8011/backtest/parameter-sensitivity \
-H "Content-Type: application/json" -d '{"symbol":"AAPL"}'Both accept a grid object to narrow or widen the search, and walk-forward
accepts n_splits, embargo_bars and objective (sharpe, return or
profit_factor).
Being explicit, because these checks are easy to over-read:
- It is one symbol at a time. Passing on AAPL says nothing about the portfolio, and running the same test on twenty symbols and reporting the best is the same multiple-testing error at a different level.
- The out-of-sample sample is small. Walk-forward over a 60-day intraday window produces a handful of round trips per fold. A Sharpe from nine trades is an anecdote; the report says so when the count is below 30.
- Costs are still assumed. Feed the measured
mean_shortfall_bpsfrom/v1/execution/qualityinto--slippage-bpsso this is validating the strategy you would actually run. - Passing is not a green light. It removes one specific way of being wrong.
One strategy on one symbol is a bet on one regime. When that regime ends the rule does not stop producing signals — it starts producing losing ones. The standard answer is to run several, but that only helps if they lose money at different times. Two momentum rules with different lookbacks are one strategy with a typo, and combining them adds cost and complexity without adding any protection.
curl http://localhost:8011/backtest/strategies| Name | Bets on | Reads |
|---|---|---|
ema_rsi_macd |
Strength. Dual-EMA trend with RSI and MACD confirmation. | ema_fast, ema_slow, rsi_buy_min, rsi_buy_max, macd_hist_min |
bollinger_reversion |
Weakness. Buys a close below the lower Bollinger band with RSI oversold; exits on a return to the mean. | bb_period, bb_std, rsi_oversold, rsi_overbought |
They are deliberately opposed: one buys what is going up, the other buys what has fallen. Their parameter sets do not overlap at all, which the test suite enforces — an overlap would make them variants of one rule rather than two rules.
request.strategy used to be a label nothing read, so a request for any other
strategy silently ran the momentum one. It now resolves through a registry and
an unknown name is an error that lists the alternatives.
Adding a third: write a signal function, declare which parameters it reads, and register it. Walk-forward, sensitivity, the portfolio and the deflated Sharpe ratio all work against the registry, so it gets validated the same way the existing two do without further work.
uv run python scripts/run_backtest.py --symbols AAPL,MSFT,NVDA --portfolioA sleeve is one (strategy, symbol, parameters) triple. Sleeves are simulated independently, aligned by timestamp, and combined by weight.
sleeve weight sharpe return trades
AAA:bollinger_reversion 16.7% 1.06 1.13% 4
AAA:ema_rsi_macd 16.7% 0.02 -0.34% 18
BBB:ema_rsi_macd 16.7% -1.05 -4.07% 19
...
Combined Sharpe -4.29
Best single sleeve 1.06 (AAA:bollinger_reversion)
Diversification ratio 2.32
[FAIL] Beats its best sleeve -4.29 vs 1.06
[PASS] Sleeves actually diversify diversification ratio 2.32
[PASS] No redundant pair highest correlation +0.04
The numbers to read, in order:
max_correlation. This is the entire mechanism. A pair above 0.7 is one position held in two accounts, paying two sets of costs. The report names the offending pair.diversification_ratio— weighted average sleeve volatility over the volatility of the combination. Above 1.0 means the whole is calmer than its parts. At 1.0 the sleeves are the same bet.best_sleeve_sharpe— the question an operator actually has: would I have been better off just running that one? Note the trap: picking it in hindsight is its own selection error.
Diversification reduces variance. It does not create return. This is the most common misreading of a good diversification ratio, and it is asserted as a test: on uncorrelated sleeves the combined return sits inside the range of its parts, always. What it buys is a smaller drawdown than the average sleeve — a smoother ride to the same place, not a better place.
--allocation equal (default) or inverse_volatility, which weights calmer
sleeves more heavily. Be clear about what the second one is: weights computed
from the same data you are evaluating on are fitted in-sample, and the
improvement they show is partly the fit. Validate the allocation the same way
you validate a parameter — on data it was not chosen from.
Phase 2 flagged this and the portfolio is where it bites: running a strategy on twenty symbols and reporting the best three is a twenty-symbol search, and the deflated Sharpe ratio can only price that in if you tell it.
# screened 50 symbols, running the best 3
uv run python scripts/run_backtest.py --symbols AAA,BBB,CCC --portfolio --considered 50Without --considered the trial count defaults to the number of sleeves, which
is only correct if you never dropped a candidate.
Stated plainly, because each of these flatters the number:
- Each sleeve gets its own capital. They are simulated independently and never compete for it. A real account has one balance, so six sleeves either run at a sixth of the size or fail to fill each other's orders. Read the combined return as an upper bound.
- No portfolio-level risk limits. The live loop has position caps, sector concentration limits and the PDT guard; the portfolio backtest has none of them, so it will happily hold six correlated longs.
- Correlation is measured on the sample. Correlations rise in a selloff — the moment diversification is supposed to help is the moment it stops. A backtest over a calm period will overstate it.
- Costs are still assumed. Six sleeves trade roughly six times as often.
Feed the measured
mean_shortfall_bpsin before believing the return.
The archive records what was seen, execution quality records what trading cost, walk-forward records whether an edge survived validation, and the portfolio records whether strategies diversify. None of that changed what the system would actually trade — a human still decided, from opinion, and nothing removed a strategy once it stopped working. This closes that loop.
Every (strategy, symbol) sleeve holds a state:
candidate ──▶ paper ──▶ live
▲ │
└── probation ◀─┘
│
retired
| State | May trade? | Meaning |
|---|---|---|
candidate |
No | Registered, no evidence yet |
paper |
No | Validated on history. Runs in the live loop and records decisions, places no orders |
live |
Yes | Permitted to place real orders |
probation |
No | Was live; decayed or breached a limit. Entries blocked, exits always allowed |
retired |
No | Off. Coming back requires re-registration, deliberately |
A sleeve nobody registered cannot trade. That is the point: a strategy nobody validated does not get to trade because it happened to emit a signal.
Worth stating, because they are what make this a control rather than a dashboard that happens to say "live".
Refuse by default. A missing measurement is a no, not a neutral. Every gate fails closed. Promoting on an absent measurement is how a system ends up trading something nobody checked — and if the roster file is unreadable, the registry loads empty and nothing may trade, rather than defaulting to permissive.
Promotion is slow, demotion is fast. Promotion needs every gate to pass; demotion needs any one trigger to fire, and demotion is never gated at all — safety must not require approval. The asymmetry is deliberate: being slow to promote costs opportunity, being slow to demote costs money.
A small sample cannot promote, but a hard breach can always demote. You
cannot conclude decay from five trades, so the Sharpe-decay check waits for
LIFECYCLE_MIN_LIVE_TRADES. A drawdown breach is not a statistical claim — it
is a fact about money already lost — and demotes immediately at any sample size.
Each one reads something an earlier phase produced. That is the loop closing: the measurements are no longer just reported, they decide.
candidate → paper (backtest evidence only, no money involved):
| Gate | Source |
|---|---|
| Deflated Sharpe ratio ≥ 0.95 | run_backtest.py --walk-forward |
| ≥ 30 out-of-sample trades | same |
| Out-of-sample return positive | same |
paper → live (every gate reads something only paper trading produces):
| Gate | Source |
|---|---|
| ≥ 20 days of paper decisions | the decision journal |
| ≥ 20 recorded decisions | same |
| Execution cost measured, not assumed | /v1/execution/quality |
| Correlation with live sleeves < 0.7 | /backtest/portfolio |
live → probation (any one fires):
| Trigger | Waits for a sample? |
|---|---|
| Live drawdown > 15% | No — a breach is a fact |
| Live Sharpe more than 2.0 below the validated figure | Yes, ≥ 20 trades |
Triggers are not weighed against each other. A profitable sleeve breaching its drawdown limit still demotes — that is a trade nobody would approve if asked directly.
probation → paper, never straight back to live. A sleeve that broke has to re-earn the live gates. Bouncing in and out of live on noise is how a bad week becomes a bad month. Three probations retires it.
curl http://localhost:8007/v1/orchestrator/lifecycle
curl -X POST "http://localhost:8007/v1/orchestrator/lifecycle/register?strategy=ema_rsi_macd&symbol=AAPL" \
-H "X-Internal-Key: $INTERNAL_API_KEY"
# Promotion is admin-gated: the top of this ladder is real money.
curl -X POST "http://localhost:8007/v1/orchestrator/lifecycle/promote?strategy=ema_rsi_macd&symbol=AAPL" \
-H "X-Internal-Key: $INTERNAL_API_KEY" -H "X-Admin-Key: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"deflated_sharpe_ratio":0.97,"out_of_sample_trades":45,"out_of_sample_return_pct":0.08}'
# Demotion is not gated.
curl -X POST "http://localhost:8007/v1/orchestrator/lifecycle/demote?strategy=ema_rsi_macd&symbol=AAPL&to=probation&reason=manual" \
-H "X-Internal-Key: $INTERNAL_API_KEY"A refused promotion names every gate that failed, so there is no need to read code to find out what to fix:
{"promoted": false,
"failed": ["no walk-forward result on file — run --walk-forward first",
"out-of-sample trade count unknown"]}autonomy-orchestrator and strategy-service's worker can each place orders —
the worker posts to execution-service directly. So the roster lives in
libs/lifecycle and both read the same state file. A safety control that one
code path can walk around is worse than no control, because it creates
confidence that is not warranted.
A gated signal is recorded, not dropped: it goes to the journal under
lifecycle_gate with would_have_traded: true. That recorded history is
exactly the evidence the sleeve's promotion to live is gated on, so a paper
sleeve that silently discarded its signals could never be promoted.
Being explicit, because "autonomous" oversells it:
- Evidence is harvested for walk-forward only.
run_backtest.py --walk-forwardrecords its own validation artifact and prints the promote call to copy, so a promotion cites a row the server reads rather than numbers the caller typed. Nothing runs it on a schedule — you still choose when to validate. - The drawdown check needs a denominator you declare. A drawdown percentage
is a fraction of the money at risk, and the journal records fills rather than
account sizes. Set
LIFECYCLE_CAPITAL_BASE_USD, orLIFECYCLE_MAX_LIVE_DRAWDOWN_USDfor an absolute limit. With neither, that trigger does not run and the health sweep says so in its warnings rather than reporting a clean bill it did not earn. - Most thresholds are conventions, not derivations. 0.95, 30 trades, 20 days, 0.7 correlation, a 15% drawdown, a 60-minute journal-gap grace — each is defensible and none is a law. They are environment variables so you can argue with them. The one exception is Sharpe decay, which is measured in standard errors of the estimate rather than as a fixed gap, because the same shortfall is evidence over 500 trades and noise over 20.
- It cannot make a bad strategy good. It only stops one reaching real money before it has shown anything, and takes it away when it stops working.
uv run python scripts/attribute_trades.py --environment paperThis answers one question and deliberately only one: when a trade went wrong,
do the recorded facts say why? It is not a performance report — the backtest
and /v1/execution/quality already do that — and it produces no
recommendation.
From the signal +75.08
Entry execution -25.82
Exit execution -22.70
------------------------------------------
Realised +26.56
Those three components are an exact identity: they reconstruct the realised result, which is what makes them worth arguing about rather than a set of categories that feel meaningful. "Signal" is what the strategy's own decisions would have earned with perfect fills; the other two are what trading cost. A test asserts the identity on every attribution.
The reading above is the point of the exercise: the strategy was right, and execution took 65% of what it earned. The realised number alone cannot distinguish that from a strategy that was simply wrong, and they call for completely different responses.
Everything approximate — max favourable/adverse excursion, capture ratio, exit reason — is reported as a diagnostic rather than folded in. Folding an approximation into an identity is how the identity stops being one.
Closed round trips 20
Fully attributable 18 (90%)
Missing inputs — this is the work L0 implies:
entry_decision_price missing on 2 trade(s)
An attribution that cannot be computed names the field it is missing rather than substituting a zero. A zero would read as "execution cost nothing" instead of "we did not record what it cost", and would make the archive look richer than it is — which is the one outcome that would make this stage worthless.
Alternative stops, holding longer, and the best exit available are computed
against bars_as_of(exit) — the series as the system held it when the trade
closed, not the corrected one. A revision that arrived afterwards must not
decide that a different exit was better; that is hindsight wearing the clothes
of analysis.
They are questions for a later phase, not recommendations. Per ADR 0001 nothing may propose a change until attribution has shown the archive can explain outcomes at all — this is L0 of that roadmap, and it proposes nothing.
The price decomposition says how much of a result came from the signal. It cannot say why the signal was right or wrong, and for a rule-based strategy the most common answer is that the rule was applied in conditions it was not built for. A trend entry taken in a range earns nothing however well it is executed, and that failure looks identical to a bad rule in the numbers above.
So each trade carries the regime at both ends, classified from bars the system
actually held at that moment — bars_as_of(entry) for the entry, not the
exit-time series, because a revision that arrived during the hold was not
knowable when the entry was decided. Results are then grouped by the regime
each trade was entered into:
BY ENTRY REGIME
regime trades realised signal win rate
ranging 8 -268.00 -220.00 0%
trending_up 6 +603.00 +630.00 100%
The headline for that archive is +335 realised, which reads as a strategy
that works. The slices say it earns in a trend and loses in a range — which
points at a missing filter rather than at a broken rule, and those imply
completely different work.
A regime that cannot be measured gets its own unknown row rather than a
residual bucket that resembles a real one. compute_adx returns 25.0 when the
series is too short, which reads as "mildly trending"; letting that into an
analysis would turn the absence of evidence into evidence. Regime is a
diagnostic and never enters the identity — a label with a threshold in it is an
opinion, and the three price components have to add up regardless of anyone's
opinion about ADX.
The same sentinel was a live defect. The strategy worker gates trend entries on
ADX < 20, and 25.0 sits above 20 — so on thin data, or with no market snapshot at all, the regime filter passed on a fabricated number. It now refuses an entry whose regime it cannot measure.
- It cannot see a trade it has no record of. Coverage below 100% means exactly that, and the fix is upstream in what gets recorded.
- Pairing is FIFO within one (strategy, symbol, environment, account). It never crosses environments, so a paper entry can never be matched to a live exit — but it also assumes FIFO matches how you think about your lots.
uv run python scripts/specialist_report.py --symbols AAPL,MSFT
uv run python scripts/specialist_report.py --symbols AAPL --as-of 2026-08-20T14:00:00ZL1 of ADR 0001. Typed specialist roles read the point-in-time archive and produce structured claims — each with the measurement behind it, the threshold it was judged against, and a reference to the rows it came from. It proposes nothing. No component reads the output to decide anything; the risk veto is L2 and is deliberately built before anything can propose.
Two constraints are enforced in code rather than written down. A specialist
receives a PointInTimeArchive pinned to a moment, never the journal, and that
object has no method returning the corrected series — so no role can consult
one even by accident. And a Claim built without evidence raises at
construction, rather than being filtered out later by something that might not
run.
Determinism is the easy half. The half that bites is point-in-time isolation: a role can be perfectly deterministic and still silently improve every time the archive is corrected, which makes every historical conclusion unfalsifiable — re-running it never reproduces what was originally said. A test records a series, assesses at T, stores a revision that would flip the classification, re-assesses at T, and requires an identical digest. A second test requires that an assessment made after the revision does see it, so the first cannot pass by ignoring revisions altogether.
That is the finding, not a limitation of the code:
| Role | Status |
|---|---|
| Market | reads bar_observations as-of |
| Technical | reads bar_observations as-of |
| News | blocked — no headline store with observed-at times |
| Sentiment | blocked — computed on request into a process-local dict, never persisted |
| Fundamentals | blocked — research reports are a TTL cache keyed by symbol; it holds the current answer, not the sequence |
The blocked three stay in the roster reporting unavailable with the storage
each needs. A missing role is a gap someone has to close; an absent one is a
gap nobody can see. None was built against its live source — an assessment "as
of" a past moment constructed from today's data is exactly the leakage the
archive prevents, and it would not be visible in the output.
On a ranging symbol the technical role reported a bullish average cross and positive MACD, while the market role reported no directional trend — "a trend-following entry here is being taken in conditions it is not built for". That is the same finding the regime slices above produced from realised losses, recovered from the archive before a trade rather than after one.
Should these roles be LLM-backed? Answered at L1: not the two that are buildable. Their claims are arithmetic over an archived series — an ADX reading against a threshold, an average cross, a histogram sign — and a model restating them would add a paraphrase while removing reproducibility, since a digest that changes between runs cannot distinguish "the market changed" from "the model did". The three blocked roles are the ones whose input is unstructured text, where a model would do work no threshold can. That question becomes live when their archives exist.
L2 of the same ADR (libs/veto), and it runs in the command above — first, and
without ever seeing a specialist claim:
Vetoed — the risk veto refuses these subjects:
THIN insufficient_history: 12 archived bars for THIN; nothing
downstream can rest on fewer than 60
OLD stale_series: the freshest bar for OLD is 4320 minutes old;
this is reasoning about a memory
Three properties are arranged rather than promised:
- Independent.
review()takes a journal and a subject. There is no parameter for an argument, so it cannot be handed one. - Rejection-only. No
approved,okorpassedfield, andVetoDecision.__bool__raises. The way this authority gets lost is a caller writingif veto_ok(x):, after whichnot rejectedandapprovedare the same bit. Here a decision cannot be used in a condition at all. - Final. A frozen dataclass with no override, and no
--forceflag on the command. A veto that can be talked out of is not a veto.
Its scope is only whether a subject can be reasoned about — enough history,
a current series, not hopelessly gapped, an instrument whose orders are not
being rejected. It never judges merit: a veto with merit criteria is an
approver with a negative sign. Checks that could not run are listed under
unchecked, because a veto that skipped half of them and said nothing looks
exactly like one that ran them all and found nothing.
Running it found a bug in its own staleness rule.
completenessreportsstale_minutes=Nonewhen the window holds no bars — the exact case the rule exists for — so a symbol dead for three days passed cleanly while a merely-late one was caught. Staleness now comes from the freshest bar held.
L3 (libs/challengers) — the first phase that proposes anything. What keeps
that safe is not the generator being careful; it is that a proposal has nowhere
to go. A Challenger is frozen, carries no lifecycle state, no sleeve id and
no environment, and has no method that writes. A test asserts the package never
imports the lifecycle authority.
Clamped, not validated. A validator rejects and lets the caller retry, which under a generator means it eventually proposes whatever it wanted. An out-of-range value is pulled to the bound and the adjustment is recorded, so a challenger pressing against a limit is visible as that rather than as one that chose the boundary on merit. A parameter with no declared bound is refused outright — otherwise the bounds only constrain the fields somebody remembered to list. Position sizing and risk ceilings are deliberately absent: those are safety policy, out of reach of anything automated.
The trial count is pooled across the campaign. This is the part that makes the statistics mean anything:
challenger OOS Sharpe DSR own DSR pooled overstated
chal-0c0c473d4099 1.635 0.732 0.684 +0.047
chal-3cd8122dee4c 3.188 0.886 0.857 +0.029
chal-56fa2d8872b0 1.599 0.755 0.702 +0.052
chal-58d6724698c9 1.635 0.732 0.684 +0.047
survivors: []
A walk-forward deflates its winner against the configurations that run tried.
Run it eight times and report each winner's own deflated ratio, and every one
of those numbers still answers the one-run question — while the search actually
performed was eight times larger. evaluate_campaign pools every trial from
every challenger and re-deflates against the pooled set. The gate reads the
pooled figure, and there is no fallback to the per-run one when pooling can't
be computed: substituting it would put the overstated number in the single
field that decides.
Challengers are content-addressed, so re-proposing the same configuration under a new name cannot inflate the bar its siblings are judged against. Nothing survived above — the expected outcome of most campaigns, reported as a result, because the alternative is a search that always finds something.
L4. A surviving challenger runs in paper alongside the champion, on the same symbols and window, and is compared using the same attribution machinery. Then it stops.
champion 24 trades realised +72.00
challenger 24 trades realised +144.00
refused: L4X:l4_demo@chal-15db42c583df was derived from a challenger
proposal and cannot be promoted to live.
The challenger doubled the champion's paper return and was refused anyway. That is the design:
sleeve.originmarks a roster row derived from a proposal, and promotion to live is refused categorically — not "insufficient evidence", but because of what it is. L3's safety rested on a proposal having nowhere to go; once it becomes a sleeve that has to be re-established by something the learner cannot satisfy.- The check reads the database row under lock, not the caller's
Sleeve. A snapshot is something an in-memory edit walks past — a test forges exactly that and is still refused. resolve_routerefuses a live route for a challenger too. The store barrier should make this unreachable, which is why it's worth having on the one row where being wrong costs real money.- Neither check touches exits. A challenger holding a live position must still be able to close it, or a safety check becomes a trapped position.
Clearing the barrier is adopt_challenger: it needs a named person —
system, learner, auto are refused — and a reason, and it is recorded as a
transition carrying that person's name. It does not promote. It only stops the
refusal; the sleeve still has to earn live through the ordinary gates.
Proposals live in lifecycle.challenger_proposal, deliberately not in
validation_artifact. A challenger is a proposal, an artifact is a
measurement, and promotion reads artifacts — putting generator output into the
table the gate trusts is the one place it must never appear.
compare declares no winner. No winner field, no score. Picking one from a
paper comparison is the step where a promotion gate gets bypassed by
arithmetic.
And challengers actually trade. The strategy worker runs each paper
challenger's recorded proposal through the identical pipeline — same entry
gates, same policy service, same sizing, same PDT budget — tagged with its
derived id, so the journal separates the two sides' fills. Parameters come from
lifecycle.challenger_proposal and nowhere else: no proposal row, no trades. A
broken challenger is skipped; the champion runs first, unconditionally.
Wiring this exposed a defect older than L4: the workers' advisory gate refused every sleeve below
live, so the signal loop never produced the paper fills that promotion evidence requires — the lifecycle ladder could not be climbed through normal operation. The gate now permits paper sleeves; the router still guarantees they reach only the simulator, the live-mode switch still gates every real-money route, and the halt latch still covers paper entries.
One deviation from the ADR. It says the two sleeves are distinguished by strategy version, but the roster's identity is
UNIQUE (strategy_id, symbol, account_id)— no version. Widening that would allow two rows for one sleeve whileget/requirereturn one, breaking the one-state-per-sleeve invariant every gate rests on. So a challenger runs under a derived id,champion@chal-abc123, and the constraint stays intact.
The orchestrator now runs the L3 learner as an operational loop when
LEARNING_ENABLED=true (enabled by the paper-only Compose deployment). It
selects only human-owned sleeves in paper, loads the exact versioned champion
artifact, reads point-in-time bars and closed paper round trips from the
journal, runs the independent veto and bounded walk-forward campaign, deflates
the result for every trial in the campaign, and appends the report and inert
proposals to PostgreSQL.
The deployed prompts and the ema_rsi_macd--v1 champion artifact are pinned
by SHA-256 in Compose. CI recalculates all three digests, so changing a prompt
or champion without updating its deployment pin fails the build.
The loop has deliberately asymmetric authority:
- It may read paper evidence and append learning records.
- It cannot register or transition a sleeve, write validation artifacts, change policy, enable live mode, edit code, or call a broker.
- A proposal can run as a paper challenger only through the existing challenger path.
- A challenger still needs a named human adoption event and the normal evidence gates before live is even possible.
Register the baseline with the artifact version, promote it to paper using stored validation evidence, and the scheduled learner will pick it up:
curl -X POST \
-H "X-Internal-Key: $INTERNAL_API_KEY" \
"http://127.0.0.1:8007/v1/orchestrator/lifecycle/register?strategy=ema_rsi_macd&symbol=AAPL&strategy_version=v1"Run the bounded cycle immediately or inspect its last result:
curl -X POST \
-H "X-Internal-Key: $INTERNAL_API_KEY" \
http://127.0.0.1:8007/v1/orchestrator/learning/run
curl http://127.0.0.1:8007/v1/orchestrator/learning/statusAn INSUFFICIENT_PAPER_EVIDENCE, VETOED, or
SKIPPED_INVALID_ARTIFACT_OR_EVIDENCE result is a normal refusal, not a cue
to lower a gate. No result from this loop is permission to use real money.
Intraday trading in the US runs into a rule that automated systems breach quickly: four or more day trades in five business days, with account equity under $25,000, gets an account designated a pattern day trader and restricted.
The orchestrator tracks day trades in a rolling five-business-day window and blocks new entries once the budget is spent. It never blocks an exit — holding a losing position past its stop to avoid a compliance flag trades a regulatory problem for a financial one.
curl http://localhost:8007/v1/orchestrator/day-trades{"enabled": true, "day_trades_used": 2, "max_day_trades": 3,
"day_trades_remaining": 1, "equity_threshold_usd": 25000.0}| Variable | Default | Description |
|---|---|---|
PDT_ENABLED |
true |
Set false if the rule does not apply to you |
PDT_EQUITY_THRESHOLD_USD |
25000 |
Above this the rule does not bite |
PDT_MAX_DAY_TRADES |
3 |
The 4th triggers designation; use 2 for margin |
PDT_STATE_PATH |
./day-trade-state.json |
Persisted; the window spans 5 days |
This is a US margin-account rule. Cash accounts face settlement constraints
instead, and non-US brokers have their own regimes. Confirm which applies to you
with your broker rather than trusting this default — being flagged is
disruptive to undo. Two known limitations: market holidays are not known, so in
a holiday week a day trade can expire one session early (set
PDT_MAX_DAY_TRADES=2 for margin); and crypto round trips are counted even
though FINRA's rule covers securities, which is conservative rather than unsafe.
Every bar, every price and every decision the system makes is recorded to a
single SQLite file (JOURNAL_PATH, default ./journal.db). Three tables:
| Table | Records | Deduplicated? |
|---|---|---|
bar_observations |
OHLCV as delivered by a provider | Yes, on (symbol, timeframe, bar time) |
price_observations |
Every price resolved — including ones refused as stale | No |
decisions |
Each pipeline stage, with the inputs that produced it | No |
The distinction that matters is between when the market printed a value and
when this system learned of it (bar_ts vs recorded_at, price_ts vs
observed_at). Research that conflates the two is research contaminated by
hindsight, and you cannot recover the difference after the fact — which is why
this is worth capturing from the first day rather than the first loss.
Refused prices are archived deliberately: a price rejected as stale explains a trade the system did not make, and a post-mortem that only sees fills cannot account for it.
curl http://localhost:8007/v1/orchestrator/journal # coverage + recent decisionsThe file is plain SQLite — open it with any client, or load it into pandas:
import pandas as pd, sqlite3
bars = pd.read_sql("SELECT * FROM bar_observations", sqlite3.connect("journal.db"))Journalling never blocks trading. Every write is best-effort: a full disk degrades research, it does not halt the loop or raise mid-order.
A strategy that looks profitable on close prices can lose money once it has to trade. The gap between the two is execution cost, and until you measure it you are guessing at the single number that decides whether an intraday edge survives contact with the market.
Orders go out as marketable limits with immediate-or-cancel, not market
orders. A market order accepts whatever price the book offers — on a thin
symbol or during a spike that can be well away from what the strategy assumed.
A marketable limit is priced LIMIT_TOLERANCE_BPS through the reference price
in the paying direction (a buy at 200.00 with 10bps sends a 200.20 limit), so
it fills immediately under normal conditions but refuses a fill beyond the
tolerance. IOC means it fills now or cancels: nothing is left working that
would later need managing or cancelling.
The trade-off is explicit and it is a real one:
| Narrow tolerance | Wide tolerance | |
|---|---|---|
| Fill rate | Lower | Higher |
| Price paid when filled | Better | Worse |
| Failure mode | Signals missed | Bad fills accepted |
Neither failure is free. Missing the fills your backtest assumed changes the strategy you are actually running, which is why misses are recorded as carefully as fills.
Set USE_LIMIT_ORDERS=false to fall back to market orders — worth doing once,
side by side, to see what the limits are costing or saving you.
Every order carries the price the decision was based on (decision_price).
When it fills, the difference against the fill price is recorded as
implementation shortfall, in basis points, signed so that positive is always
a cost — a buy filled high and a sell filled low both count as costs. Without
that convention, averaging buys and sells together cancels real costs to zero
and the system reports free trading.
Orders that did not fill are recorded too, with filled = 0. A fill rate read
only from fills is 100% by construction.
curl http://localhost:8002/v1/execution/quality{
"orders": 3,
"filled": 2,
"fill_rate": 0.6667,
"mean_shortfall_bps": 2.5,
"worst_shortfall_bps": 5.0,
"mean_shortfall_by_symbol": {"AAPL": 1.0, "THIN": 5.0}
}Feed mean_shortfall_bps back into the backtest. The backtest's slippage
assumption is otherwise a guess; this is a measurement from your own orders, on
your own symbols, at your own size. Run the strategy in paper mode long enough
to accumulate fills, then re-run the backtest with the measured figure:
uv run python scripts/run_backtest.py --slippage-bps <measured mean>Read worst_shortfall_bps and the per-symbol breakdown as well as the mean. A
tolerable average with one symbol paying five times the rest is a liquidity
problem in that symbol, not a pricing problem in the strategy.
An order that is a large fraction of a symbol's volume moves the price against
itself, so its cost becomes a function of its own size. Orders are therefore
capped at MAX_ADV_PARTICIPATION of average daily volume, inferred from the
bars already being fetched (intraday bar volumes are scaled up by the number of
bars in a session).
At the default 1% this never binds on mega-caps and binds hard on thin names — which is the intent. A symbol too thin to support even one share at the cap is skipped rather than rounded up to a size that would pay for its own impact.
Caveat, stated plainly: ADV inferred from a short intraday lookback is a rough estimate, and it says nothing about the spread or the depth at the touch. It is a guard against the obviously oversized order, not a market-impact model.
The broker is the source of truth. portfolio-service derives holdings from
our own fill history, which makes it a cache — and a cache that silently
diverges will eventually trade against a position that does not exist, or leave
a real position with nothing watching it.
A scheduled job compares the two and reports three kinds of break:
| Kind | Meaning |
|---|---|
untracked_position |
The broker holds something we do not know about |
phantom_position |
We think we hold something the broker does not — a stop watching nothing |
quantity_mismatch |
Both know the position, sizes disagree |
curl "http://localhost:8007/v1/orchestrator/reconciliation?refresh=true"Two design choices worth knowing:
- A single mismatch does not halt anything. A fill in flight legitimately
appears at the broker before it appears in our fills. Only a break that
survives
RECONCILE_BREAKS_BEFORE_HALTconsecutive checks is believed. - Only entries are blocked, never exits. Refusing to close a position you cannot account for is strictly worse than closing it.
An unreachable service is not treated as a divergence — otherwise every container restart would stop trading.
Live mode and the roster are two separate switches, and you need both. Live mode connects the system to a real broker; the roster decides which sleeves may send it anything. Enabling live mode alone produces a system that connects to your broker and places no orders — which is the safe failure, but it looks like a bug if you are not expecting it.
- Set
ETORO_DEMO=falseandBROKER=etoroin.env - Set
ADMIN_API_KEYto a strong random secret in.env - Restart all services
- Verify kill switch is OFF in the dashboard
- Set weekly cap in
config/policy-baseline.yaml(weekly_notional_cap_usd) - Call the live-mode endpoint with admin credentials:
curl -X POST http://localhost:8007/v1/orchestrator/live-mode \ -H "X-Internal-Key: $INTERNAL_API_KEY" \ -H "X-Admin-Key: $ADMIN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"enable": true, "confirmation": "I CONFIRM LIVE TRADING"}'
- Promote at least one sleeve to
live— see The Strategy Lifecycle for the gates, and the RUNBOOK for the walkthrough. Confirm with:An emptycurl http://localhost:8007/v1/orchestrator/lifecycle # "trading": [...]tradinglist means nothing will be sent to the broker. - Monitor the first 10 trades manually via the dashboard
uv run pytestRun it bare. pytest tests/ skips services/backtest-service/tests — the
backtest engine, the overfitting statistics and the portfolio all live there,
which is around a fifth of the suite. Both paths are in testpaths, so no
argument is needed.
uv run pytest -q --ignore=tests/integration # skip the ones needing Postgres
uv run ruff check . # lint