# Python strategies on Backtrade

Write a `Strategy` class, save an immutable version, and queue recorded days.
Start the local runner for that experiment. Backtrade stores the configuration,
job states, recent logs and server-calculated replay results; it never executes
uploaded Python in its API process.

Open **Agents → Code strategies** to save code and queue experiments in the UI.
A linked AI agent can use `register_strategy`, `list_strategies`, `run_strategy`,
`strategy_results` and `stop_strategy` through hosted or downloaded MCP.

## Local runner installation

Requirements: Node 22+, Docker Engine/Desktop, and an account-linked Backtrade key.
Link the normal Backtrade MCP client to your account first; the runner uses the
same private credential file. Alternatively pass `--credential /private/key.json`
using the active credential format documented in the broker bridge guide.

```sh
curl -fLO https://backtrade.dev/backtrade-runner.mjs
curl -fLO https://backtrade.dev/backtrade-runner.mjs.sha256
sha256sum -c backtrade-runner.mjs.sha256
node backtrade-runner.mjs --prepare
```

The prepare command prints the pinned Python image pull command. Run that command
once, then start the experiment shown in the UI or returned by the API:

```sh
node backtrade-runner.mjs --experiment YOUR_EXPERIMENT_ID
```

Keep the runner open. Each claimed job creates its own replay account and watch
URL. Several runners may claim different days of one experiment; a job has one
exclusive lease. A stopped or expired lease is never automatically restarted.
Create a new experiment to retry interrupted work. `Ctrl+C` stops the runner;
**Stop experiment** also cancels queued jobs and requests running jobs to stop.

Python runs as a non-root user in a network-isolated container without broker/provider credentials,
read-only code/root filesystem, 256 MiB RAM, one CPU, a process limit, and a
bounded temporary directory. No arbitrary third-party dependency installation is
supported in this version. Review code before running it; container isolation is
not a claim of protection against every host or Docker vulnerability.

## Strategy interface

```python
class Strategy:
    def on_start(self, context):
        self.entered = False

    def on_bar(self, context, bar):
        if not self.entered:
            context.submit_order(side="buy", qty=1)
            self.entered = True

    def on_fill(self, context, fill):
        context.log("Filled " + str(fill["filled_qty"]))

    def on_finish(self, context):
        context.log("Decision window complete")
```

Callbacks are optional. `on_order(context, order)` receives order-state changes;
`on_fill` includes cumulative `filled_qty` and incremental `fill_qty` / `fill_price`. The same class instance lives for one job. Every callback
gets an updated `context`:

- `parameters`: your frozen JSON parameters.
- `symbol`, `clock`: selected ticker and available replay time, in UTC.
- `account`: account, cash/equity and position snapshot. Broker numeric values may be strings.
- `orders`: the current order list, including working-order IDs for cancellation.
- `position(symbol=None)`: signed quantity, zero when flat.
- `history(limit=100)`: up to 1,000 available completed decision bars, including
  the current bar. Each has `t`, `o`, `h`, `l`, `c`, `v`, and `S`.
- `submit_order(side=..., qty=..., type="market", limit_price=..., stop_price=...)`:
  queues an order intent. DAY market, limit, stop and stop-limit are supported.
- `cancel_order(order_id)`: queues cancellation by the broker's returned ID.
- `log(message)`: appends a bounded log message visible in experiment details.

An intent is not a fill. `on_fill` receives the confirmed broker order including
`id`, `side`, `filled_qty`, `filled_avg_price` and `filled_at`. Callbacks return
intents to the runner, which validates risk limits before submitting them.
`on_finish` is read-only. Orders need to be placed before the decision window
ends. User code does not control time, access future bars, or hold API credentials.

The current runner uses the Alpaca-shaped Backtrade adapter internally. Strategy
logic uses the small context interface, preparing it for future paper adapters.
Historical replay is the default and requires no external broker. The optional
`--paper` command runs the same source through a local paper adapter; see
[the portable contract and paper workflow](PORTABLE-TRADING.md).

## Experiment semantics and results

By default, each date starts with independent capital. The default is one symbol, $100,000,
1-minute decision bars, 100 orders, a 100-share order/position limit, a 5% loss
limit, a 900-second wall-time budget and a five-second callback budget.

Minute execution defaults to 1 bp additional cost per side. Tick execution uses
recorded quotes with zero extra cost by default. Five-minute observations do not
change the execution resolution. Quote/trade observations are not exposed as
per-tick strategy callbacks in this version.

The runner warms up to `start_time` using only available data, calls `on_start`,
then delivers completed bars and fills until `end_time`. Both times are UTC;
account for US daylight saving when choosing regular-market hours. Afterward it
advances to session end without further strategy decisions to produce the normal
Backtrade scorecard. Remaining orders can execute and positions remain open;
there is no implicit liquidation. Scorecard return therefore covers the full
session end, not only the callback window. Failed/canceled/interrupted runs keep
their state and observer link but do not receive a completed scorecard.

Risk limits apply outside the Python code and include working-order exposure.
Loss limits are checked at each advance, not continuously inside a bar or tick
chunk, so a gap can exceed the threshold. Stop does not imply liquidation.
Compare identical dates, code hashes, parameters, data sources and costs; failed
runs are visible and must not be silently excluded. These are nominal-price
broker rehearsals, not protected-holdout claims of predictive skill.

Final metrics are read from Backtrade's replay ledger. Source hashes bind stored
code versions; the fact that a particular program executed is reported by your
local runner, not remotely attested. Logs are recent bounded diagnostics, not a
complete durable observation/decision transcript.

## Continuous multi-day runs

Choose **Holding period → Continuous multi-day run** in the Strategy library,
or set `config.execution_mode` to `continuous`. Select every open session in a
range of 2–20 sessions and at most 30 calendar days. One job, one Python class
instance and one replay account carry positions and cash across the range.
Single-day and independent-date experiments keep their existing behavior.

This first version supports one symbol, minute bars, and regular-session or
custom New York decision windows. Tick and model-decision runs are independent
per date. Missing calendar, tape, instrument identity or corporate-action
coverage refuses the span before jobs are queued. Dividends, splits and other
corporate actions are not simulated: periods containing recorded actions are
refused. The frozen receipt describes the providers, capture clocks and limits
of that evidence; it is not proof that no unrecorded action occurred.

`on_start` runs once. `on_day_start(context)` runs once per selected session and
`context.trading_day` names its exchange date. `on_day_end(context)` and
`on_finish(context)` are read-only. History contains at most 1,000 fully available
regular-hours decision bars across elapsed days, including regular-hours bars
outside a custom decision window. Extended hours and pre-range warmup are not
included. A 20-day moving average needs actual available history; no warmup is
invented.

The runner cancels working orders one second before each decision window ends,
then carries shares and cash. Place closing orders early enough for the next
eligible open; a market order submitted at the final minute cannot assume a
fill at the boundary. Boundary order/fill notifications are read-only. Returning an order intent from a read-only callback fails the job visibly; do not resubmit from an end-of-window cancellation notification. Idle cash
earns 0%, including nights and weekends. Order, position and sampled loss limits
apply to the whole span; an overnight gap can exceed the loss threshold. There
is no automatic liquidation on failure.

A planning floor of 60 seconds per session is required, within the 3,600-second
wall-time cap. It does not guarantee that arbitrary Python or a slow connection
will finish; timeouts remain visible failed jobs. Download the current runner:
older versions cannot claim continuous jobs. A killed Python process cannot be
resumed; create a new experiment for an explicit retry.

Results show whole-span return and drawdown, full dates on charts, and a single
starting capital. The standard peak-size benchmark values the full recorded
span; it is not a control matched to custom decision times. Use a separate
hold/cash strategy with matching entry/exit opportunities for that comparison.
Tape, engine, calendar and corporate-action receipt identities are frozen and
checked before callbacks. A changed identity requires a new evaluation.

The **Three-session example** button loads a five-share lifecycle example,
$10,000 starting cash, 5 bps costs, and a 10:00–11:00 New York window. Save it
and select three consecutive available AMD sessions with verified coverage.
It buys at the first day's decision start and submits its exit at the third
start; market fills use the next eligible open. This is a mechanics example,
not a profitable-strategy claim or a recommendation to own AMD.

## API

Authenticate with `APCA-API-KEY-ID` and `APCA-API-SECRET-KEY`. Account keys need
`orders:simulate` for mutations. Trial-only credentials cannot store strategies.
All source and results are account-private.

```text
POST /v1/strategies
{"name":"Moving average"}

POST /v1/strategy-versions
{"strategy_id":"UUID","label":"v1","source":"class Strategy: ..."}

POST /v1/experiments
{"version_id":"UUID","request_id":"unique-request-001","name":"Baseline","config":{
  "symbol":"SPY","dates":["2026-09-15"],"data":"bars","bar_interval":"1Min",
  "parameters":{"period":20},"start_time":"13:30","end_time":"20:00"
}}

GET /v1/strategies
GET /v1/strategy-versions?strategy_id=UUID
GET /v1/strategy-versions?version_id=UUID
GET /v1/experiments
GET /v1/experiments?experiment_id=UUID
POST /v1/experiments/cancel
{"experiment_id":"UUID"}
```

`request_id` makes experiment submission idempotent; reusing it with different
inputs is rejected. Source is limited to 32 KiB. Up to 20 dates per experiment,
100 queued/running jobs and 5,000 retained jobs per account in this initial build.
The runner uses `/v1/runner/claim`, `/heartbeat`, `/complete` and `/fail` with a
credential-bound lease. User code never receives the lease token.

## Results, comparison and observation

Choose up to four experiments in Strategy Lab to compare per-date P&L, return,
worst session drawdown and configuration differences. Different dates, costs,
data versions or decision windows are flagged. Failed/stopped jobs remain visible;
aggregate results require every requested date to complete. Dollar P&L includes
modeled costs; do not subtract the cost column again.

Completed observers open on the decision window. Use **Trading window** to return
to it or **Full session** to see all recorded bars. **Logs** places recent
runner-reported decisions beside orders/fills; timestamps show ET and UTC and
can focus the chart. The full source hash remains in Setup.

[Metric units and legacy-result conversion](METRIC-UNITS.md) describe version 2
scorecards and benchmark/valuation windows.

## Calendar-aware decision windows

New experiments can use `window_mode: "regular_session"` and omit `start_time`/`end_time`. Backtrade uses each recorded date’s verified exchange open and close, including early closes and daylight saving.

For a custom window, use `window_mode: "exchange_time"` with explicit New York `start_time` and `end_time` (for example `09:30`–`10:30`). Every date must fit its regular session; the API rejects an out-of-session date rather than silently shortening the window. Closed dates return 400 and unverified calendars return 503 before jobs are queued.

The API freezes each date’s effective UTC bounds and calendar provenance in `resolved_windows`. Idempotent retries keep those bounds. Runner claims expose per-job UTC `start_time`/`end_time` for older runners and `decision_window` metadata for newer runners.

Omitting `window_mode`, or using `utc`, retains the legacy UTC window defaults of `13:30`–`20:00`. Existing experiments are not reinterpreted. Final valuation and buy-and-hold still cover the full recorded session; changing the decision window does not change that benchmark.

## Experiment identity and research setup

New experiments include a server-created immutable `manifest` binding the saved
source hash, normalized configuration and frozen calendar windows. The hash uses
`sorted-json-v1`: recursively sorted object keys, ordered arrays, finite JSON
numbers and no whitespace. A calendar revision is a refresh timestamp, so a new
refresh can change manifest identity even when effective trading windows match.
Old experiments have no recorded manifest; one is not reconstructed retroactively.

Optional `config.research_setup` records how Python code was developed:

```json
{
  "schema_version": 1,
  "purpose": "code_generation",
  "objective": "Test a bounded opening-session strategy",
  "system_prompt": "Write a Python Strategy with explicit risk limits.",
  "task_prompt": "Buy one share on the first decision bar and exit on the third.",
  "model": {"provider": "declared-provider", "id": "declared-model", "settings": {"temperature": 0}},
  "tools": [{"name": "market-research", "revision": "v1"}],
  "skills": [{"name": "trading-policy", "revision": "git-commit"}],
  "memory_policy": "isolated",
  "inference_mode": "fresh"
}
```

This is **user-declared code-generation context**, not a prompt executed by the
Python worker or proof that an LLM was used. These declarations remain metadata.
Optional [model decisions](INFERENCE.md) separately enable `ctx.infer` through a
controlled socket, with server-recorded requests/responses and recorded replay.
The container still cannot access the internet directly. Fresh calls require an
explicit user allowance and the enabled pilot; no Backtrade inference credit is
provided. Keep credentials out of prompts, source and evidence. Declarations are
private to the account. Allowed settings: temperature, top_p, seed,
max_output_tokens and reasoning_effort. A seed does not guarantee deterministic
provider output. Setup is capped at 12,000 UTF-8 bytes; each prompt at 4,000
characters, with at most 20 tool/skill references each.

Setup is hash-bearing: reordered equal object keys retry identically, but changing
it with the same `request_id` returns 409. **Duplicate setup** opens an editable
copy of the same saved version and parameters. New submission receives a new
manifest and freshly resolved calendar windows. Comparison reports research-setup
differences separately from market-data and financial comparability.

## Execution evidence and export

Runner v5 records callback input fingerprints and bounded projections, callback
results, risk refusals, order acknowledgements and observed lifecycle events.
It uploads batches to `/v1/runner/evidence` under its current job lease. Retrying an
evidence batch never resubmits an order. Records are append-only, ordered by `seq`,
and contain runner-reported `market_clock` and `observed_at`, plus server-added
`received_at`. The account can inspect the evidence in experiment results or call:

```text
GET /v1/experiments/evidence?experiment_id=UUID&job_id=UUID&after=0&limit=50
GET /v1/experiments/evidence?experiment_id=UUID&job_id=UUID&export=1
```

The MCP `strategy_evidence` tool reads pages of up to 100 events and returns
`next_after`. Use API export/download for the whole job rather than filling a
model's context with a large trace. Each export has a 12 MiB response ceiling (otherwise use paginated reads) and contains one job, source/config,
manifest, event records, coverage, reconciliation and the ledger's order/fill/
account/scorecard records. User declarations, server-saved manifest, runner events
and ledger facts carry separate authority labels. An input digest identifies
what the runner sent; it cannot reconstruct the omitted full callback input.
Large detail projections carry explicit omission markers.

Terminal runner requests declare `evidence_final_seq` and `evidence_event_count`.
The server compares them to stored sequences and reports `complete`,
`incomplete_upload`, `truncated_by_cap` or `not_reported`. During a run it may report
`recording`. Complete means the declared event upload is complete—not that every
input is retained or that model/strategy execution is independently attested.
Old runners remain supported, with evidence marked not reported. Job success and
evidence completeness are separate. A risk-rejected run can have a complete trace
and zero orders. A missing trace never supplies proof of equal behavior.

Order acknowledgements reconcile bidirectionally with the ledger using
`client_order_id` and the bridge's stable order-ID mapping. Missing/extra/duplicate
acknowledgements and ID mismatches remain visible. They are not discarded to make
a run appear matched.

Limits: 8 KiB/event, 32 events and 48 KiB/batch, 6,000 events and 4 MiB payload/job,
64 MiB stored evidence payload/account. Database/index overhead is additional.
At roughly 0.8 MiB/full-day job, the account cap holds about 80 such jobs; actual
capacity depends on recorded events. At the cap, existing evidence stays intact
and later traces are explicitly truncated. This initial release has no experiment-
delete UI to reclaim capacity. Parent job/experiment/account removal cascades to
its evidence; cancellation does not delete it. Upload gaps/conflicting duplicates
are rejected. A finalized run cannot accept new evidence or a changed final
sequence declaration.

A small [inspectable experiment walkthrough](/EXPERIMENT-EVIDENCE.md) covers the first run, duplicate, export and risk-rejection check.
