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:

  1. User strategiesdata/users/{userId}/strategies/my-strategy.ts (private, highest priority)
  2. Shared strategiesdata/shared/strategies/my-strategy.ts (community, in data volume)
  3. Built-in strategiessrc/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:

MemberTypeDescription
namestringreadonly
hedgingHedgingModereadonly
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() => StrategyStateSnapshotReturn current strategy state for live monitoring
checkpoint?() => unknownFor "checkpoint" recovery: return serializable state
restore?(state: unknown) => voidFor "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

FieldTypeDescription
brokerBroker
backfilling?booleanTrue during candle replay (backfill recovery) — skip order placement

Broker Methods

MethodSignatureDescription
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

FieldTypeDescription
instrumentInstrument
sideSide
typeOrderType
unitsnumber
price?number

Tick Object

Every call to onTick receives a Tick:

FieldTypeDescription
instrumentInstrument
timestampnumber
bidnumber
asknumber

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 PropertyTypeDescription
labelstringLabel shown in the form
type"number" | "text"Input type
defaultnumber | stringDefault value (pre-filled in UI)
minnumberMinimum value (number inputs only)
stepnumberStep increment (number inputs only)
placeholderstringPlaceholder 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 PathResolves ToKey Exports
#core/strategy.jssrc/core/strategy.tsStrategyContext,RecoveryConfig,HedgingMode,StrategyIndicator,StrategyPosition,StrategyStateSnapshot,Strategy
#core/types.jssrc/core/types.tsInstrument,Candle,Tick,Side,OrderType,OrderRequest,OrderResult,Position,AccountSummary,Granularity,GRANULARITY_SECONDS,GRANULARITIES_SORTED
#core/broker.jssrc/core/broker.tsBroker
#data/instruments.jssrc/data/instruments.tsUSD_MAJORS,CROSSES,ALL_INSTRUMENTS,CURRENCIES,Currency,parsePair,findInstrument,findTriangle
#backtest/types.jssrc/backtest/types.tsBacktestConfig,SignalSnapshot,Trade,BacktestResult
#backtest/broker.jssrc/backtest/broker.tsBacktestBroker

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.instrument in onTick — you'll receive ticks for all instruments in your list.
  • Keep a DEFAULT_CONFIG object 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.jsparsePair() 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.ts is loaded with --strategy=my-strategy.