The Pipeline
What happens on every tick, from data to venue
Every tick follows the same path. This page walks through one concrete tick so you can see exactly what each layer receives, what it returns, and how it feeds the next one.
The chain
Data → Features → Strategy → Portfolio → Execution → Risk → Venue
Each layer takes the output of the previous one and produces input for the next. Here’s one tick for AAPL, traced end to end.
Step-by-step example
Data source emits a bar
# What the data source produces:
bar = Bar(market_id="AAPL", open=185.10, high=185.50, low=184.80, close=185.30, timestamp=t)
Output → next layer gets: a price snapshot for each market in the universe.
FeatureStore computes features
# The strategy declared these features:
features = {
"z": Zscore(window=20),
"vol": RealizedVol(window=60),
}
# After this tick, the store returns:
f.z["AAPL"] = -2.4 # z-score of 20-day log returns
f.vol["AAPL"] = 0.18 # annualized realized vol
Input: price bar. Output → next layer gets: a namespace of computed feature values per market.
Strategy reads features, returns Signals
def evaluate(self, f, universe):
signals = []
for market in universe:
z = f.z[market.id] # -2.4 for AAPL
vol = f.vol[market.id] # 0.18
if abs(z) > 2.0:
signals.append(
Signal.from_score(
market=market,
score=-z, # +2.4 → go long
edge_per_stdev=20, # 20 bps edge per σ
horizon="3d",
expected_expected_vol_bps=vol * 100, # 18 bps
)
)
return signals
# What the strategy returns for this tick:
[Signal(
market_id="AAPL",
direction=Direction.Increase, # long
confidence=0.72, # derived from |z|
expected_edge_bps=48.0, # 2.4 × 20
expected_expected_vol_bps=18.0,
horizon=timedelta(days=3),
)]
Input: feature values + universe. Output → next layer gets: a list of Signal objects (can be empty).
Portfolio sizer converts Signals → TargetPositions
# Kelly formula: f* = edge / vol²
# f* = 48 bps / (18 bps)² = 48 / 324 = 0.148
# Scaled: 0.148 × kelly_fraction(0.25) × confidence(0.72) = 0.0267
# Target notional: 0.0267 × equity($100,000) = $2,670
# What the sizer returns:
[TargetPosition(
market_id="AAPL",
target_notional_usd=2670.0, # go long $2,670 of AAPL
urgency=Urgency.Patient,
)]
Input: list of Signal + current positions + cash + constraints. Output → next layer gets: a list of TargetPosition objects (USD notional, not shares).
Executor converts TargetPositions → OrderActions
# Current position in AAPL: 0 shares
# Target: $2,670 long
# Current price: $185.30
# Shares needed: $2,670 / $185.30 = 14 shares
# Delta vs current: 14 - 0 = 14 shares to buy
# What the executor returns:
[OrderAction.place(
market_id="AAPL",
side="buy",
quantity=14,
price=185.30, # limit at last price
order_type="limit",
)]
Input: TargetPosition + current position + current price. Output → next layer gets: a list of OrderAction objects (concrete orders with side, quantity, price).
Risk engine checks each OrderAction → Decision
# Check 1: Kill switch? → pass (not triggered)
# Check 2: Drawdown halt? → pass (equity is at peak)
# Check 3: NaN/Inf sanity? → pass (price=185.30, qty=14)
# Check 4: Price in range? → pass (0.01 ≤ 185.30 ≤ 100,000)
# Check 5: Notional under cap? → pass ($2,594 < $50,000 cap)
# Check 6: Rate limit? → pass (first order this second)
# Check 7: Max orders/tick? → pass (1 < 50)
# Check 8: Stop-loss cooldown? → pass (no recent stop on AAPL)
# Result:
Decision.pass_() # order goes through unchanged
If any check fails, the order is either rejected (Decision.reject("reason")) or resized (Decision.resize(new_qty)).
Input: OrderAction + portfolio state + risk config. Output → next layer gets: the same OrderAction if passed, or nothing if rejected.
Venue receives the order
# Paper venue receives:
# buy 14 AAPL @ 185.30 limit
#
# Since this is a limit order at the current price,
# it fills immediately:
Fill(market_id="AAPL", side="buy", quantity=14, price=185.30, fee=0.13)
The fill flows back to the ledger, which updates the position and cash:
# After fill:
# Position: AAPL long 14 shares @ $185.30 avg
# Cash: $100,000 - $2,594.20 - $0.13 fee = $97,405.67
Input: OrderAction. Output: Fill → updates ledger → updates equity for next tick.
Metrics snapshot
# Recorded:
# equity = $100,000.00 (no price move yet, so unrealized = 0)
# cash = $97,405.67
# positions = 1
# drawdown = 0.0%
The full type chain
| Step | Takes | Returns |
|---|---|---|
| Data | - | Bar(market_id, price, timestamp) |
| Features | price history | f.z["AAPL"] = -2.4, f.vol["AAPL"] = 0.18 |
| Strategy | features + universe | list[Signal] (direction, confidence, edge, vol, horizon) |
| Portfolio | signals + positions + cash | list[TargetPosition] (market_id, target USD notional) |
| Execution | target position + current position + price | list[OrderAction] (side, quantity, price, order type) |
| Risk | order action + state + config | Decision (pass / reject / resize) |
| Venue | order action | Fill (side, quantity, price, fee) → ledger |
Each type is a frozen dataclass. You can inspect any of them at any point.
Where you plug in
Most of the time you only touch one layer:
- Write a strategy: implement
evaluate(f, universe) → list[Signal] - Custom sizing: implement
optimize(signals, positions, cash) → list[TargetPosition] - Custom risk: add
StopLoss,DrawdownGuard, or write a custom check - Custom features: subclass
Featurewith acompute()method
The rest of the pipeline stays the same.
Mode switching
The pipeline has one code path. Only two things change between modes:
| Mode | Data source | Venue |
|---|---|---|
backtest | Historical / synthetic | Paper |
paper | Live feed | Paper |
shadow | Live feed | Real (log-only) |
live | Live feed | Real |
If your backtest works, paper works. If paper works, live works. No code forks.