Welcome to API Academy
Module 17 · Production · ~25 min
Signals.
By the end of this module, your bot trades on actual events, earnings prints, news drops, model probabilities, instead of staring at chart shapes hoping for a pattern. The kind of input the market actually pays for.
To get there, you’ll react to events, news, and models, not chart shapes. Webhook in, sized order out, latency budgeted, and a backtest that actually resists overfitting.
Production tier · Reference cardHow do you build a signal-based trading bot?
Wire a webhook to an order: accept the signal POST, authenticate it with a shared-secret x-signal-secret header, validate the payload, skip anything with less than 3% edge between your probability and the market price, size the trade with the capped Kelly sizer, and place an OrderType.FOK order. Every signal carries a stable id, so the order’s clientOrderId is signal-{id}: duplicate webhook deliveries return 409 Conflict instead of firing twice, and every step fails closed. The signal itself can be anything that reaches you before the market reprices: on-chain feeds, machine-readable news, your own model’s probability, macro calendars, or a TradingView alert forwarded as a webhook. Budget your latency (roughly 150 ms from signal to confirmed fill) and validate the strategy walk-forward, never on the bars it was tuned on.
Endpoints verified 2026-06-09 against the OpenAPI spec.
Section 01
Signal sources.
A signal strategy listens for an external event and reacts. The signal might be an on-chain transaction, a news headline, a model’s prediction, or a scheduled macro print. All that matters is that you see it before anyone else crossing the book does.
On-chain feeds
Whale-wallet transfers, oracle updates, large liquidations, DEX trades. Stream from your own node or a service like Alchemy.
News APIs
Machine-readable news feeds, RSS, Benzinga, Reuters JSON, plus open-source headline scrapers.
Model outputs
Your own regression, classifier, or LLM scoring an event. Publishes a probability that your bot compares to the market price.
Macro calendars
Fed decisions, CPI prints, NFP, election dates. Known in advance, trade the surprise delta.
Custom webhooks
TradingView alerts, Grafana notifications, Telegram channels forwarded through a webhook service, or a teammate’s model posting to your endpoint. The glue layer that lets anything become a tradeable signal.
Section 02
From signal to order.
A minimal dispatcher: accept the webhook, validate the payload, compute a size with your Kelly sizer from Module 14, and place the order via orderClient.createOrder. Every signal carries a stable id, so we derive a clientOrderId of the form signal-{id}, duplicate webhook deliveries return 409 Conflict instead of firing twice. Every step fails closed.
How to run this
- Set LIMITLESS_API_KEY, PRIVATE_KEY, and SIGNAL_SECRET (any random string), the endpoint places real FOK orders. Copy the cappedKelly / capped_kelly helper from Module 14 next to this file.
- Save the snippet above as ema-signal.ts, then run npx tsx ema-signal.ts.
- Save the snippet above as ema_signal.py, then run uvicorn ema_signal:app --port 8080.
- Save the snippet above as main.go inside a Go module, then run go run main.go.
- The server prints signal dispatcher listening on :8080. curl -X POST localhost:8080/signal -H "x-signal-secret: $SIGNAL_SECRET" with a valid JSON body and you’ll see orderId and settlementStatus come back. Replay the same id and you get {ok: true, duplicate: true}.
// Module 17, Signal dispatcher (webhook → validate → size → place).
import express from 'express';
import {
HttpClient,
MarketFetcher,
OrderClient,
Side,
OrderType,
ApiError,
} from '@limitless-exchange/sdk';
import { Wallet } from 'ethers';
import { cappedKelly } from './kelly'; // from Module 14
interface Signal {
id: string; // stable per source-event → powers idempotency
slug: string;
side: 'YES' | 'NO';
probability: number; // your estimate in [0, 1]
marketPrice: number; // market's current price
source: string;
}
const app = express();
app.use(express.json());
const httpClient = new HttpClient({ apiKey: process.env.LIMITLESS_API_KEY });
const marketFetcher = new MarketFetcher(httpClient);
const wallet = new Wallet(process.env.PRIVATE_KEY!);
const orderClient = new OrderClient({ httpClient, wallet, marketFetcher });
const SHARED_SECRET = process.env.SIGNAL_SECRET ?? '';
app.post('/signal', async (req, res) => {
if (req.header('x-signal-secret') !== SHARED_SECRET) return res.sendStatus(401);
const sig = req.body as Signal;
if (!sig.id || !sig.slug) return res.sendStatus(400);
if (sig.probability <= 0 || sig.probability >= 1) return res.sendStatus(400);
if (Math.abs(sig.probability - sig.marketPrice) < 0.03) return res.sendStatus(204); // no edge
const fraction = cappedKelly({
probability: sig.probability,
marketPrice: sig.marketPrice,
nav: 10_000,
});
const makerAmount = Math.floor(fraction * 10_000);
if (makerAmount <= 0) return res.sendStatus(204);
const market = await marketFetcher.getMarket(sig.slug);
const tokenId = sig.side === 'YES' ? market.positionIds[0] : market.positionIds[1];
try {
const result = await orderClient.createOrder({
marketSlug: sig.slug,
tokenId,
side: Side.BUY,
makerAmount,
orderType: OrderType.FOK,
clientOrderId: `signal-${sig.id}`, // 409 on duplicate deliveries
});
res.json({
ok: true,
orderId: result.order.id,
settlementStatus: result.execution.settlementStatus,
txHash: result.execution.txHash ?? null,
});
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
return res.json({ ok: true, duplicate: true });
}
throw err;
}
});
app.listen(8080, () => console.log('signal dispatcher listening on :8080'));
# Module 17, Signal dispatcher (webhook → validate → size → place).
import os
from fastapi import FastAPI, Header, HTTPException, Request
from eth_account import Account
from limitless_sdk.api import HttpClient, APIError
from limitless_sdk.markets import MarketFetcher
from limitless_sdk.orders import OrderClient
from limitless_sdk.types import Side, OrderType
from kelly import capped_kelly, KellyOpts # from Module 14
app = FastAPI()
http_client = HttpClient()
market_fetcher = MarketFetcher(http_client)
wallet = Account.from_key(os.environ["PRIVATE_KEY"])
order_client = OrderClient(http_client, wallet=wallet, market_fetcher=market_fetcher)
SHARED_SECRET = os.environ.get("SIGNAL_SECRET", "")
@app.post("/signal")
async def receive_signal(
req: Request,
x_signal_secret: str = Header(default=""),
) -> dict:
if x_signal_secret != SHARED_SECRET:
raise HTTPException(401)
sig = await req.json()
if not sig.get("id") or not sig.get("slug"):
raise HTTPException(400)
if not (0 < sig["probability"] < 1):
raise HTTPException(400)
if abs(sig["probability"] - sig["market_price"]) < 0.03:
return {"ok": True, "skipped": "no_edge"}
fraction = capped_kelly(KellyOpts(
probability=sig["probability"],
market_price=sig["market_price"],
nav=10_000,
))
maker_amount = float(int(fraction * 10_000))
if maker_amount <= 0:
return {"ok": True, "skipped": "zero_size"}
market = await market_fetcher.get_market(sig["slug"])
token_id = market["positionIds"][0 if sig["side"] == "YES" else 1]
try:
result = await order_client.create_order(
token_id=token_id,
maker_amount=maker_amount,
side=Side.BUY,
order_type=OrderType.FOK,
market_slug=sig["slug"],
client_order_id=f"signal-{sig['id']}", # 409 on duplicate deliveries
)
except APIError as e:
if e.status_code == 409:
return {"ok": True, "duplicate": True}
raise
return {
"ok": True,
"order_id": result["order"]["id"],
"settlement_status": result["execution"]["settlementStatus"],
"tx_hash": result["execution"].get("txHash"),
}
// Module 17, Signal dispatcher (webhook → validate → size → place).
package main
import (
"context"
"encoding/json"
"errors"
"log"
"math"
"net/http"
"os"
limitless "github.com/limitless-labs-group/limitless-exchange-go-sdk/limitless"
)
type Signal struct {
ID string `json:"id"` // stable per source-event
Slug string `json:"slug"`
Side string `json:"side"` // "YES" | "NO"
Probability float64 `json:"probability"`
MarketPrice float64 `json:"market_price"`
Source string `json:"source"`
}
// cappedKelly from Module 14
func cappedKelly(p, price, cap float64) float64 {
b := (1 - price) / price
f := (p*b - (1 - p)) / b
return math.Min(math.Max(0, 0.5*f), cap)
}
func main() {
ctx := context.Background()
client := limitless.NewHttpClient()
marketFetcher := limitless.NewMarketFetcher(client)
orderClient, err := limitless.NewOrderClient(client, os.Getenv("PRIVATE_KEY"))
if err != nil { log.Fatal(err) }
secret := os.Getenv("SIGNAL_SECRET")
http.HandleFunc("/signal", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-signal-secret") != secret {
http.Error(w, "unauthorized", http.StatusUnauthorized); return
}
var sig Signal
if err := json.NewDecoder(r.Body).Decode(&sig); err != nil {
http.Error(w, "bad json", http.StatusBadRequest); return
}
if sig.ID == "" || sig.Slug == "" ||
sig.Probability <= 0 || sig.Probability >= 1 {
http.Error(w, "bad signal", http.StatusBadRequest); return
}
if math.Abs(sig.Probability-sig.MarketPrice) < 0.03 {
w.WriteHeader(http.StatusNoContent); return
}
fraction := cappedKelly(sig.Probability, sig.MarketPrice, 0.05)
makerAmount := float64(int(fraction * 10_000))
if makerAmount <= 0 {
w.WriteHeader(http.StatusNoContent); return
}
market, err := marketFetcher.GetMarket(ctx, sig.Slug)
if err != nil { http.Error(w, err.Error(), http.StatusBadGateway); return }
tokenID := market.Tokens.Yes
if sig.Side == "NO" { tokenID = market.Tokens.No }
result, err := orderClient.CreateOrder(ctx, limitless.CreateOrderParams{
OrderType: limitless.OrderTypeFOK,
MarketSlug: sig.Slug,
ClientOrderID: "signal-" + sig.ID, // 409 on duplicates
Args: limitless.FOKOrderArgs{
TokenID: tokenID,
Side: limitless.SideBuy,
MakerAmount: makerAmount,
},
})
var apiErr *limitless.APIError
if errors.As(err, &apiErr) && apiErr.Status == 409 {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "duplicate": true})
return
}
if err != nil { http.Error(w, err.Error(), http.StatusBadGateway); return }
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"order_id": result.Order.ID,
"settlement_status": result.Execution.SettlementStatus,
"tx_hash": result.Execution.TxHash,
})
})
http.ListenAndServe(":8080", nil)
}
Section 03
Latency budgeting.
Every signal strategy lives or dies on how long it takes the number to reach your book. Budget each step, measure each step, and know which segment is eating your edge. The SDK’s response from orderClient.createOrder includes execution.settlementStatus (UNMATCHED → MATCHED → MINED → CONFIRMED) and a txHash once the trade is on-chain, use them as your ground-truth timing marks. Target totals are tens to low hundreds of milliseconds; you’re not chasing microseconds.
Total: roughly 150 ms from signal → confirmed fill. Above ~300 ms, assume a faster bot already fronted you.
Section 04
Walk-forward validation.
The only backtest protocol that resists overfitting: train on N bars, test on the next M, slide the window forward, repeat. Pull historical OHLCV from GET /markets/{slug}/oracle-candles (Chainlink source, intervals 1m/5m/15m/1h/4h/1d) so your model trains on the exact data the market saw. Reported performance is the concatenation of every out-of-sample window, never numbers from the same bars your strategy tuned on.
// Module 17, Walk-forward validation over oracle candles.
// train on past `trainBars`, test on next `testBars`, slide forward.
import { HttpClient } from '@limitless-exchange/sdk';
interface Candle { timestamp: number; open: number; high: number; low: number; close: number; volume: number; }
interface Model { fit(bars: Candle[]): void; predict(bar: Candle): number; }
const httpClient = new HttpClient({ apiKey: process.env.LIMITLESS_API_KEY });
// GET /markets/{slug}/oracle-candles?interval=1h&from=X&to=Y
async function fetchCandles(slug: string, from: number, to: number): Promise<Candle[]> {
const resp = await httpClient.get(
`/markets/${slug}/oracle-candles?interval=1h&from=${from}&to=${to}`,
);
return resp.rows as Candle[];
}
function walkForward(
history: Candle[],
model: Model,
trainBars = 60,
testBars = 7,
) {
const out: { timestamp: number; predicted: number; actual: number }[] = [];
for (let i = trainBars; i + testBars <= history.length; i += testBars) {
model.fit(history.slice(i - trainBars, i));
for (const bar of history.slice(i, i + testBars)) {
out.push({ timestamp: bar.timestamp, predicted: model.predict(bar), actual: bar.close });
}
}
return out;
}
// Example: 30 days of 1h candles for BTC weekly market
const to = Math.floor(Date.now() / 1000);
const from = to - 30 * 24 * 3600;
(async () => {
const bars = await fetchCandles('btc-100k-weekly', from, to);
const model: Model = { fit() {}, predict: (b) => b.close };
const predictions = walkForward(bars, model);
console.log(predictions.length, 'out-of-sample predictions');
})().catch(console.error);
# Module 17, Walk-forward validation over oracle candles.
# train on past `train_bars`, test on next `test_bars`, slide forward.
import asyncio
import time
from typing import Protocol
from limitless_sdk.api import HttpClient
http_client = HttpClient()
class Candle(dict): # {"timestamp", "open", "high", "low", "close", "volume"}
pass
class Model(Protocol):
def fit(self, bars: list[Candle]) -> None: ...
def predict(self, bar: Candle) -> float: ...
async def fetch_candles(slug: str, frm: int, to: int) -> list[Candle]:
# GET /markets/{slug}/oracle-candles?interval=1h&from=X&to=Y
resp = await http_client.get(
f"/markets/{slug}/oracle-candles",
params={"interval": "1h", "from": frm, "to": to},
)
return [Candle(r) for r in resp["rows"]]
def walk_forward(
history: list[Candle],
model: Model,
train_bars: int = 60,
test_bars: int = 7,
) -> list[dict]:
out: list[dict] = []
i = train_bars
while i + test_bars <= len(history):
model.fit(history[i - train_bars : i])
for bar in history[i : i + test_bars]:
out.append({
"timestamp": bar["timestamp"],
"predicted": model.predict(bar),
"actual": bar["close"],
})
i += test_bars
return out
class Dummy:
def fit(self, bars): pass
def predict(self, bar): return bar["close"]
async def main() -> None:
to = int(time.time())
frm = to - 30 * 24 * 3600 # 30 days of 1h candles
bars = await fetch_candles("btc-100k-weekly", frm, to)
predictions = walk_forward(bars, Dummy())
print(len(predictions), "out-of-sample predictions")
await http_client.close()
if __name__ == "__main__":
asyncio.run(main())
// Module 17, Walk-forward validation over oracle candles.
package main
import (
"context"
"fmt"
"log"
"time"
limitless "github.com/limitless-labs-group/limitless-exchange-go-sdk/limitless"
)
type Candle struct {
Timestamp int64 `json:"timestamp"`
Open float64 `json:"open"`
High float64 `json:"high"`
Low float64 `json:"low"`
Close float64 `json:"close"`
Volume float64 `json:"volume"`
}
type CandlesResp struct {
Rows []Candle `json:"rows"`
}
type Model interface {
Fit(bars []Candle)
Predict(bar Candle) float64
}
// GET /markets/{slug}/oracle-candles?interval=1h&from=X&to=Y
func fetchCandles(ctx context.Context, client *limitless.HttpClient, slug string, from, to int64) ([]Candle, error) {
var resp CandlesResp
path := fmt.Sprintf("/markets/%s/oracle-candles?interval=1h&from=%d&to=%d", slug, from, to)
if err := client.GetJSON(ctx, path, &resp); err != nil {
return nil, err
}
return resp.Rows, nil
}
func WalkForward(history []Candle, model Model, trainBars, testBars int) []map[string]any {
var out []map[string]any
for i := trainBars; i+testBars <= len(history); i += testBars {
model.Fit(history[i-trainBars : i])
for _, bar := range history[i : i+testBars] {
out = append(out, map[string]any{
"timestamp": bar.Timestamp,
"predicted": model.Predict(bar),
"actual": bar.Close,
})
}
}
return out
}
type dummy struct{}
func (dummy) Fit(bars []Candle) {}
func (dummy) Predict(bar Candle) float64 { return bar.Close }
func main() {
ctx := context.Background()
client := limitless.NewHttpClient()
to := time.Now().Unix()
from := to - 30*24*3600 // 30 days of 1h candles
bars, err := fetchCandles(ctx, client, "btc-100k-weekly", from, to)
if err != nil { log.Fatal(err) }
predictions := WalkForward(bars, dummy{}, 60, 7)
fmt.Println(len(predictions), "out-of-sample predictions")
}
How to run this
- Set LIMITLESS_API_KEY, this is a pure read over oracle candles, no orders placed. Swap in your own fit / predict implementation for the dummy model before the numbers mean anything.
- Save the snippet above as walk-forward.ts, then run npx tsx walk-forward.ts.
- Save the snippet above as walk_forward.py, then run python walk_forward.py.
- Save the snippet above as main.go inside a Go module, then run go run main.go.
- The process prints a count like 670 out-of-sample predictions, every row was scored by a model that had never seen that bar. That’s the number your Sharpe and drawdown from Module 13 should be computed on.
Signal-based trading: what people ask
Each answer also ships invisibly as schema.org FAQ data for search engines and AI assistants. Tap a question to expand.
-
How do you secure a webhook that can place trades?
Authenticate every request with a shared secret before acting: the dispatcher rejects anything whosex-signal-secretheader doesn’t match theSIGNAL_SECRETenv var with a 401. Then validate the payload, a stableid, a market slug, and a probability strictly between 0 and 1, returning 400 on anything malformed. Every step fails closed: no valid secret, no valid payload, no order. -
How do you stop duplicate webhook deliveries from double-trading?
Derive the order’s idempotency key from the signal: aclientOrderIdof the formsignal-{id}, where the id is stable per source event. When a webhook is delivered twice, the second submission returns409 Conflictand the dispatcher answers{ok: true, duplicate: true}instead of placing a second trade. Replay the same id on purpose to prove the dedupe works before going live. -
How fast does a signal strategy need to be?
Tens to low hundreds of milliseconds end-to-end; you’re not chasing microseconds. The budget: ~20 ms signal arrival, ~5 ms validate and enrich, ~10 ms decision and sizing, ~3 ms risk check, ~40 ms order submit, ~80 ms fill confirm, roughly 150 ms total. Above ~300 ms, assume a faster bot already fronted you. Use the order response’ssettlementStatus(UNMATCHED→MATCHED→MINED→CONFIRMED) andtxHashas ground-truth timing marks. -
What is walk-forward validation?
The only backtest protocol that resists overfitting: train on N bars, test on the next M, slide the window forward, repeat (the example uses 60 train bars and 7 test bars over hourly candles fromGET /markets/{slug}/oracle-candles). Reported performance is the concatenation of every out-of-sample window, never numbers from the bars the strategy tuned on. Compute your Sharpe and drawdown on those out-of-sample predictions only. -
Why do signal strategies that backtest well fail live?
Usually signal lag. A backtest treats the signal’s computed-at timestamp as the moment it was actionable; live, you can’t act until it reaches your server, and an 8-second median lag turns “buy on signal” into “buy after the move”, which erases the edge in news-driven strategies where the move happens in the first minute. Backtest with the arrival timestamp and add a deliberate delay equal to your live feed’s p95 latency; if the strategy survives that handicap, you have something.
Section 05
Module checklist.
Tick each item once you’ve actually done it. The Continue button unlocks at 5/5.
I know which signal source my strategy will actually consume in production
My webhook validates payloads and a shared secret before acting on anything
I use a Kelly sizer (Module 14) to compute the trade size, not a fixed number
I measure end-to-end latency from signal arrival to confirmed fill
I validated my strategy with walk-forward, not a single in-sample backtest
Module 17 complete
Signals wired.
Your bot acts on what’s happening in the world, not on what a candle looks like. Webhook fires, signal validates, position sizes itself, order goes out, all under a latency budget you control. The way real systematic desks make money.
Concretely, webhooks fire, your bot reacts, and your backtest actually resists overfitting. Three things you walk away with:
A webhook signal endpoint that authenticates on x-signal-secret, skips trades with less than 3% edge, sizes with cappedKelly, and dedupes replays through a signal-{id} clientOrderId.
A latency budget that names every stage from signal arrival through confirmed fill, roughly 150 ms end-to-end, and the settlementStatus / txHash fields you use as ground-truth timestamps.
A walkForward() loop over /markets/{slug}/oracle-candles that trains on N bars, tests on the next M, slides forward, and reports only concatenated out-of-sample predictions.
Next up: wrapping everything you’ve built into a deployable production bot, config, health checks, graceful shutdown, Docker, and the kill switches that separate a hobby script from a service.
Complete the checklist above to unlock