Fund-Layer Primitives
The fund crate is a toolkit, not a framework. It exposes four primitives that any execution path can compose:
Deployment— a running (or runnable) instance of a strategy with caps inline.PreTradeGate— atomic, sub-microsecond pre-trade check at the OMS submit boundary.LineageTag— the single tag every kernel record (order, fill, position adjustment) carries.compute_pnl— a pure-compute aggregator from a flat fill stream to per-bucket P&L.
These are the same primitives the production Central Coordinator uses, with no Postgres / Parquet / DuckDB dependency. You wire them into your harness, your live OMS, your backtester — whichever path you need.
Why "primitives" and not a runtime? The unit-of-risk decisions vary per fund: who owns capital, which roles co-sign promotions, whether trades are net or gross at the firm level. Baking those into a runtime fights any team that needs different defaults. The primitives carry the load-bearing invariants (atomic cap checks, immutable code identity, lineage on every fill); composition is yours.
Deployment — the unit of risk
use fund::{FundContext, UsdCents};
use fund::strategy::{Deployment, LifecycleState};
let ctx = FundContext::production();
let strategy = ctx.strategy("eth-perp-mm");
let mut deployment = Deployment::new(
"paper-eth-perp-mm", // human-readable name
strategy.id, // StrategyId
version_id, // StrategyVersionId — immutable code hash
UsdCents::from_dollars(250_000).cents(), // max_position
UsdCents::from_dollars(75_000).cents(), // max_order
5_000, // max_orders_per_sec
ctx.clock(),
);
deployment.state = LifecycleState::Paper;A Deployment is the row the kernel tags fills against. Three things matter:
| Field | Why it's on the row |
|---|---|
version_id — pointer to the immutable StrategyVersion | Lets attribution answer "what code produced this fill?" even after the Strategy.name is renamed. |
max_position_cents / max_order_cents | Caps live inline rather than in a separate LimitSet aggregate. One row to read, one cache line to load on the hot path. |
state: LifecycleState ∈ {Paper, Live, Halted} | Three states, three direct transitions — no co-sign, no role gates, no skip-protection windows. Operating a single-tenant fund doesn't earn the ceremony. |
PreTradeGate — atomic, lock-free, sub-µs
Hot-path admission control. Everything is AtomicI64 / AtomicU32 / AtomicBool, so check_order is allocation-free and lock-free:
use fund::risk::{DeploymentGate, HaltAction, LimitSet, OrderIntent, PreTradeGate, Side};
let pre_trade = PreTradeGate::new();
pre_trade.register(deployment.id, DeploymentGate::new(LimitSet::new(
UsdCents::from_dollars(250_000),
UsdCents::from_dollars(75_000),
5_000,
HaltAction::HardHalt,
)));
// On every order submit:
let intent = OrderIntent {
side: Side::Buy,
notional: UsdCents::from_dollars(25_000),
venue: "binance",
symbol: "BTC-USDT",
};
match pre_trade.check(deployment.id, &intent, now_ns) {
Ok(gate) => {
gate.record_submitted(now_ns); // bump rate-limit counter
submit_to_oms(...);
}
Err(breach) => {
// breach.dimension ∈ { NotionalCap, OrderSizeCap, OrdersPerSecond, Halted }
record_rejected(breach);
}
}
// On every fill — this is where the position counter actually moves:
gate.apply_fill(signed_delta_cents);Two-phase position tracking — the most common mistake
PreTradeGate::check projects: (current_position + intent.signed_delta()).abs() <= max_position_cents. It does not mutate. The position counter only moves when apply_fill runs against an actual fill event.
If you apply position deltas optimistically at submit time, balanced buy/sell intent flow nets to zero in the gate's view even when only one side is actually filling. The cap silently never trips. Production CC's fill_router got this right; if you build your own OMS layer, make apply_fill the only path that mutates position.
HaltAction taxonomy
pub enum HaltAction {
Warn, // log + alert; order proceeds
SoftHalt, // block new orders until cleared
HardHalt, // cancel working + block new
Liquidate, // cancel working + close all positions to flat
}HaltAction is metadata on a Breach — it tells the runtime monitor what to do after the breach is raised. The gate itself only decides "admit / reject".
LineageTag — one tag, one cache line
The kernel attaches a single LineageTag to every record that matters: orders, fills, position adjustments. The tag carries only the deployment_id:
use fund::LineageTag;
pub struct LineageTag {
pub deployment_id: DeploymentId,
}Pod, strategy, version, member — all derivable from the deployment row when you need them. Five nullable UUIDs flowing through the persist layer for nothing is exactly the institutional ceremony the design cuts.
Reconstruction from a nullable database column:
let tag: Option<LineageTag> = LineageTag::from_kernel_column(fill_row.deployment_id);
// None == "unattributed" — manual orders that didn't come from a registered deployment.compute_pnl — pure-compute aggregator
Input: a flat &[FillRow]. Output: a PnlReport keyed by your chosen grouping dimension.
use fund::attribution::{compute_pnl, FillRow, GroupBy};
use chrono::Utc;
let fills: Vec<FillRow> = read_fills_from_wherever();
let mut report = compute_pnl(&fills, window_start, window_end, GroupBy::Deployment);
// Best-effort unrealized — mark each bucket's residual to whichever price source
// you care about (NBBO mid, last trade, EOD close). Symbols without a mark
// contribute zero — silently skipped, never failing the whole query.
report.enrich_unrealized(|symbol| mark_to_market(symbol));
for bucket in &report.buckets {
println!(
"{:?} realized={} unrealized={} fills={} gross={}",
bucket.key,
bucket.pnl.realized, bucket.pnl.unrealized,
bucket.fill_count, bucket.gross_volume,
);
}FillRow shape
pub struct FillRow {
pub fill_id: String,
pub node_order_id: String,
pub symbol: String,
pub side: String, // "BUY" or "SELL" — anything else treated as buy
pub qty_1e8: i64, // MAGNITUDE (positive); sign comes from `side`
pub price_1e9: i64, // quote / base × 1e9
pub fee_1e9: i64, // signed: positive = paid, negative = rebate
pub ts_ns: u64,
pub lineage_deployment_id: Option<Uuid>,
}qty_1e8 is a magnitude. Don't pass a signed quantity — compute_pnl reads side and signs internally. Double-signing silently turns every sell into a buy and inverts your residual position.
Formula
realized = Σ (side_sign · qty · price) − Σ fees
where side_sign = +1 for SELL, −1 for BUYFor round-tripped strategies (position returns to zero) this equals the textbook realized P&L. Strategies with open inventory at window-end carry per-symbol residuals in PnlBucket.open_positions_by_symbol so enrich_unrealized can finish the calculation against your chosen mark.
Grouping
pub enum GroupBy {
Total, // one bucket covering everything
Deployment, // one bucket per deployment_id
}Fills with no key for the chosen dimension collapse into the key: None bucket. Conservation of mass: every fill counts somewhere, every dollar of gross volume is accounted for.
Composing the four primitives
A minimal end-to-end pattern: pre-trade gate guards submit, fills go into a journal tagged with LineageTag, end-of-session call to compute_pnl produces the report.
// Setup
let ctx = FundContext::production();
let strategy = ctx.strategy("my-strat");
let mut deployment = Deployment::new(/* … */);
let pre_trade = PreTradeGate::new();
pre_trade.register(deployment.id, DeploymentGate::new(/* limits */));
let gate = pre_trade.get(deployment.id).expect("just registered");
// Per submit
match pre_trade.check(deployment.id, &intent, now_ns) {
Ok(g) => g.record_submitted(now_ns),
Err(_) => return reject(),
}
// Per fill (the moment exposure actually moves)
gate.apply_fill(signed_cents);
journal.push(FillRow {
lineage_deployment_id: Some(deployment.id),
// …
});
// End of session
let mut report = compute_pnl(&journal, t0, t1, GroupBy::Deployment);
report.enrich_unrealized(|sym| nbbo_mid(sym));That's the whole shape. Persistence layer is yours: Postgres in production, Parquet for nightly jobs, in-memory Vec<FillRow> for the paper harness — same compute path everywhere.
Determinism — MockClock for tests
Every Deployment::new call timestamps created_at / updated_at. In tests, freeze time so the audit trail is reproducible:
use fund::{FundContext, MockClock};
let (ctx, clock) = FundContext::for_tests(); // clock starts at epoch
let strategy = ctx.strategy("t");
// clock.advance_secs(60); — move forward when the test calls for itProduction code uses WallClock; backtests can plug their own simulated clock without modifying production paths.
See also
- NativeAlgo dev guide — the in-process strategy that produces the fills these primitives attribute.
- Paper backtest harness — the pattern that wires these four primitives end-to-end.
- Monitoring & TCA — what runs on top of
compute_pnlfor live attribution.