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.
Quick Reference
Section titled “Quick Reference”| I want to… | Go to |
|---|---|
| Understand how a trade updates a position | Trade Execution → Position Update |
| Know which Kafka topics to consume / publish | Messaging |
| Call this service from another service (gRPC) | gRPC API |
| Get a historical position snapshot at a timestamp | As-Of Snapshot |
| Look up table or ledger schema | Data Models |
| Understand the settlement event flow | Settlement Emission |
Responsibilities
Section titled “Responsibilities”- Ledger-backed position state: Maintain net positions per
(user_id, symbol)as a deterministic fold ofposition_ledgerentries. - Settlement emission: Emit
settlement.v1whenever 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.v1on every change andposition.closed.v1when 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_ledgerandpositions.
Architecture
Section titled “Architecture”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]
Messaging (Kafka Topics)
Section titled “Messaging (Kafka Topics)”All topic names are environment-suffixed at runtime via kafkacommon.BuildTopicName() (e.g., engine.event.v1-dev).
Consumed
Section titled “Consumed”| Topic | Partition Key | Purpose |
|---|---|---|
engine.event.v1 | symbol | Direct subscription to matching-engine events. The decoder dispatches only TRADE events; one event becomes two position updates (taker + maker). |
position.funding.v1 | user_id | Per-user funding-cycle deltas from the Funding Service. |
Note: Position Service consumes
engine.event.v1directly — there is no separateposition.trade.v1topic in the current architecture.
Published
Section titled “Published”| Topic | Partition Key | When |
|---|---|---|
position.updated.v1 | user_id | After every ledger append (trade, funding, liquidation). |
position.closed.v1 | user_id | Only when qty transitions from non-zero to zero. |
settlement.v1 | user_id | Whenever a monetary delta exists (trade P&L, funding P&L, or fees). Envelope-wrapped (Kafka Governance v1.0). |
risk.exposure.v2 | user_id | Debounced per-user exposure snapshot from the exposure aggregator. |
gRPC API (Read-Only)
Section titled “gRPC API (Read-Only)”Server: listens on :50061.
Service: position.v2.PositionService (shared/protos/position/v2/main.proto).
| RPC | Request | Description |
|---|---|---|
GetPosition | user_id, symbol | Single position. Returns NOT_FOUND if absent or flat. |
ListPositionsByUser | user_id, open_only | All positions for a user (filtered to non-flat when open_only=true). |
ListPositionsByUsers | user_ids[] (max 500) | Batch lookup, grouped by user_id. |
ListPositionsBySymbol | symbol, open_only, as_of_unix_ms | Symbol-wide positions, optionally as observed at a historical Unix-ms timestamp. See As-Of Snapshot. |
GetPositionLedger | user_id, symbol, since_seq, limit | Paginated ledger entries (limit defaults to 1000, capped at 1000). |
As-Of Snapshot (ListPositionsBySymbol)
Section titled “As-Of Snapshot (ListPositionsBySymbol)”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_ledgerWHERE 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.
REST API
Section titled “REST API”Mounted under /v1/positions* for backward compatibility with the v1 frontend surface. Auth via x-user-id header.
| Method | Endpoint | Description |
|---|---|---|
GET | /v1/positions | List the user’s open positions (qty ≠ 0). |
GET | /v1/positions/{symbol} | Single position by symbol; 404 if flat. |
GET | /v1/positions/aggregate | User exposure: total/long/short notional (entry-price based — frontend joins mark prices separately). |
GET | /v1/positions/history | Closed-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).
Data Models (PostgreSQL / SQLC)
Section titled “Data Models (PostgreSQL / SQLC)”All numeric columns use NUMERIC(38,18) for full decimal precision.
positions — Current Net State
Section titled “positions — Current Net State”PK: (user_id, symbol).
| Field | Type | Description |
|---|---|---|
user_id | UUID | Position owner |
symbol | TEXT | Instrument symbol |
qty | NUMERIC(38,18) | Signed net quantity (positive = long, negative = short) |
entry_price | NUMERIC(38,18) | Volume-weighted entry; zero iff qty = 0 |
cum_trade_pnl | NUMERIC(38,18) | Cumulative realized trade P&L |
cum_funding_pnl | NUMERIC(38,18) | Cumulative funding credits/debits |
cum_fees_paid | NUMERIC(38,18) | Cumulative fees |
open_qty_total | NUMERIC(38,18) | Reserved for future use |
last_seq | BIGINT | Highest position_ledger.seq folded into this row (monotonic) |
version | BIGINT | Incremented on every upsert |
opened_at, updated_at | TIMESTAMPTZ | Lifecycle timestamps |
Index: positions_open_idx on (user_id) WHERE qty <> 0.
position_ledger — Immutable Event Log
Section titled “position_ledger — Immutable Event Log”PK: seq (bigserial — global monotonic ordering).
| Field | Type | Description |
|---|---|---|
seq | BIGSERIAL | Global sequence |
user_id, symbol | UUID, TEXT | Position key |
event_kind | TEXT | trade / funding / liquidation |
event_source | TEXT | Upstream topic name (e.g., engine.event.v1, position.funding.v1) |
event_id | TEXT | Upstream event identifier |
qty_delta | NUMERIC(38,18) | Signed quantity change |
fill_price | NUMERIC(38,18) | NULL for funding |
trade_pnl_delta | NUMERIC(38,18) | P&L realized by this event |
funding_pnl_delta | NUMERIC(38,18) | Funding credit / debit |
fee_delta | NUMERIC(38,18) | Fee for this event |
qty_after, entry_price_after | NUMERIC(38,18) | Post-state snapshot |
trace_id, upstream_seq | TEXT, BIGINT | Tracing + fanout audit |
created_at | TIMESTAMPTZ | Insert time |
Constraints & indexes:
UNIQUE (event_source, event_id, user_id, symbol)— idempotency key; replays are no-ops.ledger_user_symbol_seq_idxon(user_id, symbol, seq)—GetPositionLedgerqueries.ledger_event_kind_idxon(event_kind, created_at)— drift checks.ledger_symbol_created_at_seq_idxon(symbol, created_at DESC, seq DESC)— as-of snapshots.
outbox — Transactional Publish Queue
Section titled “outbox — Transactional Publish Queue”| Field | Type | Description |
|---|---|---|
id | BIGSERIAL | Outbox row ID |
ledger_seq | BIGINT (FK) | Source ledger entry |
topic | TEXT | Target Kafka topic |
partition_key | TEXT | Kafka key |
payload | BYTEA | Confluent-Avro-framed payload |
published_at | TIMESTAMPTZ | NULL until success |
attempts, next_attempt_at, last_error, dead | — | Retry state |
Index: outbox_unpublished_idx on (next_attempt_at) WHERE published_at IS NULL AND NOT dead.
Operational Tables
Section titled “Operational Tables”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).
Lifecycle Flows
Section titled “Lifecycle Flows”Trade Execution → Position Update
Section titled “Trade Execution → Position Update”-
engine.event.v1arrives; the decoder splits the trade into taker- and maker-side intents. -
ApplyEvent(ininternal/app/apply_event.go) opens a transaction, takes aSELECT … FOR UPDATEon the row, and reserves the nextledger_seq. -
The pure domain function
ApplyTradecomputes the post-state, classifying the event as one of:Classification Effect OPEN (flat → non-flat) entry_price= trade priceEXTEND (same side) entry_price= weighted-average of old + new notionalREDUCE (opposite side, partial) Realize P&L on closed qty; entry_priceunchangedFULL CLOSE ( qty → 0)Realize P&L on entire qty; entry_price→ 0CROSS (side flip) Realize P&L on old side; reopen at trade price -
A single CTE: inserts into
position_ledger, upsertspositions(usinglast_seqfor monotonicity), and inserts the correspondingoutboxrows. -
After commit, the outbox publisher (16 SKIP-LOCKED workers) emits to Kafka.
Funding Application
Section titled “Funding Application”position.funding.v1arrives.ApplyFundingadjustscum_funding_pnlonly —qtyandentry_priceare untouched.- Same ledger + outbox CTE; emits
position.updated.v1+settlement.v1(kind =FUNDING). - Funding events on flat positions are skipped with a warning.
Settlement Emission
Section titled “Settlement Emission”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.
Concurrency & Idempotency
Section titled “Concurrency & Idempotency”- Per-key serialization:
(user_id, symbol)rows are taken withSELECT … 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)onposition_ledgermakes replays no-ops. - Monotonic upsert: positions are upserted only when the incoming
seq > positions.last_seq, suppressing losers in race conditions.
Reconciliation & Drift
Section titled “Reconciliation & Drift”- Drift Worker: periodically samples positions, re-folds the ledger from scratch, and compares against
positions. Divergence incrementsposition_drift_detected_totaland is logged with the offendinguser_id/symbol. - Exposure Aggregator: holds the
singleton_locklease, debounces position changes, and publishesrisk.exposure.v2with per-user notional totals.
Observability
Section titled “Observability”Prometheus Metrics
Section titled “Prometheus Metrics”| Metric | Type | Description |
|---|---|---|
position_events_applied_total{kind} | counter | Ledger appends by trade / funding / liquidation |
position_apply_event_seconds | histogram | ApplyEvent end-to-end latency |
position_outbox_pending_rows | gauge | Backlog in outbox |
position_drift_detected_total | counter | Divergence detections |
Tracing
Section titled “Tracing”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.
Logging
Section titled “Logging”Structured JSON via zap: user_id, symbol, event_kind, event_source, ledger_seq, qty_after, trace_id.
Health Endpoints
Section titled “Health Endpoints”:8080/healthz— liveness:8080/readyz— readiness (DB, Kafka, schema-registry):8080/metrics— Prometheus scrape
Configuration
Section titled “Configuration”| Variable | Description | Default |
|---|---|---|
POSTGRES_URL | PostgreSQL connection | required |
KAFKA_BROKERS | Brokers | required |
KAFKA_SCHEMA_REGISTRY_URL | Confluent Schema Registry | required |
KAFKA_TOPIC_ENV_SUFFIX | Topic environment suffix (e.g., -dev) | required |
GRPC_PORT | gRPC server | 50061 |
HTTP_PORT | REST + health + metrics | 8080 |
SHADOW_MODE | If true, ledger writes happen but the outbox publisher is disabled (used during cutover) | false |
Technology Stack
Section titled “Technology Stack”- 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
Related Documentation
Section titled “Related Documentation”- Funding Service — calls
ListPositionsBySymbolwithas_of_unix_msat each cycle - Wallet Service — consumes
settlement.v1 - Risk Service — consumes
risk.exposure.v2 - Kafka Events