Persistence¶
Strix's persistence is a pluggable port (Transport). Two implementations ship:
| Backend | When to use | Survives process exit? |
|---|---|---|
InMemoryTransport (default) |
Tests, short scripts, anything ephemeral | No |
LocalTransport (configured with data_dir) |
Long-running algos, anything you want to recover | Yes |
A future CloudTransport will compose LocalTransport as a write-ahead log + async push to a hosted dashboard. It's deferred to post-v1; the port shape is designed to accept it without a breaking change.
Choosing a backend¶
# Ephemeral — state lives until process exit.
strix.init()
# equivalent to:
strix.init(transport=strix.InMemoryTransport())
# Persistent — survives crashes and restarts.
transport = strix.LocalTransport(data_dir="./strix_data")
strix.init(transport=transport, risk=strix.RiskSettings(max_qty_per_order=1000))
If you hold a reference to the same InMemoryTransport across multiple init calls within one process, carry-forward still works — the backend keeps the event log in memory across sessions. You just lose everything when the process exits.
mem = strix.InMemoryTransport()
strix.init(transport=mem)
# ... trade ...
strix.init(transport=mem) # closes prior session, carries forward open orders + positions
LocalTransport on disk¶
<data_dir>/
.strix-storage # marker: {"format_version": 1}
strix.lock # OS file lock; auto-released on process exit
active_session # one line: <session_id> of currently-open session (empty if none)
sessions/
<session_id>/ # UUIDv7 dirname → sorts chronologically
events.jsonl # append-only event log
Properties worth knowing:
data_diris required, no default. Algo users have opinions about where state lives (mounted volumes, dedicated drives, network shares). Strix won't pick for you.- Marker file (
.strix-storage). Strix refuses to write into a non-empty directory without a marker, so you can't accidentally point it at your project root. Empty directories get a marker created on first use. - One process per
data_dir. Astrix.lockfile is acquired with an OS-level file lock (POSIXflockon Unix,LockFileon Windows). A second process opening the samedata_dirraisesStorageLockedError. Within one process you can run as many sequential sessions as you want. fsyncon every event. Eachappend()writes one JSON line, flushes, andfsyncs. ~1–5 ms per event, acceptable for non-HFT algo trading. The "durable on return" contract is what lets aCloudTransportlayer a WAL on top later without changing semantics.- Closed sessions persist forever. Audit-trail value beats disk cost for the trading use case. Enumerate with
strix.list_sessions(data_dir=...); remove withrm -rf sessions/<id>/if you need to reclaim space (archive_session/delete_sessionhelpers are deferred).
Event schema (JSONL)¶
Each line in events.jsonl is a JSON object describing one event. The line is self-describing — there's no per-session header.
Every event has:
| Field | Type | Notes |
|---|---|---|
type |
string | One of the event types listed below. |
session_id |
string | UUIDv7. |
seq |
int | Monotonic per session, starts at 0. |
ts |
string (ISO 8601) | Client wall clock; informational. |
schema_version |
int | Currently 1. |
| ...payload | Event-type-specific fields. |
(session_id, seq) is the dedupe key. A separate event_id UUID was considered and rejected — (session_id, seq) is already globally unique.
Decimal-valued fields are serialized as JSON strings (e.g. "100.50", not 100.50) to preserve precision across the JSON boundary. Parse them back through decimal.Decimal on the consumer side. Timestamps are ISO 8601 strings (datetime.fromisoformat-compatible). Nullable fields use JSON null.
Event types emitted in v1:
| Category | type values |
|---|---|
| Session lifecycle | SessionStarted, SessionEnded |
| Orders | OrderCreated, OrderStatusChanged, CancelAttemptFailed |
| Executions | ExecutionApplied, ExecutionAnomalyDetected |
| Risk config | RiskSettingsChanged, RiskBreach |
| P&L | PnLSnapshot |
| Stop-loss | StopLossTriggered, StopLossRecovered, StopLossManuallyOverridden |
| Broker reconcile | PositionAdjusted, ReconciliationCompleted |
Per-event payload fields¶
Beyond the common envelope (type, session_id, seq, ts, schema_version), each event carries:
SessionStarted
- reason: str — always "explicit-init" in v1 (every session starts via strix.init)
- seeded_positions: list[Position] — positions carried forward or seeded via InitialState
- seeded_open_orders: list[Order] — open orders carried forward
- risk: RiskSettings — initial risk policy (serialized as a dict; see source for field list)
- config: SessionConfig — initial behavior policy
SessionEnded
- reason: str — "new-session-implicit-close", "explicit", etc.
OrderCreated
- order: Order — full Order snapshot at creation
OrderStatusChanged
- order_id: str
- status: OrderStatus — string enum value
- reject_reason: str | null — populated when transitioning to REJECTED
ExecutionApplied
- execution: Execution — the applied fill, with order_id, symbol, side, qty, price (Decimal-strings), timestamp, execution_id
ExecutionAnomalyDetected
- execution: Execution
- category: str — "missing-order", "symbol-mismatch", "side-mismatch", "terminal-order", or "overfill"
- detail: str — human-readable description
- order_id_ref: str | null — the order_id Strix tried to match
CancelAttemptFailed
- order_id: str
- prior_status: OrderStatus — the status the order was reverted to
- reason: str — exception message from the user's cancel block
RiskSettingsChanged
- risk: RiskSettings — full new policy (replaces prior)
RiskBreach
- order_id: str — the order that breached the limit (still yielded — emitted only under on_breach="warn", the default; under "raise" the order is rejected with StrixRiskError and no event is written)
- symbol: str
- reason: str — which limit was breached and how
PnLSnapshot
- realized: str (Decimal) — cumulative session realized P&L
- unrealized: str (Decimal) — sum of by-symbol unrealized
- by_symbol: dict[str, dict] — one entry per non-zero position with keys:
- qty: str (Decimal)
- avg_price: str | null (Decimal)
- mark: str | null (Decimal) — None if no mark_to_market has reported a price for this symbol
- unrealized: str (Decimal) — per-symbol contribution to the total
StopLossTriggered / StopLossRecovered
- scope: str — "session" or "position"
- subject: str | null — symbol for position scope; null for session scope
- threshold: str (Decimal) — the threshold that fired (or recovery_threshold on Recovered)
- observed_pnl: str (Decimal) — the P&L observed at the trigger
StopLossManuallyOverridden
- cleared_halts: list[[scope, subject]] — halts that were active at override time
- reason: str — caller-supplied reason
PositionAdjusted
- symbol: str
- prev_qty: str (Decimal), new_qty: str (Decimal)
- prev_avg_price: str | null, new_avg_price: str | null (Decimals)
- reason: str — typically "broker_reconciliation"
ReconciliationCompleted
- marker: str | null — adapter's next_marker for the next reconcile call
- executions_ingested: int, executions_skipped_duplicate: int
- position_mismatches: list[PositionMismatch], order_mismatches: list[OrderMismatch]
For a typed reader, use strix.replay(data_dir=..., session_id=...) — it parses each line into the corresponding Python class and yields SessionEvent instances you can isinstance-check.
You can also cat sessions/<id>/events.jsonl | jq to inspect any session. Don't write to these files yourself.
Durability and the cloud-forward contract¶
The port contract makes one strong guarantee: append() is durable on return. When append() returns without raising, the event is on disk (or, in a future cloud impl, in the local WAL with the cloud push queued).
This is the constraint that lets the same LocalTransport serve as the v1 backend and the future cloud-WAL — no throwaway code, no migration story between the two.
What the contract does not guarantee:
- That every
Executionis durable independent of the next event. (Strix appends one event per public mutation, but you don't get a transactional batch of "order + execution" — they're separate events.) - That
fsyncsurvives unfriendly hardware (disks lying about write-through, etc.). Standard fsync caveats apply. - That
replay()reflects events written by another process holding the lock. Strix is single-writer perdata_dir.
Closing the transport cleanly¶
LocalTransport holds two OS resources: the lockfile and the currently-open event log file. They auto-release when the process exits, but you can release them eagerly via transport.close():
transport = strix.LocalTransport(data_dir="./strix_data")
try:
strix.init(transport=transport)
# ...
finally:
transport.close()
In long-lived algos this matters mostly so a sibling process can pick up the data directory cleanly. In short scripts the auto-cleanup on process exit is fine.
transport.flush() is also exposed and forces an fsync of the currently-open log file. Useful before a known-risky operation, but append() already fsyncs, so flush is rarely necessary.
If you need to delete or move a data_dir (test reset, archival, etc.), call transport.close() first. On Windows the lockfile is held open until close — shutil.rmtree and os.replace will fail with a permission error otherwise. POSIX is more forgiving but the cleanest pattern is still close-before-delete:
transport = strix.LocalTransport(data_dir="./_test_data")
try:
# ... run test ...
finally:
transport.close()
shutil.rmtree("./_test_data") # safe now
Errors you can see¶
| Error | Why it's raised |
|---|---|
NoActiveSessionError |
strix.resume(transport=...) called when no open session exists. |
StorageLockedError |
Another process holds the data_dir lock. |
StorageVersionError |
.strix-storage marker has a format_version this SDK doesn't support. |
StorageCorruptError |
Internal inconsistency: bad pointer, malformed line, unknown event type, seq mismatch on append, etc. |
All inherit from StrixStorageError, which inherits from StrixError. Catch the base if you want a single handler; catch specifics if you want to react differently. (Names shown are the Python SDK's; other SDKs expose equivalent types — see the API reference.)
What's deferred to post-v1¶
CloudTransportimplementation (port shape is locked, ready to receive it).- Compression (
events.jsonl.zst), session metadata sidecars, analytics cache files. The on-disk layout reserves room. strix.archive_session(id),strix.delete_session(id). (strix.list_sessions(data_dir=...)shipped — read-only enumeration of sessions in adata_dir.)- Backpressure / buffering policy for the cloud pusher.
- Encryption at rest (TLS handles transport; rest is a server-side concern in the cloud impl).
See DESIGN.md §6–§8 for the full architecture rationale.