# 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: ``` .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](d1.ts) for a complete example): ```ts 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 { 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. ```ts 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}` - symbol: `btc` / `eth` / `sol` (see [market-configs.ts](../market-configs.ts)) - period: `5m` / `15m` **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 0~300, 15m 0~900) | | `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=50` → `diffBps≈7`, SOL `diff=0.05` → `diffBps≈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` ```ts 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` ```ts 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): ```ts 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: ```ts 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. ```ts 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: ```ts 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. ```ts 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 ```ts 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 { 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): ```ts 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` structure; adding a new table changes only one place: ```ts // strategies/_core/fair-prob.ts export const FAIR_PROB_TABLES: Record = { "btc-5m": BTC_5M, "eth-5m": ETH_5M, // ← new "btc-15m": BTC_15M, // ← new }; ``` Generate data: run `python3 backtest-data/analyze.py --symbol --period

` 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): ```ts 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 `.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 frontend** — `updateGuards` / `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/.ts` to replay historical jsonl — for how to write the script, refer to [backtest/diff-extremes.ts](../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 state** — `resetState` 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. --- ## Appendix: related file index - [types.ts](types.ts) — interface definitions - [registry.ts](registry.ts) — registry (usually no need to change) - [_runtime/loader.ts](_runtime/loader.ts) — auto loader - [_core/fair-prob.ts](_core/fair-prob.ts) — fair probability table - [_core/diff-extremes.ts](_core/diff-extremes.ts) — diff extremes table - [_core/s6-core.ts](_core/s6-core.ts) — momentum scoring shared logic - [../market-configs.ts](../market-configs.ts) — market config (symbol / period / slug etc.) - [../server.ts](../server.ts) — main service (strategy scheduling, order placement) - [../CLAUDE.md](../CLAUDE.md) — project overview