Files
2026-05-31 13:49:36 +08:00

20 KiB

Strategy Development Guide

This document explains how to add / modify Polymarket up/down strategies in this project.


1. File naming

Strategies go under the strategies/ directory, with filename format:

<prefix><number>.ts
  • prefix by strategy type:
    • d — diff-based (market path)
    • p — prob-chase (market path)
    • t — trend arbitrage (market path)
    • l — limit-order (maker resting orders; see §6A)
    • m — momentum (reserved)
  • number increments (e.g. d1.ts, d2.ts, d3.ts)

The bundled strategies are d1 / p1 / p2 (market path). Limit-order strategies use the l prefix.

On startup _runtime/loader.ts automatically scans and registers them, with no changes to the main project code.


2. Strategy interface

Each strategy exports a class implementing the IStrategy interface as the default export.

Minimal skeleton (see d1.ts for a complete example):

import type {
  IStrategy, StrategyKey, StrategyNumber,
  StrategyTickContext, EntrySignal, ExitSignal, StrategyDescription,
  StrategyDirection,
} from "./types.js";

export default class MyStrategy implements IStrategy {
  readonly key: StrategyKey = "x1";
  readonly number: StrategyNumber = 1;
  readonly name = "My Strategy";

  // private state (must be cleared in resetState)
  private peakDiff = 0;

  getDescription(): StrategyDescription {
    return {
      key: this.key,
      number: this.number,
      name: this.name,
      title: "Strategy description",
      category: { id: "diff", label: "Diff", color: "#58a6ff" },
      supportedMarkets: ["btc-5m"],   // see §3
      lines: [
        { text: "📈 Entry condition description..." },
      ],
    };
  }

  updateGuards(_ctx: StrategyTickContext): void {
    // maintain cooldown locks, cold-start checks, etc. each tick
  }

  checkEntry(ctx: StrategyTickContext): EntrySignal | null {
    if (ctx.diffBps != null && ctx.diffBps >= 8) {
      return { direction: "up" };
    }
    return null;
  }

  checkExit(_ctx: StrategyTickContext, _direction: StrategyDirection): ExitSignal {
    // usually only return sl; take profit goes via GTC (see §6)
    return null;
  }

  resetState(): void {
    this.peakDiff = 0;
  }

  getStatePayload(): Record<string, unknown> {
    return { peakDiff: this.peakDiff };
  }
}

3. Market allowlist supportedMarkets

Declare which markets this strategy supports. Unsupported markets are not shown by the frontend and skipped during the backend tick.

supportedMarkets: ["btc-5m"]                 // BTC 5m only
supportedMarkets: ["btc-5m", "btc-15m"]      // both BTC periods
supportedMarkets: ["eth-5m", "sol-5m"]       // ETH and SOL 5m
// omitted / empty array = supports all markets (not recommended; most strategy thresholds are strongly coin-dependent)

market key format: {symbol}-{period}

Convention: write a separate strategy file per coin + period.

  • Example: BTC 5m uses d1.ts, ETH 5m uses d10.ts, BTC 15m uses d20.ts
  • Do not split logic with if-else in the same file; it is hard to maintain

4. TickContext fields

checkEntry / checkExit / updateGuards all receive a StrategyTickContext:

Field Type Meaning
rem number current window remaining seconds (5m 0300, 15m 0900)
upPct number|null Polymarket up probability, 0-100 integer
dnPct number|null down probability (= 100 - upPct)
diff number|null Binance current price - window open price (absolute USD value)
diffBps number|null diff as bps of PTB, cross-market generic
prevUpPct number|null the previous tick's upPct, used to detect threshold crossings
kline1m Kline[] Binance 1m K-lines (latest at the end)
kline5m Kline[] Binance 5m K-lines
marketHoursOnly boolean user config: whether to enter only during US stock market hours
now number current timestamp (ms)

diff vs diffBps — which to use?

  • Writing a cross-market strategy → use diffBps (recommended)
    • Example: BTC diff=50diffBps≈7, SOL diff=0.05diffBps≈25
    • Thresholds written in bps are cross-market readable: if (ctx.diffBps >= 10) means a 0.1% move
  • Writing a BTC-5m-only legacy strategy → use diff (keep as is)
    • Do not casually change the USD thresholds of the legacy d1/d2/m1 etc.; they are already hand-tuned

Unit conversion

  • 1% = 100 bps
  • 0.1% = 10 bps
  • 0.01% = 1 bps
  • diffBps is rounded to 2 decimal places

5. Entry signal checkEntry

checkEntry(ctx): EntrySignal | null {
  // return null for no entry
  // return { direction: "up" | "down" } to enter
}

When called: once every 250ms, only when strategyRuntime.state === "SCANNING" (the idle scanning period after IDLE).

Notes:

  • Do not write state-machine transition logic here — after returning a signal, server.ts handles it itself
  • With multiple concurrent strategies, the first strategy to return non-null wins, iterated in strategyKeys order
  • Data guard: skip when upPct == null / diff == null (data not ready)

6. Exit signal checkExit

checkExit(ctx, direction): ExitSignal {
  // return null for no exit
  // return { signal: "sl", reason: "..." } for stop loss
  // take profit is generally not returned here (see below)
}

Take profit goes via GTC limit order (important convention)

Why: Polymarket waives the taker fee (~1.56%) for makers and pays a rebate, so a complete buy+sell using maker orders saves ~3% in fees.

Implementation (market-path strategies, i.e. those that enter via checkEntry):

  1. The strategy implements the optional method getMarketTakeProfitPrice(): number | null returning the absolute target price (0~1):
    getMarketTakeProfitPrice(): number | null {
      return 0.98;   // null = no take profit, hold to settlement
    }
    
  2. After the buy is MINED, server.ts automatically places a GTC sell order at that price (via the cond system) using the real filled shares
  3. Do not write a tp branch in checkExit; keep only sl
  4. Fallback: GTC minimum is 5 shares; if insufficient or order placement fails, the server automatically falls back to a local market take profit

Limit-path strategies (orderType: "limit") do not use this method — they manage take profit / stop loss through getLimitConditionOrder instead. See §6A.

No-take-profit strategies

The bundled strategies (d1 / p1 / p2) deliberately do not take profit — data proves that once BTC commits to a direction it reaches 99-100% at settlement, so taking profit actually earns less. They hold to settlement and return only an sl signal from checkExit, so none of them implement getMarketTakeProfitPrice.


6A. Limit-order strategies (checkLimitOrder / checkCancelOrder / getLimitConditionOrder)

Everything above (§5 checkEntry, §6 checkExit) is the market path: the server takes the order book and buys at market on a signal. A limit-order strategy is a separate path — it rests a GTC maker buy order on the book, manages its own cancellation, and on fill places a co-managed take-profit + stop-loss pair. Using maker orders on both legs is what saves the ~3% fee.

A strategy opts into this path purely by what it implements; the main project code never changes.

Opting in

Set orderType: "limit" in getDescription() and implement checkLimitOrder. The market-path methods become no-ops:

getDescription(): StrategyDescription {
  return {
    key: this.key, number: this.number, name: this.name,
    title: "...",
    category: { id: "limit-diff", label: "Limit Diff", color: "#3fb950" },
    supportedMarkets: ["btc-5m"],
    orderType: "limit",            // ← declares this as a limit strategy
    lines: [ /* ... */ ],
  };
}

// market path unused — return nothing
checkEntry(_ctx) { return null; }
checkExit(_ctx, _dir) { return null; }

(orderType may also be "both" if a strategy uses the market and limit paths together. Omitted defaults to "market".)

The three hooks and when the server calls them

The server runs a limit tick every cycle. For each enabled limit strategy it follows this state machine:

Situation Hook called Return Server action
No active resting order this window checkLimitOrder(ctx) LimitOrderSignal place a GTC maker buy at the returned price/shares
null do nothing this tick
An order is resting (unfilled / partially filled) checkCancelOrder(ctx, order) true cancel the resting order
false leave it on the book
A resting order is confirmed filled on-chain (MINED) getLimitConditionOrder() { stopProfit?, stopLoss? } place the TP + SL conditional pair for the filled shares

Key timing rules the server enforces for you — you do not code these:

  • One order per window by default. After placing, the window is marked; checkLimitOrder won't be called again that window. (Set limitAllowReplaceAfterCancel = true to allow re-placing after a cancel — see below.)
  • checkLimitOrder and checkCancelOrder are mutually exclusive per tick: the place hook only runs when there is no resting order, the cancel hook only when there is one.
  • Remaining-shares cancellation after a fill is automatic — your checkCancelOrder does not need to handle "cancel the unfilled remainder once partially filled".
  • All resting orders and presign caches are cleared on window switch.

checkLimitOrder — place a resting buy

Called each tick while the strategy has no active order. Return a LimitOrderSignal to rest a GTC maker buy, or null to wait.

checkLimitOrder(ctx: StrategyTickContext): LimitOrderSignal | null {
  // decide direction / price / shares from ctx (rem, diff, upPct, dnPct, ...)
  if (/* your entry condition */) {
    return {
      direction: "up",     // or "down"
      price: 0.50,         // 0~1 absolute limit price
      shares: this.shares, // minimum 5 (Polymarket maker minimum)
    };
  }
  return null;
}

checkCancelOrder — pull the resting buy

Called each tick while an order is resting. Return true to cancel (e.g. the edge that justified the order has decayed, or the window is near its end), false to keep resting. The current order's runtime state is passed in:

checkCancelOrder(ctx: StrategyTickContext, order: LimitOrderRuntime): boolean {
  // order: { direction, price, shares, filledSize, windowStart }
  if (/* condition gone */) return true;
  return false;
}

getLimitConditionOrder — TP + SL after fill

Called once the resting buy is confirmed filled on-chain. Return a take-profit and/or stop-loss spec; the server places them as a shared group (triggering either one auto-cancels the other), reusing the same conditional-order infrastructure as manual TP/SL.

getLimitConditionOrder() {
  return {
    // take profit (omit for no TP → hold to settlement):
    stopProfit: { pctDelta: 0.05 },   // tp price = entryPrice + 0.05
    //   ...or an absolute price instead: { targetPrice: 0.99 }
    // stop loss (diff-crossing, market sell):
    stopLoss: { diffValue: 10, slippage: 0.15 },
    //   buy up  → triggers when diff ≤ -diffValue
    //   buy down→ triggers when diff ≥ +diffValue
  };
}
  • Omit stopProfit → no take profit, the filled shares are held to settlement.
  • Omit stopLoss → no automatic stop loss.
  • This interface takes precedence over the simpler getLimitTakeProfitPrice() (TP-only) fallback; implement one or the other.

Optional refinements

  • limitAllowReplaceAfterCancel?: boolean (default false). When true, the per-window mark is cleared after a cancel, so checkLimitOrder may place a fresh order again later in the same window (useful for full-window strategies that re-arm after a false start). When false, one cancel ends the strategy's activity for that window.

  • getPresignRequest?(): PresignRequest | null — a pure latency optimization. Declare the rem interval / directions / price / shares you expect to use, and the server pre-signs the order package in the background so that when checkLimitOrder fires it can postOrder immediately and skip the signing round-trip. Your checkLimitOrder does not need to know whether the presign hit — it just returns the signal as usual; presigning is invisible to strategy logic and is invalidated on window switch.

Minimal limit-strategy skeleton

import type {
  IStrategy, StrategyKey, StrategyNumber, StrategyDirection,
  StrategyTickContext, EntrySignal, ExitSignal, StrategyDescription,
  LimitOrderSignal, LimitOrderRuntime,
} from "./types.js";

export class MyLimitStrategy implements IStrategy {
  readonly key: StrategyKey = "l9";
  readonly number: StrategyNumber = 9;
  readonly name = "My Limit Strategy";
  shares = 5;
  readonly limitAllowReplaceAfterCancel = true;

  getDescription(): StrategyDescription {
    return {
      key: this.key, number: this.number, name: this.name,
      title: "Limit example",
      category: { id: "limit-diff", label: "Limit Diff", color: "#3fb950" },
      supportedMarkets: ["btc-5m"],
      orderType: "limit",
      lines: [{ text: "..." }],
    };
  }

  updateGuards(_ctx: StrategyTickContext): void {}
  checkEntry(_ctx: StrategyTickContext): EntrySignal | null { return null; }
  checkExit(_ctx: StrategyTickContext, _d: StrategyDirection): ExitSignal { return null; }

  checkLimitOrder(ctx: StrategyTickContext): LimitOrderSignal | null {
    // return { direction, price, shares } when conditions are met, else null
    return null;
  }

  checkCancelOrder(ctx: StrategyTickContext, _order: LimitOrderRuntime): boolean {
    // return true to cancel the resting order
    return false;
  }

  getLimitConditionOrder() {
    return { stopProfit: { pctDelta: 0.05 }, stopLoss: { diffValue: 10, slippage: 0.15 } };
  }

  resetState(): void {}
  getStatePayload(): Record<string, unknown> { return {}; }
}

The filename prefix for limit strategies is l (see §1). The frontend renders a shares input box for orderType: "limit" strategies (instead of the amount box used by market strategies).


7. Strategy lifecycle

IDLE → SCANNING → BUYING → WAIT_FILL → HOLDING → SELLING → DONE

Methods called in each state:

State Calls Description
SCANNING checkEntry look for entry opportunities
BUYING / WAIT_FILL place order / wait for fill
HOLDING checkExit look for stop loss
SELLING closing the position

updateGuards: called every tick (regardless of state), used to maintain the strategy's private state (cooldown locks, consecutive observation counts, etc.).

onEntryFilled (optional): called once after a buy fills, used to record entryPrice etc.

resetState: called on window switch / strategy switch, clears private fields.

getStatePayload: returns the strategy's private state to display on the frontend (e.g. peakDiff).


8. Data tables (fair-prob / diff-extremes)

When a strategy needs to look up a table (e.g. the p-series looks up the fair-prob deviation):

import { getFairProb } from "./_core/fair-prob.js";

const fair = getFairProb(ctx.diff, ctx.rem);
if (fair == null) return null; // current market has no table, safely skip

Adding a table for a new market

Data tables use a Record<MarketKey, RawData> structure; adding a new table changes only one place:

// strategies/_core/fair-prob.ts
export const FAIR_PROB_TABLES: Record<string, RawData> = {
  "btc-5m": BTC_5M,
  "eth-5m": ETH_5M,   // ← new
  "btc-15m": BTC_15M, // ← new
};

Generate data: run python3 backtest-data/analyze.py --symbol <sym> --period <p> and paste the output in.

Guard mechanism

  • Current market has no table → getFairProb returns null
  • Strategy receives null → does not enter
  • Combined with supportedMarkets, double protection

9. Observe panel (optional)

A strategy can display an observe panel in the frontend's top status bar (e.g. m1's factor scoring):

readonly alwaysComputeData = true; // run computeData even when the strategy is disabled

computeData(ctx: StrategyTickContext): void {
  // compute panel data, store to this.xxx
}

getObservePanel(): ObservePanelData {
  return {
    title: "Entry factors",
    color: "#3fb950",
    rows: [
      { type: "score", label: "Momentum", value: this.score, threshold: 60 },
      { type: "direction", label: "Direction", value: "up" },
    ],
  };
}

The frontend's generic renderer displays it automatically, with no extra frontend code.


10. New strategy checklist

After writing a new strategy, self-check against this list:

  • filename <prefix><number>.ts is unique
  • key matches the filename
  • number matches the filename's number and is globally unique
  • implements getDescription / updateGuards / checkEntry / checkExit / resetState / getStatePayload
  • supportedMarkets is declared (otherwise it shows in all markets but uses the wrong thresholds)
  • all private state is cleared in resetState
  • data guard: correctly handles when upPct == null / diff == null / getFairProb() returns null
  • cross-market strategy: use ctx.diffBps instead of ctx.diff
  • take profit goes via GTC: implement getTargetPrice, do not write a tp branch in checkExit
  • after restarting the service, /api/strategy/descriptions shows the new strategy
  • switching to the corresponding market on the frontend shows the strategy toggle
  • after enabling, the logs [Strategy] / [Order] show entry behavior

11. Debugging tips

Dry-run (no order) test

Add a temporary console.log to the strategy file, start the service but do not check enable on the frontendupdateGuards / computeData are still called (if alwaysComputeData is declared), so you can observe scoring without actually placing orders.

View live data

Open the frontend → look at the "Prob Chase" panel at the top for the fair / bias values, or the "Momentum" panel for the factor values.

Historical data backtest

Run npx tsx backtest/<your-script>.ts to replay historical jsonl — for how to write the script, refer to backtest/diff-extremes.ts.

Test a single market in isolation

Before starting, change .active-market.json to {"key":"eth-5m"} and restart the service. Or switch via the top dropdown on the frontend.


12. Common pitfalls

  1. Forgetting to clear stateresetState not cleaned thoroughly, leaving stale peakDiff and the like across windows.
  2. Using ctx.diff in a cross-market strategy — a BTC threshold of $35 triggers at $0.35 on ETH, inevitably causing bad entries. Use diffBps.
  3. Writing a tp branch in checkExit — conflicts with GTC and may sell twice. Take profit always via getTargetPrice.
  4. Wrong supportedMarkets key — e.g. ["BTC-5m"] (uppercase) → never matches, strategy not shown. Must be lowercase ["btc-5m"].
  5. Depending on fair-prob but forgetting to check null — switching to a market without a table makes getFairProb return null; not handling it makes NaN comparisons always false (seemingly harmless but hard to spot).
  6. Static field sharing — using static fields shares them across all strategy instances. Use private instance fields for private state.