diff --git a/.bedrock/.terragrunt/00_variables.tf b/.bedrock/.terragrunt/00_variables.tf index 92d6f48..de6e509 100644 --- a/.bedrock/.terragrunt/00_variables.tf +++ b/.bedrock/.terragrunt/00_variables.tf @@ -12,11 +12,11 @@ variable "create_core" { # the scaffolding (security groups, ALB, target group, listener, Route53, # log group, service shell, initial task-def stub). # -# Personality (image, env vars, secrets) is owned by the deploy-col-mar-mm.yml -# workflow, which builds the image, pushes to GHCR, and registers a new -# task-def revision per deploy. Both ECS service.task_definition and ECS -# task_definition.container_definitions are in lifecycle.ignore_changes; -# Terraform never updates them after first apply. +# Personality (image, public env vars, desired count) is owned by +# deploy-col-mar-mm.yml. Private keys and the Alchemy key live in Secrets +# Manager (01_secrets_manager.tf) and are injected with ECS valueFrom. +# Both ECS service.task_definition and container_definitions are in +# lifecycle.ignore_changes; Terraform never updates them after first apply. # # Service map fields (all scaffolding): # create bool - toggle the entire service stack @@ -66,8 +66,9 @@ variable "futures_mm_service" { # UNIFIED MARGIN KEEPER - SCAFFOLDING ONLY ################################################################################ # Single ECS service for coordinated perps + futures liquidation (replaces -# derivatives-marketplace svc-perps-keeper-*). Runtime config is owned by -# deploy-keeper.yml via GitHub Variables / Secrets. +# derivatives-marketplace svc-perps-keeper-*). Public runtime config is owned +# by deploy-keeper.yml from config/.env. The liquidator key, Alchemy key, +# and webhook secret are injected from Secrets Manager. ################################################################################ variable "keeper_service" { @@ -118,3 +119,44 @@ variable "foundation_tags" { variable "provider_profile" { description = "AWS profile name used by the default provider" } + +################################################################################ +# Secrets Manager (gitignored secret.auto.tfvars β€” never commit values) +################################################################################ +# Same shape in 02-dev and 04-lmn: +# alchemy_api_key = "..." +# liquidator_private_key = "0x..." +# futures_mm_private_key = "0x..." +# perps_mm_private_key = "0x..." +# webhook_secret = "" # optional; keeper WEBHOOK_SECRET + +variable "alchemy_api_key" { + description = "Alchemy API key injected into the keeper and both market makers" + type = string + sensitive = true +} + +variable "liquidator_private_key" { + description = "Keeper signer. Injected as LIQUIDATOR_PRIVATE_KEY" + type = string + sensitive = true +} + +variable "futures_mm_private_key" { + description = "Portfolio market-maker signer on the futures ECS service. Injected as PRIVATE_KEY" + type = string + sensitive = true +} + +variable "perps_mm_private_key" { + description = "Perps market-maker signer. Injected as PRIVATE_KEY on the perps ECS service. CI does not roll that service." + type = string + sensitive = true +} + +variable "webhook_secret" { + description = "Optional keeper WEBHOOK_SECRET. Empty string injects an empty value." + type = string + sensitive = true + default = "" +} diff --git a/.bedrock/.terragrunt/01_github_actions_iam.tf b/.bedrock/.terragrunt/01_github_actions_iam.tf index b3bd6b4..dedaca6 100644 --- a/.bedrock/.terragrunt/01_github_actions_iam.tf +++ b/.bedrock/.terragrunt/01_github_actions_iam.tf @@ -6,10 +6,9 @@ # - update ECS services to point at the new revisions # - PassRole the existing bedrock-foundation-role into ECS tasks # -# All runtime config (env vars, secrets, contract addresses, RPC keys) is -# managed in GitHub Variables / Secrets and baked into each task-def -# revision by the workflow. There are no AWS Secrets Manager resources to -# read here. +# Public runtime config is baked into each task-def revision by the workflow +# from config/.env. Private keys are not. CI may DescribeSecret so it +# can write valueFrom ARNs; GetSecretValue stays on bedrock-foundation-role. # # OIDC provider bootstrap (run once per account if not already present): # aws iam create-open-id-connect-provider \ @@ -178,6 +177,16 @@ resource "aws_iam_role_policy" "github_ecs_update_futures_mm" { "ecs:DescribeClusters" ] Resource = "*" + }, + { + Sid = "DescribeFuturesMmSecret" + Effect = "Allow" + Action = [ + "secretsmanager:DescribeSecret" + ] + Resource = [ + aws_secretsmanager_secret.futures_mm[0].arn + ] } ] }) @@ -238,6 +247,16 @@ resource "aws_iam_role_policy" "github_ecs_update_keeper" { "ecs:DescribeClusters" ] Resource = "*" + }, + { + Sid = "DescribeKeeperSecret" + Effect = "Allow" + Action = [ + "secretsmanager:DescribeSecret" + ] + Resource = [ + aws_secretsmanager_secret.keeper[0].arn + ] } ] }) diff --git a/.bedrock/.terragrunt/01_secrets_manager.tf b/.bedrock/.terragrunt/01_secrets_manager.tf new file mode 100644 index 0000000..2ff27bb --- /dev/null +++ b/.bedrock/.terragrunt/01_secrets_manager.tf @@ -0,0 +1,126 @@ +################################################################################ +# SECRETS MANAGER +################################################################################ +# ECS injects these at task start via valueFrom. The task definition stores +# the secret ARN, not the value. GitHub Actions only calls DescribeSecret +# (see 01_github_actions_iam.tf) so CI never receives the secret string. +# +# bedrock-foundation-role is the task execution role (local.titanio_role_arn). + +resource "aws_iam_policy" "col_mar_secret_access" { + count = (var.keeper_service.create || var.futures_mm_service.create || var.perps_mm_service.create) ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-secret-access-${substr(var.account_shortname, 8, 3)}" + description = "Allow ECS tasks to read Collateral Margin secrets from Secrets Manager" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "secretsmanager:GetSecretValue", + "secretsmanager:DescribeSecret" + ] + Resource = compact([ + var.keeper_service.create ? aws_secretsmanager_secret.keeper[0].arn : "", + var.futures_mm_service.create ? aws_secretsmanager_secret.futures_mm[0].arn : "", + var.perps_mm_service.create ? aws_secretsmanager_secret.perps_mm[0].arn : "", + ]) + } + ] + }) + + tags = merge( + var.default_tags, + var.foundation_tags, + { + Name = "Collateral Margin Secret Access Policy", + Capability = null, + }, + ) +} + +resource "aws_iam_role_policy_attachment" "col_mar_secret_access" { + count = (var.keeper_service.create || var.futures_mm_service.create || var.perps_mm_service.create) ? 1 : 0 + provider = aws.use1 + role = "bedrock-foundation-role" + policy_arn = aws_iam_policy.col_mar_secret_access[0].arn +} + +################################################################################ +# Keeper +################################################################################ + +resource "aws_secretsmanager_secret" "keeper" { + count = var.keeper_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-keeper-secrets-v3-${substr(var.account_shortname, 8, 3)}" + description = "Collateral-margin keeper secrets (liquidator key, Alchemy key, webhook secret)" + tags = merge(var.default_tags, var.foundation_tags, { + Name = "${local.shortname}-keeper-secrets-v3-${substr(var.account_shortname, 8, 3)}" + }) +} + +resource "aws_secretsmanager_secret_version" "keeper" { + count = var.keeper_service.create ? 1 : 0 + provider = aws.use1 + secret_id = aws_secretsmanager_secret.keeper[0].id + secret_string = jsonencode({ + liquidator_private_key = var.liquidator_private_key + alchemy_api_key = var.alchemy_api_key + webhook_secret = var.webhook_secret + }) +} + +################################################################################ +# Futures / portfolio market maker +################################################################################ +# deploy-col-mar-mm.yml runs the portfolio app on this service and injects +# private_key as PRIVATE_KEY. + +resource "aws_secretsmanager_secret" "futures_mm" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-futures-mm-secrets-v3-${substr(var.account_shortname, 8, 3)}" + description = "Portfolio market-maker secrets (signer key and Alchemy key)" + tags = merge(var.default_tags, var.foundation_tags, { + Name = "${local.shortname}-futures-mm-secrets-v3-${substr(var.account_shortname, 8, 3)}" + }) +} + +resource "aws_secretsmanager_secret_version" "futures_mm" { + count = var.futures_mm_service.create ? 1 : 0 + provider = aws.use1 + secret_id = aws_secretsmanager_secret.futures_mm[0].id + secret_string = jsonencode({ + private_key = var.futures_mm_private_key + alchemy_api_key = var.alchemy_api_key + }) +} + +################################################################################ +# Perps market maker +################################################################################ +# CI does not roll this service. The secret is here so the task definition +# can inject PRIVATE_KEY the same way, and so the key is not left in GitHub. + +resource "aws_secretsmanager_secret" "perps_mm" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + name = "${local.shortname}-perps-mm-secrets-v3-${substr(var.account_shortname, 8, 3)}" + description = "Perps market-maker secrets (signer key and Alchemy key)" + tags = merge(var.default_tags, var.foundation_tags, { + Name = "${local.shortname}-perps-mm-secrets-v3-${substr(var.account_shortname, 8, 3)}" + }) +} + +resource "aws_secretsmanager_secret_version" "perps_mm" { + count = var.perps_mm_service.create ? 1 : 0 + provider = aws.use1 + secret_id = aws_secretsmanager_secret.perps_mm[0].id + secret_string = jsonencode({ + private_key = var.perps_mm_private_key + alchemy_api_key = var.alchemy_api_key + }) +} diff --git a/.bedrock/.terragrunt/04_futures_mm_svc.tf b/.bedrock/.terragrunt/04_futures_mm_svc.tf index 47adb2a..8ae5831 100644 --- a/.bedrock/.terragrunt/04_futures_mm_svc.tf +++ b/.bedrock/.terragrunt/04_futures_mm_svc.tf @@ -3,7 +3,8 @@ ################################################################################ # Mirror of 04_perps_mm_svc.tf for MAKER_APP=futures. Same CI/CD-owned # personality model: Terraform builds infra, deploy-col-mar-mm.yml owns -# image / env vars / secrets / desired_count after first apply. +# image / public env vars / desired_count after first apply. +# PRIVATE_KEY and ALCHEMY_API_KEY are injected from Secrets Manager. # # Replaces the legacy futures market-maker Lambda (futures-marketplace, # 10_market_maker_lambda.tf). DNS name `futuresmm.{env}.hashpower.exchange` @@ -185,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 } } @@ -276,6 +293,17 @@ resource "aws_ecs_task_definition" "futures_mm_use1" { } ] + secrets = [ + { + name = "PRIVATE_KEY" + valueFrom = "${aws_secretsmanager_secret.futures_mm[0].arn}:private_key::" + }, + { + name = "ALCHEMY_API_KEY" + valueFrom = "${aws_secretsmanager_secret.futures_mm[0].arn}:alchemy_api_key::" + } + ] + logConfiguration = { logDriver = "awslogs" options = { diff --git a/.bedrock/.terragrunt/04_perps_mm_svc.tf b/.bedrock/.terragrunt/04_perps_mm_svc.tf index 65f4158..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 } } @@ -298,6 +314,17 @@ resource "aws_ecs_task_definition" "perps_mm_use1" { } ] + secrets = [ + { + name = "PRIVATE_KEY" + valueFrom = "${aws_secretsmanager_secret.perps_mm[0].arn}:private_key::" + }, + { + name = "ALCHEMY_API_KEY" + valueFrom = "${aws_secretsmanager_secret.perps_mm[0].arn}:alchemy_api_key::" + } + ] + logConfiguration = { logDriver = "awslogs" options = { diff --git a/.bedrock/.terragrunt/06_col_mar_keeper_svc.tf b/.bedrock/.terragrunt/06_col_mar_keeper_svc.tf index ac04cfd..da83887 100644 --- a/.bedrock/.terragrunt/06_col_mar_keeper_svc.tf +++ b/.bedrock/.terragrunt/06_col_mar_keeper_svc.tf @@ -4,8 +4,9 @@ # Replaces derivatives-marketplace perps-keeper (svc-perps-keeper-*). # One long-running task liquidates across vault, PME, perps, and futures. # -# deploy-keeper.yml owns image, env vars, secrets, and desired_count after -# the first CI/CD deploy. Terraform ships ALB + Route53 at keeper.{env}.* +# deploy-keeper.yml owns image, public env vars, and desired_count after +# the first CI/CD deploy. Private keys are injected from Secrets Manager. +# Terraform ships ALB + Route53 at keeper.{env}.* # (same hostname as the legacy perps keeper once that stack is destroyed). ################################################################################ @@ -183,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 } } @@ -272,6 +289,21 @@ resource "aws_ecs_task_definition" "keeper_use1" { } ] + secrets = [ + { + name = "LIQUIDATOR_PRIVATE_KEY" + valueFrom = "${aws_secretsmanager_secret.keeper[0].arn}:liquidator_private_key::" + }, + { + name = "ALCHEMY_API_KEY" + valueFrom = "${aws_secretsmanager_secret.keeper[0].arn}:alchemy_api_key::" + }, + { + name = "WEBHOOK_SECRET" + valueFrom = "${aws_secretsmanager_secret.keeper[0].arn}:webhook_secret::" + } + ] + logConfiguration = { logDriver = "awslogs" options = { diff --git a/.bedrock/02-dev/terraform.tfvars b/.bedrock/02-dev/terraform.tfvars index a3d4d55..c4d2dc7 100644 --- a/.bedrock/02-dev/terraform.tfvars +++ b/.bedrock/02-dev/terraform.tfvars @@ -1,17 +1,18 @@ ######################################## # Service Toggles - SCAFFOLDING ONLY ######################################## -# Runtime config (image, env vars, secrets, addresses) is owned by the -# deploy-col-mar-mm.yml workflow via GitHub Variables / GitHub Secrets. -# The maps below configure ONLY the immutable infrastructure shell. +# Public runtime config is config/dev.env. Private keys and the Alchemy key +# are Secrets Manager, seeded from gitignored secret.auto.tfvars: +# alchemy_api_key, liquidator_private_key, futures_mm_private_key, +# perps_mm_private_key, and optional webhook_secret. ######################################## create_core = true -# Perps Market Maker - active. Legacy derivatives perps MM was decommissioned -# in 2026-Q2; perpsmm.dev.hashpower.exchange now points at this stack's ALB. +# Unused. Quoting runs on the futures MM service (portfolio app). create=false +# removes this empty ECS service, its internal ALB, and perpsmm.dev.hashpower.exchange. perps_mm_service = { - create = true + create = false task_worker_qty = 1 # initial; CI/CD owns desired_count after first deploy cnt_port = 3001 task_cpu = 256 diff --git a/.bedrock/03-stg/dnsprovider.tf b/.bedrock/03-stg/dnsprovider.tf deleted file mode 100644 index c1f52e3..0000000 --- a/.bedrock/03-stg/dnsprovider.tf +++ /dev/null @@ -1,12 +0,0 @@ -########################## -# DNS Lookup specific profile -########################## -provider "aws" { - alias = "special-dns" - region = "us-east-1" - profile = var.provider_profile # or `titanio-prd` for DNS roots held by Old Prod account or `titanio-net` for DNS roots held by Bedrock - ignore_tags { - key_prefixes = ["kubernetes.io/"] - } -} - diff --git a/.bedrock/03-stg/terraform.tfvars b/.bedrock/03-stg/terraform.tfvars deleted file mode 100644 index a2e054d..0000000 --- a/.bedrock/03-stg/terraform.tfvars +++ /dev/null @@ -1,74 +0,0 @@ -######################################## -# Service Toggles - SCAFFOLDING ONLY -######################################## -# Runtime config (image, env vars, secrets, addresses) is owned by the -# deploy-col-mar-mm.yml workflow via GitHub Variables / GitHub Secrets. -######################################## - -create_core = true - -# Perps Market Maker - DNS NOTE: perpsmm.stg.hashpower.exchange currently -# belongs to derivatives-marketplace. Leave create=false until cutover. -perps_mm_service = { - create = true - task_worker_qty = 1 - cnt_port = 3001 - task_cpu = 256 - task_ram = 512 -} - -# Futures Market Maker -futures_mm_service = { - create = true - task_worker_qty = 1 - cnt_port = 3001 - task_cpu = 256 - task_ram = 512 -} - -keeper_service = { - create = false - task_worker_qty = 1 - cnt_port = 3000 - task_cpu = 256 - task_ram = 512 -} - -######################################## -# Account metadata -######################################## -provider_profile = "titanio-stg" -account_shortname = "titanio-stg" -account_number = "464450398935" -account_lifecycle = "stg" -default_region = "us-east-1" -region_shortname = "use1" - -######################################## -# Environment Specific Variables -######################################## -vpc_index = 1 -devops_keypair = "bedrock-titanio-stg-use1" -titanio_net_edge_vpn = "172.18.16.0/20" -protect_environment = false -ecs_task_role_arn = "arn:aws:iam::464450398935:role/ecsTaskExecutionRole" - -default_tags = { - ServiceOffering = "Cloud Foundation" - Department = "DevOps" - Environment = "stg" - Owner = "aws-titanio-stg@titan.io" - Scope = "Global" - CostCenter = null - Compliance = null - Classification = null - Repository = "https://github.com/Lumerin-protocol/collateral-margin.git//bedrock/03-stg" - ManagedBy = "Terraform" -} - -foundation_tags = { - Name = null - Capability = null - Application = "Lumerin Collateral Margin - STG" - LifecycleDate = null -} diff --git a/.bedrock/03-stg/terragrunt.hcl b/.bedrock/03-stg/terragrunt.hcl deleted file mode 100644 index 53a9143..0000000 --- a/.bedrock/03-stg/terragrunt.hcl +++ /dev/null @@ -1,3 +0,0 @@ -include "root" { - path = find_in_parent_folders("root.hcl") -} \ No newline at end of file diff --git a/.bedrock/04-lmn/terraform.tfvars b/.bedrock/04-lmn/terraform.tfvars index ad186ab..e3436bc 100644 --- a/.bedrock/04-lmn/terraform.tfvars +++ b/.bedrock/04-lmn/terraform.tfvars @@ -1,16 +1,18 @@ ######################################## # Service Toggles - SCAFFOLDING ONLY ######################################## -# Runtime config (image, env vars, secrets, addresses) is owned by the -# deploy-col-mar-mm.yml workflow via GitHub Variables / GitHub Secrets. +# Public runtime config is config/prd.env. Private keys and the Alchemy key +# are Secrets Manager, seeded from gitignored secret.auto.tfvars: +# alchemy_api_key, liquidator_private_key, futures_mm_private_key, +# perps_mm_private_key, and optional webhook_secret. ######################################## create_core = true -# Perps Market Maker - DNS NOTE: perpsmm.hashpower.exchange currently -# belongs to derivatives-marketplace. Leave create=false until cutover. +# Unused. Quoting runs on the futures MM service. Leave false so LMN does not +# build the empty perps service, its ALB, or perpsmm.hashpower.exchange. perps_mm_service = { - create = true + create = false task_worker_qty = 1 cnt_port = 3001 task_cpu = 256 @@ -27,7 +29,7 @@ futures_mm_service = { } keeper_service = { - create = false + create = true task_worker_qty = 1 cnt_port = 3000 task_cpu = 256 diff --git a/.github/workflows/deploy-col-mar-mm.yml b/.github/workflows/deploy-col-mar-mm.yml index b8698af..8a35597 100644 --- a/.github/workflows/deploy-col-mar-mm.yml +++ b/.github/workflows/deploy-col-mar-mm.yml @@ -10,8 +10,8 @@ name: Deploy Collateral Margin Market Maker # # 1. Builds one Docker image from market-maker/ and pushes to GHCR # 2. Renders a new task-def revision for the portfolio app (MAKER_APP=portfolio) -# with image + public config from config/.env + GitHub Secrets -# (no AWS Secrets Manager) +# with image + public config from config/.env. PRIVATE_KEY and +# ALCHEMY_API_KEY are injected by ECS from Secrets Manager (valueFrom). # 3. Calls ecs:UpdateService to point the service at the new revision # and to scale it to the operator-chosen desired_count # @@ -27,9 +27,9 @@ name: Deploy Collateral Margin Market Maker # # GitHub Secrets (configure per-environment): # AWS_ROLE_ARN_DEV / _LMN OIDC role ARNs from the TF output github_actions_role_arn -# ALCHEMY_API_KEY shared Alchemy project key (composes the RPC URL) -# FUTURES_MM_PRIVATE_KEY shared portfolio signer private key (both venues) # SLACK_WEBHOOK_URL (org or repo level) for slack-notify +# Signer and Alchemy keys live in Secrets Manager +# (`col-mar-futures-mm-secrets-v3-`), seeded from secret.auto.tfvars. on: push: @@ -235,18 +235,12 @@ jobs: TASK_FAMILY: ${{ needs.build.outputs.task_family }} REGION: ${{ needs.build.outputs.aws_region }} IMAGE: ${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }} + ENV_SUFFIX: ${{ needs.build.outputs.env_suffix }} # Deploy orchestration (not container config) MAKER_DESIRED_COUNT: ${{ vars.MAKER_DESIRED_COUNT }} - # Per-environment GitHub Secrets (encrypted personality). Secrets - # only flow into this step's process env; they are never written to - # step outputs or to disk on the runner. One shared signer runs both - # venues; we reuse the futures wallet key. - ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} - PRIVATE_KEY: ${{ secrets.FUTURES_MM_PRIVATE_KEY }} - - # Computed + # Computed. Signer and Alchemy come from Secrets Manager. MAKER_APP: portfolio MAKER_ENV: ${{ needs.build.outputs.maker_env }} COMMIT_HASH: ${{ github.sha }} @@ -278,19 +272,14 @@ jobs: .compatibilities, .registeredAt, .registeredBy) ' task-def.json > new-task-def.json - # Build the full env block from scratch. Personality lives ONLY here; - # the Terraform stub deliberately ships zero env vars. - # - # The block is every key declared in config/.env plus the - # secrets and computed values from the step env above. Sourcing - # cannot clobber a secret, because no secret is named in the config - # file. jq then reads values straight out of the environment, so each - # key is written exactly once: in the config file. - echo "πŸ”§ Injecting environment from ${CONFIG} + GitHub Secrets..." + # Public keys from config/.env plus values computed by this run. + # Secret names are stripped so they cannot land in the environment block. + echo "πŸ”§ Injecting environment from ${CONFIG}..." set -a && . "$CONFIG" && set +a KEYS=$( { sed -n 's/^[[:space:]]*\([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$CONFIG"; \ - printf '%s\n' MAKER_APP MAKER_ENV COMMIT_HASH ALCHEMY_API_KEY PRIVATE_KEY; } ) + printf '%s\n' MAKER_APP MAKER_ENV COMMIT_HASH; } \ + | grep -vxE 'ALCHEMY_API_KEY|LIQUIDATOR_PRIVATE_KEY|WEBHOOK_SECRET|PRIVATE_KEY|FUTURES_MM_PRIVATE_KEY|PERPS_MM_PRIVATE_KEY' || true ) # Empty values are dropped so the maker applies its own defaults # instead of parsing an empty string. @@ -305,11 +294,18 @@ jobs: echo " Container env keys: $(jq -r '[.[].name] | join(", ")' env-block.json)" - # Replace the entire env block; also drop any stale `secrets` block - # (we no longer use Secrets Manager). - jq --slurpfile env env-block.json ' + SECRET_ARN=$(aws secretsmanager describe-secret \ + --secret-id "col-mar-futures-mm-secrets-v3-${ENV_SUFFIX}" \ + --region "${REGION}" \ + --query ARN --output text) + echo " Secrets from: col-mar-futures-mm-secrets-v3-${ENV_SUFFIX}" + + jq --slurpfile env env-block.json --arg arn "$SECRET_ARN" ' .containerDefinitions[0].environment = $env[0] | - del(.containerDefinitions[0].secrets) + .containerDefinitions[0].secrets = [ + {"name":"PRIVATE_KEY","valueFrom":($arn + ":private_key::")}, + {"name":"ALCHEMY_API_KEY","valueFrom":($arn + ":alchemy_api_key::")} + ] ' new-task-def.json > final-task-def.json mv final-task-def.json new-task-def.json diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml index a95311a..24fae31 100644 --- a/.github/workflows/deploy-keeper.yml +++ b/.github/workflows/deploy-keeper.yml @@ -6,7 +6,10 @@ name: Deploy Collateral Margin Keeper # Terraform (.bedrock/.terragrunt/06_col_mar_keeper_svc.tf) builds ECS shell, # internal ALB, Route53 keeper.{env}.hashpower.exchange, and log group. # This workflow builds the image, pushes to GHCR, and registers task-def -# revisions with the runtime config from config/.env plus secrets. +# revisions with the public runtime config from config/.env. +# LIQUIDATOR_PRIVATE_KEY, ALCHEMY_API_KEY, and WEBHOOK_SECRET are injected +# by ECS from Secrets Manager (valueFrom). This workflow only resolves the +# secret ARN. # # Container config (NETWORK, contract addresses, log level, intervals, …) is # NOT declared here. It comes from config/dev.env and config/prd.env, which are @@ -17,10 +20,9 @@ name: Deploy Collateral Margin Keeper # KEEPER_DESIRED_COUNT default "1" (set "0" to halt) β€” deploy # orchestration, not container config # -# GitHub Secrets (per environment): -# ALCHEMY_API_KEY RPC (or set ETH_NODE_ADDRESS in config) -# LIQUIDATOR_PRIVATE_KEY same wallet as legacy perps keeper -# WEBHOOK_SECRET optional β€” Goldsky bearer token +# GitHub Secrets (per environment): none for the keeper process. +# Alchemy, the liquidator key, and WEBHOOK_SECRET live in Secrets Manager +# (`col-mar-keeper-secrets-v3-`), seeded from secret.auto.tfvars. # # Repository secrets (all environments): # AWS_ROLE_ARN_DEV / _LMN from terragrunt output github_actions_role_arn @@ -223,14 +225,12 @@ jobs: REGION: ${{ needs.build.outputs.aws_region }} IMAGE: ${{ env.GHCR_IMAGE }}:${{ needs.build.outputs.version }} CONFIG_ENV: ${{ needs.build.outputs.config_env }} + ENV_SUFFIX: ${{ needs.build.outputs.env_suffix }} - # Everything public lives in config/.env. Only secrets and - # values computed by this run are declared here. + # Public config comes from config/.env. Computed values only + # here. Signer, Alchemy, and webhook come from Secrets Manager. KEEPER_VERSION: ${{ needs.build.outputs.version }} KEEPER_DESIRED_COUNT: ${{ vars.KEEPER_DESIRED_COUNT }} - ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} - LIQUIDATOR_PRIVATE_KEY: ${{ secrets.LIQUIDATOR_PRIVATE_KEY }} - WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }} run: | set -euo pipefail CONFIG="config/${CONFIG_ENV}.env" @@ -256,15 +256,13 @@ jobs: .compatibilities, .registeredAt, .registeredBy) ' task-def.json > new-task-def.json - # The container environment is every key declared in config/.env - # plus the secrets and computed values from the step env above. - # Sourcing cannot clobber a secret, because no secret is named in the - # config file. jq then reads values straight out of the environment, - # so each key is written exactly once: in the config file. + # Public keys from config/.env plus KEEPER_VERSION. Secret names + # are stripped so they cannot land in the task definition environment. set -a && . "$CONFIG" && set +a KEYS=$( { sed -n 's/^[[:space:]]*\([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$CONFIG"; \ - printf '%s\n' KEEPER_VERSION ALCHEMY_API_KEY LIQUIDATOR_PRIVATE_KEY WEBHOOK_SECRET; } ) + printf '%s\n' KEEPER_VERSION; } \ + | grep -vxE 'ALCHEMY_API_KEY|LIQUIDATOR_PRIVATE_KEY|WEBHOOK_SECRET|PRIVATE_KEY' || true ) # Empty values are dropped so the keeper applies its own defaults # instead of parsing an empty string. @@ -279,9 +277,20 @@ jobs: echo " Container env keys: $(jq -r '[.[].name] | join(", ")' env-block.json)" - jq --slurpfile env env-block.json ' + SECRET_ARN=$(aws secretsmanager describe-secret \ + --secret-id "col-mar-keeper-secrets-v3-${ENV_SUFFIX}" \ + --region "${REGION}" \ + --query ARN --output text) + echo " Secrets from: col-mar-keeper-secrets-v3-${ENV_SUFFIX}" + + jq --slurpfile env env-block.json --arg arn "$SECRET_ARN" ' .containerDefinitions[0].environment = $env[0] | - del(.containerDefinitions[0].secrets, .containerDefinitions[0].command, .containerDefinitions[0].entryPoint) + .containerDefinitions[0].secrets = [ + {"name":"LIQUIDATOR_PRIVATE_KEY","valueFrom":($arn + ":liquidator_private_key::")}, + {"name":"ALCHEMY_API_KEY","valueFrom":($arn + ":alchemy_api_key::")}, + {"name":"WEBHOOK_SECRET","valueFrom":($arn + ":webhook_secret::")} + ] | + del(.containerDefinitions[0].command, .containerDefinitions[0].entryPoint) ' new-task-def.json > final-task-def.json NEW_TASK_DEF=$(aws ecs register-task-definition \ diff --git a/config/prd.env b/config/prd.env index 27573cf..8228df3 100644 --- a/config/prd.env +++ b/config/prd.env @@ -17,9 +17,11 @@ NETWORK=base # ── Contracts ────────────────────────────────────────────────────────────── BTC_USD_ADDRESS=0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F +BTC_USD_FEED_ADDRESS=0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F COLLATERAL_TOKEN_ADDRESS=0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 FUTURES_ADDRESS=0xf97a1bbfb5e061ef73dad8ebf25939d93639fb7f HASHPRICE_BTC_ADDRESS=0x70027c6f1b40e7461172af1241330b499c8c2e22 +HASHPRICE_USD_ADDRESS=0xf30a6489f20630bf5b1a76f0c56aadc364d031f3 PERPS_ADDRESS=0x794f9e63b7666985256f1d2763ee24cc0b528199 PME_ADDRESS=0x5F047CCE438ae7796140506a5edf3D711034aaF3 POINTS_ADDRESS=0x52e1b275d7f925e48f74d304e6d7e8ca489de6b9 @@ -46,3 +48,5 @@ 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