unrip/docs/system-architecture.md
philipp fb547f24d9
All checks were successful
deploy / deploy (push) Successful in 48s
fix: scope inventory fetch by submission time window to prevent OOM
Instead of fetching up to 50k inventory snapshots (~442MB), scope the
query to only the time range of the current submission batch with a
15-minute buffer. For a typical 1-hour batch this drops from 50k rows
to ~300 rows, well within the 1280Mi pod memory limit.

The coalesced_at_idx on intent_inventory_snapshots covers the BETWEEN
clause so this remains efficient.

Proof: history-writer OOM kills from refreshQuoteOutcomes inventory fetch
Assumptions: 15min buffer covers the attribution window for all submissions
Still fake: heuristic gap outcomes may attribute trades imprecisely
2026-06-16 17:17:19 +02:00

982 lines
23 KiB
Markdown

# Current System Architecture
This document explains the current `unrip` runtime at a high level:
- which components exist
- what responsibilities each component owns
- which signals move through Kafka/Redpanda
- what gets persisted
- which attributes are available in the stored data
- what kinds of analysis and operator workflows the data supports today
The system is intentionally bus-first and data-first:
- components are separate services
- the core trading flow moves through Kafka-compatible topics
- PostgreSQL is the durable analytical and operator history store
- some safety-critical operational state is also persisted in local/PVC-backed JSON files
- the browser reads only the operator dashboard backend, not Kafka or PostgreSQL directly
## Design Rules
The current architecture follows these rules:
- venue inputs, strategy, execution, alerting, persistence, and the dashboard are separate services
- the core trading path is event-driven, not direct service-to-service RPC
- raw and normalized events are both preserved
- decisions and execution attempts are auditable after the fact
- execution safety depends on persisted idempotency state, not Kafka consumer groups alone
## System At A Glance
```mermaid
flowchart LR
subgraph External Sources
NearWS["NEAR Intents solver relay websocket"]
RefData["Kraken + CoinGecko"]
Bridge["Bridge RPC"]
Verifier["Verifier / NEAR RPC"]
BtcObs["BTC address observer"]
end
subgraph Runtime Services
Ingest["near-intents-ingest"]
RefIngest["market-reference-ingest"]
Liquidity["liquidity-manager"]
Inventory["inventory-sync"]
Strategy["strategy-engine"]
Executor["trade-executor"]
Sentinel["ops-sentinel"]
History["history-writer"]
Dashboard["operator-dashboard"]
end
Bus[("Redpanda / Kafka")]
Postgres[("PostgreSQL")]
Browser["Operator browser"]
NearWS --> Ingest
RefData --> RefIngest
Bridge --> Liquidity
Verifier --> Liquidity
BtcObs --> Liquidity
Verifier --> Inventory
Bridge --> Inventory
Ingest -->|"raw.near_intents.quote"| Bus
Ingest -->|"norm.swap_demand"| Bus
RefIngest -->|"ref.market_price"| Bus
Liquidity -->|"ops.liquidity_action"| Bus
Liquidity -->|"ops.funding_observation"| Bus
Inventory -->|"state.intent_inventory"| Bus
Strategy -->|"decision.trade_decision"| Bus
Strategy -->|"cmd.execute_trade"| Bus
Executor -->|"exec.trade_result"| Bus
Sentinel -->|"ops.alert"| Bus
Bus --> Inventory
Bus --> Strategy
Bus --> Executor
Bus --> Sentinel
Bus --> History
Bus --> Dashboard
History --> Postgres
Postgres --> Dashboard
Dashboard --> Browser
Dashboard -. "/state + /healthz" .-> Ingest
Dashboard -. "/state + /healthz" .-> RefIngest
Dashboard -. "/state + /healthz" .-> Liquidity
Dashboard -. "/state + /healthz" .-> Inventory
Dashboard -. "/state + /healthz" .-> History
Dashboard -. "/state + /healthz" .-> Sentinel
Dashboard -. "/state + /healthz" .-> Strategy
Dashboard -. "/state + /healthz" .-> Executor
```
## Components And Responsibilities
### `near-intents-ingest`
Responsibility:
- connect to the NEAR Intents websocket
- subscribe to quote traffic
- preserve the raw upstream payload
- normalize quote requests into the canonical `swap_demand` shape
- apply pair filtering before publishing
Produces:
- `raw.near_intents.quote`
- `norm.swap_demand`
### `market-reference-ingest`
Responsibility:
- fetch BTC/EUR reference prices
- prefer Kraken and fall back to CoinGecko
- publish the reference price used for valuation and strategy checks
Produces:
- `ref.market_price`
### `liquidity-manager`
Responsibility:
- track bridge deposit handles
- observe deposits and withdrawals
- observe pre-credit funding activity
- estimate and optionally submit withdrawals
- publish treasury and funding operations signals
Produces:
- `ops.liquidity_action`
- `ops.funding_observation`
### `inventory-sync`
Responsibility:
- combine verifier balances with deposit and withdrawal side inputs
- publish the current inventory snapshot
- separate spendable balances from pending inbound and pending outbound funds
Consumes:
- `ops.liquidity_action`
- `ops.funding_observation`
Produces:
- `state.intent_inventory`
### `strategy-engine`
Responsibility:
- consume normalized demand
- read the latest reference price and inventory snapshot
- evaluate whether a quote is actionable
- record why it skipped or accepted a quote
- emit an execution command when the strategy is armed and the quote passes checks
Consumes:
- `norm.swap_demand`
- `ref.market_price`
- `state.intent_inventory`
Produces:
- `decision.trade_decision`
- `cmd.execute_trade`
### `trade-executor`
Responsibility:
- consume execution commands
- enforce arming state
- enforce command idempotency
- submit a quote response back to the venue
- publish the execution result
Consumes:
- `cmd.execute_trade`
Produces:
- `exec.trade_result`
### `ops-sentinel`
Responsibility:
- watch price freshness, inventory freshness, funding progression, liquidity events, and execution outcomes
- turn those signals into operator alert transitions
Consumes:
- `ref.market_price`
- `state.intent_inventory`
- `ops.liquidity_action`
- `ops.funding_observation`
- `exec.trade_result`
Produces:
- `ops.alert`
### `history-writer`
Responsibility:
- consume the major event topics
- route each topic into a PostgreSQL history table
- compute portfolio metrics from the durable event history
Consumes:
- `raw.near_intents.quote`
- `norm.swap_demand`
- `ref.market_price`
- `state.intent_inventory`
- `ops.liquidity_action`
- `ops.funding_observation`
- `ops.alert`
- `decision.trade_decision`
- `cmd.execute_trade`
- `exec.trade_result`
Produces:
- rows in PostgreSQL event tables
- rows in `portfolio_metrics_snapshots`
### `operator-dashboard`
Responsibility:
- expose one browser-facing API and WebSocket endpoint
- read durable history and metrics from PostgreSQL
- read live service state from `/state` and `/healthz`
- consume a small live Kafka topic set to avoid polling in the browser
- fan live updates to the frontend
Consumes:
- PostgreSQL
- service control APIs
- `norm.swap_demand`
- `ref.market_price`
- `state.intent_inventory`
- `ops.alert`
- `exec.trade_result`
Produces:
- `/api/session`
- `/api/bootstrap`
- `/api/trades`
- `/api/control/...`
- `/ws`
## Topic Classes And Signals
The topic naming scheme is meaningful:
- `raw.*` means source-native truth
- `norm.*` means canonical normalized inputs
- `ref.*` means reference market data
- `state.*` means derived state snapshots
- `ops.*` means operational or treasury state transitions
- `decision.*` means strategy output explaining what the system thought
- `cmd.*` means requested execution work
- `exec.*` means execution outcome
| Topic | Producer | Primary meaning | Main consumers |
| --- | --- | --- | --- |
| `raw.near_intents.quote` | `near-intents-ingest` | Raw venue quote frame | `history-writer` |
| `norm.swap_demand` | `near-intents-ingest` | Canonical demand signal from quote requests | `strategy-engine`, `history-writer`, `operator-dashboard` |
| `ref.market_price` | `market-reference-ingest` | Reference BTC/EUR pricing for valuation and strategy | `strategy-engine`, `ops-sentinel`, `history-writer`, `operator-dashboard` |
| `state.intent_inventory` | `inventory-sync` | Current spendable and pending inventory state | `strategy-engine`, `ops-sentinel`, `history-writer`, `operator-dashboard` |
| `ops.liquidity_action` | `liquidity-manager` | Deposit handle, deposit, withdrawal, and treasury actions | `inventory-sync`, `ops-sentinel`, `history-writer` |
| `ops.funding_observation` | `liquidity-manager` | Pre-credit inbound funding observations | `inventory-sync`, `ops-sentinel`, `history-writer` |
| `ops.alert` | `ops-sentinel` | Raised or cleared operator alerts | `history-writer`, `operator-dashboard` |
| `decision.trade_decision` | `strategy-engine` | Strategy explanation for skip or action | `history-writer` |
| `cmd.execute_trade` | `strategy-engine` | Instruction for the executor | `trade-executor`, `history-writer` |
| `exec.trade_result` | `trade-executor` | Outcome of the execution attempt | `ops-sentinel`, `history-writer`, `operator-dashboard` |
## Signal Flow
This is the high-level flow from upstream quote to operator visibility:
```mermaid
flowchart TD
A["Quote request arrives from NEAR Intents"] --> B["near-intents-ingest"]
B --> C["raw.near_intents.quote"]
B --> D["norm.swap_demand"]
E["Reference price refresh"] --> F["market-reference-ingest"]
F --> G["ref.market_price"]
H["Bridge / verifier / BTC observer state"] --> I["liquidity-manager"]
I --> J["ops.liquidity_action"]
I --> K["ops.funding_observation"]
J --> L["inventory-sync"]
K --> L
L --> M["state.intent_inventory"]
D --> N["strategy-engine"]
G --> N
M --> N
N --> O["decision.trade_decision"]
N --> P["cmd.execute_trade"]
P --> Q["trade-executor"]
Q --> R["exec.trade_result"]
G --> S["ops-sentinel"]
M --> S
J --> S
K --> S
R --> S
S --> T["ops.alert"]
C --> U["history-writer"]
D --> U
G --> U
M --> U
J --> U
K --> U
O --> U
P --> U
R --> U
T --> U
U --> V["PostgreSQL"]
V --> W["operator-dashboard bootstrap reads"]
D --> X["operator-dashboard live consumer"]
G --> X
M --> X
T --> X
R --> X
W --> Y["Browser REST bootstrap"]
X --> Z["Browser WebSocket live updates"]
```
## Sequence Diagrams
### Quote To Decision To Execution
```mermaid
sequenceDiagram
participant Venue as NEAR Intents WS
participant Ingest as near-intents-ingest
participant Bus as Redpanda/Kafka
participant Strategy as strategy-engine
participant Executor as trade-executor
participant Sentinel as ops-sentinel
participant History as history-writer
participant DB as PostgreSQL
Venue->>Ingest: quote event frame
Ingest->>Bus: raw.near_intents.quote
Ingest->>Bus: norm.swap_demand
Bus->>Strategy: norm.swap_demand
Bus->>History: raw + norm events
History->>DB: insert raw_near_intents_quotes / swap_demand_events
Note over Strategy: Strategy already tracks latest ref.market_price and state.intent_inventory
Strategy->>Bus: decision.trade_decision
alt actionable and armed
Strategy->>Bus: cmd.execute_trade
Bus->>Executor: cmd.execute_trade
Executor->>Venue: quote_response submission
Venue-->>Executor: ack / response
Executor->>Bus: exec.trade_result
else rejected
Note over Strategy: No command emitted
end
Bus->>Sentinel: exec.trade_result
Sentinel->>Bus: ops.alert (if alert state changes)
Bus->>History: decision / command / result / alert
History->>DB: insert trade_decisions / execute_trade_commands / trade_execution_results / ops_alerts
```
### Dashboard Bootstrap And Live Updates
```mermaid
sequenceDiagram
participant Browser
participant Dashboard as operator-dashboard
participant DB as PostgreSQL
participant Services as internal /state + /healthz
participant Bus as Redpanda/Kafka
Browser->>Dashboard: GET /api/bootstrap
Dashboard->>DB: load portfolio metric, price, inventory, quotes, trades, alerts
Dashboard->>Services: GET /state and /healthz across services
DB-->>Dashboard: durable history + metrics
Services-->>Dashboard: live service state
Dashboard-->>Browser: bootstrap JSON
Browser->>Dashboard: WebSocket /ws
Dashboard-->>Browser: session.ready + initial live state
Bus->>Dashboard: norm.swap_demand
Dashboard-->>Browser: quotes.recent
Bus->>Dashboard: ref.market_price / state.intent_inventory / ops.alert / exec.trade_result
Dashboard-->>Browser: status_bar.updated / alerts.updated
```
## Event Envelope
Every bus event shares the same outer shape:
```json
{
"event_id": "string",
"event_type": "string",
"venue": "string",
"source": "string|null",
"schema_version": 1,
"observed_at": "ISO-8601|null",
"ingested_at": "ISO-8601",
"payload": {},
"raw": {}
}
```
This matters because it gives every downstream consumer a stable minimum contract:
- unique event identity
- event type
- venue/source attribution
- two timestamps
- the domain payload
- optional raw payload preservation
## What Is Persisted
There are three different persistence layers in the current system.
### 1. Kafka / Redpanda Retention
Kafka is the operational event backbone:
- it retains the event stream for replay and decoupling
- it is not the main analytical store
- it lets multiple consumers read the same event flow independently
What this layer is good for:
- fan-out
- short-horizon replay
- decoupled service design
- live dashboard updates without browser polling
### 2. PostgreSQL Durable History
PostgreSQL is the main durable queryable history layer for operator views and analysis.
All event tables share the same durable columns:
| Column | Meaning |
| --- | --- |
| `event_id` | Unique event id from the envelope |
| `topic` | Kafka topic name |
| `venue` | Venue or source family |
| `source` | Producing service or adapter |
| `event_type` | Domain event type |
| `observed_at` | When the upstream thing happened, if known |
| `ingested_at` | When this service emitted the event |
| `quote_id` | Quote correlation key when available |
| `pair` | Pair string when available |
| `decision_key` | Topic-specific correlation key such as decision id or command id |
| `payload` | The structured event payload |
| `raw` | The raw upstream payload when preserved |
#### `raw_near_intents_quotes`
Source topic:
- `raw.near_intents.quote`
Key payload attributes:
- `message`: original upstream frame
Useful for:
- schema drift inspection
- replay or re-normalization
- debugging unexpected parser behavior
- checking what fields the venue actually sent
#### `swap_demand_events`
Source topic:
- `norm.swap_demand`
Key payload attributes:
- `quote_id`
- `asset_in`
- `asset_out`
- `pair`
- `request_kind`
- `amount_in`
- `amount_out`
- `min_deadline_ms`
Useful for:
- quote frequency by time
- pair mix and direction analysis
- size distributions
- exact-in vs exact-out behavior
- quote freshness analysis
#### `market_price_events`
Source topic:
- `ref.market_price`
Key payload attributes:
- `price_id`
- `pair`
- `eur_per_btc`
- `eure_per_btc`
- `btc_per_eur`
- `btc_per_eure`
- `source_used`
- `fallback_in_use`
- `divergence_pct`
- `eure_per_eur_assumption`
- `kraken.price`
- `kraken.observed_at`
- `kraken.age_ms`
- `kraken.healthy`
- `kraken.error`
- `coingecko.price`
- `coingecko.observed_at`
- `coingecko.age_ms`
- `coingecko.healthy`
- `coingecko.error`
Useful for:
- valuation at a point in time
- price freshness tracking
- fallback source usage
- disagreement between Kraken and CoinGecko
- linking strategy decisions to the exact price snapshot they saw
#### `intent_inventory_snapshots`
Source topic:
- `state.intent_inventory`
Key payload attributes:
- `inventory_id`
- `account_id`
- `synced_at`
- `spendable`
- `pending_inbound`
- `pending_outbound`
- `deposits`
- `withdrawals`
- `reconciliation_status`
Deposit record attributes:
- `tx_hash`
- `chain`
- `asset_id`
- `amount`
- `address`
- `status`
- `decimals`
Withdrawal record attributes:
- `withdrawal_hash`
- `asset_id`
- `chain`
- `amount`
- `status`
- `address`
Useful for:
- spendable balance history
- pending transfer tracking
- treasury state over time
- deposit and withdrawal visibility
- linking a decision to the inventory snapshot it used
#### `liquidity_actions`
Source topic:
- `ops.liquidity_action`
Key payload attributes:
- `liquidity_action_id`
- `account_id`
- `action_type`
- `status`
- `chain`
- `asset_id`
- `details`
The `details` object varies by action type. Common examples include:
- refreshed deposit addresses
- observed deposit records
- tracked withdrawal objects
- changed withdrawal status objects
- submitted withdrawal details
Useful for:
- treasury workflow timelines
- deposit handle rotation history
- withdrawal lifecycle tracking
- identifying when operator actions changed state
#### `funding_observations`
Source topic:
- `ops.funding_observation`
Key payload attributes:
- `funding_observation_id`
- `account_id`
- `asset_id`
- `chain`
- `funding_handle`
- `source`
- `tx_hash`
- `status`
- `amount`
- `confirmations`
- `first_seen_at`
- `last_seen_at`
- `credited_at`
- `bridge_deposit_tx_hash`
- `bridge_status`
- `credit_correlation`
Useful for:
- pre-credit deposit detection
- funding aging and stuck funding analysis
- credit lag measurement
- correlation between observed chain transactions and bridge crediting
#### `ops_alerts`
Source topic:
- `ops.alert`
Key payload attributes:
- `alert_event_id`
- `alert_code`
- `status`
- `severity`
- `reason`
- `service_scope`
- `pair`
- `asset_id`
- `raised_at`
- `cleared_at`
- `details`
Useful for:
- alert timelines
- mean time to recovery
- stale data and treasury issue analysis
- operator incident review
#### `trade_decisions`
Source topic:
- `decision.trade_decision`
Key payload attributes:
- `decision_id`
- `quote_id`
- `pair`
- `direction`
- `request_kind`
- `decision`
- `decision_reason`
- `threshold_pct`
- `max_notional_eure`
- `strategy_armed`
- `assumptions.eure_per_eur`
- `price_freshness_ms`
- `inventory_freshness_ms`
- `reference_rate`
- `implied_rate`
- `gross_edge_pct`
- `inventory_asset`
- `inventory_required`
- `inventory_available`
- `inventory_id`
- `price_id`
- `eure_notional`
- `proposed_amount_in`
- `proposed_amount_out`
- `pending_inbound`
Useful for:
- understanding why quotes were skipped
- measuring how many opportunities reached the actionable state
- analyzing insufficient inventory vs stale data vs threshold rejects
- reconstructing the exact decision context used by the strategy
#### `execute_trade_commands`
Source topic:
- `cmd.execute_trade`
Key payload attributes:
- `command_id`
- `decision_id`
- `idempotency_key`
- `execution_key`
- `quote_id`
- `pair`
- `asset_in`
- `asset_out`
- `amount_in`
- `amount_out`
- `request_kind`
- `min_deadline_ms`
- `quote_output.amount_in`
- `quote_output.amount_out`
- `proposed_amount_in`
- `proposed_amount_out`
Useful for:
- seeing exactly what execution was asked to do
- duplicate detection analysis
- comparing command intent to result outcome
- joining strategy decisions to actual execution attempts
#### `trade_execution_results`
Source topic:
- `exec.trade_result`
Key payload attributes:
- `command_id`
- `decision_id`
- `idempotency_key`
- `execution_key`
- `quote_id`
- `pair`
- `status`
- `result_code`
- `note`
- `venue_response`
- `error`
Useful for:
- execution success and failure rates
- venue response analysis
- distinguishing disarmed rejects from submission failures
- command-to-result conversion tracking
#### `portfolio_metrics_snapshots`
This table is derived from durable history rather than copied directly from a Kafka event.
Top-level columns:
- `metric_id`
- `computed_at`
- `baseline_anchor_at`
- `baseline_status`
- `payload`
Key payload attributes:
- `metric_version`
- `baseline_status`
- `command_count`
- `result_count`
- `current_price.price_id`
- `current_price.observed_at`
- `current_price.eure_per_btc`
- `current_inventory.inventory_id`
- `current_inventory.synced_at`
- `current_inventory.btc_units`
- `current_inventory.btc`
- `current_inventory.eure_units`
- `current_inventory.eure`
- `current_portfolio_value_eure`
- `current_btc_mark_value_eure`
- `current_eure_cash_value_eure`
- `trade_pnl_eure`
- `mark_to_market_pnl_eure`
- `price_move_pnl_eure`
- `baseline_portfolio_value_eure_at_baseline_price`
- `baseline_portfolio_value_eure_at_current_price`
- `current_portfolio_value_eure_at_baseline_price`
- `inventory_delta.btc_units`
- `inventory_delta.btc`
- `inventory_delta.eure_units`
- `inventory_delta.eure`
- `baseline.anchor`
- `baseline.command_at`
- `baseline.price`
- `baseline.inventory`
Useful for:
- current operator PnL display
- separating trade contribution from price movement
- baseline vs current comparison
- current portfolio valuation using the current price snapshot
### 3. JSON / PVC-Backed Operational State
Some important state is persisted outside PostgreSQL because it is operational control state, not analytical history.
#### Strategy armed state
Stored in:
- `strategyStateDir/strategy-engine-control.json`
Attributes:
- `armed`
- `updated_at`
Purpose:
- preserve strategy arming/disarming across restarts
#### Executor armed state
Stored in:
- `executorStateDir/trade-executor-control.json`
Attributes:
- `armed`
- `updated_at`
Purpose:
- preserve executor arming/disarming across restarts
#### Executor command idempotency state
Stored in:
- `executorStateDir/trade-executor-commands.json`
Attributes per command id:
- `quote_id`
- `idempotency_key`
- `execution_key`
- `status`
- `updated_at`
- result or error metadata when available
Purpose:
- prevent duplicate execution across restart or replay scenarios
#### Liquidity manager state
Stored in:
- `liquidityStateDir/liquidity.json`
Key attributes:
- `paused`
- `funding_observer_paused`
- `withdrawals_frozen`
- `deposit_addresses`
- `deposits`
- `tracked_withdrawals`
- `supported_tokens`
- `funding_observations`
- `funding_observations_by_handle`
- `funding_visibility_by_asset`
- `uncredited_funding_total_by_asset`
- `credit_correlation`
- `observer_health`
- `last_refresh_at`
- `last_funding_observation_at`
- `funding_observer_last_refresh_at`
- `funding_observer_last_error`
- `last_error`
- `last_withdrawal_request`
- `last_withdrawal_result`
- `publish_count`
- `funding_publish_count`
Purpose:
- preserve treasury and observer operational state across restarts
- back service `/state` responses
## What The Data Lets You Do
With the currently persisted data, you can already answer questions like:
- How many quotes arrived, at what sizes, and in which direction?
- Which quotes were rejected because of stale price, stale inventory, insufficient inventory, pending deposits, unsupported pair, or max notional?
- Which decisions became actual commands?
- Which commands became successful submitted results?
- What was the reference price and inventory snapshot seen by the strategy when it made a decision?
- How often did the pricing source fall back from Kraken to CoinGecko?
- How much pre-credit funding was visible before bridge crediting happened?
- How long did funding remain in a pending or stuck state?
- What alert transitions happened and how long did they remain raised?
- What is the current portfolio value, and how much of the change looks like price move vs trade contribution?
## Current Limits
A few important limits are worth naming plainly:
- Kafka retention is not the long-term historical warehouse; PostgreSQL is the main durable query surface
- raw venue schemas can drift, so raw quote storage matters for parser changes
- execution results currently represent submission outcomes, not full realized settlement economics
- fee coverage is not yet complete enough to treat all PnL numbers as full realized net profit
- some safety-critical state lives in JSON/PVC-backed operational stores, not only in PostgreSQL
## Recommended Reading Order
If you want to trace the system from code:
1. `src/lib/config.mjs`
2. `src/venues/near-intents/ws.mjs`
3. `src/apps/strategy-engine.mjs`
4. `src/apps/trade-executor.mjs`
5. `src/apps/history-writer.mjs`
6. `src/core/history-records.mjs`
7. `src/lib/postgres.mjs`
8. `src/apps/operator-dashboard.mjs`