Sequence/docs

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.

Note

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

rust
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:

FieldWhy it's on the row
version_id — pointer to the immutable StrategyVersionLets attribution answer "what code produced this fill?" even after the Strategy.name is renamed.
max_position_cents / max_order_centsCaps 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:

rust
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

rust
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:

rust
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:

rust
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.

rust
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

rust
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

text
realized = Σ (side_sign · qty · price) − Σ fees
where side_sign = +1 for SELL, −1 for BUY

For 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

rust
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.

rust
// 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:

rust
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 it

Production code uses WallClock; backtests can plug their own simulated clock without modifying production paths.


See also