Trade Lifecycle State Management
A confirmation for a physical power deal arrives twice — once over EDI and once through a counterparty’s REST callback three seconds later — and unless the second copy is a no-op, the trade jumps from confirmed back to captured, drops out of the day’s match run, and shows up as an unmatched position after the settlement window has already closed. That failure is not a matching bug; it is a state-management bug. Within the Trade Ingestion & Matching Workflows domain, this component owns the question of what state a trade is in and how it is allowed to change, so that duplicate events, out-of-order deliveries, and retried batches never corrupt a position on its way from capture to cash. It sits between raw ingestion and the financial engine: schema-valid records enter with a lifecycle state attached, transition only along permitted edges, and expose a clean, matched position to Settlement Calculation & Validation Engines downstream.
The states themselves are few — captured, confirmed, matched, settled, resettled, cancelled — but the discipline around them is everything. Each transition must be idempotent (applying the same event twice changes nothing the second time), guarded (a trade cannot skip confirmation to reach settlement), and audited (every state change leaves a durable, hash-linked record). The diagram below shows the lifecycle as an explicit state machine, with the happy path across the top and the two terminal branches — cancellation and resettlement — dropping below.
Because every state change is expressed as a named event against a small, fixed set of states, the whole component becomes replayable: given the same ordered stream of events, a trade always lands in the same state with the same history. That property is what lets an operations team re-run a day, recover from a crashed batch, or reconcile against a counterparty’s records without fear of silently double-applying an event. The sections below define the standards this state model answers to, the step-by-step build of a production transition engine, the edge cases that break naive implementations, and the alerting and testing that keep the model honest.
Specification & Standards
A trade’s lifecycle is not an internal convenience; its states map onto obligations fixed by market operators and regulators. Three reference points constrain the model:
- NAESB WEQ business practices. The North American Energy Standards Board Wholesale Electric Quadrant defines the confirmation and dispute lifecycle for wholesale transactions — how a deal is agreed, how discrepancies are raised, and the timing windows in which a confirmation must be issued or contested. The
captured → confirmededge in this model is the system’s representation of that agreement handshake. - FERC recordkeeping and EQR. FERC-jurisdictional sellers must retain a complete, defensible record of each transaction and report it on the Electric Quarterly Report. A lifecycle that discards intermediate states or overwrites history in place cannot answer “what did this trade look like on the day we reported it,” so the state store is append-only and every transition is retained, not just the current state.
- ISO/RTO settlement calendars. Whether a trade is eligible to move to
settledor must instead move toresettledis governed entirely by which settlement run is open for its trading day. Those windows are defined per market and mapped through the broader taxonomy; thesettled → resettlededge exists precisely because markets reopen a trading day for true-up long after the initial invoice.
The entry precondition for the whole model is a schema-valid record. A trade may only be admitted to the captured state once it has passed the contract enforced by Schema Validation Frameworks — a record with a malformed timestamp or an unrecognized node_id never gets a lifecycle state at all; it routes to a dead-letter path instead. This keeps the state machine’s guards focused on lifecycle legality rather than structural validity, which is a different concern owned upstream.
The canonical state set and its permitted transitions are the contract every other section builds on:
| From state | Event | To state | Guard condition |
|---|---|---|---|
| (none) | capture |
captured |
Record passed schema validation |
captured |
confirm |
confirmed |
Counterparty confirmation matches economic terms |
captured |
cancel |
cancelled |
Cancellation authorized; not yet settled |
confirmed |
match |
matched |
Paired to a counterparty leg within tolerance |
confirmed |
cancel |
cancelled |
Cancellation authorized; not yet settled |
matched |
settle |
settled |
Settlement run open for the trading day |
settled |
resettle |
resettled |
Correction within tariff dispute window |
resettled |
resettle |
resettled |
Successive true-up on the same trade |
Any event not present in this table is rejected. There is no captured → settled shortcut, no un-cancel, and no path out of cancelled; those absences are as much a part of the specification as the edges that exist.
Step-by-Step Implementation
Building the transition engine proceeds in four stages: fix the state and event vocabulary, express the allowed edges as data, apply events idempotently, and persist an audit trail. A complete finite-state-machine implementation with guard functions and a hash-linked audit log is developed in full on Modeling trade state transitions with a finite state machine; this section establishes the shape the code takes.
Step 1 — Enumerate states and events as closed sets. Represent both as enums so an invalid state can never be constructed by a typo. The state set is fixed; new business requirements add events and edges, not free-form strings.
from enum import Enum
class TradeState(str, Enum):
CAPTURED = "captured"
CONFIRMED = "confirmed"
MATCHED = "matched"
SETTLED = "settled"
RESETTLED = "resettled"
CANCELLED = "cancelled"
class TradeEvent(str, Enum):
CONFIRM = "confirm"
MATCH = "match"
SETTLE = "settle"
RESETTLE = "resettle"
CANCEL = "cancel"
Step 2 — Declare the transition map as the single source of truth. Encode the table above as a dictionary keyed by (state, event). Nothing outside this map is allowed to mutate a trade’s state, which makes the legal graph auditable by reading one structure.
# (current_state, event) -> next_state. Absence means "not permitted".
TRANSITIONS: dict[tuple[TradeState, TradeEvent], TradeState] = {
(TradeState.CAPTURED, TradeEvent.CONFIRM): TradeState.CONFIRMED,
(TradeState.CAPTURED, TradeEvent.CANCEL): TradeState.CANCELLED,
(TradeState.CONFIRMED, TradeEvent.MATCH): TradeState.MATCHED,
(TradeState.CONFIRMED, TradeEvent.CANCEL): TradeState.CANCELLED,
(TradeState.MATCHED, TradeEvent.SETTLE): TradeState.SETTLED,
(TradeState.SETTLED, TradeEvent.RESETTLE): TradeState.RESETTLED,
(TradeState.RESETTLED, TradeEvent.RESETTLE): TradeState.RESETTLED,
}
Step 3 — Attach a guard to every transition. A legal edge is necessary but not sufficient; the business precondition must also hold. A settle event on a matched trade is only valid if the settlement run for that trading day is actually open. Guards are pure predicates over the trade and the event payload, so they are trivially testable and never perform I/O.
from decimal import Decimal
def guard_match(trade: dict, payload: dict) -> tuple[bool, str]:
"""A confirmed trade may match only if its economic terms agree within tolerance."""
our_notional = Decimal(str(trade["notional_usd"]))
cpty_notional = Decimal(str(payload["counterparty_notional_usd"]))
tolerance = Decimal("0.01") # one cent, quantized money comparison
if abs(our_notional - cpty_notional) > tolerance:
return False, f"notional mismatch: {our_notional} vs {cpty_notional}"
return True, "ok"
Step 4 — Apply idempotently and persist the transition. The apply function keys each event by a deterministic event_id. If that id has already been recorded for the trade, the event is a no-op that returns the current state unchanged — this is what makes a redelivered confirmation harmless. Otherwise the guard runs, the state advances, and a transition record is appended to the audit log keyed by (trade_id, event_id). The full apply/persist implementation, including the SHA-256 hash chain that links each transition to the one before it, is given on the companion implementation guide.
Ordering these four steps matters: the transition map (Step 2) is inert data, the guards (Step 3) hold the business rules, and only the apply function (Step 4) touches state — mirroring the pure-core, idempotent-boundary discipline used across the ingestion domain.
Edge Cases & Failure Modes
Naive state machines pass the happy-path demo and then corrupt positions in production the first time reality misbehaves. The recurring failure modes below are the ones that actually break energy trade lifecycles, each with its defense.
Duplicate and out-of-order events. Counterparties and message buses deliver at-least-once, so the same confirm can arrive twice, and a match can occasionally arrive before the confirm it logically follows. Idempotency keys defuse the duplicate; the transition map defuses the reorder, because a match event on a still-captured trade is simply not a legal edge and is parked for retry rather than applied out of sequence.
Cancel racing settlement. A cancellation request and a settlement run can target the same trade in the same window. Because settled has no cancel edge, once a trade settles it can only be unwound through resettle — the state graph itself prevents a cancel from silently voiding an already-invoiced position. The race resolves deterministically on whichever event the store commits first.
Resettlement after finalization. A trade in settled is not immutable — markets reopen trading days for true-up. The settled → resettled edge, and the resettled → resettled self-loop for successive corrections, is the lifecycle counterpart of the delta recomputation performed by Resettlement & True-Up Processing; the state model records that a true-up occurred while the calculation engine computes its financial effect.
Stale state on concurrent writers. Two workers reading the same trade, each applying a different event, can clobber one another on write-back. An optimistic-concurrency version counter on the trade record forces the second writer to re-read and re-evaluate rather than overwrite.
| Failure mode | Trigger | Naive result | Defense in this model |
|---|---|---|---|
| Duplicate event | At-least-once delivery | State advances twice | Idempotency key → no-op on replay |
| Out-of-order event | Bus reordering | Illegal transition applied | Edge absent in map → parked for retry |
| Cancel vs settle race | Concurrent requests | Settled trade voided | No cancel edge from settled |
| Late correction | Tariff dispute window | Overwrites final in place | resettle edge + append-only history |
| Concurrent writers | Two workers, one trade | Lost update | Version counter, optimistic retry |
| Zero-volume leg | Trade with 0 MWh | Matches spuriously | Guard rejects non-economic legs |
Every one of these defenses lives in either the transition map or a guard — never in ad-hoc conditionals scattered across the codebase, which is exactly where lifecycle bugs hide.
Thresholds & Alerting
A state machine that silently parks or rejects events is as dangerous as one that mis-applies them, so the component instruments its own transitions. The parameters below are configuration, tuned per market and per counterparty rather than hard-coded.
- Stuck-state age. A trade that has sat in
capturedorconfirmedpast a configurable age (for example, nomatchwithin the market’s confirmation window) is an aging exception. The threshold is expressed as an SLA, not a constant, because confirmation timing differs across bilateral and market-operator flows. - Rejected-transition rate. A rising rate of illegal-edge rejections for one counterparty usually signals upstream schema drift or a clock-skew problem, not a flurry of genuinely bad trades. Alert on the rate, not on individual rejections.
- Resettlement volume. An unusual spike in
settle → resettletransitions for a trading day flags a systemic pricing or meter correction rather than isolated disputes, and should page the settlement desk.
Alerts route by tier: informational transitions are logged, aging exceptions notify the owning analyst, and any anomaly that would touch an already-settled position escalates to block-and-review. The tiering and routing mechanics are shared with the settlement engine’s Threshold Tuning & Alerts subsystem, so a single exception taxonomy spans ingestion and calculation.
from datetime import datetime, timezone, timedelta
def flag_stuck_trades(trades: list[dict], sla: dict[str, timedelta]) -> list[dict]:
"""Return trades that have exceeded the per-state age SLA."""
now = datetime.now(timezone.utc)
stuck = []
for trade in trades:
state = trade["state"]
age = now - trade["state_entered_at"]
if state in sla and age > sla[state]:
stuck.append({
"trade_id": trade["trade_id"],
"state": state,
"age_hours": round(age.total_seconds() / 3600, 1),
"sla_hours": round(sla[state].total_seconds() / 3600, 1),
})
return stuck
Testing & Reconciliation
The transition engine earns trust by being tested exhaustively and reconciled against reality. Because the state graph is small and finite, the test surface is enumerable rather than sampled.
Exhaustive edge testing. Assert that every (state, event) pair either appears in the transition map and lands on the documented target, or is rejected. With six states and five events there are thirty pairs; testing all of them is cheap and catches an accidentally-added illegal edge immediately.
def test_only_declared_edges_are_legal():
"""Every state/event pair not in TRANSITIONS must be rejected."""
for state in TradeState:
for event in TradeEvent:
expected = TRANSITIONS.get((state, event))
result = try_transition(state, event) # returns next state or None
assert result == expected, (
f"({state}, {event}) -> {result}, expected {expected}"
)
Idempotency testing. Apply the same event twice with the same event_id and assert the state and audit-log length are unchanged after the second application. This is the single most important test in the component, because it proves the duplicate-delivery defense.
Shadow reconciliation. Replay a production event stream through the engine in a shadow environment and diff the resulting terminal states against the live ledger. A divergence means either an event was applied out of order in production or a guard changed behavior between code revisions — both are reconciliation findings, not code bugs to be patched blindly. The matched positions this component emits feed directly into settlement, so the reconciliation ties back to the same idempotent-upsert contract the Settlement Calculation & Validation Engines rely on.
Reconciling the audit trail is itself a check: because each transition record is hash-linked to its predecessor, recomputing the chain and comparing digests proves that no state change was inserted, dropped, or reordered after the fact.
Frequently Asked Questions
Why model trade lifecycle as an explicit state machine instead of status flags?
Status flags accumulate ad-hoc conditionals — “if confirmed and not cancelled and settlement open then…” — that drift apart across the codebase until two paths disagree about what a trade is allowed to do. An explicit state machine puts every legal transition in one declared map and every business precondition in one guard, so the rules are auditable by reading a single structure and testable by enumerating a finite graph. It also makes replay deterministic, which flag-based code rarely achieves.
How does idempotency prevent duplicate confirmations from corrupting a trade?
Each event carries a deterministic event_id, and the apply function records which ids it has already processed for a trade. When a redelivered confirmation arrives with an id already on record, the apply function returns the current state unchanged rather than re-running the transition. The second delivery therefore changes neither the state nor the audit log, so at-least-once message delivery becomes safe without any special-casing at the call site.
What is the difference between cancelling and resettling a trade?
Cancellation is only legal before settlement: a captured or confirmed trade can move to the terminal cancelled state, voiding it entirely. Once a trade is settled it has been invoiced, so it can no longer be cancelled — a later correction moves it to resettled instead, which records that a true-up occurred while the settlement engine computes the financial delta. The state graph enforces this by simply not providing a cancel edge out of settled.
How is the transition audit trail made tamper-evident?
Every transition appends a record containing the trade id, the event, the from and to states, a UTC timestamp, and a SHA-256 hash computed over those fields plus the previous record’s hash. That back-linking forms a chain: altering, inserting, or dropping any historical transition breaks every digest after it, so recomputing the chain and comparing the final hash proves the lifecycle history is intact — satisfying FERC recordkeeping and counterparty dispute needs.