AggregatedPositionTracker¶
Thread-safe aggregated position tracking across multiple exchanges.
Header¶
Synopsis¶
template <size_t MaxExchanges = 8>
class AggregatedPositionTracker : public ISubsystem
{
public:
struct PositionSnapshot {
Quantity quantity{};
Price avgEntryPrice{};
Volume costBasis{};
};
// Per-exchange position (lock-free read)
PositionSnapshot position(ExchangeId exchange, SymbolId symbol) const;
// Aggregated position across all exchanges (lock-free read, O(MaxExchanges))
PositionSnapshot totalPosition(SymbolId symbol) const;
// Custom valuator for nonlinear positions (AMM LP, options)
void setValuator(const IPositionValuator* valuator);
// Unrealized PnL (lazy, not on hot path)
Volume unrealizedPnl(SymbolId symbol, Price currentPrice) const;
// Position update (writer thread only)
void onFill(ExchangeId exchangeId, SymbolId symbol,
Quantity filledQty, Price fillPrice);
// Reset
void reset(SymbolId symbol);
void resetAll();
};
The public API takes and returns fixed-point types (Quantity, Price, Volume), not raw
int64_t. int64_t appears only in the private atomic state. Decimal's int64_t constructor is
explicit, so passing .raw() values to these methods does not compile — pass the typed values.
No method is noexcept.
Thread Safety¶
Reads are lock-free atomic loads; the writer publishes with release semantics.
- Writer thread:
onFill()updates position atomically - Reader threads:
position(),totalPosition()use acquire loads
Per-symbol state lives in a SymbolStateMap<AtomicPositionState> per exchange, so position() on an
unseen symbol returns a default-constructed snapshot rather than allocating.
Usage¶
Basic Setup¶
AggregatedPositionTracker<4> tracker;
// Subscribe to ExecutionBus for fill events
// or call onFill() directly
Recording Fills¶
// Buy 1 BTC @ $50,000 on Binance (exchange 0)
tracker.onFill(0, symbol,
Quantity::fromDouble(1.0),
Price::fromDouble(50000.0));
// Buy 0.5 BTC @ $50,001 on Bybit (exchange 1)
tracker.onFill(1, symbol,
Quantity::fromDouble(0.5),
Price::fromDouble(50001.0));
// Sell 0.3 BTC @ $50,002 on Kraken (exchange 2)
// Note: negative quantity for sells
tracker.onFill(2, symbol,
Quantity::fromDouble(-0.3),
Price::fromDouble(50002.0));
Query Positions¶
// Per-exchange position (lock-free)
auto binancePos = tracker.position(0, symbol);
std::cout << "Binance: qty=" << binancePos.quantity.toDouble()
<< " avg=" << binancePos.avgEntryPrice.toDouble() << "\n";
// Aggregated position across all exchanges (lock-free)
auto total = tracker.totalPosition(symbol);
std::cout << "Total: qty=" << total.quantity.toDouble()
<< " avg=" << total.avgEntryPrice.toDouble() << "\n";
avgEntryPrice is derived as costBasis / quantity and is left default-constructed when quantity is
zero.
Unrealized PnL¶
Price currentPrice = Price::fromDouble(50100.0);
Volume pnl = tracker.unrealizedPnl(symbol, currentPrice);
// pnl = totalQty * (currentPrice - avgEntry)
Custom Valuation¶
setValuator() plugs in an IPositionValuator for positions whose value is not linear in quantity —
AMM LP positions, options. When a valuator is set it is consulted on every unrealizedPnl() call,
including at zero tracked quantity, because such a position derives value from its own state rather
than from a tracked size. With no valuator (the default) PnL is linear.
Position Math¶
Cost basis is signed and follows the quantity: a long carries a positive cost,
a short a negative one, so cost / qty reads back as the entry price on either
side. What a fill does depends on whether it moves in the same direction as the
position, not on whether it is a buy or a sell.
Opening or adding (fill in the same direction as the position, or flat)¶
Opening a short from flat goes through here, with a negative filledQty, so
the short is booked at its own entry price. The earlier code read the sign of
the fill instead and treated every sell as a reduction. With nothing to reduce
it divided by a zero quantity and booked the short at price 0, which left
unrealized PnL equal to the whole notional: a 10 BTC short at 50,000 that had
not moved a cent reported -500,000, and a cross-venue hedge carried a fixed
error of roughly a third of its notional at every price.
Reducing (fill against the position)¶
Price avgEntry = cost / qty; // signed on both sides
cost = cost - (reduceSigned * avgEntry); // unwind at the average already booked
qty = qty - reduceSigned;
Crossing through flat¶
A fill larger than the position closes it at the average and opens the remainder on the other side at the fill price, in that order.
Close to Flat¶
Performance¶
| Operation | Complexity |
|---|---|
position() |
O(1) |
totalPosition() |
O(MaxExchanges) |
onFill() |
O(1) |
unrealizedPnl() |
O(MaxExchanges) |
Cache Alignment¶
The per-position state is aligned to 64-byte cache lines:
struct alignas(64) AtomicPositionState {
std::atomic<int64_t> quantityRaw{0};
std::atomic<int64_t> costBasisRaw{0};
};
See Also¶
- CompositeBookMatrix - Get current prices for PnL calculation
- SplitOrderTracker - Track split orders and their fills