Writing a Strategy
Everything you need to build, backtest, and deploy a custom trading strategy.
Overview
Strategies are TypeScript files that implement the Strategy interface.
The same code runs in backtest, paper, and live modes — the runner swaps the broker implementation
while your strategy logic stays identical.
Strategy files can live in three places, loaded in priority order:
- User strategies —
data/users/{userId}/strategies/my-strategy.ts(private, highest priority) - Shared strategies —
data/shared/strategies/my-strategy.ts(community, in data volume) - Built-in strategies —
src/strategies/my-strategy.ts(shipped with the app, read-only)
If you have a strategy with the same filename at multiple levels, the highest-priority version is loaded. Copy a built-in or shared strategy to customize it.
All imports must use #-prefixed subpath aliases (e.g., #core/strategy.js)
so they resolve correctly regardless of where the file is on disk.
Minimal Example
A complete strategy that buys EUR/USD when the spread narrows below a threshold:
import type { Strategy, StrategyContext, StrategyStateSnapshot } from "#core/strategy.js";
import type { Tick } from "#core/types.js";
export const strategyMeta = {
name: "Simple Spread",
description: "Buys EUR/USD when spread is tight, sells when it widens.",
configFields: {
common: {
spreadThreshold: {
label: "Max spread (pips)", type: "number" as const,
default: 1.5, min: 0, step: 0.1,
},
},
backtest: {},
live: {
units: { label: "Units", type: "number" as const, default: 1000, min: 1 },
},
},
};
interface Config {
spreadThreshold: number;
units: number;
}
const DEFAULTS: Config = { spreadThreshold: 1.5, units: 1000 };
export class SimpleSpreadStrategy implements Strategy {
readonly name = "simple-spread";
readonly hedging = "forbidden" as const;
readonly instruments = ["EUR_USD"] as const;
private config: Config;
private inPosition = false;
constructor(cfg: Record<string, unknown>) {
this.config = {
spreadThreshold: (cfg.spreadThreshold as number) ?? DEFAULTS.spreadThreshold,
units: (cfg.units as number) ?? DEFAULTS.units,
};
}
async init() {}
async onTick(ctx: StrategyContext, tick: Tick) {
if (tick.instrument !== "EUR_USD") return;
const spread = (tick.ask - tick.bid) * 10_000; // convert to pips
if (!this.inPosition && spread < this.config.spreadThreshold) {
await ctx.broker.submitOrder({
instrument: "EUR_USD",
side: "buy",
type: "market",
units: this.config.units,
});
this.inPosition = true;
} else if (this.inPosition && spread > this.config.spreadThreshold * 2) {
await ctx.broker.closePosition("EUR_USD");
this.inPosition = false;
}
}
async dispose() {}
getState(): StrategyStateSnapshot {
return {
phase: this.inPosition ? "In position" : "Watching",
indicators: [],
positions: [],
};
}
}Strategy Interface
Your class must implement every member of the Strategy interface from #core/strategy.js:
| Member | Type | Description |
|---|---|---|
name | string | readonly |
hedging | HedgingMode | readonly |
instruments? | readonly string[] | Instruments this strategy needs streamed. If absent, the runner uses a default set. |
init | (ctx: StrategyContext) => Promise<void> | Called once when the strategy starts |
onTick | (ctx: StrategyContext, tick: Tick) => Promise<void> | Called on each price tick |
dispose | () => Promise<void> | Called when the strategy is stopped |
getState | () => StrategyStateSnapshot | Return current strategy state for live monitoring |
checkpoint? | () => unknown | For "checkpoint" recovery: return serializable state |
restore? | (state: unknown) => void | For "checkpoint" recovery: restore from serialized state |
recover? | (ctx: StrategyContext, positions: Position[]) => Promise<void> | For "custom" recovery: strategy handles its own recovery |
Context & Broker API
The ctx parameter gives you access to ctx.broker, which implements
the Broker interface. The same interface is backed by OANDA in live mode and a
simulated broker in backtests.
StrategyContext
| Field | Type | Description |
|---|---|---|
broker | Broker | |
backfilling? | boolean | True during candle replay (backfill recovery) — skip order placement |
Broker Methods
| Method | Signature | Description |
|---|---|---|
getAccountSummary | () => Promise<AccountSummary> | |
getPositions | () => Promise<Position[]> | |
submitOrder | (order: OrderRequest) => Promise<OrderResult> | |
closePosition | (instrument: Instrument) => Promise<OrderResult> | |
getCandles | (instrument: Instrument, granularity: Granularity, count: number) => Promise<Candle[]> | |
getPrice | (instrument: Instrument) => Promise<Tick> |
OrderRequest
| Field | Type | Description |
|---|---|---|
instrument | Instrument | |
side | Side | |
type | OrderType | |
units | number | |
price? | number |
Tick Object
Every call to onTick receives a Tick:
| Field | Type | Description |
|---|---|---|
instrument | Instrument | |
timestamp | number | |
bid | number | |
ask | number |
Ticks arrive for all instruments listed in your instruments property.
Always check tick.instrument before using the price.
Strategy Metadata
Export a strategyMeta constant to control how your strategy appears in the UI.
This is optional but recommended.
export const strategyMeta = {
name: "My Strategy", // Display name
description: "What this strategy does...", // Shown on the strategies page
configFields: { ... }, // See Config Fields below
recovery: { mode: "clean" }, // See Live Recovery below
}; If you don't export strategyMeta, the name is derived from the filename
(e.g., my-strategy.ts becomes "My Strategy").
Config Fields
Config fields define form inputs that appear in the UI when running backtests or starting
live sessions. Values are passed to your strategy constructor as a Record<string, unknown>.
configFields: {
common: {
// Shown in both backtest and live UI
entryZ: { label: "Entry z-score", type: "number", default: 2.0, min: 0, step: 0.1 },
},
backtest: {
// Backtest-only fields
},
live: {
// Live-only fields
units: { label: "Units", type: "number", default: 10000, min: 1 },
},
} | Field Property | Type | Description |
|---|---|---|
label | string | Label shown in the form |
type | "number" | "text" | Input type |
default | number | string | Default value (pre-filled in UI) |
min | number | Minimum value (number inputs only) |
step | number | Step increment (number inputs only) |
placeholder | string | Placeholder text (text inputs only) |
In your constructor, read these from the config object and merge with defaults:
constructor(cfg: Record<string, unknown>) {
// Clean empty strings from UI form values before merging
const cleaned: Record<string, unknown> = {};
for (const [k, v] of Object.entries(cfg)) {
if (v !== "" && v != null) cleaned[k] = v;
}
this.config = { ...DEFAULTS, ...cleaned };
}Live Monitoring (getState)
The getState() method powers the live monitoring page. It's called after every tick
and the returned snapshot is displayed in real time. Return useful indicators and position info
to make monitoring easier.
function getStateExample(): StrategyStateSnapshot {
return {
phase: "Scanning", // Current state label
detail: "Warming up 45/60 ticks", // Optional extra info
indicators: [
{ label: "Z-Score", instrument: "AUD_CAD", value: "1.82", signal: "neutral" },
{ label: "Spread", value: "1.2 pips", signal: "buy" },
],
positions: [
{ instrument: "EUR_GBP", side: "sell", entryPrice: 0.8421, pnl: -2.30 },
],
};
} The signal field on indicators controls color coding: "buy" (green), "sell" (red), "neutral" (gray), "warn" (yellow).
Available Imports
Use #-prefixed aliases for all imports. These resolve via the project's package.json imports field.
| Import Path | Resolves To | Key Exports |
|---|---|---|
#core/strategy.js | src/core/strategy.ts | StrategyContext,RecoveryConfig,HedgingMode,StrategyIndicator,StrategyPosition,StrategyStateSnapshot,Strategy |
#core/types.js | src/core/types.ts | Instrument,Candle,Tick,Side,OrderType,OrderRequest,OrderResult,Position,AccountSummary,Granularity,GRANULARITY_SECONDS,GRANULARITIES_SORTED |
#core/broker.js | src/core/broker.ts | Broker |
#data/instruments.js | src/data/instruments.ts | USD_MAJORS,CROSSES,ALL_INSTRUMENTS,CURRENCIES,Currency,parsePair,findInstrument,findTriangle |
#backtest/types.js | src/backtest/types.ts | BacktestConfig,SignalSnapshot,Trade,BacktestResult |
#backtest/broker.js | src/backtest/broker.ts | BacktestBroker |
Backtest Compatibility
During backtests, ctx.broker is a BacktestBroker. You can check
this to record signal snapshots for the trade journal:
import { BacktestBroker } from "#backtest/broker.js";
import type { SignalSnapshot } from "#backtest/types.js";
// Inside onTick:
function recordSignalExample(ctx: StrategyContext) {
if (ctx.broker instanceof BacktestBroker) {
const signal: SignalSnapshot = {
zScore: 2.1,
deviation: 0.003,
deviationMean: 0.001,
deviationStd: 0.001,
impliedRate: 0.9012,
actualRate: 0.9042,
legA: "AUD_USD", legAPrice: 0.6543,
legB: "USD_CAD", legBPrice: 1.3789,
};
ctx.broker.setEntrySignal(signal);
}
} Signal snapshots are attached to trades in the backtest report, making it easier to debug entry/exit decisions.
Live Recovery
When a strategy is restarted (service restart, crash recovery), the service uses the recovery field in strategyMeta to decide how to restore state.
If omitted, the default is "clean" (restart fresh).
Recovery Modes
// In strategyMeta:
const clean: RecoveryConfig = { mode: "clean" };
const backfill: RecoveryConfig = { mode: "backfill", lookback: 120, granularity: "M1" };
const checkpoint: RecoveryConfig = { mode: "checkpoint" };
const custom: RecoveryConfig = { mode: "custom" }; Backfill: skip orders during replay
async function onTickWithBackfill(ctx: StrategyContext, tick: Tick) {
if (ctx.backfilling) {
// Update indicators but skip order placement
return;
}
// Normal trading logic...
} Checkpoint: serialize/restore state
class CheckpointExample {
private zScores = new Map<string, number>();
private lastPrices = new Map<string, number>();
checkpoint(): unknown {
return {
zScores: [...this.zScores.entries()],
lastPrices: [...this.lastPrices.entries()],
};
}
restore(state: unknown): void {
const s = state as { zScores: [string, number][]; lastPrices: [string, number][] };
this.zScores = new Map(s.zScores);
this.lastPrices = new Map(s.lastPrices);
}
} Custom: full control
async function recoverExample(ctx: StrategyContext, positions: Position[]) {
// Fetch recent candles to rebuild indicator state
const candles = await ctx.broker.getCandles("EUR_USD", "M1", 60);
// Check existing positions from broker
const existing = await ctx.broker.getPositions();
// Rebuild strategy state from candles and positions...
}Tips
- Use
hedging: "forbidden"unless you specifically need simultaneous long/short on the same pair. Most OANDA practice accounts use netting. - Always filter ticks by
tick.instrumentinonTick— you'll receive ticks for all instruments in your list. - Keep a
DEFAULT_CONFIGobject and merge with constructor args. Config values from the UI may be missing or zero. - The live service automatically closes all positions on shutdown, so
dispose()only needs to clean up your internal state. - Test with backtests first. Use the "Realistic" preset (1.5x spread, 0.5 pip slippage) to avoid overfitting to ideal conditions.
- For cross-pair strategies, use the helpers from
#data/instruments.js—parsePair()splits"AUD_CAD"into currencies,findTriangle()finds the USD legs for any cross. - The class name must end with
Strategy(e.g.,MyCustomStrategy). This is how the loader auto-discovers it. - The filename is the strategy ID. Use kebab-case:
my-strategy.tsis loaded with--strategy=my-strategy.