Execution Graphs
Every order is a graph. A market buy is a 1-node graph. A bracket is a 3-node graph (entry → take-profit + stop-loss). A streaming hedge is a 2-node graph with a triggered edge. The graph engine is the universal primitive — same path for everything.
This guide covers the builder shape across both SDKs, every edge Trigger, every Sizing transform, the 4-layer RiskConfig, and a cookbook of common multi-leg recipes (bracket, TWAP, streaming hedge, conditional entry).
For the conceptual deep-dive on why graphs are the universal primitive, see Concepts → Execution Graphs.
Building a graph
use sequence_sdk::{Policy, Urgency};
use sequence_sdk::builders::graph::{Node, Trigger, Sizing, FailureAction};
seq.graph()
.node("spot", Node::buy ("ETH-USD", 200.0).policy(Policy::Sor))
.node("hedge", Node::sell("ETH-USD-PERP", 200.0).perp().venue("hyperliquid"))
.edge("spot", "hedge", Trigger::FillPct(0.5))
.risk(|r| r
.max_notional_usd(500_000.0)
.max_unhedged_qty(5.0)
.on_runtime_breach(FailureAction::CancelAll))
.submit().await?;GraphBuilder methods: .node(id, Node), .edge(from, to, Trigger), .edge_with(from, to, Trigger, |EdgeConfig|), .risk(|RiskConfig|), .sandbox(), .metadata(json), .submit().
Nodes with no incoming edge auto-activate as root; the rest as wait.
Triggers — when an edge fires
| Trigger | Wire form | Fires when |
|---|---|---|
Trigger::OnAccepted | "on_accepted" | SOR ACKs the parent (node_order_id assigned) |
Trigger::FirstFill | "on_first_fill" | First partial fill lands |
Trigger::OnFill | "on_fill" | Every fill — fires repeatedly (streaming hedge) |
Trigger::FullFill | "on_full_fill" | Parent 100% filled |
Trigger::FillPct(p) | "on_fill_ratio" + condition.fill_ratio_gte | Fill ratio crosses threshold (one-shot) |
Trigger::Timeout(ms) | {"on_timeout": {"ms": ...}} | N ms after source node activation |
Trigger::OnCancel | "on_cancel" | Parent cancelled |
Trigger::OnDone | "on_done" | Any terminal state (filled / cancelled / rejected / expired) |
Trigger::OnPrice {…} | {"on_price": {...}} | Price condition on a symbol |
OnFill vs FillPct — these are different triggers. OnFill fires on every fill event regardless of ratio (use for streaming hedge). FillPct(0.5) fires once when filled-qty crosses 50%. They write different wire forms (on_fill vs on_fill_ratio); CC ignores condition.fill_ratio_gte when the trigger is on_fill.
Sizing — how child qty is derived
Edges can transform the child node's quantity based on the parent's fill state. The child node must declare .derived() (Rust) or omit a fixed qty (in graph form, set qty to 0 with "derived": true in execution).
| Sizing | Effect |
|---|---|
Sizing::ParentFilledQty | Child uses parent's cumulative filled qty |
Sizing::IncrementalFill | Only the delta since last edge fire (streaming hedge) |
Sizing::LinearHedge(m) | Multiply parent filled qty by m (negative = opposite side, e.g. -1.0 = 1:1 short hedge) |
Sizing::ScaledNotional { multiplier, cap_usd } | Scale by notional, optional USD cap |
Sizing::Residual | Parent target − parent filled (remainder after partial) |
Sizing::Fixed(q) | Ignore parent — fixed child qty |
Risk — 4-layer model
RiskConfig evaluates at four points in the graph lifecycle. Layer 1+2 are pre-trade; Layer 3 enforces continuously while the graph runs; Layer 4 specifies what happens on breach.
seq.graph()
.node("spot", Node::buy("BTC-USD", 1.0))
.risk(|r| r
// Layer 1 — Admission (before any order leaves)
.max_notional_usd(500_000.0)
.max_leverage(3.0)
.max_concurrent_graphs(5)
// Layer 2 — Pre-trade (per-node gates)
.max_node_notional_usd(100_000.0)
.require_balance_check()
.max_slippage_bps(15)
// Layer 3 — Runtime (continuously)
.max_unhedged_qty(0.5)
.max_loss_usd(2_000.0)
.max_drawdown_usd(5_000.0)
// Layer 4 — Failure action
.on_runtime_breach(FailureAction::CancelAll)
.on_disconnect(FailureAction::Pause))
.submit().await?;FailureAction variants: CancelAll, CancelPending, Pause, Continue (serialized as cancel_all_open / pause_graph / do_nothing).
Recipes
TWAP — slice over time
Sub-graph expansion happens automatically when a node has .twap(slices, interval_ms) — under the hood the engine spawns N child slice nodes.
seq.graph()
.node("twap", Node::buy("BTC-USD", 1.0).twap(10, 30_000))
.submit().await?;Bracket — entry + take-profit + stop-loss
seq.graph()
.node("entry", Node::buy ("BTC-USD", 0.1))
.node("tp", Node::sell("BTC-USD", 0.0).derived().limit_price(80_000.0))
.node("sl", Node::sell("BTC-USD", 0.0).derived())
.edge("entry", "tp", Trigger::FullFill)
.edge("entry", "sl", Trigger::OnPrice {
symbol: "BTC-USD".into(),
direction: "below".into(),
offset_pct: -5.0,
})
.risk(|r| r.max_loss_usd(2_000.0).on_runtime_breach(FailureAction::CancelAll))
.submit().await?;Streaming hedge — every fill triggers proportional perp sell
seq.graph()
.node("spot", Node::buy ("ETH-USD", 100.0))
.node("hedge", Node::sell("ETH-PERP", 0.0).perp().derived())
.edge_with("spot", "hedge", Trigger::OnFill,
|e| e.sizing(Sizing::LinearHedge(-1.0)))
.submit().await?;OnFill (not FillPct) is the streaming-hedge trigger — it fires on every fill event. LinearHedge(-1.0) sizes the child to the opposite-side fill delta.
Inspect a running graph
let s = seq.graph_status("graph_…").await?;
for (id, n) in &s.nodes {
println!("{:8} {} {}/{}", id, n.status,
n.filled_qty_1e8, n.target_qty_1e8);
}GraphStatusResponse { graph_id, client_id, status, nodes: HashMap<_, NodeStatus>, edges: HashMap<_, EdgeStatus> }. Top-level statuses: pending | active | partial_fill | completed | aborted | expired | paused.
Cancel / resume / amend a node
seq.graph_cancel("graph_…").await?;
seq.graph_resume("graph_…").await?;
// Retune execution params on a *running* node (different from amend()
// which works on resting orders)
seq.amend_order("graph_…", "spot", serde_json::json!({
"urgency": "high",
"max_slippage_bps": 20,
"horizon_ms": 60_000,
})).await?;amend_order (PUT-on-node) updates execution parameters on a running node — different from the flat amend() verb (price/qty on a resting order). Use the flat one for resting amends, this one to retune live execution.