Skip to content

Position Service

The Position Service (v2, live since 2026-05-10) is a Go microservice that owns net-mode position tracking for cross-margin perpetuals. It is an event-sourced rewrite of the legacy v1 service: every state change is appended to an immutable position_ledger, the current positions table is a fold of that ledger, and a transactional outbox publishes the resulting position, settlement, and exposure events.

v2 design note: Position Service does not store mark prices. It tracks entry prices and cumulative components (trade P&L, funding P&L, fees). Mark-to-market unrealized P&L is computed caller-side by clients that join mark prices from the Market Data Service.

I want to…Go to
Understand how a trade updates a positionTrade Execution → Position Update
Know which Kafka topics to consume / publishMessaging
Call this service from another service (gRPC)gRPC API
Get a historical position snapshot at a timestampAs-Of Snapshot
Look up table or ledger schemaData Models
Understand the settlement event flowSettlement Emission
  • Ledger-backed position state: Maintain net positions per (user_id, symbol) as a deterministic fold of position_ledger entries.
  • Settlement emission: Emit settlement.v1 whenever a trade, funding, or fee delta touches a position. Consumed by the Wallet Service for ledger postings and margin updates.
  • Position lifecycle events: Emit position.updated.v1 on every change and position.closed.v1 when a position transitions to flat.
  • Exposure aggregation: Debounced per-user exposure snapshots emitted as risk.exposure.v2.
  • Historical reconstruction: Serve point-in-time position snapshots (used by Funding Service at cycle boundaries).
  • Reconciliation: Drift worker periodically re-folds the ledger to detect divergence between position_ledger and positions.
graph TB ME[Matching Engine] -->|engine.event.v1
TRADE| Kafka1[Kafka] Funding[Funding Service] -->|position.funding.v1| Kafka1 Kafka1 --> PosSvc[Position Service] PosSvc -->|ApplyEvent CTE| PG[(PostgreSQL
positions + ledger + outbox)] PG --> OutboxPub[Outbox Publisher] OutboxPub -->|position.updated.v1
position.closed.v1
settlement.v1
risk.exposure.v2| Kafka2[Kafka] Kafka2 --> Wallet[Wallet Service] Kafka2 --> Risk[Risk Service] Kafka2 --> Frontend[Frontend / UI] PosSvc -.gRPC.-> Clients[Order / Risk / Funding Services]

All topic names are environment-suffixed at runtime via kafkacommon.BuildTopicName() (e.g., engine.event.v1-dev).

TopicPartition KeyPurpose
engine.event.v1symbolDirect subscription to matching-engine events. The decoder dispatches only TRADE events; one event becomes two position updates (taker + maker).
position.funding.v1user_idPer-user funding-cycle deltas from the Funding Service.

Note: Position Service consumes engine.event.v1 directly — there is no separate position.trade.v1 topic in the current architecture.

TopicPartition KeyWhen
position.updated.v1user_idAfter every ledger append (trade, funding, liquidation).
position.closed.v1user_idOnly when qty transitions from non-zero to zero.
settlement.v1user_idWhenever a monetary delta exists (trade P&L, funding P&L, or fees). Envelope-wrapped (Kafka Governance v1.0).
risk.exposure.v2user_idDebounced per-user exposure snapshot from the exposure aggregator.

Server: listens on :50061.
Service: position.v2.PositionService (shared/protos/position/v2/main.proto).

RPCRequestDescription
GetPositionuser_id, symbolSingle position. Returns NOT_FOUND if absent or flat.
ListPositionsByUseruser_id, open_onlyAll positions for a user (filtered to non-flat when open_only=true).
ListPositionsByUsersuser_ids[] (max 500)Batch lookup, grouped by user_id.
ListPositionsBySymbolsymbol, open_only, as_of_unix_msSymbol-wide positions, optionally as observed at a historical Unix-ms timestamp. See As-Of Snapshot.
GetPositionLedgeruser_id, symbol, since_seq, limitPaginated ledger entries (limit defaults to 1000, capped at 1000).

When as_of_unix_ms > 0, the request reconstructs positions as they existed at or before the given timestamp by replaying position_ledger:

SELECT DISTINCT ON (user_id, symbol) qty_after, entry_price_after, ...
FROM position_ledger
WHERE symbol = $1 AND created_at <= to_timestamp($2 / 1000.0)
ORDER BY user_id, symbol, created_at DESC, seq DESC;

The Funding Service uses this at cycle boundaries to snapshot positions atomically and deterministically. Cumulative fields (cum_trade_pnl, cum_funding_pnl, cum_fees_paid) are returned as zero in the as-of response by design.

The supporting index ledger_symbol_created_at_seq_idx on (symbol, created_at DESC, seq DESC) was added 2026-05-24 to keep this query off the heap.

Mounted under /v1/positions* for backward compatibility with the v1 frontend surface. Auth via x-user-id header.

MethodEndpointDescription
GET/v1/positionsList the user’s open positions (qty ≠ 0).
GET/v1/positions/{symbol}Single position by symbol; 404 if flat.
GET/v1/positions/aggregateUser exposure: total/long/short notional (entry-price based — frontend joins mark prices separately).
GET/v1/positions/historyClosed-position history reconstructed from ledger entries where qty_after = 0. Query: limit (1–500, default 50), offset (0–100k, default 0).

Response shapes preserve v1 field names (userId, entryPrice, realizedPnL) and explicitly return null for fields the service no longer owns (markPrice, leverage, unrealizedPnL).

All numeric columns use NUMERIC(38,18) for full decimal precision.

PK: (user_id, symbol).

FieldTypeDescription
user_idUUIDPosition owner
symbolTEXTInstrument symbol
qtyNUMERIC(38,18)Signed net quantity (positive = long, negative = short)
entry_priceNUMERIC(38,18)Volume-weighted entry; zero iff qty = 0
cum_trade_pnlNUMERIC(38,18)Cumulative realized trade P&L
cum_funding_pnlNUMERIC(38,18)Cumulative funding credits/debits
cum_fees_paidNUMERIC(38,18)Cumulative fees
open_qty_totalNUMERIC(38,18)Reserved for future use
last_seqBIGINTHighest position_ledger.seq folded into this row (monotonic)
versionBIGINTIncremented on every upsert
opened_at, updated_atTIMESTAMPTZLifecycle timestamps

Index: positions_open_idx on (user_id) WHERE qty <> 0.

PK: seq (bigserial — global monotonic ordering).

FieldTypeDescription
seqBIGSERIALGlobal sequence
user_id, symbolUUID, TEXTPosition key
event_kindTEXTtrade / funding / liquidation
event_sourceTEXTUpstream topic name (e.g., engine.event.v1, position.funding.v1)
event_idTEXTUpstream event identifier
qty_deltaNUMERIC(38,18)Signed quantity change
fill_priceNUMERIC(38,18)NULL for funding
trade_pnl_deltaNUMERIC(38,18)P&L realized by this event
funding_pnl_deltaNUMERIC(38,18)Funding credit / debit
fee_deltaNUMERIC(38,18)Fee for this event
qty_after, entry_price_afterNUMERIC(38,18)Post-state snapshot
trace_id, upstream_seqTEXT, BIGINTTracing + fanout audit
created_atTIMESTAMPTZInsert time

Constraints & indexes:

  • UNIQUE (event_source, event_id, user_id, symbol)idempotency key; replays are no-ops.
  • ledger_user_symbol_seq_idx on (user_id, symbol, seq)GetPositionLedger queries.
  • ledger_event_kind_idx on (event_kind, created_at) — drift checks.
  • ledger_symbol_created_at_seq_idx on (symbol, created_at DESC, seq DESC) — as-of snapshots.
FieldTypeDescription
idBIGSERIALOutbox row ID
ledger_seqBIGINT (FK)Source ledger entry
topicTEXTTarget Kafka topic
partition_keyTEXTKafka key
payloadBYTEAConfluent-Avro-framed payload
published_atTIMESTAMPTZNULL until success
attempts, next_attempt_at, last_error, deadRetry state

Index: outbox_unpublished_idx on (next_attempt_at) WHERE published_at IS NULL AND NOT dead.

  • singleton_lock — exclusive lease for the exposure aggregator and drift worker.
  • failed_events — DLQ for decoding/validation failures; unique on (topic, event_id, user_id, symbol).
  1. engine.event.v1 arrives; the decoder splits the trade into taker- and maker-side intents.

  2. ApplyEvent (in internal/app/apply_event.go) opens a transaction, takes a SELECT … FOR UPDATE on the row, and reserves the next ledger_seq.

  3. The pure domain function ApplyTrade computes the post-state, classifying the event as one of:

    ClassificationEffect
    OPEN (flat → non-flat)entry_price = trade price
    EXTEND (same side)entry_price = weighted-average of old + new notional
    REDUCE (opposite side, partial)Realize P&L on closed qty; entry_price unchanged
    FULL CLOSE (qty → 0)Realize P&L on entire qty; entry_price → 0
    CROSS (side flip)Realize P&L on old side; reopen at trade price
  4. A single CTE: inserts into position_ledger, upserts positions (using last_seq for monotonicity), and inserts the corresponding outbox rows.

  5. After commit, the outbox publisher (16 SKIP-LOCKED workers) emits to Kafka.

  1. position.funding.v1 arrives.
  2. ApplyFunding adjusts cum_funding_pnl only — qty and entry_price are untouched.
  3. Same ledger + outbox CTE; emits position.updated.v1 + settlement.v1 (kind = FUNDING).
  4. Funding events on flat positions are skipped with a warning.

In buildOutboxArrays(), settlement.v1 is enqueued whenever any of trade_pnl_delta, funding_pnl_delta, or fee_delta is non-zero. Wrapped in the Kafka Governance v1.0 envelope:

{
"event_id": "<uuid>",
"event_type": "settlement.v1",
"source_service": "position-service",
"trace_id": "<from upstream>",
"timestamp": "<RFC3339Nano>",
"version": "v1",
"data": {
"settlement_id": "<ledger_seq>",
"user_id": "<uuid>",
"symbol": "BTCUSDT-PERP",
"kind": "TRADE", // or FUNDING
"trade_pnl": "<signed>",
"funding_pnl": "<signed>",
"fee": "<signed>",
"currency": "USDT",
"source_event": "<kind>:<event_id>",
"order_id": "<uuid|null>",
"trade_id": "<uuid|null>",
"filled_qty": "<decimal|null>",
"is_maker": "<bool|null>",
"occurred_at": "<RFC3339Nano>"
}
}

Consumed by the Wallet Service, which posts to the user’s account ledger and adjusts margin / available balance.

  • Per-key serialization: (user_id, symbol) rows are taken with SELECT … FOR UPDATE; the consumer dispatches taker and maker sides in parallel only when they target distinct keys.
  • Idempotent appends: UNIQUE (event_source, event_id, user_id, symbol) on position_ledger makes replays no-ops.
  • Monotonic upsert: positions are upserted only when the incoming seq > positions.last_seq, suppressing losers in race conditions.
  • Drift Worker: periodically samples positions, re-folds the ledger from scratch, and compares against positions. Divergence increments position_drift_detected_total and is logged with the offending user_id / symbol.
  • Exposure Aggregator: holds the singleton_lock lease, debounces position changes, and publishes risk.exposure.v2 with per-user notional totals.
MetricTypeDescription
position_events_applied_total{kind}counterLedger appends by trade / funding / liquidation
position_apply_event_secondshistogramApplyEvent end-to-end latency
position_outbox_pending_rowsgaugeBacklog in outbox
position_drift_detected_totalcounterDivergence detections

OpenTelemetry spans: position.apply_event, position.outbox_publish, position.exposure_aggregate, position.drift_check. trace_id is propagated from upstream Kafka headers and stamped on position_ledger, every outbox payload, and the settlement.v1 envelope.

Structured JSON via zap: user_id, symbol, event_kind, event_source, ledger_seq, qty_after, trace_id.

  • :8080/healthz — liveness
  • :8080/readyz — readiness (DB, Kafka, schema-registry)
  • :8080/metrics — Prometheus scrape
VariableDescriptionDefault
POSTGRES_URLPostgreSQL connectionrequired
KAFKA_BROKERSBrokersrequired
KAFKA_SCHEMA_REGISTRY_URLConfluent Schema Registryrequired
KAFKA_TOPIC_ENV_SUFFIXTopic environment suffix (e.g., -dev)required
GRPC_PORTgRPC server50061
HTTP_PORTREST + health + metrics8080
SHADOW_MODEIf true, ledger writes happen but the outbox publisher is disabled (used during cutover)false
  • Language: Go 1.21+
  • Storage: PostgreSQL, SQLC (typed queries), Atlas (migrations)
  • Messaging: confluent-kafka-go with Avro + Schema Registry
  • gRPC: google.golang.org/grpc
  • Decimal: shopspring/decimal (38,18 precision)
  • Metrics & Tracing: Prometheus client_golang, OpenTelemetry