From 27e43a6ac3f60ea0363c44d342b8b5a4190ee6bc Mon Sep 17 00:00:00 2001 From: abs2023 <93659489+abs2023@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:51:19 -0400 Subject: [PATCH 1/2] Write LMN health-check aliases from the account that owns hashpower.exchange. Co-authored-by: Cursor --- .bedrock/.terragrunt/04_futures_mm_svc.tf | 22 ++++++++++++++++--- .bedrock/.terragrunt/04_perps_mm_svc.tf | 22 ++++++++++++++++--- .bedrock/.terragrunt/06_col_mar_keeper_svc.tf | 22 ++++++++++++++++--- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/.bedrock/.terragrunt/04_futures_mm_svc.tf b/.bedrock/.terragrunt/04_futures_mm_svc.tf index 4992a2c..8ae5831 100644 --- a/.bedrock/.terragrunt/04_futures_mm_svc.tf +++ b/.bedrock/.terragrunt/04_futures_mm_svc.tf @@ -186,16 +186,32 @@ resource "aws_alb_listener" "futures_mm_int_443_use1" { ) } +# Public alias in the Hashpower zone. Dev zones live in the workload account. +# LMN writes hashpower.exchange in titanio-net (aws.titanio-net). resource "aws_route53_record" "futures_mm_int_use1" { - count = var.futures_mm_service.create ? 1 : 0 + count = var.futures_mm_service.create && !local.is_lmn ? 1 : 0 provider = aws.use1 zone_id = local.hp_dns["exc"].zone_id name = "futuresmm.${local.hp_dns["exc"].name}" type = "A" alias { - name = aws_alb.futures_mm_int_use1[count.index].dns_name - zone_id = aws_alb.futures_mm_int_use1[count.index].zone_id + name = aws_alb.futures_mm_int_use1[0].dns_name + zone_id = aws_alb.futures_mm_int_use1[0].zone_id + evaluate_target_health = true + } +} + +resource "aws_route53_record" "futures_mm_int_lmn" { + count = var.futures_mm_service.create && local.is_lmn ? 1 : 0 + provider = aws.titanio-net + zone_id = local.hp_dns["exc"].zone_id + name = "futuresmm.${local.hp_dns["exc"].name}" + type = "A" + + alias { + name = aws_alb.futures_mm_int_use1[0].dns_name + zone_id = aws_alb.futures_mm_int_use1[0].zone_id evaluate_target_health = true } } diff --git a/.bedrock/.terragrunt/04_perps_mm_svc.tf b/.bedrock/.terragrunt/04_perps_mm_svc.tf index f845557..e48ae17 100644 --- a/.bedrock/.terragrunt/04_perps_mm_svc.tf +++ b/.bedrock/.terragrunt/04_perps_mm_svc.tf @@ -197,16 +197,32 @@ resource "aws_alb_listener" "perps_mm_int_443_use1" { ) } +# Public alias in the Hashpower zone. Dev zones live in the workload account. +# LMN writes hashpower.exchange in titanio-net (aws.titanio-net). resource "aws_route53_record" "perps_mm_int_use1" { - count = var.perps_mm_service.create ? 1 : 0 + count = var.perps_mm_service.create && !local.is_lmn ? 1 : 0 provider = aws.use1 zone_id = local.hp_dns["exc"].zone_id name = "perpsmm.${local.hp_dns["exc"].name}" type = "A" alias { - name = aws_alb.perps_mm_int_use1[count.index].dns_name - zone_id = aws_alb.perps_mm_int_use1[count.index].zone_id + name = aws_alb.perps_mm_int_use1[0].dns_name + zone_id = aws_alb.perps_mm_int_use1[0].zone_id + evaluate_target_health = true + } +} + +resource "aws_route53_record" "perps_mm_int_lmn" { + count = var.perps_mm_service.create && local.is_lmn ? 1 : 0 + provider = aws.titanio-net + zone_id = local.hp_dns["exc"].zone_id + name = "perpsmm.${local.hp_dns["exc"].name}" + type = "A" + + alias { + name = aws_alb.perps_mm_int_use1[0].dns_name + zone_id = aws_alb.perps_mm_int_use1[0].zone_id evaluate_target_health = true } } diff --git a/.bedrock/.terragrunt/06_col_mar_keeper_svc.tf b/.bedrock/.terragrunt/06_col_mar_keeper_svc.tf index 31a74ab..da83887 100644 --- a/.bedrock/.terragrunt/06_col_mar_keeper_svc.tf +++ b/.bedrock/.terragrunt/06_col_mar_keeper_svc.tf @@ -184,16 +184,32 @@ resource "aws_alb_listener" "keeper_int_443_use1" { ) } +# Public alias in the Hashpower zone. Dev zones live in the workload account. +# LMN writes hashpower.exchange in titanio-net (aws.titanio-net). resource "aws_route53_record" "keeper_int_use1" { - count = var.keeper_service.create ? 1 : 0 + count = var.keeper_service.create && !local.is_lmn ? 1 : 0 provider = aws.use1 zone_id = local.hp_dns["exc"].zone_id name = "keeper.${local.hp_dns["exc"].name}" type = "A" alias { - name = aws_alb.keeper_int_use1[count.index].dns_name - zone_id = aws_alb.keeper_int_use1[count.index].zone_id + name = aws_alb.keeper_int_use1[0].dns_name + zone_id = aws_alb.keeper_int_use1[0].zone_id + evaluate_target_health = true + } +} + +resource "aws_route53_record" "keeper_int_lmn" { + count = var.keeper_service.create && local.is_lmn ? 1 : 0 + provider = aws.titanio-net + zone_id = local.hp_dns["exc"].zone_id + name = "keeper.${local.hp_dns["exc"].name}" + type = "A" + + alias { + name = aws_alb.keeper_int_use1[0].dns_name + zone_id = aws_alb.keeper_int_use1[0].zone_id evaluate_target_health = true } } From 9b701758a95be75dda5de0350954ebbc344abf79 Mon Sep 17 00:00:00 2001 From: "Oleksandr (Shev) Shevchuk" Date: Tue, 22 Sep 2026 14:52:58 +0200 Subject: [PATCH 2/2] fix(market-maker): correct the realized-volatility estimate inflating spreads Quoted spreads were far wider than intended because sigma was overstated by two independent bugs. Decimal mismatch in the backfill. OracleTracker seeded its window from the hashprice subgraph (8 decimals) and then appended live on-chain answers (6 decimals), so a single ln(1/100) return dominated the window. The history source now declares the aggregator address and decimals it is reporting, and OracleTracker reconciles that against the live oracle before rebasing; a mismatched aggregator skips the backfill instead of poisoning sigma. This replaces the previous magnitude-based guess at the scale factor. Wrong estimator for an irregularly sampled feed. volatilityPerSecond divided each log return by sqrt(dt), which is only valid if the feed samples a Brownian process at times unrelated to its moves. These oracles publish on a deviation threshold, so |r| is essentially independent of elapsed time (measured corr(log dt, log|r|) = -0.13 over a stable day, -0.11 over 96h) and dividing by sqrt(dt) injects a spurious 1/sqrt(dt) term. It read ~1.74x high in every period sampled. Replaced with the realized-variance estimator sqrt(sum r^2 / sum dt), which is unbiased under threshold publication and far more robust to bursts: rolling sigma spread over 96h was 20x for the old form versus 4x for the new one. Verified against production in dry run, where sigma fell from 69 to 41 bps over a 1h horizon. Also in this change: - Gate vault deposits on dryRun. CollateralTracker.maybeTopUp() submitted real deposits even in dry-run mode. - Replace oracle.historyLookbackMultiplier with historyMaxAgeSec. The old knob scaled the backfill window by pollInterval, which has no bearing on how often the oracle updates. Backfill now requests exactly windowSize samples and uses the age bound only to keep a stale regime out of sigma. - Move shared config documentation from the YAML files into the TypeBox schema descriptions so hover docs reach every environment, leaving only env-specific rationale inline. Corrects a stale claim that hashprice updates every ~5 min (measured median is ~3 min) and drops a comment describing a tx-weight field that no longer exists. - Consolidate the three copies of fractionToNumber into math.ts and use it in oracleTracker, which was calling Fraction.valueOf() directly and logging null for sigma. Sigma is now reported at info level on backfill. - Finish the migration to unified portfolio.*.yml configs: drop the per-app perps/futures configs and the stg variants, retire the matching package scripts, and replace the two per-app smoke tests with one portfolio test. --- config/prd.env | 1 + market-maker/.vscode/settings.json | 3 +- market-maker/README.md | 34 ++-- market-maker/configs/futures.dev.yml | 94 ---------- market-maker/configs/futures.local.yml | 81 -------- market-maker/configs/futures.prd.yml | 87 --------- market-maker/configs/futures.stg.yml | 87 --------- market-maker/configs/perps.dev.yml | 98 ---------- market-maker/configs/perps.local.yml | 88 --------- market-maker/configs/perps.prd.yml | 90 --------- market-maker/configs/perps.stg.yml | 89 --------- market-maker/configs/portfolio.dev.yml | 37 +--- market-maker/configs/portfolio.local.yml | 21 +-- market-maker/configs/portfolio.prd.yml | 30 +-- market-maker/configs/portfolio.stg.yml | 135 -------------- market-maker/package.json | 6 - market-maker/schemas/futures.json | 16 +- market-maker/schemas/perps.json | 16 +- market-maker/schemas/portfolio.json | 25 +-- .../src/adapters/futures/instrument.ts | 5 + market-maker/src/adapters/futures/venue.ts | 7 + market-maker/src/adapters/perps/instrument.ts | 5 + market-maker/src/adapters/perps/venue.ts | 7 + market-maker/src/apps/futures/config.ts | 2 +- market-maker/src/apps/futures/main.ts | 4 +- market-maker/src/apps/perps/config.ts | 2 +- market-maker/src/apps/perps/main.ts | 4 +- market-maker/src/apps/portfolio/config.ts | 20 +- market-maker/src/apps/portfolio/main.ts | 4 +- market-maker/src/core/adapter.ts | 15 ++ market-maker/src/core/collateralTracker.ts | 26 ++- market-maker/src/core/config/base.ts | 20 +- market-maker/src/core/healthcheck.ts | 11 +- .../src/core/historicalPriceSource.ts | 70 +++++-- market-maker/src/core/marketRuntime.ts | 6 +- market-maker/src/core/math.ts | 60 ++++-- market-maker/src/core/oracleTracker.ts | 176 ++++++++++++------ market-maker/src/core/portfolioHealth.ts | 6 +- market-maker/src/core/rawOracle.ts | 11 ++ .../tests/apps/futures/main.smoke.test.ts | 29 --- .../tests/apps/perps/main.smoke.test.ts | 30 --- .../tests/apps/portfolio/main.smoke.test.ts | 39 ++++ .../tests/core/collateralTracker.test.ts | 14 ++ market-maker/tests/core/math.test.ts | 52 +++++- market-maker/tests/core/oracleTracker.test.ts | 108 ++++++++++- 45 files changed, 602 insertions(+), 1169 deletions(-) delete mode 100644 market-maker/configs/futures.dev.yml delete mode 100644 market-maker/configs/futures.local.yml delete mode 100644 market-maker/configs/futures.prd.yml delete mode 100644 market-maker/configs/futures.stg.yml delete mode 100644 market-maker/configs/perps.dev.yml delete mode 100644 market-maker/configs/perps.local.yml delete mode 100644 market-maker/configs/perps.prd.yml delete mode 100644 market-maker/configs/perps.stg.yml delete mode 100644 market-maker/configs/portfolio.stg.yml delete mode 100644 market-maker/tests/apps/futures/main.smoke.test.ts delete mode 100644 market-maker/tests/apps/perps/main.smoke.test.ts create mode 100644 market-maker/tests/apps/portfolio/main.smoke.test.ts diff --git a/config/prd.env b/config/prd.env index 89f5ad8..8228df3 100644 --- a/config/prd.env +++ b/config/prd.env @@ -49,3 +49,4 @@ LOG_LEVEL=info # ── Market-maker runtime ─────────────────────────────────────────────────── MAKER_LOG_LEVEL=info HASHPRICE_ORACLE_SUBGRAPH_URL=https://api.goldsky.com/api/public/project_cmmz5dm4l7ocp01xng61y5nwr/subgraphs/hpow-oracles/lmn-latest/gn +ETH_PRICE_FEED_ADDRESS=0x50015f8b17fb2C290Dde41fDc246ed0dcEE93a8b diff --git a/market-maker/.vscode/settings.json b/market-maker/.vscode/settings.json index 7e5d4a7..418b9dc 100644 --- a/market-maker/.vscode/settings.json +++ b/market-maker/.vscode/settings.json @@ -1,7 +1,6 @@ { "yaml.schemas": { - "./schemas/perps.json": "configs/perps.*.yml", - "./schemas/futures.json": "configs/futures.*.yml" + "./schemas/portfolio.json": "configs/portfolio.*.yml" }, "yaml.format.enable": true, "yaml.validate": true, diff --git a/market-maker/README.md b/market-maker/README.md index b43352a..2224e0b 100644 --- a/market-maker/README.md +++ b/market-maker/README.md @@ -125,35 +125,35 @@ on the book for hot restarts. ## Configuration -Each app ships per-environment YAML configs under `configs/`: +Per-environment YAML configs live under `configs/`. One shared +portfolio config per environment covers perps and every futures expiry +in a single process: | File | Network | |---|---| -| `perps.local.yml` / `futures.local.yml` | hardhat | -| `perps.dev.yml` / `futures.dev.yml` | base-sepolia | -| `perps.stg.yml` / `futures.stg.yml` | base-mainnet | -| `perps.prd.yml` / `futures.prd.yml` | base-mainnet | +| `portfolio.local.yml` | hardhat | +| `portfolio.dev.yml` | base-sepolia | +| `portfolio.prd.yml` | base-mainnet | Pick one with `--config ` (CLI flag), `MAKER_CONFIG=` (env -variable), or `MAKER_ENV=` inside the docker +variable), or `MAKER_ENV=` inside the docker entrypoint. Precedence is `--config` > `MAKER_CONFIG` > docker `MAKER_ENV` lookup. The YAMLs are validated against generated JSON Schemas (autocomplete and type-checking work in any editor with the YAML extension). They interpolate `${VAR}` tokens from environment variables. On startup -both apps load `.env` from `market-maker/` and from the parent +the app loads `.env` from `market-maker/` and from the parent `collateral-margin/` (in that priority order); live `process.env` always wins over file contents. ```bash -pnpm local:perps # node … --config configs/perps.local.yml | pino-pretty -pnpm dev:futures # node … --config configs/futures.dev.yml | pino-pretty -pnpm stg:perps # node … --config configs/perps.stg.yml -pnpm prd:futures # node … --config configs/futures.prd.yml +pnpm local:portfolio # node … --config configs/portfolio.local.yml | pino-pretty +pnpm dev:portfolio # node … --config configs/portfolio.dev.yml | pino-pretty +pnpm prd:portfolio # node … --config configs/portfolio.prd.yml # One-off / custom path: -node src/apps/perps/main.ts --config /tmp/my-perps.yml +node src/apps/portfolio/main.ts --config /tmp/my-portfolio.yml ``` All operational tuning (sizes, spreads, risk caps, gas budgets, @@ -168,13 +168,15 @@ else lives in YAML. | Variable | Required by | Description | |---|---|---| | `PRIVATE_KEY` | all | Hex-encoded private key for the MM wallet | -| `ALCHEMY_API_KEY` | dev / stg / prd | Used by the bundled YAMLs to compose the RPC URL | -| `PERPS_ADDRESS` | perps app | Deployed `HashPowerPerpsDEX` proxy address | -| `FUTURES_ADDRESS` | futures app | Deployed `Futures` proxy address | +| `ALCHEMY_API_KEY` | dev / prd | Used by the bundled YAMLs to compose the RPC URL | +| `PERPS_ADDRESS` | all | Deployed `HashPowerPerpsDEX` proxy address | +| `FUTURES_ADDRESS` | all | Deployed `Futures` proxy address | +| `HASHPRICE_ORACLE_SUBGRAPH_URL` | dev / prd | Hashprice-oracle subgraph, used to backfill the σ window at startup | +| `ETH_PRICE_FEED_ADDRESS` | dev / prd | Chainlink ETH/USD aggregator; required for USD-denominated gas budgets | Custom YAMLs may reference additional `${VAR}` tokens (e.g. a non-Alchemy RPC URL, a chain id override). The bundled YAMLs in -`configs/` only reference the four above plus the RPC URL. +`configs/` only reference those above plus the RPC URL. ## Getting started diff --git a/market-maker/configs/futures.dev.yml b/market-maker/configs/futures.dev.yml deleted file mode 100644 index 02856ea..0000000 --- a/market-maker/configs/futures.dev.yml +++ /dev/null @@ -1,94 +0,0 @@ -# yaml-language-server: $schema=../schemas/futures.json -# Titan Market Maker - Futures - DEV (base-sepolia). -# -# PRIVATE_KEY - hex private key of the dev market-making wallet -# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) -# FUTURES_ADDRESS - Futures address on base-sepolia -# ETH_PRICE_FEED_ADDRESS - optional Chainlink ETH/USD aggregator on base-sepolia -# HASHPRICE_ORACLE_SUBGRAPH_URL - optional hashprice-oracle subgraph URL for σ backfill - -nodeEnv: development -commitHash: ${COMMIT_HASH:-unknown} -logLevel: ${MAKER_LOG_LEVEL:-debug} -dryRun: false -# Dev iterates fast; leave resting orders on base-sepolia on Ctrl-C so we -# don't burn gas on cancel-then-reopen across every restart. -cancelOrdersOnShutdown: false - -wallets: - primary: - privateKey: ${PRIVATE_KEY} - -network: - name: base-sepolia - rpcUrl: https://base-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY} - ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} - -venue: - kind: futures - address: ${FUTURES_ADDRESS} - wallet: primary - -pricing: - strategy: reservation-price - riskAversion: 0.001 - marginCallTimeSec: 3600 - # The MM reads the unrounded oracle answer (FuturesVenue.getRawMarketPrice), - # so the reservation price `r` lands between ticks and `bid = floor(r/tick)`, - # `ask = ceil(r/tick)` already differ by exactly 1 tick. minSpreadBps is just - # the floor before tick rounding; 0 would still yield 1-tick spread on - # off-tick mids, but a tiny non-zero value protects the rare exact-tick case. - minSpreadBps: 0 - # Disable vol-based spread widening so the spread stays at the tick floor. - volatilityMultiplier: 0 - maxSkewTicks: 0 - -sizing: - strategy: geometric-taper - baseQuantity: 8 # venue-native (contracts base units) - numLevelsPerSide: 10 - taperRatio: 0.6 - -risk: - maxPositionSize: 50 - maxUtilizationPct: 80 - minCollateralBalance: 10 - maxDailyLossUsd: 500 - maxGasBudgetPerHourUsd: 50 - maxGasBudgetPerDayUsd: 100 - gasSpikeThresholdPct: 200 - gasPenaltyBps: 5 - urgentRequoteThresholdTicks: 10 - -gas: - gasCapMultiplier: 2.0 - -timing: - pollIntervalSec: 10 - requoteCooldownSec: 1 - resyncIntervalSec: 60 - levelSpacingTicks: 1 - staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 50 - -collateral: - autoDeposit: true - autoDepositMinAmount: 500 - maxCollateralAmount: 5000 - -oracle: - # Window de-duplicates by price, so 60 samples ≈ 60 oracle updates regardless - # of poll cadence. Multiplier 4× compensates for Chainlink's slow heartbeat - # so backfill returns a full window. - windowSize: 60 - precisionBits: 48 - historyLookbackMultiplier: 4 - history: - subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL:-} - -health: - port: ${MAKER_HEALTH_PORT:-3001} - -readBatchSize: 30 -# Per-operation batch sizes for writes (futures: closeOrder limit / createOrders limit). -writeBatchSize: 100 diff --git a/market-maker/configs/futures.local.yml b/market-maker/configs/futures.local.yml deleted file mode 100644 index 6c46f23..0000000 --- a/market-maker/configs/futures.local.yml +++ /dev/null @@ -1,81 +0,0 @@ -# yaml-language-server: $schema=../schemas/futures.json -# Titan Market Maker - Futures - LOCAL (hardhat). -# -# PRIVATE_KEY - hex private key of the market-making wallet -# FUTURES_ADDRESS - Futures address on the local chain - -nodeEnv: development -commitHash: ${COMMIT_HASH:-dev} -logLevel: debug -dryRun: false -# Local/dev iterates fast; leave resting orders on the hardhat book on Ctrl-C -# so you don't pay the cancel-then-reopen round-trip on every restart. -cancelOrdersOnShutdown: false - -wallets: - primary: - privateKey: ${PRIVATE_KEY} - -network: - name: "hardhat" - rpcUrl: "http://127.0.0.1:8545" - ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} - -venue: - kind: futures - address: ${FUTURES_ADDRESS} - wallet: primary - -pricing: - strategy: reservation-price - riskAversion: 0.001 - marginCallTimeSec: 3600 - minSpreadBps: 20 - volatilityMultiplier: 2.5 - maxSkewTicks: 0 - -sizing: - strategy: geometric-taper - baseQuantity: "10000000" # venue-native (contracts base units) - numLevelsPerSide: 10 - taperRatio: 0.6 - -risk: - maxPositionSize: 10 - maxUtilizationPct: 80 - minCollateralBalance: 1 - maxDailyLossUsd: 100 - maxGasBudgetPerHourUsd: 50 - maxGasBudgetPerDayUsd: 500 - gasSpikeThresholdPct: 200 - gasPenaltyBps: 5 - urgentRequoteThresholdTicks: 10 - -gas: - gasCapMultiplier: 2.0 - -timing: - pollIntervalSec: 3 - requoteCooldownSec: 1 - resyncIntervalSec: 60 - levelSpacingTicks: 1 - staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 50 - -collateral: - autoDeposit: false - autoDepositMinAmount: 0 - -oracle: - # No `history:` block → cold start, σ warms up live as the local oracle - # ticks (likely never on hardhat unless you script price updates). - windowSize: 60 - precisionBits: 48 - historyLookbackMultiplier: 4 - -health: - port: 3001 - -readBatchSize: 100 -# Per-operation batch sizes for writes. -writeBatchSize: 100 diff --git a/market-maker/configs/futures.prd.yml b/market-maker/configs/futures.prd.yml deleted file mode 100644 index 78c796f..0000000 --- a/market-maker/configs/futures.prd.yml +++ /dev/null @@ -1,87 +0,0 @@ -# yaml-language-server: $schema=../schemas/futures.json -# Titan Market Maker - Futures - PRODUCTION (base-mainnet). -# -# PRIVATE_KEY - hex private key of the production wallet (Secrets Manager) -# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) -# FUTURES_ADDRESS - Futures address on base-mainnet (production deployment) -# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet -# HASHPRICE_ORACLE_SUBGRAPH_URL - hashprice-oracle subgraph URL for σ backfill at startup - -nodeEnv: production -commitHash: ${COMMIT_HASH:-unknown} -logLevel: ${MAKER_LOG_LEVEL:-info} -dryRun: ${MAKER_DRY_RUN:-false} -# Cancel resting orders on SIGINT/SIGTERM. Set false for hot-restart deploys -# where you'd rather absorb the brief stale-quote risk than pay cancel gas. -cancelOrdersOnShutdown: ${MAKER_CANCEL_ORDERS_ON_SHUTDOWN:-true} - -wallets: - primary: - privateKey: ${PRIVATE_KEY} - -network: - name: base - rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} - ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} - -venue: - kind: futures - address: ${FUTURES_ADDRESS} - wallet: primary - -pricing: - strategy: reservation-price - riskAversion: 0.001 - marginCallTimeSec: 3600 - minSpreadBps: 15 - volatilityMultiplier: 2.5 - maxSkewTicks: 0 - -sizing: - strategy: geometric-taper - baseQuantity: "500000000" # venue-native (contracts base units) - numLevelsPerSide: 10 - taperRatio: 0.6 - -risk: - maxPositionSize: 1000 - maxUtilizationPct: 75 - minCollateralBalance: 100 - maxDailyLossUsd: 1000 - maxGasBudgetPerHourUsd: 50 - maxGasBudgetPerDayUsd: 500 - gasSpikeThresholdPct: 200 - gasPenaltyBps: 5 - urgentRequoteThresholdTicks: 10 - -gas: - gasCapMultiplier: 2.0 - -timing: - pollIntervalSec: 3 - requoteCooldownSec: 1 - resyncIntervalSec: 60 - levelSpacingTicks: 1 - staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 50 - -collateral: - autoDeposit: true - autoDepositMinAmount: 1 - maxCollateralAmount: 10000 -oracle: - # 60 de-duplicated samples → ±9% standard error on σ. With Chainlink heartbeat - # of a few minutes and historyLookbackMultiplier=4, backfill covers ~4 hours - # of oracle activity, easily enough to fill the window on cold start. - windowSize: 60 - precisionBits: 48 - historyLookbackMultiplier: 4 - history: - subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL} - -health: - port: ${MAKER_HEALTH_PORT:-3001} - -readBatchSize: 100 -# Per-operation batch sizes for writes. -writeBatchSize: 100 diff --git a/market-maker/configs/futures.stg.yml b/market-maker/configs/futures.stg.yml deleted file mode 100644 index b560c94..0000000 --- a/market-maker/configs/futures.stg.yml +++ /dev/null @@ -1,87 +0,0 @@ -# yaml-language-server: $schema=../schemas/futures.json -# Titan Market Maker - Futures - STAGING (base-mainnet). -# -# PRIVATE_KEY - hex private key of the staging market-making wallet -# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) -# FUTURES_ADDRESS - Futures address on base-mainnet (staging deployment) -# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet -# HASHPRICE_ORACLE_SUBGRAPH_URL - hashprice-oracle subgraph URL for σ backfill at startup - -nodeEnv: staging -commitHash: ${COMMIT_HASH:-unknown} -logLevel: ${MAKER_LOG_LEVEL:-debug} -dryRun: ${MAKER_DRY_RUN:-false} -# Cancel resting orders on SIGINT/SIGTERM. Set false for hot-restart deploys -# where you'd rather absorb the brief stale-quote risk than pay cancel gas. -cancelOrdersOnShutdown: ${MAKER_CANCEL_ORDERS_ON_SHUTDOWN:-true} - -wallets: - primary: - privateKey: ${PRIVATE_KEY} - -network: - name: base - rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} - ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} - -venue: - kind: futures - address: ${FUTURES_ADDRESS} - wallet: primary - -pricing: - strategy: reservation-price - riskAversion: 0.001 - marginCallTimeSec: 3600 - minSpreadBps: 15 - volatilityMultiplier: 2.5 - maxSkewTicks: 0 - -sizing: - strategy: geometric-taper - baseQuantity: "100000000" # venue-native (contracts base units) - numLevelsPerSide: 10 - taperRatio: 0.6 - -risk: - maxPositionSize: 50 - maxUtilizationPct: 80 - minCollateralBalance: 10 - maxDailyLossUsd: 500 - maxGasBudgetPerHourUsd: 50 - maxGasBudgetPerDayUsd: 500 - gasSpikeThresholdPct: 200 - gasPenaltyBps: 5 - urgentRequoteThresholdTicks: 10 - -gas: - gasCapMultiplier: 2.0 - -timing: - pollIntervalSec: 3 - requoteCooldownSec: 1 - resyncIntervalSec: 60 - levelSpacingTicks: 1 - staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 50 - -collateral: - autoDeposit: true - autoDepositMinAmount: 1 - maxCollateralAmount: 4000 -oracle: - # 60 de-duplicated samples → ±9% standard error on σ. With Chainlink heartbeat - # of a few minutes and historyLookbackMultiplier=4, backfill covers ~4 hours - # of oracle activity, easily enough to fill the window on cold start. - windowSize: 60 - precisionBits: 48 - historyLookbackMultiplier: 4 - history: - subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL} - -health: - port: ${MAKER_HEALTH_PORT:-3001} - -readBatchSize: 100 -# Per-operation batch sizes for writes. -writeBatchSize: 100 diff --git a/market-maker/configs/perps.dev.yml b/market-maker/configs/perps.dev.yml deleted file mode 100644 index 9eebf9a..0000000 --- a/market-maker/configs/perps.dev.yml +++ /dev/null @@ -1,98 +0,0 @@ -# yaml-language-server: $schema=../schemas/perps.json -# Titan Market Maker - Perps - DEV (base-sepolia). -# -# Real quoting on base-sepolia. Small sizes, debug-level logs, autoDeposit -# on so the wallet stays funded. -# -# PRIVATE_KEY - hex private key of the dev market-making wallet -# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) -# PERPS_ADDRESS - HashPowerPerpsDEX address on base-sepolia -# ETH_PRICE_FEED_ADDRESS - optional Chainlink ETH/USD aggregator on base-sepolia -# HASHPRICE_ORACLE_SUBGRAPH_URL - optional hashprice-oracle subgraph URL for σ backfill - -nodeEnv: development -commitHash: ${COMMIT_HASH:-unknown} -logLevel: ${MAKER_LOG_LEVEL:-debug} -dryRun: ${MAKER_DRY_RUN:-false} -# Dev iterates fast; leave resting orders on base-sepolia on Ctrl-C so we -# don't burn gas on cancel-then-reopen across every restart. -cancelOrdersOnShutdown: true - -wallets: - primary: - privateKey: ${PRIVATE_KEY} - -network: - name: base-sepolia - rpcUrl: https://base-sepolia.g.alchemy.com/v2/${ALCHEMY_API_KEY} - ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} - -venue: - kind: perps - address: ${PERPS_ADDRESS} - wallet: primary - -pricing: - strategy: effective-spread - # The MM reads the unrounded oracle answer (PerpsVenue.getRawMarketPrice), - # so the mid lands between ticks and tick-rounding alone produces 1-tick - # bid/ask separation. A non-zero floor still buys insurance for the rare - # case `r` lands exactly on a tick. - minSpreadBps: 0 - volatilityMultiplier: 0 - inventorySkewGamma: 0.5 - maxSkewTicks: 20 - -sizing: - strategy: geometric-taper - baseQuantity: "1000000" # venue-native units (hashrate base) - numLevelsPerSide: 10 - taperRatio: 0.6 - -risk: - maxPositionSize: 50 - maxUtilizationPct: 80 - minCollateralBalance: 10 - maxDailyLossUsd: 500 - maxGasBudgetPerHourUsd: 50 - maxGasBudgetPerDayUsd: 500 - gasSpikeThresholdPct: 200 - gasPenaltyBps: 5 - urgentRequoteThresholdTicks: 10 - -gas: - gasCapMultiplier: 2.0 - -timing: - pollIntervalSec: 30 - requoteCooldownSec: 1 - resyncIntervalSec: 60 - # Perps matching is "limit" — deeper levels fill conditional on shallower - # ones being hit first, so production normally wants levelSpacing ≥ 3 to - # spread inventory risk along the book. In dev we keep it tight (1 tick) - # to match futures and visually verify the level layout. - levelSpacingTicks: 1 - staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 50 - -collateral: - autoDeposit: true - autoDepositMinAmount: 500 - maxCollateralAmount: 5000 - -oracle: - # Window de-duplicates by price, so 60 samples ≈ 60 oracle updates regardless - # of poll cadence. Multiplier 4× compensates for Chainlink's slow heartbeat - # so backfill returns a full window. - windowSize: 60 - precisionBits: 48 - historyLookbackMultiplier: 4 - history: - subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL:-} - -health: - port: ${MAKER_HEALTH_PORT:-3002} - -readBatchSize: 100 -# Per-operation batch sizes for writes (perps: individual cancelOrder / createOrder). -writeBatchSize: 100 diff --git a/market-maker/configs/perps.local.yml b/market-maker/configs/perps.local.yml deleted file mode 100644 index 24fe3d9..0000000 --- a/market-maker/configs/perps.local.yml +++ /dev/null @@ -1,88 +0,0 @@ -# yaml-language-server: $schema=../schemas/perps.json -# Titan Market Maker - Perps - LOCAL (hardhat). -# -# Local development against a hardhat node. Dry-run on by default, -# debug logs, tiny sizes. Override via env vars (loaded from -# market-maker/.env or collateral-margin/.env on startup, in that -# priority order). -# -# PRIVATE_KEY - hex private key of the market-making wallet -# PERPS_ADDRESS - HashPowerPerpsDEX address on the local chain - -nodeEnv: development -commitHash: ${COMMIT_HASH:-dev} -logLevel: ${MAKER_LOG_LEVEL:-debug} -dryRun: ${MAKER_DRY_RUN:-true} -# Local/dev iterates fast; leave resting orders on the hardhat book on Ctrl-C -# so you don't pay the cancel-then-reopen round-trip on every restart. -cancelOrdersOnShutdown: false - -wallets: - primary: - privateKey: ${PRIVATE_KEY} - -network: - name: ${NETWORK:-hardhat} - rpcUrl: http://127.0.0.1:8545 - ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} - -venue: - kind: perps - address: ${PERPS_ADDRESS} - wallet: primary - -pricing: - strategy: effective-spread - minSpreadBps: 20 # wider in dev so test fills are obvious - volatilityMultiplier: 2.0 - inventorySkewGamma: 0.5 - maxSkewTicks: 20 - -sizing: - strategy: geometric-taper - baseQuantity: "100000" # venue-native units (hashrate base) - numLevelsPerSide: 10 - taperRatio: 0.6 - -risk: - # All *Usd fields are USD (decimals OK). Loader converts to 6-dec USDC. - maxPositionSize: 10 - maxUtilizationPct: 80 - minCollateralBalance: 1 - maxDailyLossUsd: 100 - maxGasBudgetPerHourUsd: 50 - maxGasBudgetPerDayUsd: 500 - gasSpikeThresholdPct: 200 - gasPenaltyBps: 5 - urgentRequoteThresholdTicks: 10 - -gas: - gasCapMultiplier: 2.0 - -timing: - # All *Sec fields are seconds (decimals OK). Loader converts to ms. - pollIntervalSec: 3 - requoteCooldownSec: 1 - resyncIntervalSec: 60 - levelSpacingTicks: 5 - staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 50 - -collateral: - # Off in dev so you can inspect un-deposited wallet balance. - autoDeposit: false - autoDepositMinAmount: 0 - -oracle: - # No `history:` block → cold start, σ warms up live as the local oracle - # ticks (likely never on hardhat unless you script price updates). - windowSize: 60 - precisionBits: 48 - historyLookbackMultiplier: 4 - -health: - port: ${MAKER_HEALTH_PORT:-3001} - -readBatchSize: 100 -# Per-operation batch sizes for writes. -writeBatchSize: 100 diff --git a/market-maker/configs/perps.prd.yml b/market-maker/configs/perps.prd.yml deleted file mode 100644 index b16bd18..0000000 --- a/market-maker/configs/perps.prd.yml +++ /dev/null @@ -1,90 +0,0 @@ -# yaml-language-server: $schema=../schemas/perps.json -# Titan Market Maker - Perps - PRODUCTION (base-mainnet). -# -# Real money. Tighter risk caps and a higher utilization headroom; logs at -# info to keep CloudWatch ingestion costs bounded. Tune sizing per -# liquidity provision target. -# -# PRIVATE_KEY - hex private key of the production wallet (Secrets Manager) -# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) -# PERPS_ADDRESS - HashPowerPerpsDEX address on base-mainnet (production deployment) -# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet (USD-denominated risk gates) -# HASHPRICE_ORACLE_SUBGRAPH_URL - hashprice-oracle subgraph URL for σ backfill at startup - -nodeEnv: production -commitHash: ${COMMIT_HASH:-unknown} -logLevel: ${MAKER_LOG_LEVEL:-info} -dryRun: ${MAKER_DRY_RUN:-false} -# Cancel resting orders on SIGINT/SIGTERM. Set false for hot-restart deploys -# where you'd rather absorb the brief stale-quote risk than pay cancel gas. -cancelOrdersOnShutdown: ${MAKER_CANCEL_ORDERS_ON_SHUTDOWN:-true} - -wallets: - primary: - privateKey: ${PRIVATE_KEY} - -network: - name: base - rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} - ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} - -venue: - kind: perps - address: ${PERPS_ADDRESS} - wallet: primary - -pricing: - strategy: effective-spread - minSpreadBps: 10 - volatilityMultiplier: 2.0 - inventorySkewGamma: 0.5 - maxSkewTicks: 20 - -sizing: - strategy: geometric-taper - baseQuantity: "10000000" # venue-native units (hashrate base) - numLevelsPerSide: 10 - taperRatio: 0.6 - -risk: - maxPositionSize: 1000 - maxUtilizationPct: 75 # tighter than dev/stg - minCollateralBalance: 100 # operational floor - maxDailyLossUsd: 1000 # daily loss circuit-breaker - maxGasBudgetPerHourUsd: 50 - maxGasBudgetPerDayUsd: 500 - gasSpikeThresholdPct: 200 - gasPenaltyBps: 5 - urgentRequoteThresholdTicks: 10 - -gas: - gasCapMultiplier: 2.0 - -timing: - pollIntervalSec: 3 - requoteCooldownSec: 1 - resyncIntervalSec: 60 - levelSpacingTicks: 5 - staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 50 - -collateral: - autoDeposit: true - autoDepositMinAmount: 1 - maxCollateralAmount: 10000 -oracle: - # 60 de-duplicated samples → ±9% standard error on σ. With Chainlink heartbeat - # of a few minutes and historyLookbackMultiplier=4, backfill covers ~4 hours - # of oracle activity, easily enough to fill the window on cold start. - windowSize: 60 - precisionBits: 48 - historyLookbackMultiplier: 4 - history: - subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL} - -health: - port: ${MAKER_HEALTH_PORT:-3001} - -readBatchSize: 100 -# Per-operation batch sizes for writes. -writeBatchSize: 100 diff --git a/market-maker/configs/perps.stg.yml b/market-maker/configs/perps.stg.yml deleted file mode 100644 index 821792c..0000000 --- a/market-maker/configs/perps.stg.yml +++ /dev/null @@ -1,89 +0,0 @@ -# yaml-language-server: $schema=../schemas/perps.json -# Titan Market Maker - Perps - STAGING (base-mainnet). -# -# Real money on base-mainnet, but pre-prod sizes / risk caps. Debug-level -# logs to make incident triage easier in shared infra. -# -# PRIVATE_KEY - hex private key of the staging market-making wallet -# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) -# PERPS_ADDRESS - HashPowerPerpsDEX address on base-mainnet (staging deployment) -# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet -# HASHPRICE_ORACLE_SUBGRAPH_URL - hashprice-oracle subgraph URL for σ backfill at startup - -nodeEnv: staging -commitHash: ${COMMIT_HASH:-unknown} -logLevel: ${MAKER_LOG_LEVEL:-debug} -dryRun: ${MAKER_DRY_RUN:-false} -# Cancel resting orders on SIGINT/SIGTERM. Set false for hot-restart deploys -# where you'd rather absorb the brief stale-quote risk than pay cancel gas. -cancelOrdersOnShutdown: ${MAKER_CANCEL_ORDERS_ON_SHUTDOWN:-true} - -wallets: - primary: - privateKey: ${PRIVATE_KEY} - -network: - name: base - rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} - ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} - -venue: - kind: perps - address: ${PERPS_ADDRESS} - wallet: primary - -pricing: - strategy: effective-spread - minSpreadBps: 15 - volatilityMultiplier: 2.0 - inventorySkewGamma: 0.5 - maxSkewTicks: 20 - -sizing: - strategy: geometric-taper - baseQuantity: "1000000" # venue-native units (hashrate base) - numLevelsPerSide: 10 - taperRatio: 0.6 - -risk: - maxPositionSize: 50 - maxUtilizationPct: 80 - minCollateralBalance: 10 - maxDailyLossUsd: 500 - maxGasBudgetPerHourUsd: 50 - maxGasBudgetPerDayUsd: 500 - gasSpikeThresholdPct: 200 - gasPenaltyBps: 5 - urgentRequoteThresholdTicks: 10 - -gas: - gasCapMultiplier: 2.0 - -timing: - pollIntervalSec: 3 - requoteCooldownSec: 1 - resyncIntervalSec: 60 - levelSpacingTicks: 5 - staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 50 - -collateral: - autoDeposit: true - autoDepositMinAmount: 1 - maxCollateralAmount: 4000 -oracle: - # 60 de-duplicated samples → ±9% standard error on σ. With Chainlink heartbeat - # of a few minutes and historyLookbackMultiplier=4, backfill covers ~4 hours - # of oracle activity, easily enough to fill the window on cold start. - windowSize: 60 - precisionBits: 48 - historyLookbackMultiplier: 4 - history: - subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL} - -health: - port: ${MAKER_HEALTH_PORT:-3001} - -readBatchSize: 100 -# Per-operation batch sizes for writes. -writeBatchSize: 100 diff --git a/market-maker/configs/portfolio.dev.yml b/market-maker/configs/portfolio.dev.yml index c12594a..ca6cf40 100644 --- a/market-maker/configs/portfolio.dev.yml +++ b/market-maker/configs/portfolio.dev.yml @@ -7,11 +7,6 @@ # FUTURES_ADDRESS - Futures address on base-sepolia # ETH_PRICE_FEED_ADDRESS - optional Chainlink ETH/USD aggregator on base-sepolia # HASHPRICE_ORACLE_SUBGRAPH_URL - optional hashprice-oracle subgraph URL for σ backfill -# -# One process, one signer, one shared collateral vault. Perps and every -# selected futures expiry quote together; the TxCoordinator sequences their -# txs on a single nonce and each market is isolated behind its own circuit -# breaker. nodeEnv: development commitHash: ${COMMIT_HASH:-unknown} @@ -25,7 +20,6 @@ wallets: primary: privateKey: ${PRIVATE_KEY} -# Single shared signer for the whole portfolio. wallet: primary network: @@ -38,10 +32,9 @@ venues: address: ${PERPS_ADDRESS} maxPositionSize: 50 pricing: - # The MM reads the unrounded oracle answer (PerpsVenue.getRawMarketPrice), - # so the mid lands between ticks and tick-rounding alone produces 1-tick - # bid/ask separation. A non-zero floor still buys insurance for the rare - # case `r` lands exactly on a tick. + # No padding on dev: lean entirely on the 1-tick separation that oracle + # rounding already gives (see minSpreadBps in the schema) so the book + # stays as tight as the venue permits. strategy: effective-spread minSpreadBps: 0 volatilityMultiplier: 0 @@ -49,14 +42,13 @@ venues: maxSkewTicks: 20 sizing: strategy: geometric-taper - baseQuantity: "1000000" # venue-native (perps hashrate base units) + baseQuantity: "1000000" numLevelsPerSide: 10 taperRatio: 0.6 - kind: futures address: ${FUTURES_ADDRESS} maxPositionSize: 50 - # Quote the three nearest expiries; the roll adds/drops markets as dates mature. marketSelection: mode: nearest count: 3 @@ -69,16 +61,13 @@ venues: maxSkewTicks: 0 sizing: strategy: geometric-taper - # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = - # baseQuantity×numLevels/1e6 = 10. Split across `count` expiries; base=4 - # keeps taper levels non-zero. Order count is 2×levels×expiries (=60). - baseQuantity: 4 # venue-native (contracts) + # Perps only runs 10/side on dev, so 4 × 10 × 3 overshoots; base 4 is + # kept anyway because anything smaller zeroes the taper's last levels. + baseQuantity: 4 numLevelsPerSide: 10 taperRatio: 0.6 - # Nearest expiry keeps full size; each further date × this factor. expirySizeDecay: 0.6 -# Shared portfolio-wide budget across every market. risk: maxPositionSize: 50 maxUtilizationPct: 80 @@ -108,29 +97,21 @@ collateral: maxCollateralAmount: 10000 oracle: - # Window de-duplicates by price, so 60 samples ≈ 60 oracle updates regardless - # of poll cadence. Multiplier 4× compensates for Chainlink's slow heartbeat - # so backfill returns a full window. windowSize: 60 precisionBits: 48 - historyLookbackMultiplier: 4 + historyMaxAgeSec: 86400 history: subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL:-} health: port: ${MAKER_HEALTH_PORT:-3001} -# Centralized submission / nonce recovery. txCoordinator: - # Cost units per tx, NOT raw call count. One unit ≈ the cheapest call: - # perps = one order per price level; futures = one contract (qty=1). Futures - # createOrder gas scales with qty, so a full 3-expiry quote is many units and - # must be chunked to avoid out-of-gas. Lowered from 50 for base-sepolia. confirmationTimeoutSec: 60 maxReplacements: 2 replacementFeeBumpPct: 15 + maxNonceResyncs: 5 -# Per-market fault isolation. circuitBreaker: quarantineThreshold: 3 baseBackoffSec: 5 diff --git a/market-maker/configs/portfolio.local.yml b/market-maker/configs/portfolio.local.yml index 9b4f980..3006b3d 100644 --- a/market-maker/configs/portfolio.local.yml +++ b/market-maker/configs/portfolio.local.yml @@ -4,11 +4,6 @@ # PRIVATE_KEY - hex private key of the single market-making wallet # PERPS_ADDRESS - HashPowerPerpsDEX address on the local chain # FUTURES_ADDRESS - Futures address on the local chain -# -# One process, one signer, one shared collateral vault. Perps and every -# selected futures expiry quote together; the TxCoordinator sequences their -# txs on a single nonce and each market is isolated behind its own circuit -# breaker. nodeEnv: development commitHash: ${COMMIT_HASH:-dev} @@ -20,7 +15,6 @@ wallets: primary: privateKey: ${PRIVATE_KEY} -# Single shared signer for the whole portfolio. wallet: primary network: @@ -40,14 +34,13 @@ venues: maxSkewTicks: 5 sizing: strategy: geometric-taper - baseQuantity: "10000000" # venue-native (perps hashrate base units) + baseQuantity: "10000000" numLevelsPerSide: 10 taperRatio: 0.6 - kind: futures address: ${FUTURES_ADDRESS} maxPositionSize: 10 - # Quote the three nearest expiries; the roll adds/drops markets as dates mature. marketSelection: mode: nearest count: 3 @@ -60,15 +53,12 @@ venues: maxSkewTicks: 0 sizing: strategy: geometric-taper - # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = - # 10e6×10/1e6 = 100. Split across 3 expiries: 4×10×3 = 120. - baseQuantity: 4 # venue-native (contracts) + # Perps runs 100/side here, so 4 × 10 × 3 expiries = 120 is the nearest match. + baseQuantity: 4 numLevelsPerSide: 10 taperRatio: 0.6 - # Nearest expiry keeps full size; each further date × this factor. expirySizeDecay: 0.6 -# Shared portfolio-wide budget across every market. risk: maxPositionSize: 10 maxUtilizationPct: 80 @@ -98,18 +88,17 @@ collateral: oracle: windowSize: 60 precisionBits: 48 - historyLookbackMultiplier: 4 + historyMaxAgeSec: 86400 health: port: 3001 -# Centralized submission / nonce recovery. txCoordinator: confirmationTimeoutSec: 60 maxReplacements: 2 replacementFeeBumpPct: 15 + maxNonceResyncs: 5 -# Per-market fault isolation. circuitBreaker: quarantineThreshold: 3 baseBackoffSec: 5 diff --git a/market-maker/configs/portfolio.prd.yml b/market-maker/configs/portfolio.prd.yml index bd826f1..f110b46 100644 --- a/market-maker/configs/portfolio.prd.yml +++ b/market-maker/configs/portfolio.prd.yml @@ -7,25 +7,17 @@ # FUTURES_ADDRESS - Futures address on base-mainnet (production deployment) # ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet # HASHPRICE_ORACLE_SUBGRAPH_URL - hashprice-oracle subgraph URL for σ backfill at startup -# -# One process, one signer, one shared collateral vault. Perps and every -# selected futures expiry quote together; the TxCoordinator sequences their -# txs on a single nonce and each market is isolated behind its own circuit -# breaker. nodeEnv: production commitHash: ${COMMIT_HASH:-unknown} logLevel: ${MAKER_LOG_LEVEL:-info} dryRun: ${MAKER_DRY_RUN:-false} -# Cancel resting orders on SIGINT/SIGTERM. Set false for hot-restart deploys -# where you'd rather absorb the brief stale-quote risk than pay cancel gas. cancelOrdersOnShutdown: ${MAKER_CANCEL_ORDERS_ON_SHUTDOWN:-true} wallets: primary: privateKey: ${PRIVATE_KEY} -# Single shared signer for the whole portfolio. wallet: primary network: @@ -45,14 +37,13 @@ venues: maxSkewTicks: 20 sizing: strategy: geometric-taper - baseQuantity: "10000000" # venue-native (perps hashrate base units) + baseQuantity: "10000000" numLevelsPerSide: 10 taperRatio: 0.6 - kind: futures address: ${FUTURES_ADDRESS} maxPositionSize: 1000 - # Quote the three nearest expiries; the roll adds/drops markets as dates mature. marketSelection: mode: nearest count: 3 @@ -65,17 +56,13 @@ venues: maxSkewTicks: 0 sizing: strategy: geometric-taper - # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = - # baseQuantity×numLevels/1e6 = 100. Split across `count` expiries: - # futures_base × futures_levels × expiries ≈ perps_total - # 4 × 10 × 3 = 120 ≈ 100. Order count is 2×levels×expiries (=60). - baseQuantity: 4 # venue-native (contracts) + # Perps runs 100/side here, so 4 × 10 × 3 expiries = 120 is the nearest + # match; base 4 also keeps the taper's last level non-zero. + baseQuantity: 4 numLevelsPerSide: 10 taperRatio: 0.6 - # Nearest expiry keeps full size; each further date × this factor. expirySizeDecay: 0.6 -# Shared portfolio-wide budget across every market. risk: maxPositionSize: 1000 maxUtilizationPct: 75 @@ -106,24 +93,19 @@ collateral: oracle: windowSize: 60 precisionBits: 48 - historyLookbackMultiplier: 4 + historyMaxAgeSec: 86400 history: subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL} health: port: ${MAKER_HEALTH_PORT:-3001} -# Centralized submission / nonce recovery. txCoordinator: - # Cost units per tx, NOT raw call count. One unit ≈ the cheapest call: - # perps = one order per price level; futures = one contract (qty=1). Futures - # createOrder gas scales with qty, so a full 3-expiry quote is many units and - # must be chunked to avoid out-of-gas. confirmationTimeoutSec: 60 maxReplacements: 2 replacementFeeBumpPct: 15 + maxNonceResyncs: 5 -# Per-market fault isolation. circuitBreaker: quarantineThreshold: 3 baseBackoffSec: 5 diff --git a/market-maker/configs/portfolio.stg.yml b/market-maker/configs/portfolio.stg.yml deleted file mode 100644 index e2b0b37..0000000 --- a/market-maker/configs/portfolio.stg.yml +++ /dev/null @@ -1,135 +0,0 @@ -# yaml-language-server: $schema=../schemas/portfolio.json -# Titan Market Maker - Portfolio (perps + all futures expiries) - STAGING (base-mainnet). -# -# PRIVATE_KEY - hex private key of the staging market-making wallet -# ALCHEMY_API_KEY - Alchemy API key (URL is composed below) -# PERPS_ADDRESS - HashPowerPerpsDEX address on base-mainnet (staging deployment) -# FUTURES_ADDRESS - Futures address on base-mainnet (staging deployment) -# ETH_PRICE_FEED_ADDRESS - Chainlink ETH/USD aggregator on base-mainnet -# HASHPRICE_ORACLE_SUBGRAPH_URL - hashprice-oracle subgraph URL for σ backfill at startup -# -# One process, one signer, one shared collateral vault. Perps and every -# selected futures expiry quote together; the TxCoordinator sequences their -# txs on a single nonce and each market is isolated behind its own circuit -# breaker. - -nodeEnv: staging -commitHash: ${COMMIT_HASH:-unknown} -logLevel: ${MAKER_LOG_LEVEL:-debug} -dryRun: ${MAKER_DRY_RUN:-false} -# Cancel resting orders on SIGINT/SIGTERM. Set false for hot-restart deploys -# where you'd rather absorb the brief stale-quote risk than pay cancel gas. -cancelOrdersOnShutdown: ${MAKER_CANCEL_ORDERS_ON_SHUTDOWN:-true} - -wallets: - primary: - privateKey: ${PRIVATE_KEY} - -# Single shared signer for the whole portfolio. -wallet: primary - -network: - name: base - rpcUrl: https://base-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY} - ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} - -venues: - - kind: perps - address: ${PERPS_ADDRESS} - maxPositionSize: 50 - pricing: - strategy: effective-spread - minSpreadBps: 15 - volatilityMultiplier: 2.0 - inventorySkewGamma: 0.5 - maxSkewTicks: 20 - sizing: - strategy: geometric-taper - baseQuantity: "1000000" # venue-native (perps hashrate base units) - numLevelsPerSide: 10 - taperRatio: 0.6 - - - kind: futures - address: ${FUTURES_ADDRESS} - maxPositionSize: 50 - # Quote the three nearest expiries; the roll adds/drops markets as dates mature. - marketSelection: - mode: nearest - count: 3 - pricing: - strategy: reservation-price - riskAversion: 0.001 - marginCallTimeSec: 3600 - minSpreadBps: 15 - volatilityMultiplier: 2.5 - maxSkewTicks: 0 - sizing: - strategy: geometric-taper - # 1 futures contract ≈ 1.0 perps qty (1e6). Perps total/side = - # baseQuantity×numLevels/1e6 = 10. Split across `count` expiries; base=4 - # keeps taper levels non-zero. Order count is 2×levels×expiries (=60). - baseQuantity: 4 # venue-native (contracts) - numLevelsPerSide: 10 - taperRatio: 0.6 - # Nearest expiry keeps full size; each further date × this factor. - expirySizeDecay: 0.6 - -# Shared portfolio-wide budget across every market. -risk: - maxPositionSize: 50 - maxUtilizationPct: 80 - minCollateralBalance: 10 - maxDailyLossUsd: 500 - maxGasBudgetPerHourUsd: 50 - maxGasBudgetPerDayUsd: 500 - gasSpikeThresholdPct: 200 - gasPenaltyBps: 5 - urgentRequoteThresholdTicks: 10 - -gas: - gasCapMultiplier: 2.0 - -timing: - pollIntervalSec: 3 - requoteCooldownSec: 1 - resyncIntervalSec: 60 - levelSpacingTicks: 1 - staleBandAllowanceUsd: 0.03 - staleSizeAllowanceUsd: 50 - -collateral: - autoDeposit: true - autoDepositMinAmount: 1 - maxCollateralAmount: 4000 - -oracle: - windowSize: 60 - precisionBits: 48 - historyLookbackMultiplier: 4 - history: - subgraphUrl: ${HASHPRICE_ORACLE_SUBGRAPH_URL} - -health: - port: ${MAKER_HEALTH_PORT:-3001} - -# Centralized submission / nonce recovery. -txCoordinator: - # Cost units per tx, NOT raw call count. One unit ≈ the cheapest call: - # perps = one order per price level; futures = one contract (qty=1). Futures - # createOrder gas scales with qty, so a full 3-expiry quote is many units and - # must be chunked to avoid out-of-gas. - confirmationTimeoutSec: 60 - maxReplacements: 2 - replacementFeeBumpPct: 15 - -# Per-market fault isolation. -circuitBreaker: - quarantineThreshold: 3 - baseBackoffSec: 5 - maxBackoffSec: 180 - -rollCheckIntervalSec: 300 -sharedStalenessGraceSec: 30 - -readBatchSize: 100 -writeBatchSize: 100 diff --git a/market-maker/package.json b/market-maker/package.json index 49258e0..a17db2c 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -18,14 +18,8 @@ "perps": "pnpm node --watch src/apps/perps/main.ts", "futures": "pnpm node --watch src/apps/futures/main.ts", "portfolio": "pnpm node --watch src/apps/portfolio/main.ts", - "local:perps": "pnpm node --env-file-if-exists=../.env --env-file-if-exists=.env --watch src/apps/perps/main.ts --config configs/perps.local.yml | pino-pretty", - "local:futures": "pnpm node --env-file-if-exists=../.env --env-file-if-exists=.env --watch src/apps/futures/main.ts --config configs/futures.local.yml | pino-pretty", "local:portfolio": "pnpm node --env-file-if-exists=../.env --env-file-if-exists=.env --watch src/apps/portfolio/main.ts --config configs/portfolio.local.yml | pino-pretty", - "dev:perps": "pnpm node --env-file=../config/dev.env --env-file-if-exists=../.env --env-file-if-exists=.env --watch src/apps/perps/main.ts --config configs/perps.dev.yml | pino-pretty", - "dev:futures": "pnpm node --env-file=../config/dev.env --env-file-if-exists=../.env --env-file-if-exists=.env --watch src/apps/futures/main.ts --config configs/futures.dev.yml | pino-pretty", "dev:portfolio": "pnpm node --env-file=../config/dev.env --env-file-if-exists=../.env --env-file-if-exists=.env --watch src/apps/portfolio/main.ts --config configs/portfolio.dev.yml | pino-pretty", - "prd:perps": "pnpm node --env-file=../config/prd.env --env-file-if-exists=../.env --env-file-if-exists=.env src/apps/perps/main.ts --config configs/perps.prd.yml", - "prd:futures": "pnpm node --env-file=../config/prd.env --env-file-if-exists=../.env --env-file-if-exists=.env src/apps/futures/main.ts --config configs/futures.prd.yml", "prd:portfolio": "pnpm node --env-file=../config/prd.env --env-file-if-exists=../.env --env-file-if-exists=.env src/apps/portfolio/main.ts --config configs/portfolio.prd.yml", "lint:fix": "biome check --write ." }, diff --git a/market-maker/schemas/futures.json b/market-maker/schemas/futures.json index 02eb4a2..01c2013 100644 --- a/market-maker/schemas/futures.json +++ b/market-maker/schemas/futures.json @@ -273,7 +273,7 @@ "type": "string" }, "baseQuantity": { - "description": "Total per-side budget in venue-native units (futures: contract base units). Distributed via taperRatio. Use a string for values > 2^53.", + "description": "Total per-side budget in venue-native units (futures: contract base units). Distributed via taperRatio. One contract ≈ 1.0 perps qty (1e6 perps base units), which is how the two venues are kept at comparable notional: futures baseQuantity × numLevelsPerSide × quoted expiries should land near the perps per-side total. Resting order count is 2 × numLevelsPerSide × quoted expiries. Use a string for values > 2^53.", "anyOf": [ { "anyOf": [ @@ -351,7 +351,7 @@ }, "risk": { "additionalProperties": false, - "description": "Risk caps, circuit-breakers, and gas-price guards.", + "description": "Risk caps, circuit-breakers, and gas-price guards. One shared budget spanning every market in the process, not a per-venue allowance.", "type": "object", "required": [ "maxPositionSize", @@ -694,7 +694,7 @@ "required": [ "windowSize", "precisionBits", - "historyLookbackMultiplier" + "historyMaxAgeSec" ], "properties": { "windowSize": { @@ -709,7 +709,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Number of de-duplicated price samples retained for realized-vol estimation. 60 is enough for a ±9% standard error on σ; tune up for smoother σ at the cost of slower regime tracking.", + "description": "Number of de-duplicated price samples retained for realized-vol estimation. The window counts oracle updates rather than wall-clock, so how far back it reaches follows the feed's own cadence. That is deliberate: when a feed slows, a count-based window still fills (just reaching further back), whereas a fixed duration would thin out precisely when σ matters most — `historyMaxAgeSec` is the backstop against reaching into a stale regime. 60 gives roughly a ±9% standard error on σ; tune up for smoother σ at the cost of slower regime tracking.", "default": 60 }, "precisionBits": { @@ -728,11 +728,11 @@ "description": "Bits of fractional precision for the bigint ln/sqrt approximations underpinning σ. 48 is plenty for vol math; raise only if a strategy demonstrably needs more.", "default": 48 }, - "historyLookbackMultiplier": { + "historyMaxAgeSec": { "anyOf": [ { "minimum": 1, - "type": "number" + "type": "integer" }, { "type": "string", @@ -740,8 +740,8 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Backfill fetches `windowSize × multiplier × pollInterval` of history from the subgraph, then trims duplicates. Multiplier > 1 absorbs Chainlink's slow update cadence so the window arrives full.", - "default": 4 + "description": "Backfill discards samples older than this. It asks the subgraph for the newest `windowSize` oracle updates, so how far back the window reaches is set by the feed's own cadence, not by any setting here — this is only an upper bound so a stale price regime can't seed σ. Set it well above `windowSize × the feed's typical update interval`, leaving room for the feed slowing down: hashprice normally spaces updates ~3 min apart (60 samples ≈ 4h), but that tripled during an observed degraded stretch. Too low and backfill returns short, leaving σ at 0 until live polls warm the window.", + "default": 86400 }, "history": { "additionalProperties": false, diff --git a/market-maker/schemas/perps.json b/market-maker/schemas/perps.json index 1929c59..f30baa3 100644 --- a/market-maker/schemas/perps.json +++ b/market-maker/schemas/perps.json @@ -203,7 +203,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Floor on the half-spread in bps. Quotes never tighten below this." + "description": "Floor on the half-spread in bps. Quotes never tighten below this. Note the MM prices off the unrounded oracle answer (PerpsVenue.getRawMarketPrice), so the mid normally falls between ticks and rounding alone already separates bid from ask by one tick; a non-zero floor is insurance for the rare case the mid lands exactly on a tick." }, "volatilityMultiplier": { "anyOf": [ @@ -328,7 +328,7 @@ }, "risk": { "additionalProperties": false, - "description": "Risk caps, circuit-breakers, and gas-price guards.", + "description": "Risk caps, circuit-breakers, and gas-price guards. One shared budget spanning every market in the process, not a per-venue allowance.", "type": "object", "required": [ "maxPositionSize", @@ -671,7 +671,7 @@ "required": [ "windowSize", "precisionBits", - "historyLookbackMultiplier" + "historyMaxAgeSec" ], "properties": { "windowSize": { @@ -686,7 +686,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Number of de-duplicated price samples retained for realized-vol estimation. 60 is enough for a ±9% standard error on σ; tune up for smoother σ at the cost of slower regime tracking.", + "description": "Number of de-duplicated price samples retained for realized-vol estimation. The window counts oracle updates rather than wall-clock, so how far back it reaches follows the feed's own cadence. That is deliberate: when a feed slows, a count-based window still fills (just reaching further back), whereas a fixed duration would thin out precisely when σ matters most — `historyMaxAgeSec` is the backstop against reaching into a stale regime. 60 gives roughly a ±9% standard error on σ; tune up for smoother σ at the cost of slower regime tracking.", "default": 60 }, "precisionBits": { @@ -705,11 +705,11 @@ "description": "Bits of fractional precision for the bigint ln/sqrt approximations underpinning σ. 48 is plenty for vol math; raise only if a strategy demonstrably needs more.", "default": 48 }, - "historyLookbackMultiplier": { + "historyMaxAgeSec": { "anyOf": [ { "minimum": 1, - "type": "number" + "type": "integer" }, { "type": "string", @@ -717,8 +717,8 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Backfill fetches `windowSize × multiplier × pollInterval` of history from the subgraph, then trims duplicates. Multiplier > 1 absorbs Chainlink's slow update cadence so the window arrives full.", - "default": 4 + "description": "Backfill discards samples older than this. It asks the subgraph for the newest `windowSize` oracle updates, so how far back the window reaches is set by the feed's own cadence, not by any setting here — this is only an upper bound so a stale price regime can't seed σ. Set it well above `windowSize × the feed's typical update interval`, leaving room for the feed slowing down: hashprice normally spaces updates ~3 min apart (60 samples ≈ 4h), but that tripled during an observed degraded stretch. Too low and backfill returns short, leaving σ at 0 until live polls warm the window.", + "default": 86400 }, "history": { "additionalProperties": false, diff --git a/market-maker/schemas/portfolio.json b/market-maker/schemas/portfolio.json index 2471905..7d912bf 100644 --- a/market-maker/schemas/portfolio.json +++ b/market-maker/schemas/portfolio.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "Titan Market Maker - Portfolio config", "additionalProperties": false, - "description": "Titan Market Maker — unified portfolio app config.", + "description": "Titan Market Maker — unified portfolio app config. One process, one signer, one shared collateral vault: perps and every selected futures expiry quote together, the TxCoordinator sequences their txs on a single nonce, and each market is isolated behind its own circuit breaker.", "type": "object", "required": [ "nodeEnv", @@ -62,6 +62,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], + "description": "Cancel resting orders on SIGINT/SIGTERM. Set false for hot-restart deploys where you'd rather absorb the brief stale-quote risk than pay cancel gas.", "default": true }, "wallets": { @@ -234,7 +235,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Floor on the half-spread in bps. Quotes never tighten below this." + "description": "Floor on the half-spread in bps. Quotes never tighten below this. Note the MM prices off the unrounded oracle answer (PerpsVenue.getRawMarketPrice), so the mid normally falls between ticks and rounding alone already separates bid from ask by one tick; a non-zero floor is insurance for the rare case the mid lands exactly on a tick." }, "volatilityMultiplier": { "anyOf": [ @@ -420,7 +421,7 @@ ] }, "marketSelection": { - "description": "Which futures expiries to quote: 'nearest' N dates, or explicit 'indices' into the nearest-first window.", + "description": "Which futures expiries to quote: 'nearest' N dates, or explicit 'indices' into the nearest-first window. Re-evaluated every `rollCheckIntervalSec`, so markets are added and dropped automatically as delivery dates mature.", "anyOf": [ { "additionalProperties": false, @@ -579,7 +580,7 @@ "type": "string" }, "baseQuantity": { - "description": "Total per-side budget in venue-native units (futures: contract base units). Distributed via taperRatio. Use a string for values > 2^53.", + "description": "Total per-side budget in venue-native units (futures: contract base units). Distributed via taperRatio. One contract ≈ 1.0 perps qty (1e6 perps base units), which is how the two venues are kept at comparable notional: futures baseQuantity × numLevelsPerSide × quoted expiries should land near the perps per-side total. Resting order count is 2 × numLevelsPerSide × quoted expiries. Use a string for values > 2^53.", "anyOf": [ { "anyOf": [ @@ -662,7 +663,7 @@ }, "risk": { "additionalProperties": false, - "description": "Risk caps, circuit-breakers, and gas-price guards.", + "description": "Risk caps, circuit-breakers, and gas-price guards. One shared budget spanning every market in the process, not a per-venue allowance.", "type": "object", "required": [ "maxPositionSize", @@ -1005,7 +1006,7 @@ "required": [ "windowSize", "precisionBits", - "historyLookbackMultiplier" + "historyMaxAgeSec" ], "properties": { "windowSize": { @@ -1020,7 +1021,7 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Number of de-duplicated price samples retained for realized-vol estimation. 60 is enough for a ±9% standard error on σ; tune up for smoother σ at the cost of slower regime tracking.", + "description": "Number of de-duplicated price samples retained for realized-vol estimation. The window counts oracle updates rather than wall-clock, so how far back it reaches follows the feed's own cadence. That is deliberate: when a feed slows, a count-based window still fills (just reaching further back), whereas a fixed duration would thin out precisely when σ matters most — `historyMaxAgeSec` is the backstop against reaching into a stale regime. 60 gives roughly a ±9% standard error on σ; tune up for smoother σ at the cost of slower regime tracking.", "default": 60 }, "precisionBits": { @@ -1039,11 +1040,11 @@ "description": "Bits of fractional precision for the bigint ln/sqrt approximations underpinning σ. 48 is plenty for vol math; raise only if a strategy demonstrably needs more.", "default": 48 }, - "historyLookbackMultiplier": { + "historyMaxAgeSec": { "anyOf": [ { "minimum": 1, - "type": "number" + "type": "integer" }, { "type": "string", @@ -1051,8 +1052,8 @@ "description": "Environment variable interpolation (resolved at startup)" } ], - "description": "Backfill fetches `windowSize × multiplier × pollInterval` of history from the subgraph, then trims duplicates. Multiplier > 1 absorbs Chainlink's slow update cadence so the window arrives full.", - "default": 4 + "description": "Backfill discards samples older than this. It asks the subgraph for the newest `windowSize` oracle updates, so how far back the window reaches is set by the feed's own cadence, not by any setting here — this is only an upper bound so a stale price regime can't seed σ. Set it well above `windowSize × the feed's typical update interval`, leaving room for the feed slowing down: hashprice normally spaces updates ~3 min apart (60 samples ≈ 4h), but that tripled during an observed degraded stretch. Too low and backfill returns short, leaving σ at 0 until live polls warm the window.", + "default": 86400 }, "history": { "additionalProperties": false, @@ -1377,7 +1378,7 @@ "circuitBreaker": { "additionalProperties": false, "default": {}, - "description": "Per-market circuit-breaker tuning.", + "description": "Per-market circuit-breaker tuning; isolates one market's faults from the rest.", "type": "object", "required": [ "quarantineThreshold", diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 7ee8821..9497f25 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -8,6 +8,7 @@ import type { ExecuteOrdersResult, InstrumentAdapter, InstrumentContext, + OracleScale, OrderBookSnapshot, OrderIntent, Position, @@ -60,6 +61,10 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { return await this.venue.getRawMarketPrice(); } + getOracleScale(): Promise { + return this.venue.getOracleScale(); + } + async getPosition(): Promise { const pos = await this.venue.publicClient.readContract({ address: this.venue.address, diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index 19bb9d5..ed2d39d 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -7,6 +7,7 @@ import type { CollateralSnapshot, InstrumentAdapter, MarginReadPlan, + OracleScale, VenueAdapter, VenueEvents, WalletContext, @@ -145,6 +146,7 @@ export class FuturesVenueAdapter implements VenueAdapter { return { oracle, divisor: 10n ** BigInt(oracleDecimals - tokenDecimals), + tokenDecimals, }; }, }); @@ -320,6 +322,11 @@ export class FuturesVenueAdapter implements VenueAdapter { return this.rawOracle.read(); } + /** Aggregator and fixed-point scale behind `getRawMarketPrice()`. */ + getOracleScale(): Promise { + return this.rawOracle.scale(); + } + /** * The mark from the most recent `getRawMarketPrice()`, or `null` before the first * read. Lets the synchronous `estimateOrderMargin` charge an order's instant fill diff --git a/market-maker/src/adapters/perps/instrument.ts b/market-maker/src/adapters/perps/instrument.ts index d7e1703..fd7cf0d 100644 --- a/market-maker/src/adapters/perps/instrument.ts +++ b/market-maker/src/adapters/perps/instrument.ts @@ -8,6 +8,7 @@ import type { ExecuteOrdersResult, InstrumentAdapter, InstrumentContext, + OracleScale, OrderBookSnapshot, OrderIntent, OwnOrder, @@ -48,6 +49,10 @@ export class PerpsInstrumentAdapter implements InstrumentAdapter { return await this.venue.getRawMarketPrice(); } + getOracleScale(): Promise { + return this.venue.getOracleScale(); + } + async getPosition(): Promise { const owner = this.venue.wallet.account.address; const pos = await this.venue.publicClient.readContract({ diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts index 60873ab..8014542 100644 --- a/market-maker/src/adapters/perps/venue.ts +++ b/market-maker/src/adapters/perps/venue.ts @@ -7,6 +7,7 @@ import type { CollateralSnapshot, InstrumentAdapter, MarginReadPlan, + OracleScale, VenueAdapter, VenueEvents, WalletContext, @@ -128,6 +129,7 @@ export class PerpsVenueAdapter implements VenueAdapter { return { oracle, divisor: 10n ** BigInt(oracleDecimals - tokenDecimals), + tokenDecimals, }; }, }); @@ -230,6 +232,11 @@ export class PerpsVenueAdapter implements VenueAdapter { return this.rawOracle.read(); } + /** Aggregator and fixed-point scale behind `getRawMarketPrice()`. */ + getOracleScale(): Promise { + return this.rawOracle.scale(); + } + /** * The mark from the most recent `getRawMarketPrice()`, or `null` before the first * read. Lets the synchronous `estimateOrderMargin` charge an order's instant fill diff --git a/market-maker/src/apps/futures/config.ts b/market-maker/src/apps/futures/config.ts index f50fe82..f93d623 100644 --- a/market-maker/src/apps/futures/config.ts +++ b/market-maker/src/apps/futures/config.ts @@ -102,7 +102,7 @@ export const futuresSizingSchema = Type.Object( [Type.String({ pattern: "^\\d+$" }), Type.Number()], { description: - "Total per-side budget in venue-native units (futures: contract base units). Distributed via taperRatio. Use a string for values > 2^53.", + "Total per-side budget in venue-native units (futures: contract base units). Distributed via taperRatio. One contract ≈ 1.0 perps qty (1e6 perps base units), which is how the two venues are kept at comparable notional: futures baseQuantity × numLevelsPerSide × quoted expiries should land near the perps per-side total. Resting order count is 2 × numLevelsPerSide × quoted expiries. Use a string for values > 2^53.", }, ), numLevelsPerSide: Type.Number({ diff --git a/market-maker/src/apps/futures/main.ts b/market-maker/src/apps/futures/main.ts index 6a2604e..48e7bf8 100644 --- a/market-maker/src/apps/futures/main.ts +++ b/market-maker/src/apps/futures/main.ts @@ -63,9 +63,8 @@ async function main(): Promise { const oracle = new OracleTracker(instrument, logger, { windowSize: config.oracle.windowSize, precisionBits: config.oracle.precisionBits, - historyLookbackMultiplier: config.oracle.historyLookbackMultiplier, + historyMaxAgeSec: config.oracle.historyMaxAgeSec, history, - pollIntervalMs: config.timing.pollIntervalMs, }); const gas = new GasTracker( network.publicClient, @@ -90,6 +89,7 @@ async function main(): Promise { autoDeposit: config.collateral.autoDeposit, autoDepositMinAmount: config.collateral.autoDepositMinAmount, maxCollateralAmount: config.collateral.maxCollateralAmount, + dryRun: config.dryRun, }, logger, ); diff --git a/market-maker/src/apps/perps/config.ts b/market-maker/src/apps/perps/config.ts index 700d76e..035a9a2 100644 --- a/market-maker/src/apps/perps/config.ts +++ b/market-maker/src/apps/perps/config.ts @@ -63,7 +63,7 @@ export const perpsPricingSchema = Type.Object( minSpreadBps: Type.Number({ minimum: 0, description: - "Floor on the half-spread in bps. Quotes never tighten below this.", + "Floor on the half-spread in bps. Quotes never tighten below this. Note the MM prices off the unrounded oracle answer (PerpsVenue.getRawMarketPrice), so the mid normally falls between ticks and rounding alone already separates bid from ask by one tick; a non-zero floor is insurance for the rare case the mid lands exactly on a tick.", }), volatilityMultiplier: Type.Number({ minimum: 0, diff --git a/market-maker/src/apps/perps/main.ts b/market-maker/src/apps/perps/main.ts index 4ef60e3..ea59498 100644 --- a/market-maker/src/apps/perps/main.ts +++ b/market-maker/src/apps/perps/main.ts @@ -60,9 +60,8 @@ async function main(): Promise { const oracle = new OracleTracker(instrument, logger, { windowSize: config.oracle.windowSize, precisionBits: config.oracle.precisionBits, - historyLookbackMultiplier: config.oracle.historyLookbackMultiplier, + historyMaxAgeSec: config.oracle.historyMaxAgeSec, history, - pollIntervalMs: config.timing.pollIntervalMs, }); const gas = new GasTracker( network.publicClient, @@ -87,6 +86,7 @@ async function main(): Promise { autoDeposit: config.collateral.autoDeposit, autoDepositMinAmount: config.collateral.autoDepositMinAmount, maxCollateralAmount: config.collateral.maxCollateralAmount, + dryRun: config.dryRun, }, logger, ); diff --git a/market-maker/src/apps/portfolio/config.ts b/market-maker/src/apps/portfolio/config.ts index bd7dd44..72db045 100644 --- a/market-maker/src/apps/portfolio/config.ts +++ b/market-maker/src/apps/portfolio/config.ts @@ -71,7 +71,7 @@ const marketSelectionSchema = Type.Union( ], { description: - "Which futures expiries to quote: 'nearest' N dates, or explicit 'indices' into the nearest-first window.", + "Which futures expiries to quote: 'nearest' N dates, or explicit 'indices' into the nearest-first window. Re-evaluated every `rollCheckIntervalSec`, so markets are added and dropped automatically as delivery dates mature.", }, ); @@ -147,7 +147,11 @@ const circuitBreakerSchema = Type.Object( description: "Backoff ceiling (seconds).", }), }, - { ...Closed, default: {}, description: "Per-market circuit-breaker tuning." }, + { + ...Closed, + default: {}, + description: "Per-market circuit-breaker tuning; isolates one market's faults from the rest.", + }, ); export const portfolioRootSchema = Type.Object( @@ -156,7 +160,11 @@ export const portfolioRootSchema = Type.Object( commitHash: Type.String({ default: "unknown" }), logLevel: Type.String({ default: "info" }), dryRun: Type.Boolean({ default: false }), - cancelOrdersOnShutdown: Type.Boolean({ default: true }), + cancelOrdersOnShutdown: Type.Boolean({ + default: true, + description: + "Cancel resting orders on SIGINT/SIGTERM. Set false for hot-restart deploys where you'd rather absorb the brief stale-quote risk than pay cancel gas.", + }), wallets: Type.Record(Type.String(), walletSchema, { description: "Named signer wallets; `wallet` selects the portfolio signer.", }), @@ -191,7 +199,11 @@ export const portfolioRootSchema = Type.Object( readBatchSize: Type.Number({ minimum: 1, default: 10 }), writeBatchSize: Type.Number({ minimum: 1, default: 100 }), }, - { ...Closed, description: "Titan Market Maker — unified portfolio app config." }, + { + ...Closed, + description: + "Titan Market Maker — unified portfolio app config. One process, one signer, one shared collateral vault: perps and every selected futures expiry quote together, the TxCoordinator sequences their txs on a single nonce, and each market is isolated behind its own circuit breaker.", + }, ); /** diff --git a/market-maker/src/apps/portfolio/main.ts b/market-maker/src/apps/portfolio/main.ts index 29a70b9..7187eed 100644 --- a/market-maker/src/apps/portfolio/main.ts +++ b/market-maker/src/apps/portfolio/main.ts @@ -48,9 +48,8 @@ function buildOracle(instrument: InstrumentAdapter, ctx: BuildContext): OracleTr return new OracleTracker(instrument, ctx.logger, { windowSize: ctx.config.oracle.windowSize, precisionBits: ctx.config.oracle.precisionBits, - historyLookbackMultiplier: ctx.config.oracle.historyLookbackMultiplier, + historyMaxAgeSec: ctx.config.oracle.historyMaxAgeSec, history, - pollIntervalMs: ctx.config.timing.pollIntervalMs, }); } @@ -242,6 +241,7 @@ async function main(): Promise { autoDeposit: config.collateral.autoDeposit, autoDepositMinAmount: config.collateral.autoDepositMinAmount, maxCollateralAmount: config.collateral.maxCollateralAmount, + dryRun: config.dryRun, }, logger, ); diff --git a/market-maker/src/core/adapter.ts b/market-maker/src/core/adapter.ts index 71bfb68..027f050 100644 --- a/market-maker/src/core/adapter.ts +++ b/market-maker/src/core/adapter.ts @@ -316,6 +316,14 @@ export interface WalletContext { // ─── Instrument adapter ───────────────────────────────────────────────────── +/** Identity and fixed-point scale of the price feed an instrument quotes against. */ +export interface OracleScale { + /** Aggregator address the index price is read from. */ + address: `0x${string}`; + /** Decimals `getIndexPrice()` returns its answer in (the collateral token's). */ + decimals: number; +} + /** * Per-instrument interface. Perps and futures return a singleton from * `VenueAdapter.getInstrument()`; an options venue would expose many. @@ -327,6 +335,13 @@ export interface InstrumentAdapter { readonly ownOrders: OwnOrderSource; getIndexPrice(): Promise; + /** + * The aggregator behind `getIndexPrice()` and the fixed-point scale its + * answers come back in. Lets consumers that mix in prices from elsewhere + * (the σ-window backfill) prove both sides describe the same feed on the + * same scale instead of inferring it from magnitudes. + */ + getOracleScale(): Promise; getPosition(): Promise; getContext(): Promise; diff --git a/market-maker/src/core/collateralTracker.ts b/market-maker/src/core/collateralTracker.ts index fe86848..63f3b90 100644 --- a/market-maker/src/core/collateralTracker.ts +++ b/market-maker/src/core/collateralTracker.ts @@ -19,6 +19,12 @@ export interface CollateralTrackerConfig { * wallet balance. */ maxCollateralAmount?: bigint; + /** + * When true, log the deposit that would have been made and broadcast + * nothing. Deposits are the only wallet write outside the order path, so + * this has to be honoured here for `dryRun` to mean "sends no transactions". + */ + dryRun?: boolean; } /** @@ -80,15 +86,17 @@ export class CollateralTracker { if (headroom === 0n) return; if (amount > headroom) amount = headroom; } - this.logger.info( - { - amount: amount.toString(), - wallet: this.walletTokenBalance.toString(), - vault: this.vaultBalance.toString(), - max: max?.toString(), - }, - "depositing wallet balance into vault", - ); + const detail = { + amount: amount.toString(), + wallet: this.walletTokenBalance.toString(), + vault: this.vaultBalance.toString(), + max: max?.toString(), + }; + if (this.cfg.dryRun) { + this.logger.info(detail, "dry run: skipping vault deposit"); + return; + } + this.logger.info(detail, "depositing wallet balance into vault"); await this.account.deposit(amount); await this.update(); } diff --git a/market-maker/src/core/config/base.ts b/market-maker/src/core/config/base.ts index 70905e9..5032af0 100644 --- a/market-maker/src/core/config/base.ts +++ b/market-maker/src/core/config/base.ts @@ -123,7 +123,11 @@ export const riskSchema = Type.Object( "Tick distance from oracle at which a stale order is requoted immediately, ignoring cooldown.", }), }, - { ...Closed, description: "Risk caps, circuit-breakers, and gas-price guards." }, + { + ...Closed, + description: + "Risk caps, circuit-breakers, and gas-price guards. One shared budget spanning every market in the process, not a per-venue allowance.", + }, ); export const gasSchema = Type.Object( @@ -226,7 +230,7 @@ export const oracleSchema = Type.Object( minimum: 3, default: 60, description: - "Number of de-duplicated price samples retained for realized-vol estimation. 60 is enough for a ±9% standard error on σ; tune up for smoother σ at the cost of slower regime tracking.", + "Number of de-duplicated price samples retained for realized-vol estimation. The window counts oracle updates rather than wall-clock, so how far back it reaches follows the feed's own cadence. That is deliberate: when a feed slows, a count-based window still fills (just reaching further back), whereas a fixed duration would thin out precisely when σ matters most — `historyMaxAgeSec` is the backstop against reaching into a stale regime. 60 gives roughly a ±9% standard error on σ; tune up for smoother σ at the cost of slower regime tracking.", }), precisionBits: Type.Integer({ minimum: 16, @@ -235,11 +239,11 @@ export const oracleSchema = Type.Object( description: "Bits of fractional precision for the bigint ln/sqrt approximations underpinning σ. 48 is plenty for vol math; raise only if a strategy demonstrably needs more.", }), - historyLookbackMultiplier: Type.Number({ + historyMaxAgeSec: Type.Integer({ minimum: 1, - default: 4, + default: 86400, description: - "Backfill fetches `windowSize × multiplier × pollInterval` of history from the subgraph, then trims duplicates. Multiplier > 1 absorbs Chainlink's slow update cadence so the window arrives full.", + "Backfill discards samples older than this. It asks the subgraph for the newest `windowSize` oracle updates, so how far back the window reaches is set by the feed's own cadence, not by any setting here — this is only an upper bound so a stale price regime can't seed σ. Set it well above `windowSize × the feed's typical update interval`, leaving room for the feed slowing down: hashprice normally spaces updates ~3 min apart (60 samples ≈ 4h), but that tripled during an observed degraded stretch. Too low and backfill returns short, leaving σ at 0 until live polls warm the window.", }), history: Type.Optional( Type.Object( @@ -324,7 +328,7 @@ export interface ParsedCollateralConfig { export interface ParsedOracleConfig { windowSize: number; precisionBits: number; - historyLookbackMultiplier: number; + historyMaxAgeSec: number; /** Undefined when `history` is omitted; backfill is then skipped. */ history?: { subgraphUrl: string }; } @@ -356,7 +360,7 @@ interface RawCollateral { interface RawOracle { windowSize: number; precisionBits: number; - historyLookbackMultiplier: number; + historyMaxAgeSec: number; history?: { subgraphUrl: string }; } @@ -424,7 +428,7 @@ export function parseOracleConfig(raw: RawOracle): ParsedOracleConfig { return { windowSize: raw.windowSize, precisionBits: raw.precisionBits, - historyLookbackMultiplier: raw.historyLookbackMultiplier, + historyMaxAgeSec: raw.historyMaxAgeSec, history: url ? { subgraphUrl: url } : undefined, }; } diff --git a/market-maker/src/core/healthcheck.ts b/market-maker/src/core/healthcheck.ts index 99c422e..bb731bb 100644 --- a/market-maker/src/core/healthcheck.ts +++ b/market-maker/src/core/healthcheck.ts @@ -1,7 +1,7 @@ import { createServer } from "node:http"; import type { Server, ServerResponse } from "node:http"; import type pino from "pino"; -import type Fraction from "fraction.js"; +import { fractionToNumber } from "./math.ts"; import type { OracleTracker } from "./oracleTracker.ts"; import type { InventoryManager } from "./inventoryManager.ts"; import type { CollateralTracker } from "./collateralTracker.ts"; @@ -282,15 +282,6 @@ export class HealthCheck { } } -function fractionToNumber(value: Fraction): number { - // diagnostic only — never used in trading math. - // Realized-vol Fractions can have 1000+ bit numerators/denominators (sqrt at - // 48-bit precision over a 60-sample window), so a naive Number cast overflows - // both sides to Infinity and JSON-serialises as `null`. Simplify first to - // collapse the magnitude before the cast. - const v = value.simplify(1e-12); - return (Number(v.s) * Number(v.n)) / Number(v.d); -} function bigIntReplacer(_key: string, value: unknown): unknown { return typeof value === "bigint" ? value.toString() : value; diff --git a/market-maker/src/core/historicalPriceSource.ts b/market-maker/src/core/historicalPriceSource.ts index cf92e51..0226f09 100644 --- a/market-maker/src/core/historicalPriceSource.ts +++ b/market-maker/src/core/historicalPriceSource.ts @@ -10,10 +10,14 @@ * `HashpriceUsd` time-series entity, so a single shared source serves both * apps. * - * Log returns `ln(p_i / p_{i-1})` are scale-invariant, so we deliberately - * skip rebasing subgraph prices to token decimals — the rolling window only - * needs the *ratios*, and avoiding the rebase keeps this module independent - * of the venue adapters. + * Prices come back in whatever fixed-point scale the subgraph stores, which + * is the aggregator's own decimals — not the token decimals that live oracle + * reads are rebased to. Ratios *within* this series are unaffected, so the + * raw scale is kept here and the module stays independent of the venue + * adapters. The series is not interchangeable with live samples, though: + * `OracleTracker.initialize` rebases it onto the live scale before pushing, + * because a window holding both scales at once produces a log return the + * size of the decimal difference at the join. */ import type pino from "pino"; @@ -21,17 +25,30 @@ import type pino from "pino"; export interface PricePoint { /** Unix timestamp in seconds. */ timestampSec: number; - /** Raw price as stored by the source (units irrelevant — log returns are scale-free). */ + /** Price in the series' declared fixed-point scale; consumers rebase it. */ price: bigint; } +/** A historical series together with the feed and scale it describes. */ +export interface HistoricalPriceSeries { + /** Aggregator the source indexed, for cross-checking against the live oracle. */ + address: `0x${string}`; + /** Fixed-point decimals every `points[].price` is expressed in. */ + decimals: number; + /** Samples, oldest first. */ + points: PricePoint[]; +} + export interface HistoricalPriceSource { /** - * Returns up to `maxPoints` price samples within the last `lookbackSec` - * seconds, oldest first. Implementations should silently truncate if the - * source has fewer matching points; callers tolerate short results. + * Returns the newest `maxPoints` samples that are no older than + * `maxAgeSec`, oldest first. Implementations should silently truncate if + * the source has fewer matching points; callers tolerate short results. + * + * Implementations must report the feed and scale the samples belong to + * rather than leaving the caller to infer them — see `HistoricalPriceSeries`. */ - fetch(opts: { lookbackSec: number; maxPoints: number }): Promise; + fetch(opts: { maxAgeSec: number; maxPoints: number }): Promise; } /** @@ -55,8 +72,8 @@ export class HashpriceOracleSubgraphSource implements HistoricalPriceSource { this.fetchImpl = opts.fetchImpl ?? fetch; } - async fetch(opts: { lookbackSec: number; maxPoints: number }): Promise { - const sinceSec = Math.floor(Date.now() / 1000) - Math.max(0, Math.floor(opts.lookbackSec)); + async fetch(opts: { maxAgeSec: number; maxPoints: number }): Promise { + const sinceSec = Math.floor(Date.now() / 1000) - Math.max(0, Math.floor(opts.maxAgeSec)); // The Graph's `Timestamp` scalar is **microseconds since Unix epoch**, not // seconds. Both the `where: { timestamp_gte: ... }` filter and the returned // field use µs. We rescale at the boundary so the rest of the codebase @@ -73,8 +90,15 @@ export class HashpriceOracleSubgraphSource implements HistoricalPriceSource { // number gets rejected with `Invalid value provided for argument "since": // Int(Number(...))`. Verified against the goldsky gateway with // introspection + a probe. + // `hashpriceMeta` carries the aggregator the subgraph indexed and the + // decimals it stores `price` in. Both are needed to line the series up + // with live oracle reads, so they travel with it in one round trip. const query = ` query HashpriceHistory($since: Timestamp!, $first: Int!) { + hashpriceMetas(first: 1) { + hashpriceUsdAddress + hashpriceUsdDecimals + } hashpriceUsds( where: { timestamp_gte: $since } orderBy: timestamp @@ -102,7 +126,10 @@ export class HashpriceOracleSubgraphSource implements HistoricalPriceSource { ); } const json = (await res.json()) as { - data?: { hashpriceUsds?: Array<{ timestamp: string | number; price: string }> }; + data?: { + hashpriceMetas?: Array<{ hashpriceUsdAddress: string; hashpriceUsdDecimals: string | number }>; + hashpriceUsds?: Array<{ timestamp: string | number; price: string }>; + }; errors?: Array<{ message: string }>; }; if (json.errors && json.errors.length > 0) { @@ -111,6 +138,13 @@ export class HashpriceOracleSubgraphSource implements HistoricalPriceSource { ); } + const meta = json.data?.hashpriceMetas?.[0]; + if (!meta) { + // Without the meta row the series cannot declare its feed or scale, and + // guessing either is what this whole path exists to avoid. + throw new Error(`hashprice-subgraph: no HashpriceMeta row at ${this.url}`); + } + const rows = json.data?.hashpriceUsds ?? []; const points: PricePoint[] = rows.map((r) => ({ // Timestamp is microseconds (see the `since` rescale above); convert @@ -123,10 +157,18 @@ export class HashpriceOracleSubgraphSource implements HistoricalPriceSource { // Subgraph returned newest-first; flip so callers can push in chronological order. points.reverse(); + const address = meta.hashpriceUsdAddress.toLowerCase() as `0x${string}`; + const decimals = Number(meta.hashpriceUsdDecimals); + if (!Number.isInteger(decimals) || decimals < 0) { + throw new Error( + `hashprice-subgraph: invalid hashpriceUsdDecimals "${meta.hashpriceUsdDecimals}"`, + ); + } + this.logger.debug( - { url: this.url, requested: first, got: points.length, sinceSec }, + { url: this.url, requested: first, got: points.length, sinceSec, address, decimals }, "fetched hashprice history", ); - return points; + return { address, decimals, points }; } } diff --git a/market-maker/src/core/marketRuntime.ts b/market-maker/src/core/marketRuntime.ts index 63729c4..9242430 100644 --- a/market-maker/src/core/marketRuntime.ts +++ b/market-maker/src/core/marketRuntime.ts @@ -1,5 +1,5 @@ import type pino from "pino"; -import type Fraction from "fraction.js"; +import { fractionToNumber } from "./math.ts"; import type { InstrumentAdapter } from "./adapter.ts"; import type { BookTracker } from "./bookTracker.ts"; import type { InventoryManager } from "./inventoryManager.ts"; @@ -174,7 +174,3 @@ export class MarketRuntime { } } -function fractionToNumber(value: Fraction): number { - const v = value.simplify(1e-12); - return (Number(v.s) * Number(v.n)) / Number(v.d); -} diff --git a/market-maker/src/core/math.ts b/market-maker/src/core/math.ts index 26ec540..6526f63 100644 --- a/market-maker/src/core/math.ts +++ b/market-maker/src/core/math.ts @@ -86,10 +86,24 @@ export function bigAbs(v: bigint): bigint { export const bigMin = (a: bigint, b: bigint) => (a < b ? a : b); export const bigMax = (a: bigint, b: bigint) => (a > b ? a : b); +/** + * Collapse a Fraction to a JS number for logging and health output. + * + * Diagnostic only — never use in trading math. Realized-vol Fractions carry + * 1000+ bit numerators and denominators (`sqrt` at 48-bit precision over a + * 60-sample window), so a naive `valueOf()` overflows both sides to Infinity + * and yields NaN, which JSON-serialises as `null`. Simplifying first collapses + * the magnitude before the cast. + */ +export function fractionToNumber(value: Fraction): number { + const v = value.simplify(1e-12); + return (Number(v.s) * Number(v.n)) / Number(v.d); +} + /** * Rolling window of bigint samples. Computes: * - per-step realized volatility = stddev of log returns (Fraction-precise) - * - per-second realized volatility = stddev of time-normalised log returns + * - per-second realized volatility = √(Σ r² / Σ Δt) * (requires timestamps on every push) * - median (bigint) * @@ -102,16 +116,27 @@ export const bigMax = (a: bigint, b: bigint) => (a > b ? a : b); * * # Per-second volatility math * - * Each step covers a possibly-variable Δt_i seconds. For a Brownian process - * with per-second stddev σ_s, Var(r_i) = σ_s² · Δt_i, so the time-normalised - * return x_i = r_i / √Δt_i has constant variance σ_s². Then σ_s is the sample - * stddev of {x_i}: + * Steps cover variable Δt_i, so we pool squared returns against total elapsed + * time — the realized-variance estimator for irregularly spaced observations: * * Δt_i = t_i − t_{i-1} - * x_i = r_i / √Δt_i - * σ_s² = Σ (x_i − μ)² / (N − 1) + * σ_s² = Σ r_i² / Σ Δt_i * σ_s = sqrt(σ_s²) (units: dimensionless × s^-1/2) * + * The tempting alternative — normalising each return individually as + * x_i = r_i/√Δt_i and taking the sample stddev — assumes the feed samples a + * Brownian process at times unrelated to its moves. Our oracles publish on a + * deviation threshold instead, so |r_i| is roughly constant and independent of + * Δt_i (measured corr(log Δt, log|r|) ≈ −0.13 on hashprice). Dividing by √Δt + * then injects a spurious 1/√Δt term, and clustered updates inflate σ: the + * per-sample form reads ~1.7× high in steady state, and its spread across a + * rolling window blew out to 20× (vs 4× here) during a period of bursty + * updates. Pooling is both unbiased under threshold publication and far more + * robust to those bursts. + * + * Realized variance is deliberately not mean-centred; over these horizons + * drift is negligible and subtracting an estimated mean only adds noise. + * * Notes: * - We compute log returns as `ln(curr/prev)`, NOT `ln(curr) − ln(prev)` as * two separate logs — Fraction.div is exact, and one ln call is half the @@ -177,14 +202,17 @@ export class RollingWindow { } /** - * Realized per-second volatility (stddev of √Δt-normalised log returns). - * 0 if fewer than 3 samples or if any required timestamp is missing / - * non-monotonic. Units: dimensionless × s^-1/2. + * Realized per-second volatility: √(Σ r² / Σ Δt) over the window. Pairs with + * a missing / non-monotonic timestamp are skipped. Returns 0 if fewer than 3 + * samples or fewer than 2 usable pairs. Units: dimensionless × s^-1/2. */ volatilityPerSecond(): Fraction { if (this.samples.length < 3) return new Fraction(0n); - const xs: Fraction[] = []; + let sumSqReturns = new Fraction(0n); + let sumDtSec = new Fraction(0n); + let pairs = 0; + for (let i = 1; i < this.samples.length; i++) { const prev = this.samples[i - 1]; const curr = this.samples[i]; @@ -194,15 +222,15 @@ export class RollingWindow { if (!Number.isFinite(dtSec) || dtSec <= 0) continue; const r = ln(new Fraction(curr, prev), this.precisionBits); + sumSqReturns = sumSqReturns.add(r.mul(r)); // Encode Δt as a Fraction with millisecond resolution; sub-ms precision // is irrelevant given the ≤2^-precisionBits truncation in `sqrt`. - const dt = new Fraction(BigInt(Math.round(dtSec * 1000)), 1000n); - const x = r.div(sqrt(dt, this.precisionBits)); - xs.push(x); + sumDtSec = sumDtSec.add(new Fraction(BigInt(Math.round(dtSec * 1000)), 1000n)); + pairs++; } - if (xs.length < 2) return new Fraction(0n); - return sampleStddev(xs, this.precisionBits); + if (pairs < 2) return new Fraction(0n); + return sqrt(sumSqReturns.div(sumDtSec), this.precisionBits); } /** Median of samples (bigint). */ diff --git a/market-maker/src/core/oracleTracker.ts b/market-maker/src/core/oracleTracker.ts index aa7d995..1d68952 100644 --- a/market-maker/src/core/oracleTracker.ts +++ b/market-maker/src/core/oracleTracker.ts @@ -1,8 +1,12 @@ import type pino from "pino"; import Fraction from "fraction.js"; -import type { InstrumentAdapter } from "./adapter.ts"; -import type { HistoricalPriceSource } from "./historicalPriceSource.ts"; -import { RollingWindow } from "./math.ts"; +import type { InstrumentAdapter, OracleScale } from "./adapter.ts"; +import type { + HistoricalPriceSeries, + HistoricalPriceSource, + PricePoint, +} from "./historicalPriceSource.ts"; +import { fractionToNumber, RollingWindow } from "./math.ts"; export interface OracleTrackerConfig { /** Maximum number of samples kept in the rolling window. Defaults to 60. */ @@ -16,20 +20,15 @@ export interface OracleTrackerConfig { */ history?: HistoricalPriceSource; /** - * Live poll cadence in milliseconds. Used together with `windowSize` to - * size the historical lookback (`windowSize × pollIntervalMs`). Required - * when `history` is provided; otherwise ignored. + * Upper bound on the age of a backfilled sample, in seconds. + * + * Backfill asks for the newest `windowSize` oracle updates, so the span the + * window covers is decided by the feed's own cadence — this only stops a + * stale regime from seeding σ when the feed has been quiet. Must stay above + * `windowSize × the feed's update interval` or the backfill comes back + * short. Defaults to 24h. */ - pollIntervalMs?: number; - /** - * Multiplier applied to the lookback window when fetching history. The - * underlying oracle (Chainlink) only updates on deviation/heartbeat, so - * `windowSize × pollIntervalMs` of wall-clock typically yields fewer than - * `windowSize` samples. Querying a wider window and trimming gets us a - * full window. Defaults to 4× — generous enough for slow feeds, small - * enough to keep the gateway response under a few hundred kB. - */ - historyLookbackMultiplier?: number; + historyMaxAgeSec?: number; /** * Test seam for deterministic per-second σ math. Returns the current time * in seconds (with sub-second precision is fine). Defaults to @@ -55,9 +54,13 @@ export interface OracleTrackerConfig { * # Why backfill * * Cold starts otherwise need ~`windowSize × medianUpdateInterval` of - * wall-clock before σ is meaningful. With the subgraph-backed - * `HistoricalPriceSource`, the window is already populated when the first - * live tick lands. + * wall-clock before σ is meaningful — hours on a feed that updates every few + * minutes. With the subgraph-backed `HistoricalPriceSource`, the window is + * already populated when the first live tick lands. + * + * Note that the poll interval plays no part in sizing the backfill. The + * window de-duplicates, so it holds `windowSize` *oracle updates* however + * often we poll; asking the source for that many is the whole sizing rule. */ export class OracleTracker { currentPrice = 0n; @@ -71,9 +74,8 @@ export class OracleTracker { private readonly instrument: InstrumentAdapter; private readonly priceWindow: RollingWindow; private readonly history: HistoricalPriceSource | undefined; - private readonly pollIntervalMs: number | undefined; private readonly windowSize: number; - private readonly historyLookbackMultiplier: number; + private readonly historyMaxAgeSec: number; private readonly nowSec: () => number; private readonly logger: pino.Logger; private lastSampledPrice: bigint | null = null; @@ -83,8 +85,7 @@ export class OracleTracker { this.windowSize = cfg.windowSize ?? 60; this.priceWindow = new RollingWindow(this.windowSize, cfg.precisionBits ?? 48); this.history = cfg.history; - this.pollIntervalMs = cfg.pollIntervalMs; - this.historyLookbackMultiplier = cfg.historyLookbackMultiplier ?? 4; + this.historyMaxAgeSec = cfg.historyMaxAgeSec ?? 86_400; this.nowSec = cfg.nowSec ?? (() => Date.now() / 1000); this.logger = logger.child({ component: "oracle" }); } @@ -95,41 +96,65 @@ export class OracleTracker { * are equivalent to plain `update()`. */ async initialize(): Promise { - if (this.history && this.pollIntervalMs && this.pollIntervalMs > 0) { - const lookbackSec = (this.windowSize * this.pollIntervalMs * this.historyLookbackMultiplier) / 1000; + if (this.history) { try { - const samples = await this.history.fetch({ - lookbackSec, - maxPoints: this.windowSize * this.historyLookbackMultiplier, - }); - let pushed = 0; - for (const s of samples) { - if (s.price <= 0n) continue; - if (this.lastSampledPrice !== null && s.price === this.lastSampledPrice) continue; - this.priceWindow.push(s.price, s.timestampSec); - this.lastSampledPrice = s.price; - pushed++; + const [live, series] = await Promise.all([ + this.instrument.getOracleScale(), + // The window keeps `windowSize` de-duplicated samples, so that is + // exactly how many oracle updates to ask for; the feed decides how + // far back that reaches. + this.history.fetch({ + maxPoints: this.windowSize, + maxAgeSec: this.historyMaxAgeSec, + }), + ]); + const mismatch = reconcileScales(series, live); + if (mismatch !== null) { + this.logger.warn( + { + reason: mismatch, + historyAddress: series.address, + historyDecimals: series.decimals, + oracleAddress: live.address, + oracleDecimals: live.decimals, + }, + "historical series does not match the live oracle; skipping backfill", + ); + } else { + const rebased = rebase(series, live.decimals); + let pushed = 0; + for (const s of rebased) { + if (s.price <= 0n) continue; + if (this.lastSampledPrice !== null && s.price === this.lastSampledPrice) continue; + this.priceWindow.push(s.price, s.timestampSec); + this.lastSampledPrice = s.price; + pushed++; + } + if (pushed > 0) { + // Compute σ now so it's already meaningful before the first live tick; + // `update()` only recomputes when a *new* price arrives, and the live + // poll often duplicates the last backfilled sample. + this.volatilityPerSecond = this.priceWindow.volatilityPerSecond(); + } + this.logger.info( + { + fetched: series.points.length, + pushed, + windowSize: this.windowSize, + maxAgeSec: this.historyMaxAgeSec, + spanSec: sampleSpanSec(series), + decimalShift: series.decimals - live.decimals, + volatilityPerSec: fractionToNumber(this.volatilityPerSecond), + }, + "backfilled volatility window from historical source", + ); } - if (pushed > 0) { - // Compute σ now so it's already meaningful before the first live tick; - // `update()` only recomputes when a *new* price arrives, and the live - // poll often duplicates the last backfilled sample. - this.volatilityPerSecond = this.priceWindow.volatilityPerSecond(); - } - this.logger.info( - { fetched: samples.length, pushed, windowSize: this.windowSize, lookbackSec }, - "backfilled volatility window from historical source", - ); } catch (err) { this.logger.warn( - { err, windowSize: this.windowSize, lookbackSec }, + { err, windowSize: this.windowSize, maxAgeSec: this.historyMaxAgeSec }, "history backfill failed; volatility will warm up from live polls", ); } - } else if (this.history) { - this.logger.warn( - "history provided without pollIntervalMs; skipping backfill", - ); } await this.update(); @@ -146,10 +171,57 @@ export class OracleTracker { this.logger.debug( { price: price.toString(), - volatilityPerSec: this.volatilityPerSecond.valueOf(), + volatilityPerSec: fractionToNumber(this.volatilityPerSecond), windowFill: this.priceWindow.length, }, "oracle tick", ); } } + +/** + * Check a historical series against the live oracle before its samples are + * allowed into the window. Returns `null` when they agree, otherwise a short + * reason for the log. + * + * Both sides declare their feed and their fixed-point scale — the aggregator + * address and decimals come off chain via `getOracleScale()`, and the source + * reports the pair it indexed. Nothing is inferred from the magnitude of the + * prices: a series from a different feed can look perfectly plausible next to + * the live one, and a decimal difference is indistinguishable from a real + * price move once you are only comparing numbers. + */ +function reconcileScales(series: HistoricalPriceSeries, live: OracleScale): string | null { + if (series.address.toLowerCase() !== live.address.toLowerCase()) { + return "aggregator address differs"; + } + if (!Number.isInteger(series.decimals) || series.decimals < 0) { + return "history decimals are not a valid scale"; + } + return null; +} + +/** + * Restate a series on `targetDecimals`. + * + * Historical sources publish the aggregator's own answer, while live reads + * arrive rebased to token decimals (see `RawOracleReader`). Mixing the two + * unrebased fabricates a log return the size of the decimal difference at the + * seam, which then dominates σ. + */ +/** Wall-clock the fetched samples cover, for spotting a window that came back short. */ +function sampleSpanSec(series: HistoricalPriceSeries): number { + const { points } = series; + if (points.length < 2) return 0; + return Math.round(points[points.length - 1].timestampSec - points[0].timestampSec); +} + +function rebase(series: HistoricalPriceSeries, targetDecimals: number): readonly PricePoint[] { + const shift = series.decimals - targetDecimals; + if (shift === 0) return series.points; + const factor = 10n ** BigInt(Math.abs(shift)); + return series.points.map((s) => ({ + ...s, + price: shift > 0 ? s.price / factor : s.price * factor, + })); +} diff --git a/market-maker/src/core/portfolioHealth.ts b/market-maker/src/core/portfolioHealth.ts index dc38414..2c05bae 100644 --- a/market-maker/src/core/portfolioHealth.ts +++ b/market-maker/src/core/portfolioHealth.ts @@ -1,7 +1,7 @@ import { createServer } from "node:http"; import type { Server, ServerResponse } from "node:http"; import type pino from "pino"; -import type Fraction from "fraction.js"; +import { fractionToNumber } from "./math.ts"; import type { CollateralTracker } from "./collateralTracker.ts"; import type { GasTracker } from "./gasTracker.ts"; import type { RiskManager } from "./riskManager.ts"; @@ -244,10 +244,6 @@ export class PortfolioHealthCheck { } } -function fractionToNumber(value: Fraction): number { - const v = value.simplify(1e-12); - return (Number(v.s) * Number(v.n)) / Number(v.d); -} function bigIntReplacer(_key: string, value: unknown): unknown { return typeof value === "bigint" ? value.toString() : value; diff --git a/market-maker/src/core/rawOracle.ts b/market-maker/src/core/rawOracle.ts index c2cc940..39a935e 100644 --- a/market-maker/src/core/rawOracle.ts +++ b/market-maker/src/core/rawOracle.ts @@ -20,6 +20,7 @@ */ import type { PublicClient } from "viem"; +import type { OracleScale } from "./adapter.ts"; /** Chainlink AggregatorV3Interface — read-only slice we need for the raw mid. */ export const chainlinkAggregatorAbi = [ @@ -49,6 +50,8 @@ export interface RawOracleConfig { oracle: `0x${string}`; /** 10^(oracle.decimals − token.decimals); used to rebase the answer to token decimals. */ divisor: bigint; + /** Token decimals — the scale `read()` returns answers in, after the divisor. */ + tokenDecimals: number; } export class RawOracleReader { @@ -89,6 +92,14 @@ export class RawOracleReader { return this.lastAnswer; } + /** Aggregator identity and the scale `read()` returns answers in. */ + async scale(): Promise { + if (this.cache === null) { + this.cache = await this.resolve(); + } + return { address: this.cache.oracle, decimals: this.cache.tokenDecimals }; + } + /** * The most recent price `read()` returned, or `null` before the first read. * diff --git a/market-maker/tests/apps/futures/main.smoke.test.ts b/market-maker/tests/apps/futures/main.smoke.test.ts deleted file mode 100644 index c609ca5..0000000 --- a/market-maker/tests/apps/futures/main.smoke.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { resolve } from "node:path"; -import { loadFuturesConfig } from "../../../src/apps/futures/config.ts"; - -/** - * Smoke test for the bundled futures configs. Catches drift between the - * schema and the per-env YAMLs shipped under configs/{dev,stg,prd}/futures.yml. - */ -describe("futures app config smoke", () => { - const envs = ["local", "dev", "stg", "prd"] as const; - - for (const e of envs) { - it(`loads configs/futures.${e}.yml with stub env`, () => { - const path = resolve(import.meta.dirname, `../../../configs/futures.${e}.yml`); - const env: NodeJS.ProcessEnv = { - PRIVATE_KEY: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - ALCHEMY_API_KEY: "stub-alchemy-key", - FUTURES_ADDRESS: "0x1234567890123456789012345678901234567890", - HASHPRICE_ORACLE_SUBGRAPH_URL: "https://stub.example/subgraph", - }; - const cfg = loadFuturesConfig({ path, env }); - assert.equal(cfg.venue.kind, "futures"); - assert.equal(cfg.pricing.strategy, "reservation-price"); - assert.equal(cfg.sizing.strategy, "geometric-taper"); - assert.ok(cfg.sizing.taperRatio > 0 && cfg.sizing.taperRatio < 1); - }); - } -}); diff --git a/market-maker/tests/apps/perps/main.smoke.test.ts b/market-maker/tests/apps/perps/main.smoke.test.ts deleted file mode 100644 index febd7c7..0000000 --- a/market-maker/tests/apps/perps/main.smoke.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { resolve } from "node:path"; -import { loadPerpsConfig } from "../../../src/apps/perps/config.ts"; - -/** - * Smoke test for the bundled perps configs. Catches drift between the - * schema and the per-env YAMLs shipped under configs/{dev,stg,prd}/perps.yml. - */ -describe("perps app config smoke", () => { - const envs = ["local", "dev", "stg", "prd"] as const; - - for (const e of envs) { - it(`loads configs/perps.${e}.yml with stub env`, () => { - const path = resolve(import.meta.dirname, `../../../configs/perps.${e}.yml`); - const env: NodeJS.ProcessEnv = { - PRIVATE_KEY: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", - ALCHEMY_API_KEY: "stub-alchemy-key", - PERPS_ADDRESS: "0x1234567890123456789012345678901234567890", - HASHPRICE_ORACLE_SUBGRAPH_URL: "https://stub.example/subgraph", - }; - const cfg = loadPerpsConfig({ path, env }); - assert.equal(cfg.venue.kind, "perps"); - assert.equal(cfg.pricing.strategy, "effective-spread"); - assert.equal(cfg.sizing.strategy, "geometric-taper"); - assert.ok(cfg.sizing.taperRatio > 0 && cfg.sizing.taperRatio < 1); - assert.ok(cfg.timing.levelSpacingTicks >= 1); - }); - } -}); diff --git a/market-maker/tests/apps/portfolio/main.smoke.test.ts b/market-maker/tests/apps/portfolio/main.smoke.test.ts new file mode 100644 index 0000000..a6f6633 --- /dev/null +++ b/market-maker/tests/apps/portfolio/main.smoke.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { loadPortfolioConfig } from "../../../src/apps/portfolio/config.ts"; + +/** + * Smoke test for the bundled portfolio configs. Catches drift between the + * schema and the per-env YAMLs shipped under configs/portfolio..yml. + */ +describe("portfolio app config smoke", () => { + const envs = ["local", "dev", "prd"] as const; + + for (const e of envs) { + it(`loads configs/portfolio.${e}.yml with stub env`, () => { + const path = resolve(import.meta.dirname, `../../../configs/portfolio.${e}.yml`); + const env: NodeJS.ProcessEnv = { + PRIVATE_KEY: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + ALCHEMY_API_KEY: "stub-alchemy-key", + PERPS_ADDRESS: "0x1234567890123456789012345678901234567890", + FUTURES_ADDRESS: "0x2345678901234567890123456789012345678901", + HASHPRICE_ORACLE_SUBGRAPH_URL: "https://stub.example/subgraph", + }; + const cfg = loadPortfolioConfig({ path, env }); + + const perps = cfg.venues.find((v) => v.kind === "perps"); + const futures = cfg.venues.find((v) => v.kind === "futures"); + assert.ok(perps, "expected a perps venue"); + assert.ok(futures, "expected a futures venue"); + assert.equal(perps.pricing.strategy, "effective-spread"); + assert.equal(futures.pricing.strategy, "reservation-price"); + + for (const venue of cfg.venues) { + assert.equal(venue.sizing.strategy, "geometric-taper"); + assert.ok(venue.sizing.taperRatio > 0 && venue.sizing.taperRatio < 1); + } + assert.ok(cfg.timing.levelSpacingTicks >= 1); + }); + } +}); diff --git a/market-maker/tests/core/collateralTracker.test.ts b/market-maker/tests/core/collateralTracker.test.ts index 22f193d..98a5943 100644 --- a/market-maker/tests/core/collateralTracker.test.ts +++ b/market-maker/tests/core/collateralTracker.test.ts @@ -73,6 +73,20 @@ describe("CollateralTracker.maybeTopUp", () => { assert.deepStrictEqual(env.deposits, [50_000_000n]); }); + it("does not deposit in dry run", async () => { + // Deposits are the only wallet write outside the order path, so dryRun + // has to stop them here or "dry run" still moves funds. + env.setBalance(50_000_000n); + const t = new CollateralTracker( + env.account, + { autoDeposit: true, autoDepositMinAmount: 1_000_000n, dryRun: true }, + logger, + ); + await t.update(); + await t.maybeTopUp(); + assert.deepStrictEqual(env.deposits, []); + }); + it("caps deposit so the vault balance does not exceed maxCollateralAmount", async () => { env = makeAccount({ vaultBalance: 30_000_000n }); // 30 USDC already in vault env.setBalance(500_000_000n); // 500 USDC in wallet diff --git a/market-maker/tests/core/math.test.ts b/market-maker/tests/core/math.test.ts index ae88711..1c3c14e 100644 --- a/market-maker/tests/core/math.test.ts +++ b/market-maker/tests/core/math.test.ts @@ -138,17 +138,57 @@ describe("RollingWindow per-second volatility", () => { assert.equal(fracVal(w.volatilityPerSecond()), 0); }); - it("uniform Δt: σ_per_sec ≈ σ_per_step / √Δt", () => { + it("uniform Δt: σ_per_sec = rms(log returns) / √Δt", () => { const w = new RollingWindow(10); const prices = [100n, 102n, 98n, 101n, 99n, 103n]; const dt = 4; // seconds between samples for (let i = 0; i < prices.length; i++) w.push(prices[i], i * dt); - const perStep = w.volatility().simplify(1e-12).valueOf(); - const perSec = fracVal(w.volatilityPerSecond()); - // For uniform Δt the relationship is exact: σ_step = σ_sec · √Δt. + + // Realized variance pools Σr²/ΣΔt and is not mean-centred, so the + // reference is the root-mean-square return, not the sample stddev. + let sumSq = 0; + for (let i = 1; i < prices.length; i++) { + sumSq += Math.log(Number(prices[i]) / Number(prices[i - 1])) ** 2; + } + const expected = Math.sqrt(sumSq / ((prices.length - 1) * dt)); + + assert.ok( + Math.abs(fracVal(w.volatilityPerSecond()) - expected) < 1e-9, + `expected σ_sec=${expected}, got ${fracVal(w.volatilityPerSecond())}`, + ); + }); + + it("a burst of rapid updates does not inflate σ", () => { + // Our oracles publish on a deviation threshold, so a cluster of fast + // updates carries the same move size as slow ones. Pooling Σr²/ΣΔt keeps + // σ flat across the burst; per-sample r/√Δt normalisation would spike it. + const steady = new RollingWindow(64); + const bursty = new RollingWindow(64); + const moves = [1.002, 0.998, 1.003, 0.997, 1.001, 0.999]; + + let t = 0; + let p = 1_000_000n; + for (let i = 0; i < 24; i++) { + p = BigInt(Math.round(Number(p) * moves[i % moves.length])); + t += 200; + steady.push(p, t); + } + + // Same price path and same total elapsed time, but the middle third + // arrives as a 2-second burst that the tail then compensates for. + t = 0; + p = 1_000_000n; + for (let i = 0; i < 24; i++) { + p = BigInt(Math.round(Number(p) * moves[i % moves.length])); + t += i >= 8 && i < 16 ? 2 : 200 + (8 * 198) / 16; + bursty.push(p, t); + } + + const a = fracVal(steady.volatilityPerSecond()); + const b = fracVal(bursty.volatilityPerSecond()); assert.ok( - Math.abs(perStep - perSec * Math.sqrt(dt)) < 1e-9, - `expected σ_step=${perStep} ≈ σ_sec=${perSec} × √${dt}`, + Math.abs(b / a - 1) < 0.05, + `burst should leave σ within 5%: steady=${a}, bursty=${b} (${(b / a).toFixed(2)}×)`, ); }); diff --git a/market-maker/tests/core/oracleTracker.test.ts b/market-maker/tests/core/oracleTracker.test.ts index b6b5cf0..c11d863 100644 --- a/market-maker/tests/core/oracleTracker.test.ts +++ b/market-maker/tests/core/oracleTracker.test.ts @@ -8,6 +8,11 @@ import type { PricePoint, } from "../../src/core/historicalPriceSource.ts"; +/** Aggregator both the live oracle mock and the history mock claim by default. */ +const ORACLE = "0xf30a6489f20630bf5b1a76f0c56aadc364d031f3" as const; +/** Collateral-token decimals, i.e. the scale `getIndexPrice()` answers in. */ +const TOKEN_DECIMALS = 6; + const noop = () => {}; function makeLogger(): never { return { @@ -30,6 +35,7 @@ function makeInstrument(prices: bigint[]): InstrumentAdapter { bootstrap: async () => {}, }, getIndexPrice: async () => prices[Math.min(i++, prices.length - 1)], + getOracleScale: async () => ({ address: ORACLE, decimals: TOKEN_DECIMALS }), getPosition: async () => ({ netQuantity: 0n, entryPrice: 0n }), getContext: async () => ({}), encodeCreate: () => "0x", @@ -41,8 +47,17 @@ function makeInstrument(prices: bigint[]): InstrumentAdapter { }; } -function makeHistory(points: PricePoint[]): HistoricalPriceSource { - return { fetch: async () => points }; +function makeHistory( + points: PricePoint[], + opts: { address?: `0x${string}`; decimals?: number } = {}, +): HistoricalPriceSource { + return { + fetch: async () => ({ + address: opts.address ?? ORACLE, + decimals: opts.decimals ?? TOKEN_DECIMALS, + points, + }), + }; } /** A monotonically advancing clock so successive updates produce distinct timestamps. */ @@ -139,7 +154,6 @@ describe("OracleTracker", () => { ]); const tracker = new OracleTracker(makeInstrument([105n]), makeLogger(), { history, - pollIntervalMs: 10_000, windowSize: 60, // Live tick lands ~5s after the last backfilled sample so it pushes too. nowSec: () => now, @@ -151,6 +165,93 @@ describe("OracleTracker", () => { ); }); + it("rebases historical samples from the source's decimals onto the live scale", async () => { + // The subgraph serves the aggregator's 8-decimal answer; live reads arrive + // rebased to 6-decimal token units. Unrebased, the join between the two + // contributes ln(1/100) and σ explodes to ~0.45. + const now = 1_700_000_000; + const history = makeHistory( + [ + { timestampSec: now - 900, price: 4_100_000_000n }, + { timestampSec: now - 600, price: 4_095_000_000n }, + { timestampSec: now - 300, price: 4_102_000_000n }, + { timestampSec: now - 30, price: 4_094_000_000n }, + ], + { decimals: 8 }, + ); + const tracker = new OracleTracker(makeInstrument([40_969_000n]), makeLogger(), { + history, + windowSize: 60, + nowSec: () => now, + }); + await tracker.initialize(); + + const sigma = fracVal(tracker.volatilityPerSecond); + assert.ok(sigma > 0, "expected the backfill to be used, got σ = 0"); + assert.ok(sigma < 1e-3, `expected σ from a single-scale window, got ${sigma}`); + }); + + it("skips a backfill indexed from a different aggregator", async () => { + const now = 1_700_000_000; + const history = makeHistory( + [ + { timestampSec: now - 300, price: 4_100_000_000n }, + { timestampSec: now - 200, price: 4_095_000_000n }, + { timestampSec: now - 100, price: 4_102_000_000n }, + ], + { address: "0x1111111111111111111111111111111111111111", decimals: 8 }, + ); + const tracker = new OracleTracker(makeInstrument([40_969_000n]), makeLogger(), { + history, + windowSize: 60, + nowSec: () => now, + }); + await tracker.initialize(); + + assert.equal(tracker.currentPrice, 40_969_000n); + assert.equal(fracVal(tracker.volatilityPerSecond), 0); + }); + + it("matches the aggregator address case-insensitively", async () => { + const now = 1_700_000_000; + const history = makeHistory( + [ + { timestampSec: now - 900, price: 4_100_000_000n }, + { timestampSec: now - 600, price: 4_095_000_000n }, + { timestampSec: now - 300, price: 4_102_000_000n }, + { timestampSec: now - 30, price: 4_094_000_000n }, + ], + { address: ORACLE.toUpperCase().replace("0X", "0x") as `0x${string}`, decimals: 8 }, + ); + const tracker = new OracleTracker(makeInstrument([40_969_000n]), makeLogger(), { + history, + windowSize: 60, + nowSec: () => now, + }); + await tracker.initialize(); + assert.ok(fracVal(tracker.volatilityPerSecond) > 0, "expected the backfill to be used"); + }); + + it("sizes the backfill from windowSize alone, bounded by historyMaxAgeSec", async () => { + // The window de-duplicates, so it holds `windowSize` oracle updates + // whatever the poll cadence — poll interval must play no part here. + let seen: { maxPoints: number; maxAgeSec: number } | null = null; + const history: HistoricalPriceSource = { + fetch: async (opts) => { + seen = opts; + return { address: ORACLE, decimals: TOKEN_DECIMALS, points: [] }; + }, + }; + const tracker = new OracleTracker(makeInstrument([100n]), makeLogger(), { + history, + windowSize: 45, + historyMaxAgeSec: 7200, + nowSec: makeClock(), + }); + await tracker.initialize(); + assert.deepEqual(seen, { maxPoints: 45, maxAgeSec: 7200 }); + }); + it("backfill failures fall back gracefully to live warm-up", async () => { const failing: HistoricalPriceSource = { fetch: async () => { @@ -159,7 +260,6 @@ describe("OracleTracker", () => { }; const tracker = new OracleTracker(makeInstrument([100n]), makeLogger(), { history: failing, - pollIntervalMs: 1000, nowSec: makeClock(), }); // initialize() must not throw even when backfill errors out — startup is