Deploy a Python program that uses the Sequence SDK and let Sequence run it for
you. Sequence handles the container, the credentials, the lifecycle, and the
log capture; you write the strategy.
Hosted strategies are intentionally separate from WASM algos:
WASM stays the small, deterministic hot-path runtime. Sub-millisecond,
microbenchmark-friendly, no syscalls.
Hosted strategies get normal process flexibility: external APIs, dynamic
market selection, SDK calls, background tasks, richer dependencies.
Use a hosted strategy when you want to write code that thinks — discovers
markets, talks to other services, makes high-level decisions. Use a WASM algo
when you want code that reacts — sub-ms order placement on every tick.
CLI version requirement. The sequence strategy * subcommands ship in
CLI 0.4.0+. If your sequence binary is older (sequence --version
shows 0.3.x or earlier), you'll get error: unrecognized subcommand 'strategy'. Update first:
bash
sequence updatesequence --version # should be 0.4.0+
If you can't update the CLI for any reason, every sequence strategy *
command has a direct REST equivalent — see the Endpoints +
response shape section below for the curl
patterns that bypass the CLI entirely.
Quick start
The minimal hosted strategy is two files — your code and a Sequence.toml
that tells the CLI how to deploy it. The SDK auto-vendors at deploy time, so
you don't have to copy or pip install anything.
cd my-strategysequence strategy deploy . --startsequence strategy logs btc-dip-buyer --follow
That's it. The CLI reads Sequence.toml, embeds the SDK into the artifact,
uploads, and the runner picks up the strategy within a second or two. CLI
flags still work for one-off overrides (--memory-mb 1024 would override the
toml value for this single deploy).
Endpoints + response shape
Behind the CLI, the wire calls are:
Verb
Endpoint
Purpose
POST
/v1/strategies
Create + upload (this is the hosted Python path)
GET
/v1/strategies
List your strategies
GET
/v1/strategies/:strategy_id
Inspect one
POST
/v1/strategies/:strategy_id/start
Re-queue a stopped strategy
POST
/v1/strategies/:strategy_id/stop
Graceful stop (SIGTERM via heartbeat)
DELETE
/v1/strategies/:strategy_id
Purge artifact + row + logs
GET
/v1/strategies/:strategy_id/logs?run_id=...
Logs (latest-run by default)
Don't confuse /v1/strategies with /v1/deployments. The latter is
the WASM algo path — different artifact format (wasm_base64),
different runtime, different lifecycle. Hosted Python is /v1/strategies
exclusively.
Store strategy_id — every subsequent lifecycle call is keyed on it.
There is no separate "bundle ID"; the artifact is bundled into the strategy
row at create time. artifact_sha256 is useful as a "which version is
running" hash but changes on every redeploy.
What you get
When Sequence claims your strategy, the runner injects this environment into
your process:
Variable
Source
SEQUENCE_API_KEY
A seq_live_* key, encrypted at rest in CC, decrypted just before injection. Use this in Sequence(api_key, ...).
SEQUENCE_API_URL
The CC URL the strategy should hit. Loopback in standard prod deployments (CC and runner share the host).
SEQUENCE_STRATEGY_ID
UUID of the strategy row — useful for trace correlation.
SEQUENCE_RUN_ID
UUID of the current run (changes on restart).
SEQUENCE_CLIENT_ID
Your client short code.
SEQUENCE_IS_SANDBOX
"true" for seq_test_* keys, "false" for live.
Passing your own config (DB DSNs, webhook URLs, market lists, …)
There are three slots for strategy-specific configuration, each with a
different at-rest posture and a different ergonomic shape:
Slot
At-rest
Visible in GET /v1/strategies/:id
Best for
[hosted.env]
plain JSONB
yes
non-secret env vars (URLs, log levels, thresholds)
[hosted.secrets]
AEAD-sealed (secrets_ciphertext + secrets_nonce)
no — never returned
tokens, webhook URLs containing credentials
manifest
plain JSONB
yes
structured config (lists of markets, nested objects) — not flat strings
The first two land as real environment variables in your Python process.
The manifest lands as a JSON file that the runner writes to
/workspace/.sequence/manifest.json and points at via
SEQUENCE_STRATEGY_MANIFEST. Pick whichever shape matches the data:
flat strings are env vars; arrays and nested objects belong in the
manifest.
The CLI also accepts ad-hoc overrides if you don't want to commit a value
to Sequence.toml:
--env KEY=VALUE (repeatable) — layered on top of [hosted.env].
--secrets-file path.json — JSON object of secret env vars merged on
top of [hosted.secrets]. The file never leaves your machine; the CLI
sends the secrets in the create-strategy request body, where the
coordinator seals them before persistence.
--manifest path.json — overrides the auto-synthesized manifest with
your own JSON object (use this when you have nested arrays/objects that
don't fit cleanly into env vars).
Plain env and decrypted secrets land as regular environment variables —
collisions resolve in favor of secrets, so a [hosted.secrets] entry can
shadow a same-named [hosted.env] entry. The manifest is a JSON file you
load yourself.
python
import json, os# [hosted.env] + [hosted.secrets] — both are just env vars by the time# your code reads them. The runner won't let user env shadow the# SEQUENCE_* invariants (those names are reserved).log_level = os.environ["LOG_LEVEL"]gcp_url = os.environ["GCP_CALLBACK_URL"]gcp_token = os.environ["GCP_CALLBACK_TOKEN"]slack_hook = os.environ["SLACK_WEBHOOK"]# manifest — for nested config that doesn't squeeze into a string env var.manifest = json.load(open(os.environ["SEQUENCE_STRATEGY_MANIFEST"]))pairs = manifest["PRED_ARB_PAIRS_JSON"] # already a list, not a string
What's encrypted at rest, and what isn't.[hosted.secrets] and
sequence_api_key are AEAD-sealed under the coordinator's
CREDENTIAL_ENCRYPTION_KEY — they live as secrets_ciphertext /
sequence_api_key_ciphertext columns and are never returned from the
public API. [hosted.env] and manifest are stored as plain JSONB and
are visible in GET /v1/strategies/:id. Use the right slot for the
sensitivity tier — and for venue API keys, don't put them anywhere here:
register them once with sequence connect <venue> and the strategy
uses them transitively when it places orders.
Restart semantics
Process exits are read like Unix exit codes:
Exit
Status
Auto-restart?
0 (clean completion)
stopped
No — Sequence treats this as "I'm done, don't bring me back."
Non-zero / killed / OOM
failed
Yes — re-claimed and re-spawned automatically.
If you want a daemon that runs forever, write while True: ... and don't
exit. Re-running a stopped strategy is opt-in via sequence strategy start <name> — that flips status back to queued and the next runner picks it up.
Your first hosted strategy: end-to-end prediction-market test
This strategy exercises the entire stack — SDK calls, market discovery,
WebSocket streams, order book walk, graph submission, status check, and clean
cancel — against both Kalshi and Polymarket. Total spend: under one dollar
across both venues, with both orders priced deliberately below the bid so they
sit harmlessly and never cross.
It's also a working reference for the things that trip up a first hosted
strategy: bundling the SDK, hand-rolling a WebSocket without pip install,
and falling back from YES to NO when an outcome's book is one-sided.
That's enough. The CLI auto-vendors the Python SDK into the artifact at
deploy time — you don't need a sequence_markets/ directory in your
project. (You can commit one if you want to pin a specific SDK version;
the CLI skips auto-vendoring when it sees one.)
If you're curious why this matters: the runner spawns your strategy in
python:3.12-slim, a minimal image with no pip-installed packages, so the
SDK has to be in the artifact tarball. The CLI handles that for you.
main.py
python
"""End-to-end hosted-strategy smoke. 1. Boot + log injected env (verifies runner injection works) 2. Health check the API 3. Discover an active Kalshi market via /v1/markets 4. Discover an active Polymarket market via /v1/markets?expand=outcomes 5. WS-subscribe to `prices:{symbol}` for each (raw stdlib socket — no `websockets` dep needed in the slim runner image) 6. Walk each book via /v1/quotes (top 5 bids + asks per side) 7. Submit one limit-buy graph per venue, deliberately priced below the market so it rests harmlessly and won't fill 8. Sleep, then check graph status 9. Cancel both graphs 10. Exit 0"""import base64import jsonimport osimport socketimport sslimport structimport sysimport timeimport urllib.parsefrom typing import Any, Dict, Iterator, List, Optionalfrom sequence_markets import Sequence, SequenceErrordef log(event: str, **fields: Any) -> None: """One JSON line per logical event. Easy to grep in `strategy logs`.""" rec = {"ts": round(time.time(), 3), "event": event, **fields} print(json.dumps(rec, default=str, sort_keys=False), flush=True)
Two things worth pointing out before the rest of the file:
flush=True on every print. Python defaults to block buffering on a
pipe, so without explicit flush the runner sees nothing for the whole run
and a flood at exit. The runner sets PYTHONUNBUFFERED=1 as a belt-and-
braces, but your code shouldn't depend on it.
One JSON record per log() call. The runner streams stdout line-by-line
into the hosted_strategy_logs table, so structured one-line records make
sequence strategy logs --follow | jq pleasant.
Raw-stdlib WebSocket
python:3.12-slim doesn't ship the websockets package. Rather than
installing into a read-only container, we hand-roll the handshake plus
masked-frame protocol — about 80 lines of stdlib socket + ssl + struct:
python
def ws_subscribe( base_url: str, api_key: str, channels: List[str], *, duration_s: float, max_msgs: int,) -> Iterator[Dict[str, Any]]: """Open a WS to /v1/stream, subscribe to `channels`, yield decoded JSON messages. Bounded by both `duration_s` and `max_msgs` so the strategy never streams indefinitely.""" url = base_url.replace("http://", "ws://", 1).replace("https://", "wss://", 1) + "/v1/stream" parsed = urllib.parse.urlparse(url) port = parsed.port or (443 if parsed.scheme == "wss" else 80) raw = socket.create_connection((parsed.hostname, port), timeout=15) sock = ssl.create_default_context().wrap_socket(raw, server_hostname=parsed.hostname) \ if parsed.scheme == "wss" else raw sock.settimeout(2) key = base64.b64encode(os.urandom(16)).decode() sock.sendall("\r\n".join([ f"GET {parsed.path or '/'} HTTP/1.1", f"Host: {parsed.hostname}", "Upgrade: websocket", "Connection: Upgrade", f"Sec-WebSocket-Key: {key}", "Sec-WebSocket-Version: 13", f"Authorization: Bearer {api_key}", "", "", ]).encode()) response = b"" while b"\r\n\r\n" not in response: response += sock.recv(4096) if b" 101 " not in response.split(b"\r\n", 1)[0]: raise RuntimeError("WS handshake failed") # ... send masked subscribe frame, then yield messages until deadline. # Full source: crates/sdk/sequence-py/examples/hosted_strategy_prediction.py
Discovery — the YES/NO fallback trick
Volume-sorted prediction markets often have one-sided books — only resting
bids on one outcome, nothing on the other. A naive "pick top market →
submit at half-bid" crashes when there's no bid to halve. Iterate
candidates, probe each quote, and fall back from YES to NO:
python
def _probe_two_sided(seq, symbol): """Return the quote dict if the book has a real bid > 0, else None.""" try: q = seq.quote(symbol, depth=3) except SequenceError: return None bid = ((q or {}).get("nbbo") or {}).get("bid") or 0 return q if bid > 0 else Nonedef find_kalshi_tradable(seq): r = seq.markets(venue="kalshi", limit=30, order="volume", active=True) for m in (r or {}).get("markets", []): if (m.get("liquidity") or 0) < 100: continue for side, token_field in (("yes", "yes_token_id"), ("no", "no_token_id")): tok = m.get(token_field) if tok and _probe_two_sided(seq, tok) is not None: return {**m, "_chosen_side": side, "_chosen_symbol": tok} return Nonedef find_polymarket_tradable(seq): # `expand=outcomes` flattens multi-outcome events (e.g. "World Cup # Winner") into one row per outcome with `yes_token_id` populated. r = seq.markets( venue="polymarket", limit=200, order="volume", active=True, expand="outcomes", ) for m in (r or {}).get("markets", []): if m.get("is_placeholder_outcome") or (m.get("liquidity") or 0) < 1000: continue tok = m.get("yes_token_id") if tok and _probe_two_sided(seq, tok) is not None: return {**m, "_chosen_side": "yes", "_chosen_symbol": tok} return None
Order submission — half-bid limit, guaranteed not to cross
python
def safe_resting_price(best_bid: float) -> float: """Half of best_bid, floored at 1¢, rounded down to whole cents. Both venues use 0.01 as their default tick — a 50% discount lands well below any cross even if the bid moves while submitting.""" if best_bid is None or best_bid <= 0: return 0.01 half_cents = int(best_bid * 100 / 2) return max(0.01, half_cents / 100.0)def submit_test_graph(seq, venue, label, symbol, qty, best_bid): price = safe_resting_price(best_bid) log("graph_submit_intent", venue=venue, label=label, qty=qty, limit_price=price, best_bid=best_bid, notional_usd=round(qty * price, 4)) node = seq.node( node_id="leg", symbol=symbol, side="buy", qty=qty, venue=venue, instrument_type="prediction", # ← required for Kalshi & Polymarket limit_price=price, ) return seq.graph([node]).get("graph_id")
instrument_type="prediction" is the one non-obvious arg — without it the
graph engine assumes spot and the routing layer can't find your venue's
edge. Limit-price units are the venue's natural cents (0.01–0.99).
main()
python
def main() -> int: base_url = os.environ.get("SEQUENCE_API_URL", "https://api.sequencemkts.com").rstrip("/") api_key = os.environ.get("SEQUENCE_API_KEY") log("boot", sequence_api_url=base_url, has_api_key=bool(api_key), strategy_id=os.environ.get("SEQUENCE_STRATEGY_ID"), run_id=os.environ.get("SEQUENCE_RUN_ID")) if not api_key: log("fatal", reason="SEQUENCE_API_KEY not injected") return 1 seq = Sequence(api_key, base_url) log("health_check", ok=seq.health()) kalshi = find_kalshi_tradable(seq) poly = find_polymarket_tradable(seq) if not kalshi or not poly: log("fatal", reason="discovery_incomplete") return 1 log("discovery_done", kalshi_slug=kalshi["slug"], kalshi_chosen_side=kalshi["_chosen_side"], polymarket_slug=poly["slug"], polymarket_outcome=poly.get("outcome_name")) # WS subscribe — bounded so the strategy stays finite for msg in ws_subscribe(base_url, api_key, [ f"prices:{kalshi['_chosen_symbol']}", f"prices:{poly['_chosen_symbol']}", ], duration_s=12.0, max_msgs=8): ch, data = msg.get("channel") or "", msg.get("data") or {} if ch and data: log("ws_msg", channel=ch[:60], bid=data.get("bid"), ask=data.get("ask"), mid=data.get("mid")) # Walk both books (logs top-5 levels per side per venue) kalshi_q = walk_book(seq, "kalshi", kalshi["_chosen_symbol"]) poly_q = walk_book(seq, "polymarket", poly["_chosen_symbol"]) # Submit + cancel both graphs submitted = [] for venue, mkt, q, qty in [ ("kalshi", kalshi, kalshi_q, 1.0), ("polymarket", poly, poly_q, 5.0), ]: bid = (q.get("nbbo") or {}).get("bid") or 0 if bid > 0: gid = submit_test_graph(seq, venue, mkt["slug"], mkt["_chosen_symbol"], qty, float(bid)) if gid: submitted.append((venue, gid)) time.sleep(4) for venue, gid in submitted: log("graph_status", venue=venue, graph_id=gid, status=(seq.graph_status(gid) or {}).get("status")) for venue, gid in submitted: seq.cancel(gid) log("graph_cancelled", venue=venue, graph_id=gid) log("done", graphs_submitted=len(submitted)) return 0if __name__ == "__main__": try: sys.exit(main()) except Exception as exc: log("crash", error=f"{type(exc).__name__}: {exc}") sys.exit(1)
The full source — including walk_book, the WebSocket frame loop, and
defensive timeout handling — is in
crates/sdk/sequence-py/examples/hosted_strategy_prediction.py
(roughly 280 lines including comments). Use it as a starting point for your
own strategy.
Deploying
bash
# 1. Stage the strategy (SDK auto-vendors at deploy time)mkdir -p /tmp/my-strategycp crates/sdk/sequence-py/examples/hosted_strategy_prediction.py /tmp/my-strategy/main.py# Optional: drop in a Sequence.toml so subsequent deploys are flaglesscat > /tmp/my-strategy/Sequence.toml <<'EOF'[strategy]name = "e2e-pred-test"runtime = "python"[hosted]entrypoint = "main.py"[hosted.placement]latency_target = ["coinbase"]EOF# 2. Deploy. With Sequence.toml present, no flags needed.sequence strategy deploy /tmp/my-strategy --start# 3. Tail the JSON log stream as it runssequence strategy logs e2e-pred-test --follow
--latency-target is the placement hint — coinbase routes to a
us-east-1 runner, kalshi or polymarket route to eu-west-1. Pick the
one closest to whatever the strategy talks to most. The CLI bundles the
directory into a tar.gz and uploads it; the runner verifies the SHA-256,
unpacks into a per-run tempdir, and execs python -u main.py.
End-to-end runtime: roughly 17–25 seconds depending on how fast the WebSocket
gets a price tick. Total dollars committed: $0.84, all of which is returned
on cancel.
Submitting orders that actually fill
The example strategy above submits resting orders priced below the bid —
deliberately so they don't cross the spread. To force-fill (taker behavior),
two things have to be right.
1. Cross by at least one tick, force-aligned to the venue's tick size.
Submitting a BUY at exactly best_ask is racy: by the time the order lands
the ask may have moved up a tick and your order rests instead of filling.
Bump by one tick and align:
python
def _venue_tick(venue: str) -> float: # Kalshi is 0.01. Polymarket varies — most markets are 0.01 but # some run 0.001, and the displayed quote tick is the source of # truth. Use the smaller value to be safe. return 0.001 if venue == "polymarket" else 0.01def force_cross_price(side: str, reference: float, tick: float) -> float: raw = reference + tick if side == "buy" else reference - tick return max(tick, round(raw / tick) * tick)# BUY 1 tick above ask: force_cross_price("buy", best_ask, 0.01) → ask + 0.01# SELL 1 tick below bid: force_cross_price("sell", best_bid, 0.01) → bid - 0.01
Pair this with urgency="high" on the node — the SDK signal for "match
now":
2. Respect the venue's minimum notional. Polymarket has a
$1 minimum notional. An order below it is silently accepted — you get
back a graph_id, status="active", no error — but the CLOB never crosses
it; the order just sits forever. Kalshi has no minimum-notional check (1
contract is fine). At a $0.15 mid, you need at least 7 Polymarket shares to
clear $1. Use 8 to leave headroom for quote movement.
3. Multi-leg / parallel-root graphs. A graph with N nodes and zero
edges is N parallel orders that fire on submit. Useful for cross-venue
arb: hit Kalshi YES and Polymarket NO at the same time without a sequencer
in your strategy.
python
seq.graph( nodes=[ seq.node(node_id="kalshi-leg", symbol=kalshi_ticker, side="buy", qty=1.0, venue="kalshi", instrument_type="prediction", limit_price=force_cross_price("buy", k_ask, 0.01), urgency="high"), seq.node(node_id="polymarket-leg", symbol=poly_token_id, side="sell", qty=8.0, venue="polymarket", instrument_type="prediction", limit_price=force_cross_price("sell", p_bid, 0.001), urgency="high"), ], edges=[], # ← no edges → both nodes are roots → fire in parallel sandbox=False, # ← True for paper-fill against live NBBO)# → {"graph_id": "graph_…", "status": "active", "node_count": 2, "edge_count": 0}
The SDK auto-sets activation = "root" on any node not appearing as a
target of some edge. So an empty edges list = all-nodes-root. Wire-shape
if you're not using the SDK:
If you want conditional legs (don't hedge until the spot fills, etc.),
use edges with triggers — see Execution Graphs.
Sandbox routing duality. Two ways to force sandbox: pass sandbox=True
on each call, OR deploy the strategy with a seq_test_* API key — that
key prefix forces sandbox on every order regardless of the per-call flag,
and SEQUENCE_IS_SANDBOX="true" lands in the strategy's env so it can
read its own mode.
4. Detect fills via seq.fills(), not graph_status.graph_status
lags fills by several seconds — the actual fill rows can land in the
database while the graph is still showing status="active". A strategy
that decides "no fill → skip round-trip" based on graph_status will
incorrectly skip fills that already happened.
The pattern that works:
python
# Snapshot fills count before submittingbefore = seq.fills(symbol=symbol, limit=10)before_count = len((before if isinstance(before, list) else before.get("fills", [])) or [])resp = seq.graph([buy_node])graph_id = resp["graph_id"]# Wait for fills delta, not graph_statusdeadline = time.time() + 8.0while time.time() < deadline: after = seq.fills(symbol=symbol, limit=10) after_list = after if isinstance(after, list) else after.get("fills", []) new_fills = [f for f in (after_list or []) if f.get("node_order_id", "").startswith(f"graph:{graph_id}:")] if new_fills: log("filled", count=len(new_fills), qty_1e8=sum(f["qty_1e8"] for f in new_fills)) break time.sleep(0.5)
The node_order_id prefix-match against the graph_id pins fills to the
specific submission — so a strategy that's been buying and selling the same
symbol in a loop doesn't false-positive on prior runs' fills.
Common pitfalls
SEQUENCE_API_KEY not injected. You forgot the sequence_api_key field
on the deploy. The CLI's --start flag automatically injects your current
config key, but if you used the API directly you have to pass it
explicitly.
No bid and the strategy skips submission. Far-OTM Kalshi outcomes often
have one-sided books. Probe before committing to a market — see
_probe_two_sided above. The YES↔NO fallback fixes most cases.
Order accepted but never fills (Polymarket). Almost always one of:
(a) notional is below $1 — the venue silently rests sub-$1 orders; bump qty
to clear $1 of notional, or (b) you submitted at the ask instead of one
tick above it; see Submitting orders that actually
fill.
graph_status says active but fills already exist. Status reflects
the graph's overall state machine, which lags the per-fill events by a few
seconds. Don't gate strategy logic on graph_status — query
seq.fills(symbol=...) and filter by node_order_id prefix to detect
fills against your submission.
Empty WebSocket frames. The first message after subscribe is sometimes a
welcome/ack with empty data. Filter on msg.get("channel") and msg.get("data").
websockets ImportError. The python:3.12-slim runner image doesn't
have it. Either bundle a wheel into your artifact alongside main.py, or
use the raw-stdlib handshake shown above.
runtime must be 'python'. Hosted strategies are Python-only today. A
prebuilt-Rust path existed briefly but was removed; bring it back when there's
a real story for sysroot detection and target-arch validation.
Strategy gets re-claimed every minute. You're probably hitting the
auto-restart-on-exit_code != 0 path — check that your strategy ends with
sys.exit(0) (not just falling off the end of main() after an
exception). For "process exited 0 = done, don't restart" semantics, see
Restart Semantics above.