diff --git a/.github/workflows/deploy-keeper.yml b/.github/workflows/deploy-keeper.yml index d3c6138..ec1b2b8 100644 --- a/.github/workflows/deploy-keeper.yml +++ b/.github/workflows/deploy-keeper.yml @@ -447,35 +447,15 @@ jobs: fi echo "βœ… Service converged on the deployed revision." - - name: Smoke-test /health reports the deployed version - if: steps.rollout.outputs.diagnose == 'true' - env: - HEALTH_URL: ${{ needs.build.outputs.keeper_health_url }} - EXPECTED_VERSION: ${{ needs.build.outputs.version }} - run: | - set -euo pipefail - echo "πŸ”Ž GET ${HEALTH_URL} (expecting version=${EXPECTED_VERSION})" - - # Give the new task a moment to bind the health port, then poll. - ATTEMPTS=12 - for i in $(seq 1 "$ATTEMPTS"); do - BODY=$(curl -fsS -m 10 "$HEALTH_URL" 2>/dev/null || true) - if [ -n "$BODY" ]; then - STATUS=$(echo "$BODY" | jq -r '.status // "?"') - LIVE_VERSION=$(echo "$BODY" | jq -r '.info.version // "?"') - echo " attempt ${i}: status=${STATUS} version=${LIVE_VERSION}" - if [ "$STATUS" = "ok" ] && [ "$LIVE_VERSION" = "$EXPECTED_VERSION" ]; then - echo "βœ… /health is OK and serving the deployed version." - exit 0 - fi - else - echo " attempt ${i}: no response yet" - fi - sleep 10 - done - - echo "::error::/health never reported status=ok with version=${EXPECTED_VERSION}. Last body: ${BODY:-}" - exit 1 + # NOTE: There is intentionally no public HTTP smoke-test of /health here. + # The keeper sits behind an INTERNAL ALB (see + # .bedrock/.terragrunt/06_col_mar_keeper_svc.tf) that only accepts traffic + # from the VPC CIDR and VPN range β€” keeper.{env}.hashpower.exchange is not + # resolvable/reachable from GitHub-hosted runners on the public internet. + # Health is already proven above: the ALB target group runs a /health + # check, and ECS only reports rolloutState=COMPLETED once the new tasks + # pass it, which the rollout verification asserts alongside the exact + # deployed task-def ARN. - name: Diagnose failed rollout if: failure() && steps.rollout.outputs.diagnose == 'true' diff --git a/.github/workflows/keeper-test.yml b/.github/workflows/keeper-test.yml index 2af4944..9597f96 100644 --- a/.github/workflows/keeper-test.yml +++ b/.github/workflows/keeper-test.yml @@ -63,23 +63,18 @@ jobs: working-directory: ./contracts run: pnpm install --frozen-lockfile - # TEMPORARY: pinned to feat/liquidate-to-im-buffer because the keeper's - # liquidate-to-IM-buffer orchestration calls contract functions - # (`liquidatePositions` on futures, `liquidatePosition(user, closeQty)` on - # perps) that only exist on those feature branches. Revert both refs back - # to `dev` once derivatives-marketplace#73 and futures-marketplace#203 merge. - name: Checkout derivatives-marketplace (perps contracts) uses: actions/checkout@v5 with: repository: Lumerin-protocol/derivatives-marketplace - ref: feat/liquidate-to-im-buffer + ref: c8078b9e430df8a5ec8bc64865d692b38cc69d8c path: perps - name: Checkout futures-marketplace uses: actions/checkout@v5 with: repository: Lumerin-protocol/futures-marketplace - ref: feat/liquidate-to-im-buffer + ref: 5e28bb346b125d90eb138370a8a78c69487d1c26 path: futures-marketplace - name: Install perps contracts dependencies diff --git a/contracts/contracts/PortfolioMarginEngine.sol b/contracts/contracts/PortfolioMarginEngine.sol index bf64c97..2e4c2ca 100644 --- a/contracts/contracts/PortfolioMarginEngine.sol +++ b/contracts/contracts/PortfolioMarginEngine.sol @@ -194,7 +194,8 @@ contract PortfolioMarginEngine is netDelta += pos.netQuantity * int256(WAD) / qtyScale; } - // Futures delta: sum(deliveryDurationDays * qty) * WAD per active position (optional) + // Futures delta: sum(Β±qty) * WAD per active position β€” one WAD per contract + // (1 PH/s/day), sign per side. No duration multiplier. (optional) if (address(futures) != address(0)) { netDelta += futures.getNetPositionDelta(user); } diff --git a/indexer/package.json b/indexer/package.json index 11b6e94..1972e8e 100644 --- a/indexer/package.json +++ b/indexer/package.json @@ -16,7 +16,7 @@ "remove-local": "graph remove --node http://localhost:8020/ collateral-vault", "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 --version-label 0 collateral-vault", "setup-local": "pnpm prepare-local && pnpm codegen && pnpm build && pnpm create-local && pnpm deploy-local", - "test": "graph test", + "test": "graph test -v 0.6.0", "indexer": "docker compose --env-file ../.env up", "graph:api": "open http://localhost:8030/graphql/playground" }, diff --git a/keeper/README.md b/keeper/README.md index addbf19..ecba36e 100644 --- a/keeper/README.md +++ b/keeper/README.md @@ -216,14 +216,14 @@ Suites cover: - `pme/health` β€” multicall batching + `imUtilization` precision - `venues/perps` β€” long/short PnL math, `PERPS_MARKET_ID` sentinel, position id -- `venues/futures` β€” buyer/seller PnL, `deliveryAt` β†’ marketId, `deliveryDurationDays` caching +- `venues/futures` β€” buyer/seller PnL (one contract = 1 PH/sΒ·day, duration-free), `deliveryAt` β†’ marketId - `coordinator/queue` β€” BigInt-safe ordering, `upsert` re-ranking, snapshot semantics - `coordinator/planner` β€” orders-leg, position ranking, `OrdersStillOpen`-replay, bad-debt - `alert/notifier` β€” dedupe window, severity promotion, ordering, retry-on-failure - `discovery/tracker` β€” checksum dedupe, `onAdded` / `onChanged` listeners, startup backfill - `discovery/webhook` β€” payload extraction across `data` / `records` / array shapes - `runtime/scheduler` β€” alert ladder thresholds, queue upsert + executor kick wiring -- `oracle/priceFeed` β€” rebase to token decimals, dispatch, no-op on unchanged answer +- `oracle/priceFeed` β€” rebase to token decimals + contract-size unit (`CONTRACT_SIZE_HPS_DAY / ORACLE_UNIT_HPS_DAY`, Γ—10 at defaults), dispatch, no-op on unchanged answer - `predict/mm` β€” net delta, stress, perp/futures unrealized loss, mm/im surplus - `predict/solve` β€” long/short downside & upside thresholds, drag from orderMargin/funding - `predict/predictiveIndex` β€” upsert/invalidate, sorted crossings on rise & drop diff --git a/keeper/package.json b/keeper/package.json index 3b54b66..93ad925 100644 --- a/keeper/package.json +++ b/keeper/package.json @@ -21,8 +21,8 @@ "dependencies": { "amaro": "^1.1.9", "collateral-margin-abi": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi", - "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#feat/liquidate-to-im-buffer&path:/contracts/abi", - "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#feat/liquidate-to-im-buffer&path:/contracts/abi", + "derivatives-marketplace-abi": "github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi", + "futures-marketplace-abi": "github:Lumerin-protocol/futures-marketplace#5e28bb346b125d90eb138370a8a78c69487d1c26&path:/contracts/abi", "pino": "^10.3.1", "viem": "^2.48.8" }, diff --git a/keeper/pnpm-lock.yaml b/keeper/pnpm-lock.yaml index 0a1becd..d1efb4f 100644 --- a/keeper/pnpm-lock.yaml +++ b/keeper/pnpm-lock.yaml @@ -20,11 +20,11 @@ importers: specifier: github:Lumerin-protocol/collateral-margin#dev&path:/contracts/abi version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/4ba5a84b54a01c37f4a92e9f30fa0ff2281c6855#path:/contracts/abi(patch_hash=7c97e1895133c2310ee81b6c24248a113d66b141bfa49a51d5b3ed468df4045d) derivatives-marketplace-abi: - specifier: github:Lumerin-protocol/derivatives-marketplace#feat/liquidate-to-im-buffer&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) + specifier: github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d) futures-marketplace-abi: - specifier: github:Lumerin-protocol/futures-marketplace#feat/liquidate-to-im-buffer&path:/contracts/abi - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a6484385fc64dee9be9bad992e9c6fa4e6013175#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) + specifier: github:Lumerin-protocol/futures-marketplace#5e28bb346b125d90eb138370a8a78c69487d1c26&path:/contracts/abi + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a) pino: specifier: ^10.3.1 version: 10.3.1 @@ -150,8 +150,8 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} - derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c} version: 0.0.0 end-of-stream@1.4.5: @@ -166,8 +166,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a6484385fc64dee9be9bad992e9c6fa4e6013175#path:/contracts/abi: - resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a6484385fc64dee9be9bad992e9c6fa4e6013175} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts/abi: + resolution: {gitHosted: true, path: /contracts/abi, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26} version: 0.0.0 help-me@5.0.0: @@ -352,7 +352,7 @@ snapshots: dateformat@4.6.3: {} - derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/085d5a87d5bd7e5ee5a18e9dfd1315230c4d268c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} + derivatives-marketplace-abi@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts/abi(patch_hash=96cf5481d03a402c55577d18ce7e4576a0252323c61d0b1f48f6fec7d7001c2d): {} end-of-stream@1.4.5: dependencies: @@ -364,7 +364,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/a6484385fc64dee9be9bad992e9c6fa4e6013175#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} + futures-marketplace-abi@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts/abi(patch_hash=834111deab90972a9bdd88062197b2e3b180844df8a46e300657b1160df2723a): {} help-me@5.0.0: {} diff --git a/keeper/scripts/debug-delivery-bootstrap.ts b/keeper/scripts/debug-delivery-bootstrap.ts index 8600441..b3ef811 100644 --- a/keeper/scripts/debug-delivery-bootstrap.ts +++ b/keeper/scripts/debug-delivery-bootstrap.ts @@ -73,15 +73,15 @@ const now = BigInt(Math.floor(Date.now() / 1000)); const block = await client.getBlock(); console.log("wall-clock now:", now, " block.timestamp:", block.timestamp); -const deliveryDurationDays = (await client.readContract({ +const expirationIntervalDays = (await client.readContract({ address: FUTURES, abi: FuturesAbi, - functionName: "deliveryDurationDays", + functionName: "expirationIntervalDays", })) as number; -const window = BigInt(deliveryDurationDays) * 86_400n; +const window = BigInt(expirationIntervalDays) * 86_400n; console.log( - "deliveryDurationDays:", - deliveryDurationDays, + "expirationIntervalDays:", + expirationIntervalDays, "β†’ window:", window, "s", diff --git a/keeper/src/oracle/priceFeed.ts b/keeper/src/oracle/priceFeed.ts index 76f3443..f262744 100644 --- a/keeper/src/oracle/priceFeed.ts +++ b/keeper/src/oracle/priceFeed.ts @@ -1,3 +1,4 @@ +import { HashPowerPerpsDEXAbi } from "derivatives-marketplace-abi/HashPowerPerpsDEX.ts"; import type pino from "pino"; import type { Chain } from "../chain.ts"; import type { Config } from "../config.ts"; @@ -40,6 +41,12 @@ export type PriceListener = (update: PriceUpdate) => void; * we rescale to the perps/futures token decimals (USDC = 6) so consumers * compare apples to apples with `getMarketPrice()`. * + * The oracle quotes the price of `ORACLE_UNIT_HPS_DAY` (100 TH/s over a day), but the + * venues denominate one contract in `contractSizeHpsDay` (default 1e15 = 1 PH/s over a + * day). We read that contract-size multiplier from the perps venue once at `start()` and + * apply `contractSizeHpsDay / ORACLE_UNIT_HPS_DAY` so the streamed price matches on-chain + * `getMarketPrice()`. Both venues are assumed to share the same contract size. + * * Lifecycle: * - `start()`: read decimals, prime `current` via one `latestRoundData`, * then attach the watcher. Returns once the first read has resolved. @@ -53,6 +60,10 @@ export class PriceFeed { private unwatch: (() => void) | undefined; /** 10^(oracleDecimals - tokenDecimals). Set during `start()`. */ private rescaleDivisor: bigint = 1n; + /** Contract size in hashes/sΒ·day (`contractSizeHpsDay`). Set during `start()`. */ + private contractSizeHpsDay: bigint = 1n; + /** Oracle quote basis in hashes/sΒ·day (`ORACLE_UNIT_HPS_DAY`). Set during `start()`. */ + private oracleUnitHpsDay: bigint = 1n; private readonly chain: Chain; private readonly config: Config; @@ -91,6 +102,28 @@ export class PriceFeed { } this.rescaleDivisor = 10n ** BigInt(oracleDecimals - this.tokenDecimals); + // Rebase from the oracle's quote basis (100 TH/s/day) to one contract unit + // (contractSizeHpsDay/day), matching `getMarketPrice()` on-chain. + const [contractSizeHpsDay, oracleUnitHpsDay] = await Promise.all([ + this.chain.publicClient.readContract({ + address: this.config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "CONTRACT_SIZE_HPS_DAY", + }) as Promise, + this.chain.publicClient.readContract({ + address: this.config.perps.address, + abi: HashPowerPerpsDEXAbi, + functionName: "ORACLE_UNIT_HPS_DAY", + }) as Promise, + ]); + if (contractSizeHpsDay <= 0n || oracleUnitHpsDay <= 0n) { + throw new Error( + `PriceFeed: invalid contract size (contractSizeHpsDay=${contractSizeHpsDay}, ORACLE_UNIT_HPS_DAY=${oracleUnitHpsDay})`, + ); + } + this.contractSizeHpsDay = contractSizeHpsDay; + this.oracleUnitHpsDay = oracleUnitHpsDay; + await this.refresh("start"); // We watch BTC/USDC (not HashpriceUSD) because HashpriceUSD is a pure @@ -114,6 +147,8 @@ export class PriceFeed { btcUsdcFeed: this.config.oracle.btcUsdcFeedAddress, oracleDecimals, tokenDecimals: this.tokenDecimals, + contractSizeHpsDay: this.contractSizeHpsDay, + oracleUnitHpsDay: this.oracleUnitHpsDay, currentPrice: this.currentPrice, }, "PriceFeed started", @@ -164,7 +199,9 @@ export class PriceFeed { return; } - const next = answer / this.rescaleDivisor; + // Mirror on-chain `getMarketPrice()`: rebase decimals first, then apply the + // contract-size multiplier (contractSizeHpsDay / ORACLE_UNIT_HPS_DAY). + const next = ((answer / this.rescaleDivisor) * this.contractSizeHpsDay) / this.oracleUnitHpsDay; const prev = this.currentPrice; if (prev === next) return; diff --git a/keeper/src/predict/mm.ts b/keeper/src/predict/mm.ts index 5b56c97..5e37f9a 100644 --- a/keeper/src/predict/mm.ts +++ b/keeper/src/predict/mm.ts @@ -18,7 +18,7 @@ import type { AccountSnapshot, MMParams } from "./types.ts"; * - perp.orderMargin (constant) * - perp.unrealizedLoss = max(0, -((P - entry) * netQty / qtyScale)) * - futures.orderMargin (constant) - * - futures.unrealizedLoss = sum_i max(0, -(buyer? : Β±)(P - entry_i)*deliveryDays) + * - futures.unrealizedLoss = sum_i max(0, -(buyer? : Β±)(P - entry_i)) * - perp.fundingOwed (constant β€” short-term, refreshed on snapshot) * * Total mmRequired(P) is therefore piecewise-linear with kinks at the @@ -40,7 +40,7 @@ function abs(x: bigint): bigint { * Aggregate net delta in WAD (matches `_aggregateGreeks` for pure-delta). * * perpDelta = perpNetQty * WAD / 10^perpQtyDecimals - * futuresDelta = sum_i (isBuyer ? +1 : -1) * deliveryDays * WAD + * futuresDelta = sum_i (isBuyer ? +1 : -1) * WAD * * Note: the on-chain `getNetPositionDelta` already returns this sum for the * futures leg in WAD; we re-derive it here off-chain because the snapshot @@ -52,7 +52,7 @@ export function netDeltaWad(snap: AccountSnapshot, params: MMParams): bigint { let delta = (snap.perp.netQty * WAD) / perpQtyScale; for (const pos of snap.futures.positions) { const sign = pos.isBuyer ? 1n : -1n; - delta += sign * snap.futures.deliveryDays * WAD; + delta += sign * WAD; } return delta; } @@ -99,17 +99,18 @@ export function perpUnrealizedLoss(snap: AccountSnapshot, params: MMParams, P: b } /** - * Sum of per-position futures unrealized losses at price P. Each contract: + * Sum of per-position futures unrealized losses at price P. Each contract + * settles `pricePerDay` of notional (no duration multiplier): * * diffPerDay = isBuyer ? (P - entryPerDay) : (entryPerDay - P) - * pnl = diffPerDay * deliveryDays + * pnl = diffPerDay * loss = max(0, -pnl) */ export function futuresUnrealizedLoss(snap: AccountSnapshot, P: bigint): bigint { let sum = 0n; for (const pos of snap.futures.positions) { const diffPerDay = pos.isBuyer ? P - pos.entryPricePerDay : pos.entryPricePerDay - P; - const pnl = diffPerDay * snap.futures.deliveryDays; + const pnl = diffPerDay; if (pnl < 0n) sum += -pnl; } return sum; diff --git a/keeper/src/predict/snapshot.ts b/keeper/src/predict/snapshot.ts index 4afbcbd..d62f35f 100644 --- a/keeper/src/predict/snapshot.ts +++ b/keeper/src/predict/snapshot.ts @@ -56,7 +56,7 @@ export async function readMMParams( * function of price. Two RPC round-trips: * * 1. Bulk multicall: balance, perp position/orderMargin/funding, - * futures orderMargin/positionIds, deliveryDurationDays. + * futures orderMargin/positionIds. * 2. Per-position multicall: hydrate each futures position so we know its * `(buyer, buyPricePerDay, sellPricePerDay)` for off-chain PnL. * @@ -75,7 +75,6 @@ export async function readAccountSnapshot( perpFunding, futuresOrderMargin, futuresPositionIds, - deliveryDurationDays, ] = await chain.publicClient.multicall({ contracts: [ { @@ -114,11 +113,6 @@ export async function readAccountSnapshot( functionName: "getPositionIds" as const, args: [user] as const, }, - { - address: config.futures.address, - abi: FuturesAbi, - functionName: "deliveryDurationDays" as const, - }, ] as const, allowFailure: false, }); @@ -164,7 +158,6 @@ export async function readAccountSnapshot( futures: { positions: futuresPositions, orderMargin: futuresOrderMargin as bigint, - deliveryDays: BigInt(deliveryDurationDays as number), }, }; } diff --git a/keeper/src/predict/solve.ts b/keeper/src/predict/solve.ts index 17c3abe..01ef95e 100644 --- a/keeper/src/predict/solve.ts +++ b/keeper/src/predict/solve.ts @@ -208,7 +208,7 @@ export function simulateFuturesClose( const diffPerDay = pos.isBuyer ? currentPrice - pos.entryPricePerDay : pos.entryPricePerDay - currentPrice; - const pnl = diffPerDay * snap.futures.deliveryDays; + const pnl = diffPerDay; balanceDelta += pnl - liquidationFee; } return { @@ -281,11 +281,7 @@ export function solveFuturesLotsToTarget( // shapes WHICH lots that tx closes. The prefix search below is unchanged, so // we still stop at the deepest in-band subset (reaching IM stays the // priority β€” balance is best-effort within that). - const ranked = rankLotsBalancedAcrossExpirations( - positions, - snap.futures.deliveryDays, - currentPrice, - ); + const ranked = rankLotsBalancedAcrossExpirations(positions, currentPrice); const n = ranked.length; let best: Hex[] | undefined; @@ -394,11 +390,10 @@ type FuturesLot = AccountSnapshot["futures"]["positions"][number]; */ function rankLotsBalancedAcrossExpirations( positions: readonly FuturesLot[], - deliveryDays: bigint, currentPrice: bigint, ): FuturesLot[] { - const lossOf = (p: FuturesLot) => lotUnrealizedLoss(p, deliveryDays, currentPrice); - const notionalOf = (p: FuturesLot) => p.entryPricePerDay * deliveryDays; + const lossOf = (p: FuturesLot) => lotUnrealizedLoss(p, currentPrice); + const notionalOf = (p: FuturesLot) => p.entryPricePerDay; const groups = new Map(); for (const p of positions) { @@ -442,11 +437,10 @@ function rankLotsBalancedAcrossExpirations( /** Per-lot unrealized loss at `P` (token decimals); 0 when in profit. */ function lotUnrealizedLoss( pos: AccountSnapshot["futures"]["positions"][number], - deliveryDays: bigint, P: bigint, ): bigint { const diffPerDay = pos.isBuyer ? P - pos.entryPricePerDay : pos.entryPricePerDay - P; - const pnl = diffPerDay * deliveryDays; + const pnl = diffPerDay; return pnl < 0n ? -pnl : 0n; } diff --git a/keeper/src/predict/types.ts b/keeper/src/predict/types.ts index 00a5537..1d5b201 100644 --- a/keeper/src/predict/types.ts +++ b/keeper/src/predict/types.ts @@ -31,9 +31,10 @@ export interface AccountSnapshot { }; /** - * One entry per active futures position. Each contract is a single unit; - * PnL accrues `(P_perDay - entryPricePerDay) Γ— deliveryDays` from the - * holder's perspective (`+` for buyers, `βˆ’` for sellers). + * One entry per active futures position. Each contract is a single unit that + * settles `pricePerDay` of notional (no duration multiplier); PnL accrues + * `(P_perDay - entryPricePerDay)` from the holder's perspective (`+` for + * buyers, `βˆ’` for sellers). */ futures: { positions: Array<{ @@ -46,15 +47,12 @@ export interface AccountSnapshot { * a `deliveryAt` are the same market/order-book; the liquidation solver * groups on it to balance closures across expirations rather than * draining one expiry's book. It does NOT affect PnL/margin math β€” every - * lot is valued with the single global `deliveryDays` (mirroring the - * on-chain `deliveryDurationDays`). + * lot contributes a single unit. */ deliveryAt: bigint; }>; /** Constant in P: `getFuturesOrderMargin(user)`. */ orderMargin: bigint; - /** Same delivery duration applies to every active position. */ - deliveryDays: bigint; }; } diff --git a/keeper/src/venues/futures.ts b/keeper/src/venues/futures.ts index cfbb9e7..550212e 100644 --- a/keeper/src/venues/futures.ts +++ b/keeper/src/venues/futures.ts @@ -20,11 +20,9 @@ import type { /** * `Venue` adapter for the Futures contract. * - * Caches `deliveryDurationDays` lazily on first use: the contract setting - * is immutable within an epoch and only ever ratchets on admin action, so - * we read it once per process and re-read after restart. Position PnL math - * uses this value as a multiplier (`priceDiffPerDay * deliveryDurationDays`) - * β€” caching it keeps `readPositions` to one RPC + one multicall. + * One matched unit settles `pricePerDay` of notional (there is no duration + * multiplier). Position PnL is therefore just `priceDiffPerDay` per contract, + * mirroring `getFuturesUnrealizedPnl` on-chain. */ export class FuturesVenue implements Venue { readonly name = "futures" as const; @@ -33,7 +31,6 @@ export class FuturesVenue implements Venue { private readonly config: Config; private readonly logger: pino.Logger; private readonly ethUsdFeed: EthUsdFeed | undefined; - private deliveryDurationDays: bigint | undefined; private mmParams: MMParams | undefined; constructor( @@ -89,7 +86,7 @@ export class FuturesVenue implements Venue { } async readPositions(user: Address): Promise { - const [positionIds, marketPrice, deliveryDurationDays] = await Promise.all([ + const [positionIds, marketPrice] = await Promise.all([ this.chain.publicClient.readContract({ address: this.config.futures.address, abi: FuturesAbi, @@ -101,7 +98,6 @@ export class FuturesVenue implements Venue { abi: FuturesAbi, functionName: "getMarketPrice", }) as Promise, - this.getDeliveryDurationDays(), ]); if (positionIds.length === 0) return []; @@ -119,8 +115,8 @@ export class FuturesVenue implements Venue { const userAddr = getAddress(user); return positionIds.map((id, i) => { const pos = positions[i]; - // Each position is a single contract; PnL accrues per day across the - // full delivery window (matches `getFuturesUnrealizedPnl` on-chain). + // Each position is a single contract that settles `pricePerDay` of notional + // (no duration multiplier), matching `getFuturesUnrealizedPnl` on-chain. const isBuyer = getAddress(pos.buyer) === userAddr; const entryPricePerDay = isBuyer ? pos.buyPricePerDay @@ -128,9 +124,9 @@ export class FuturesVenue implements Venue { const priceDiffPerDay = isBuyer ? marketPrice - entryPricePerDay // long: lose when market drops : entryPricePerDay - marketPrice; // short: lose when market rises - const pnl = priceDiffPerDay * deliveryDurationDays; + const pnl = priceDiffPerDay; const unrealizedLoss = pnl < 0n ? -pnl : 0n; - const notional = entryPricePerDay * deliveryDurationDays; + const notional = entryPricePerDay; return { id, @@ -235,22 +231,6 @@ export class FuturesVenue implements Venue { return this.mmParams; } - /** - * Read `deliveryDurationDays` lazily and cache it. The contract returns - * `uint8` (decoded as `number`); we widen to `bigint` so downstream - * arithmetic stays in bigint land. - */ - private async getDeliveryDurationDays(): Promise { - if (this.deliveryDurationDays !== undefined) - return this.deliveryDurationDays; - const days = (await this.chain.publicClient.readContract({ - address: this.config.futures.address, - abi: FuturesAbi, - functionName: "deliveryDurationDays", - })) as number; - this.deliveryDurationDays = BigInt(days); - return this.deliveryDurationDays; - } } /** `bytes32(uint256(deliveryAt))` β€” same encoding the indexer uses. */ diff --git a/keeper/tests/delivery/coordinator.test.ts b/keeper/tests/delivery/coordinator.test.ts index 49262ed..f4d8981 100644 --- a/keeper/tests/delivery/coordinator.test.ts +++ b/keeper/tests/delivery/coordinator.test.ts @@ -94,8 +94,6 @@ function lotClosedLog(lotId: Hex): LotClosedLog { interface ChainStubOptions { /** Fixed `block.timestamp`-style number returned by `getBlockNumber`. */ blockNumber?: bigint; - /** Value `deliveryDurationDays` returns (uint8 β†’ number). Defaults to 7. */ - deliveryDurationDays?: number; /** Recorded calls to `simulateContract`. The handler is per-call so tests can vary outcomes. */ simulate?: (args: readonly unknown[]) => { request: { ok: true } } | { error: unknown }; writeHash?: `0x${string}`; @@ -148,7 +146,6 @@ function makeChain(opts: ChainStubOptions = {}): Chain & { functionName: string; args?: readonly unknown[]; }) => { - if (functionName === "deliveryDurationDays") return opts.deliveryDurationDays ?? 7; // start()'s pre-flight asserts the keeper signer == validator. Default // matches `VALIDATOR` (the chain stub's account.address), so existing // tests don't need to opt into anything. Set `validator: 0x...other` @@ -907,7 +904,7 @@ describe("DeliveryCoordinator: view-based discovery", () => { const coordinator = new DeliveryCoordinator(chain, makeConfig(), silentLogger); await coordinator.start(); // Force getPositionIds to blow up *after* startup (which uses readContract - // for `deliveryDurationDays`). The listener path must never throw β€” + // for `validatorAddress`). The listener path must never throw β€” // bubbling out would crash the tracker's onAdded fan-out. chain.publicClient.readContract = (async () => { throw new Error("rpc down"); diff --git a/keeper/tests/integration/deployStack.ts b/keeper/tests/integration/deployStack.ts index 86e8070..e9c9c2e 100644 --- a/keeper/tests/integration/deployStack.ts +++ b/keeper/tests/integration/deployStack.ts @@ -92,7 +92,20 @@ export interface DeployedStack { config: { tokenDecimals: number; oracleDecimals: number; + /** + * Raw hashprice oracle answer (per 100 TH/sΒ·day, i.e. `ORACLE_UNIT_HPS_DAY`). + * Both venues rebase this to a per-contract mark via + * `market = answer Γ— CONTRACT_SIZE_HPS_DAY / ORACLE_UNIT_HPS_DAY` (= Γ—10), + * so this seed is `initialMarketPrice / 10`. Fixtures that need to re-post + * the oracle (e.g. delivery settlement) write this value directly. + */ initialHashprice: bigint; + /** + * Per-contract mark at deploy time (= `initialHashprice Γ— 10`). This is the + * unit orders and positions are denominated in β€” scenarios use it as the + * at-the-money entry price. + */ + initialMarketPrice: bigint; initialBtcUsdc: bigint; minimumPriceIncrement: bigint; quantityDecimals: number; @@ -101,7 +114,6 @@ export interface DeployedStack { perpsMakerFeeBps: bigint; futuresTakerFee: bigint; futuresLiquidationFee: bigint; - futuresDeliveryDurationDays: number; futuresFirstDeliveryDate: bigint; insuranceFund: bigint; initialUserBalance: bigint; @@ -112,8 +124,25 @@ const TOKEN_DECIMALS = 6; const ORACLE_DECIMALS = 6; const QUANTITY_DECIMALS = 6; -/** Hashprice = $4.21 / 100 TH/s / day (recent Braiins index), 6 decimals. */ -const INITIAL_HASHPRICE = parseUnits("4.21", ORACLE_DECIMALS); +/** + * Ratio by which both venues rebase the oracle answer into a per-contract mark: + * `CONTRACT_SIZE_HPS_DAY / ORACLE_UNIT_HPS_DAY = 1e15 / 1e14 = 10`. The oracle + * quotes 100 TH/sΒ·day; one contract settles 1 PH/sΒ·day, so the mark is Γ—10 the + * raw answer. Exported so scenarios convert market prices β†’ oracle answers in + * one place. + */ +export const ORACLE_TO_MARKET_MULTIPLIER = 10n; + +/** + * Per-contract mark at deploy time. Positions and orders are denominated in this + * (contract) unit; the oracle answer is seeded at `/ ORACLE_TO_MARKET_MULTIPLIER` + * so `getMarketPrice()` (answer Γ— 10) lands back here. Kept at $4.21 so the + * pre-existing perps fixtures (which never carried the duration factor) keep + * their dollar sizing unchanged. + */ +const INITIAL_MARKET_PRICE = parseUnits("4.21", TOKEN_DECIMALS); +/** Raw hashprice oracle answer (per 100 TH/sΒ·day) β€” rebased Γ—10 into the mark above. */ +const INITIAL_HASHPRICE = INITIAL_MARKET_PRICE / ORACLE_TO_MARKET_MULTIPLIER; /** Reference BTC/USDC mid-price; only the *delta* matters for predictor tests. */ const INITIAL_BTC_USDC = parseUnits("65000", ORACLE_DECIMALS); @@ -124,10 +153,9 @@ const PERPS_MAKER_FEE_BPS = 0n; const FUTURES_TAKER_FEE = parseUnits("1", TOKEN_DECIMALS); const FUTURES_LIQUIDATION_FEE = parseUnits("1", TOKEN_DECIMALS); const FUTURES_LIQUIDATION_MARGIN_PCT = 20; -const FUTURES_DELIVERY_DURATION_DAYS = 7; -const FUTURES_DELIVERY_INTERVAL_DAYS = 7; +/** Spacing, in days, between successive expiries (renamed from delivery interval). */ +const FUTURES_EXPIRATION_INTERVAL_DAYS = 7; const FUTURES_FUTURE_DELIVERY_DATES_COUNT = 10; -const FUTURES_SPEED_HPS = parseUnits("100", 12); const INSURANCE_FUND = parseUnits("100000", TOKEN_DECIMALS); const INITIAL_USER_BALANCE = parseUnits("10000", TOKEN_DECIMALS); @@ -223,8 +251,12 @@ export async function deployStack(rpcUrl: string): Promise { vault, ]); const latestBlock = await publicClient.getBlock(); + // First expiry sits one interval out from now (the duration constant is gone β€” + // hashpower settles per-day, so only the expiry spacing schedules the book). const firstDeliveryDate = - latestBlock.timestamp + BigInt(FUTURES_DELIVERY_DURATION_DAYS * 24 * 3600); + latestBlock.timestamp + BigInt(FUTURES_EXPIRATION_INTERVAL_DAYS * 24 * 3600); + // initialize(hashrateOracle, liquidationMarginPercent, minimumPriceIncrement, + // expirationIntervalDays, futureDeliveryDatesCount, firstFutureDeliveryDate) const futures = await deployProxy( publicClient, owner.client, @@ -234,10 +266,8 @@ export async function deployStack(rpcUrl: string): Promise { [ hashpriceOracle, FUTURES_LIQUIDATION_MARGIN_PCT, - FUTURES_SPEED_HPS, MIN_PRICE_INCREMENT, - FUTURES_DELIVERY_DURATION_DAYS, - FUTURES_DELIVERY_INTERVAL_DAYS, + FUTURES_EXPIRATION_INTERVAL_DAYS, FUTURES_FUTURE_DELIVERY_DATES_COUNT, firstDeliveryDate, ], @@ -389,6 +419,7 @@ export async function deployStack(rpcUrl: string): Promise { tokenDecimals: TOKEN_DECIMALS, oracleDecimals: ORACLE_DECIMALS, initialHashprice: INITIAL_HASHPRICE, + initialMarketPrice: INITIAL_MARKET_PRICE, initialBtcUsdc: INITIAL_BTC_USDC, minimumPriceIncrement: MIN_PRICE_INCREMENT, quantityDecimals: QUANTITY_DECIMALS, @@ -397,7 +428,6 @@ export async function deployStack(rpcUrl: string): Promise { perpsMakerFeeBps: PERPS_MAKER_FEE_BPS, futuresTakerFee: FUTURES_TAKER_FEE, futuresLiquidationFee: FUTURES_LIQUIDATION_FEE, - futuresDeliveryDurationDays: FUTURES_DELIVERY_DURATION_DAYS, futuresFirstDeliveryDate: firstDeliveryDate, insuranceFund: INSURANCE_FUND, initialUserBalance: INITIAL_USER_BALANCE, diff --git a/keeper/tests/integration/keeper.integration.test.ts b/keeper/tests/integration/keeper.integration.test.ts index 06029e1..692ff35 100644 --- a/keeper/tests/integration/keeper.integration.test.ts +++ b/keeper/tests/integration/keeper.integration.test.ts @@ -307,8 +307,9 @@ describe("Futures liquidation", () => { { timeout: 60_000 }, async () => { // Precondition: alice holds a single long futures contract at the - // first delivery date. The unrealized loss is `(entryPrice βˆ’ marketPrice) - // Β· deliveryDurationDays Β· qty`; sized so the deposit can't cover it. + // first delivery date. Duration-free: the unrealized loss is + // `(entryPrice βˆ’ marketPrice) Β· qty` (multiplier of 1); sized so the + // deposit can't cover it. const ctx = await loadFixture(futuresLongCrashFixture, testClient); keeper = buildKeeper(ctx); await keeper.start(); @@ -382,7 +383,7 @@ describe("Liquidate down to the IM buffer", () => { { timeout: 60_000 }, async () => { // Precondition: alice holds 12 long futures lots; a moderate crash - // (4.21 β†’ 3.90) breaks MM but a subset close restores the IM buffer. + // ($40 β†’ $30 mark) breaks MM but a subset close restores the IM buffer. // Contract under test (the screenshot bug fix): the planner must NOT // fan out into one-lot-per-tx churn. Instead a single // `liquidatePositions(user, ids[])` closes the worst-first subset in @@ -432,10 +433,10 @@ describe("Liquidate down to the IM buffer", () => { { timeout: 60_000 }, async () => { // Precondition: alice holds 6 long futures lots on EACH of two delivery - // dates (12 total). The moderate crash (4.21 β†’ 3.90) breaks MM; because - // the risk model weights every lot by the same global - // `deliveryDurationDays`, the aggregate margin equals the single-expiry - // 12-lot case, so a worst-first subset restores the IM buffer. + // dates (12 total). The moderate crash ($40 β†’ $30 mark) breaks MM; because + // the duration-free risk model weights every lot by the same per-day value + // (Β±1 delta each) regardless of expiry, the aggregate margin equals the + // single-expiry 12-lot case, so a worst-first subset restores the IM buffer. // // Contract under test (the balancing feature): the ONE // `liquidatePositions(user, ids[])` call must draw its closed lots from @@ -545,9 +546,9 @@ describe("Liquidate down to the IM buffer", () => { { timeout: 60_000 }, async () => { // Precondition: alice is long 40 perps AND long 1 futures lot; a moderate - // crash (4.21 β†’ 3.00) puts the *combined* portfolio below MM. The perps - // leg dominates by unrealized loss ($48.40 vs $8.47), so the planner - // reduces it first. + // crash (4.21 β†’ 3.00 mark) puts the *combined* portfolio below MM. The + // perps leg dominates by unrealized loss ($48.40 vs $1.21, duration-free), + // so the planner reduces it first. // // Contract under test: the perps `reduceToTarget` sizes its partial // `closeQty` against WHOLE-portfolio margin β€” the still-open futures leg's @@ -598,11 +599,12 @@ describe("Liquidate down to the IM buffer", () => { "cross-venue: a substantially underwater account is swept on BOTH venues into the [MM, IM] band", { timeout: 60_000 }, async () => { - // Precondition: alice is long 6 futures lots AND long 25 perps; a moderate - // crash (4.21 β†’ 3.00) leaves the combined portfolio SUBSTANTIALLY under MM - // (~$8.12 deficit). The futures leg dominates by loss, so it's reduced - // first β€” but fully closing all 6 lots only frees ~$6.30 of MM stress, - // short of the deficit, so the account is still under MM. + // Precondition: alice is long 12 futures lots AND long 11 perps (staged at + // a $40 mark); a moderate crash ($40 β†’ $30 mark) leaves the combined + // portfolio SUBSTANTIALLY under MM (~$14.50 deficit). The futures leg + // dominates by loss ($120 vs $110), so it's reduced first β€” but fully + // closing all 12 lots realizes $120 of loss + $12 fee, still short of the + // residual perps MM requirement, so the account is still under MM. // // Contract under test: the planner's position loop must then take a // SECOND iteration and reduce the perps leg (partial, continuous qty) to @@ -616,12 +618,12 @@ describe("Liquidate down to the IM buffer", () => { const alice = ctx.accounts.alice.account.address; const perpsBefore = await readPerpsPosition(ctx, alice); - assert.equal(perpsBefore.netQuantity, ctx.alicePerpsQty, "precondition: alice long 25 perps"); + assert.equal(perpsBefore.netQuantity, ctx.alicePerpsQty, "precondition: alice long 11 perps"); const futuresBefore = await readFuturesPositionIds(ctx, alice); assert.equal( futuresBefore.length, ctx.aliceFuturesQty, - "precondition: alice holds 6 futures lots", + "precondition: alice holds 12 futures lots", ); await ctx.makeLiquidatable(); @@ -717,8 +719,8 @@ describe("Cross-venue coordination", () => { { timeout: 60_000 }, async () => { // Precondition: 100-qty perps long ($420 loss) + 1-unit futures - // long ($29.40 loss). The planner's `rankPositions` orders by - // `unrealizedLoss DESC`, so perps must be closed strictly before + // long ($4.20 loss, duration-free). The planner's `rankPositions` orders + // by `unrealizedLoss DESC`, so perps must be closed strictly before // futures. Observable signal: the block number of the perps // `PositionLiquidated` event is strictly less than the futures one. const ctx = await loadFixture(crossVenuePerpsDominantFixture, testClient); @@ -748,9 +750,9 @@ describe("Cross-venue coordination", () => { { timeout: 60_000 }, async () => { // Precondition: inverted from the previous test β€” 1-qty perps long - // ($4.20 loss) + 20-unit futures long ($588 loss across the 7-day - // delivery window). Futures must be closed strictly before perps, - // confirming the planner's ranking is by loss size and not by a + // ($4.20 loss) + 12-unit futures long ($50.40 loss, duration-free: + // 12 Β· ($4.21 βˆ’ $0.01 mark)). Futures must be closed strictly before + // perps, confirming the planner's ranking is by loss size and not by a // hard-coded venue order. const ctx = await loadFixture(crossVenueFuturesDominantFixture, testClient); keeper = buildKeeper(ctx); diff --git a/keeper/tests/integration/scenarios.ts b/keeper/tests/integration/scenarios.ts index 3282a64..e853026 100644 --- a/keeper/tests/integration/scenarios.ts +++ b/keeper/tests/integration/scenarios.ts @@ -1,6 +1,11 @@ import { parseUnits, type Address } from "viem"; import { hardhat } from "viem/chains"; -import { deployStack, type DeployedStack, type Wallet } from "./deployStack.ts"; +import { + deployStack, + ORACLE_TO_MARKET_MULTIPLIER, + type DeployedStack, + type Wallet, +} from "./deployStack.ts"; /** * Fixture builders. @@ -31,21 +36,31 @@ import { deployStack, type DeployedStack, type Wallet } from "./deployStack.ts"; // ───────────────────────────────────────────────────────────────────────── export interface BaseFixture extends DeployedStack { + /** Write a raw hashprice *oracle answer* (per 100 TH/sΒ·day). */ bumpHashprice(newPrice: bigint): Promise; bumpBtcUsdc(newPrice: bigint): Promise; + /** + * Set the per-contract *mark* (`getMarketPrice()` value). Internally divides + * by `ORACLE_TO_MARKET_MULTIPLIER` (Γ—10 rebase) before writing the oracle, so + * callers can reason in the same contract unit that orders/positions use. + * Does not touch BTC/USDC β€” used to stage a fixture's at-the-money entry mark. + */ + setMark(marketPrice: bigint): Promise; /** Deposit USDC into the vault from the given (test-known) wallet. */ deposit(userAddr: Address, amount: bigint): Promise; /** - * Apply a fresh hashprice and a *paired* BTC/USDC tick. The predictor - * only listens to the BTC/USDC channel, so the second write is what - * makes the event-driven liquidation path observable; the hashprice - * write is what actually moves PnL. + * Apply a fresh mark and a *paired* BTC/USDC tick. The predictor only listens + * to the BTC/USDC channel, so the second write is what makes the event-driven + * liquidation path observable; the mark write is what actually moves PnL. + * + * The argument is a per-contract *mark* (contract unit), rebased Γ—10 down to + * the oracle answer internally β€” the same unit as entry/order prices. * * `crashOracles` moves BTC/USDC *down* (long-side loss); `pumpOracles` * moves it *up* (short-side loss). */ - crashOracles(hashpricePrice: bigint): Promise; - pumpOracles(hashpricePrice: bigint): Promise; + crashOracles(marketPrice: bigint): Promise; + pumpOracles(marketPrice: bigint): Promise; } export interface AliceDepositFixture extends BaseFixture { @@ -132,10 +147,10 @@ export interface PerpsPartialCrashFixture extends BaseFixture { * Alice holds equal-size futures long books on TWO delivery dates (separate * markets) and takes the same *moderate* crash as `FuturesPartialCrashFixture`. * A subset close restores the IM buffer β€” and because every lot carries the - * same global `deliveryDurationDays` risk weight, the aggregate margin matches - * the single-expiry 12-lot case. Used to prove the keeper's ONE - * `liquidatePositions` tx spreads the close *across both expirations* instead - * of draining one book first. + * same per-day risk weight (duration-free, Β±1 delta each) regardless of expiry, + * the aggregate margin matches the single-expiry 12-lot case. Used to prove the + * keeper's ONE `liquidatePositions` tx spreads the close *across both + * expirations* instead of draining one book first. */ export interface MultiExpiryFuturesPartialCrashFixture extends BaseFixture { aliceDeposit: bigint; @@ -174,20 +189,27 @@ export interface CrossVenueOrdersAndPositionsFixture extends CrossVenueFixture { // Base fixture // ───────────────────────────────────────────────────────────────────────── +/** Convert a per-contract mark into the raw oracle answer the venues rebase Γ—10. */ +function markToOracle(marketPrice: bigint): bigint { + return marketPrice / ORACLE_TO_MARKET_MULTIPLIER; +} + export async function baseFixture(rpcUrl: string): Promise { const stack = await deployStack(rpcUrl); return { ...stack, bumpHashprice: (price) => writeOracle(stack, stack.addresses.hashpriceOracle, price), bumpBtcUsdc: (price) => writeOracle(stack, stack.addresses.btcUsdcFeed, price), + setMark: (marketPrice) => + writeOracle(stack, stack.addresses.hashpriceOracle, markToOracle(marketPrice)), deposit: (user, amount) => depositTo(stack, user, amount), - crashOracles: async (hashpricePrice) => { - await writeOracle(stack, stack.addresses.hashpriceOracle, hashpricePrice); + crashOracles: async (marketPrice) => { + await writeOracle(stack, stack.addresses.hashpriceOracle, markToOracle(marketPrice)); const movedBtc = (stack.config.initialBtcUsdc * 9n) / 10n; await writeOracle(stack, stack.addresses.btcUsdcFeed, movedBtc); }, - pumpOracles: async (hashpricePrice) => { - await writeOracle(stack, stack.addresses.hashpriceOracle, hashpricePrice); + pumpOracles: async (marketPrice) => { + await writeOracle(stack, stack.addresses.hashpriceOracle, markToOracle(marketPrice)); const movedBtc = (stack.config.initialBtcUsdc * 11n) / 10n; await writeOracle(stack, stack.addresses.btcUsdcFeed, movedBtc); }, @@ -233,7 +255,7 @@ export function perpsLongCrashFixtureBuilder(rpcUrl: string) { await matchPerpsTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, quantity: aliceQty, }); @@ -272,7 +294,7 @@ export function perpsShortCrashFixtureBuilder(rpcUrl: string) { await matchPerpsTrade(base, { buyer: base.accounts.bob, seller: base.accounts.alice, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, quantity: aliceQty, }); @@ -312,11 +334,11 @@ export function twoUnderwaterUsersFixtureBuilder(rpcUrl: string) { await placePerpsOrder( base, base.accounts.bob, - base.config.initialHashprice, + base.config.initialMarketPrice, -(aliceQty + daveQty), ); - await placePerpsOrder(base, base.accounts.alice, base.config.initialHashprice, aliceQty); - await placePerpsOrder(base, base.accounts.dave, base.config.initialHashprice, daveQty); + await placePerpsOrder(base, base.accounts.alice, base.config.initialMarketPrice, aliceQty); + await placePerpsOrder(base, base.accounts.dave, base.config.initialMarketPrice, daveQty); return { ...base, @@ -359,7 +381,7 @@ export function perpsOrdersAndPositionFixtureBuilder(rpcUrl: string) { await matchPerpsTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, quantity: aliceQty, }); @@ -380,15 +402,17 @@ export function perpsOrdersAndPositionFixtureBuilder(rpcUrl: string) { /** * Alice holds a long futures contract at the first delivery date; Bob is - * the matched seller. Position PnL accrues per day across the full - * delivery window: at entry 4.21 / day Γ— 7 days = $29.47 notional per - * unit. A crash to 0.01 puts ($4.20 Γ— 7) = $29.40 of unrealized loss per - * unit β€” sized so 12 units exceed Alice's $200 deposit. + * the matched seller. Duration-free model: one contract settles the per-day + * value with a multiplier of 1 (no Γ— delivery window), so at the $4.21 mark + * each unit carries $4.21 of notional. A crash to a $0.01 mark inflicts + * ($4.21 βˆ’ $0.01) = $4.20 of unrealized loss per unit β†’ 12 units = $50.40, + * far exceeding Alice's post-fee balance ($40 βˆ’ $12 taker fee = $28) so the + * account is deeply underwater and fully liquidates into bad debt. */ export function futuresLongCrashFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("200", base.config.tokenDecimals); + const aliceDeposit = parseUnits("40", base.config.tokenDecimals); const bobDeposit = parseUnits("2000", base.config.tokenDecimals); const aliceFuturesQty = 12; @@ -398,7 +422,7 @@ export function futuresLongCrashFixtureBuilder(rpcUrl: string) { await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, deliveryAt: base.config.futuresFirstDeliveryDate, quantity: aliceFuturesQty, }); @@ -408,7 +432,7 @@ export function futuresLongCrashFixtureBuilder(rpcUrl: string) { aliceDeposit, aliceFuturesQty, makeLiquidatable: () => - base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + base.crashOracles(parseUnits("0.01", base.config.tokenDecimals)), }; }; } @@ -426,7 +450,7 @@ export function futuresLongCrashFixtureBuilder(rpcUrl: string) { export function futuresOrdersAndPositionFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("200", base.config.tokenDecimals); + const aliceDeposit = parseUnits("40", base.config.tokenDecimals); const bobDeposit = parseUnits("2000", base.config.tokenDecimals); const aliceFuturesQty = 12; @@ -436,14 +460,14 @@ export function futuresOrdersAndPositionFixtureBuilder(rpcUrl: string) { await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, deliveryAt: base.config.futuresFirstDeliveryDate, quantity: aliceFuturesQty, }); - // Stale buy order well below the current mark β€” no counterparty + // Stale buy order well below the current $4.21 mark β€” no counterparty // exists at this price level so the order rests on the book. - const restingPrice = parseUnits("2.00", base.config.oracleDecimals); + const restingPrice = parseUnits("2.00", base.config.tokenDecimals); await placeFuturesOrder( base, base.accounts.alice, @@ -458,7 +482,7 @@ export function futuresOrdersAndPositionFixtureBuilder(rpcUrl: string) { aliceFuturesQty, restingOrderCount: 1, makeLiquidatable: () => - base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + base.crashOracles(parseUnits("0.01", base.config.tokenDecimals)), }; }; } @@ -471,11 +495,11 @@ export function futuresOrdersAndPositionFixtureBuilder(rpcUrl: string) { export function multiFuturesFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("200", base.config.tokenDecimals); + const aliceDeposit = parseUnits("40", base.config.tokenDecimals); const bobDeposit = parseUnits("3000", base.config.tokenDecimals); const firstDeliveryAt = base.config.futuresFirstDeliveryDate; const secondDeliveryAt = - firstDeliveryAt + BigInt(7 * 24 * 3600); // matches `FUTURES_DELIVERY_INTERVAL_DAYS`. + firstDeliveryAt + BigInt(7 * 24 * 3600); // matches `FUTURES_EXPIRATION_INTERVAL_DAYS`. await base.deposit(base.accounts.alice.account.address, aliceDeposit); await base.deposit(base.accounts.bob.account.address, bobDeposit); @@ -484,7 +508,7 @@ export function multiFuturesFixtureBuilder(rpcUrl: string) { await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, deliveryAt, quantity: 6, }); @@ -495,38 +519,49 @@ export function multiFuturesFixtureBuilder(rpcUrl: string) { aliceDeposit, deliveryDates: [firstDeliveryAt, secondDeliveryAt] as const, makeLiquidatable: () => - base.crashOracles(parseUnits("0.01", base.config.oracleDecimals)), + base.crashOracles(parseUnits("0.01", base.config.tokenDecimals)), }; }; } /** - * Alice holds 12 long futures lots at the first delivery date; a moderate - * hashprice crash (4.21 β†’ 3.90 / 100 TH/s / day) drives her below MM while - * leaving enough headroom that closing a worst-first subset of lots restores - * `balance >= IM`. Sizing (deliveryDurationDays = 7, PME shocks 10% IM / 5% - * MM, $1 flat liquidation fee): - * - unrealized loss / lot after crash β‰ˆ (4.21 βˆ’ 3.90) Β· 7 = $2.17 - * - MM stress / lot β‰ˆ 0.05 Β· 3.90 Β· 7 = $1.365, IM stress β‰ˆ $2.73 - * - MM_reqβ‚€ β‰ˆ 12 Β· (1.365 + 2.17) = $42.42 > $40 deposit β‡’ underwater - * - closing ~7–10 lots frees enough MM stress to re-cross MM while staying - * at/under IM (the rest stay open) β€” a genuine partial liquidation. - * Deposit $40 also clears the entry IM (12 Β· 0.10 Β· 4.21 Β· 7 ... perp-free - * futures IM β‰ˆ $35.36) so Alice can open the position pre-crash. + * Alice holds 12 long futures lots at the first delivery date; a moderate crash + * drives her below MM while leaving enough headroom that closing a worst-first + * subset of lots restores `balance >= IM`. + * + * Duration-free rescale (mirrors the unit `solveTarget` fixture): each contract + * settles the per-day value Γ—1 (no Γ—7 window), so a shallow $4.21β†’$3.90 move no + * longer clears the flat $1/lot liquidation fee (0.05Β·3.90 = $0.195 < $1) and a + * partial close could never help. We therefore stage the book at a $40 mark and + * crash to a $30 mark β€” the same shape used by the unit fixtures β€” so the + * per-lot MM stress freed by a close (0.05Β·$30 = $1.50) exceeds the $1 fee. + * + * Sizing (PME shocks 10% IM / 5% MM, $1 flat liquidation fee, entry = $40 mark): + * - unrealized loss / lot after crash = (40 βˆ’ 30) = $10 + * - MM stress / lot = 0.05Β·30 = $1.50 ; IM stress / lot = 0.10Β·30 = $3.00 + * - MM_req = 12Β·(1.50 + 10) = $138 > $136 deposit β‡’ underwater by ~$2 + * - IM_req = 12Β·(3.00 + 10) = $156 + * - each closed lot nets +$0.50 to MM surplus ($1.50 stress βˆ’ $1 fee) and + * +$2.00 to IM surplus ($3.00 stress βˆ’ $1 fee), so closing ~10 lots lands + * the account on the IM boundary with 2 lots still open β€” a genuine partial. + * Entry IM (at the $40 mark, no PnL) = 12Β·0.10Β·40 = $48, well under the $136 + * deposit, so Alice can open pre-crash (taker fee zeroed β€” see below). */ export function futuresPartialCrashFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("40", base.config.tokenDecimals); + // Stage the entry mark at $40 (oracle answer $4.00 Γ— 10). Larger than the + // default $4.21 so the moderate-crash stress clears the flat liquidation fee. + const entryMark = parseUnits("40", base.config.tokenDecimals); + await base.setMark(entryMark); + + const aliceDeposit = parseUnits("136", base.config.tokenDecimals); const bobDeposit = parseUnits("3000", base.config.tokenDecimals); const aliceFuturesQty = 12; - // Zero the futures taker fee for this fixture only. Opening 12 lots costs - // an entry IM of 84Β·(0.1Β·$4.21) = $35.36, which fits the $40 deposit β€” but - // the default $1/lot taker fee ($12) would drop the post-match balance to - // $28 < IM and revert `InsufficientMarginBalance`. Zeroing it keeps the - // [MM, IM] band math clean; the $1/lot *liquidation* fee still applies to - // the sweep (so the solver's fee-aware sizing is still exercised). + // Zero the futures taker fee for this fixture only so the entry IM ($48) + // isn't inflated by the $1/lot open cost; the $1/lot *liquidation* fee still + // applies to the sweep (so the solver's fee-aware sizing is exercised). await setFuturesTakerFee(base, 0n); await base.deposit(base.accounts.alice.account.address, aliceDeposit); @@ -535,7 +570,7 @@ export function futuresPartialCrashFixtureBuilder(rpcUrl: string) { await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: entryMark, deliveryAt: base.config.futuresFirstDeliveryDate, quantity: aliceFuturesQty, }); @@ -544,38 +579,39 @@ export function futuresPartialCrashFixtureBuilder(rpcUrl: string) { ...base, aliceDeposit, aliceFuturesQty, - // Moderate crash: 4.21 β†’ 3.90. Deep enough to break MM, shallow enough + // Moderate crash: $40 β†’ $30 mark. Deep enough to break MM, shallow enough // that a subset of lots restores the IM buffer. - makeLiquidatable: () => - base.crashOracles(parseUnits("3.90", base.config.oracleDecimals)), + makeLiquidatable: () => base.crashOracles(parseUnits("30", base.config.tokenDecimals)), }; }; } /** * Alice holds 6 long futures lots on EACH of two delivery dates (12 total), - * then takes the same moderate crash (4.21 β†’ 3.90) as - * `futuresPartialCrashFixtureBuilder`. Because the on-chain futures risk model - * weights every lot by the single global `deliveryDurationDays` (7) regardless - * of which date it delivers on, the aggregate MM/IM and unrealized loss are - * identical to the single-expiry 12-lot fixture β€” so the same $40 deposit - * breaks MM and a worst-first subset restores the IM buffer. The distinction - * under test: the keeper's ONE `liquidatePositions` sweep must close lots from - * BOTH expirations (balanced), not empty the first book before touching the - * second. + * then takes the same moderate crash ($40 β†’ $30 mark) as + * `futuresPartialCrashFixtureBuilder`. In the duration-free model each lot + * carries the same per-day risk weight (multiplier 1) regardless of which date + * it expires on, so the aggregate MM/IM and unrealized loss are identical to the + * single-expiry 12-lot fixture β€” the same $136 deposit breaks MM and a + * worst-first subset restores the IM buffer. The distinction under test: the + * keeper's ONE `liquidatePositions` sweep must close lots from BOTH expirations + * (balanced), not empty the first book before touching the second. */ export function futuresMultiExpiryPartialCrashFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("40", base.config.tokenDecimals); + // Stage the entry mark at $40 (see `futuresPartialCrashFixtureBuilder`). + const entryMark = parseUnits("40", base.config.tokenDecimals); + await base.setMark(entryMark); + + const aliceDeposit = parseUnits("136", base.config.tokenDecimals); const bobDeposit = parseUnits("3000", base.config.tokenDecimals); const perExpiryQty = 6; const firstDeliveryAt = base.config.futuresFirstDeliveryDate; - const secondDeliveryAt = firstDeliveryAt + BigInt(7 * 24 * 3600); // FUTURES_DELIVERY_INTERVAL_DAYS + const secondDeliveryAt = firstDeliveryAt + BigInt(7 * 24 * 3600); // FUTURES_EXPIRATION_INTERVAL_DAYS // Zero the taker fee (see `futuresPartialCrashFixtureBuilder`) so the 12-lot - // entry IM (~$35.36) fits the $40 deposit; the liquidation-fee payout is - // already disabled contract-side. + // entry IM ($48) fits the $136 deposit; the liquidation fee still applies. await setFuturesTakerFee(base, 0n); await base.deposit(base.accounts.alice.account.address, aliceDeposit); @@ -585,7 +621,7 @@ export function futuresMultiExpiryPartialCrashFixtureBuilder(rpcUrl: string) { await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: entryMark, deliveryAt, quantity: perExpiryQty, }); @@ -596,8 +632,7 @@ export function futuresMultiExpiryPartialCrashFixtureBuilder(rpcUrl: string) { aliceDeposit, deliveryDates: [firstDeliveryAt, secondDeliveryAt] as const, perExpiryQty, - makeLiquidatable: () => - base.crashOracles(parseUnits("3.90", base.config.oracleDecimals)), + makeLiquidatable: () => base.crashOracles(parseUnits("30", base.config.tokenDecimals)), }; }; } @@ -625,7 +660,7 @@ export function perpsPartialCrashFixtureBuilder(rpcUrl: string) { await matchPerpsTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, quantity: aliceQty, }); @@ -642,20 +677,21 @@ export function perpsPartialCrashFixtureBuilder(rpcUrl: string) { /** * Cross-venue *partial* crash β€” the reduce-to-IM-buffer path spanning both * venues. Alice holds a dominant 40-qty perps long plus a small 1-lot futures - * long. A moderate crash (4.21 β†’ 3.00) puts the *combined* portfolio below MM, - * but the account is recoverable by a partial close. Because both legs are long - * at the same entry, the portfolio behaves like one net-long book of size - * `perpQty + deliveryDaysΒ·futuresLots` (= 40 + 7 = 47 delta units) for margin - * purposes. + * long. A moderate crash (4.21 β†’ 3.00 mark) puts the *combined* portfolio below + * MM, but the account is recoverable by a partial close. In the duration-free + * model each futures lot is Β±1 delta, so the portfolio behaves like one net-long + * book of size `perpQty + futuresLots` (= 40 + 1 = 41 delta units) for margin. * - * Sizing (PME 10% IM / 5% MM, deliveryDays = 7, fee payout disabled): - * - mmReq(3.00) = 47 Β· (4.21 βˆ’ 3.00Β·0.95) = 47 Β· 1.36 = $63.92 - * - imReq(3.00) = 47 Β· (4.21 βˆ’ 3.00Β·0.90) = 47 Β· 1.51 = $70.97 - * - $61 deposit < $63.92 β‡’ underwater by ~$2.92 - * - each closed delta unit lifts mmSurplus by mmShockΒ·P = $0.15, imSurplus by - * $0.30, so the deepest in-band close is Ξ΄ β‰ˆ (70.97βˆ’61)/0.30 β‰ˆ 33.2 delta - * units β€” a PARTIAL perps close (β‰ˆ6.8 units of the 40 stay open), suppliable - * by the perps leg alone so the futures leg is never touched. + * Sizing (PME 10% IM / 5% MM, entry = $4.21 mark, futures taker fee disabled): + * - mmReq(3.00) = 41Β·0.05Β·3.00 + 40Β·(4.21βˆ’3.00) + 1Β·(4.21βˆ’3.00) + * = 6.15 + 48.40 + 1.21 = $55.76 + * - imReq(3.00) = 41Β·0.10Β·3.00 + 49.61 = 12.30 + 49.61 = $61.91 + * - $53 deposit < $55.76 β‡’ underwater by ~$2.76 + * - closing a perps unit frees imShockΒ·P = $0.30 of IM surplus (its realized + * loss cancels the freed unrealized loss), so the deepest in-band close is + * Ξ΄ β‰ˆ (61.91 βˆ’ 53 + $1 flat fee)/0.30 β‰ˆ 33 units β€” a PARTIAL perps close + * (~7 of the 40 stay open), suppliable by the perps leg alone so the futures + * leg is never touched. * * The flat $1/lot futures taker fee is zeroed for this fixture (as in * `futuresPartialCrashFixtureBuilder`) so it doesn't eat into the narrow @@ -672,7 +708,7 @@ export function perpsPartialCrashFixtureBuilder(rpcUrl: string) { export function crossVenuePartialCrashFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("61", base.config.tokenDecimals); + const aliceDeposit = parseUnits("53", base.config.tokenDecimals); const bobDeposit = parseUnits("5000", base.config.tokenDecimals); const alicePerpsQty = parseUnits("40", base.config.quantityDecimals); const aliceFuturesQty = 1; @@ -685,13 +721,13 @@ export function crossVenuePartialCrashFixtureBuilder(rpcUrl: string) { await matchPerpsTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, quantity: alicePerpsQty, }); await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, deliveryAt: base.config.futuresFirstDeliveryDate, quantity: aliceFuturesQty, }); @@ -702,7 +738,7 @@ export function crossVenuePartialCrashFixtureBuilder(rpcUrl: string) { alicePerpsQty, aliceFuturesQty, makeLiquidatable: () => - base.crashOracles(parseUnits("3.00", base.config.oracleDecimals)), + base.crashOracles(parseUnits("3.00", base.config.tokenDecimals)), }; }; } @@ -715,32 +751,47 @@ export function crossVenuePartialCrashFixtureBuilder(rpcUrl: string) { * in the partial regime (distinct from both the single-venue-suffices partial * test and the 99.8% deep-crash test that wipes everything into bad debt). * - * Alice holds a dominant 6-lot futures long + a 25-qty perps long (delta units: - * futures 6Β·7 = 42, perps 25; S = 67). Moderate crash 4.21 β†’ 3.00: - * - mmReq(3.00) = 67 Β· 1.36 = $91.12 ; imReq = 67 Β· 1.51 = $101.17 - * - $83 deposit β‡’ underwater by ~$8.12 (substantial) - * - futures is worst by loss ($50.82 > $30.25), so it's reduced first β€” but - * even fully closing all 6 futures lots only lifts mmSurplus by 6Β·0.15Β·7 = - * $6.30, short of the $8.12 deficit, so the account is STILL under MM (the - * futures leg simply doesn't have the lots to close the gap alone). - * - the planner then takes a SECOND iteration and reduces the perps leg. Perps - * closes by a *continuous* quantity, so the solver lands the account - * precisely on the IM boundary β€” a robust in-band result (residual perps - * ~6.4 qty stays open), unlike the discrete futures-lot granularity. + * Staged at a $40 mark (crash to $30) β€” the duration-free equivalent of the old + * $4.21-scale sizing. Alice holds a dominant 12-lot futures long + an 11-qty + * perps long (delta units: futures 12Β·1 = 12, perps 11; S = 23). Futures is made + * the worst leg by lot count (each lot now Β±1 delta, so its loss out-numbers the + * perps qty). Moderate crash 40 β†’ 30: + * - mmReq(30) = 23Β·0.05Β·30 + 11Β·(40βˆ’30) + 12Β·(40βˆ’30) + * = 34.50 + 110 + 120 = $264.50 + * - imReq(30) = 23Β·0.10Β·30 + 230 = 69 + 230 = $299 + * - $235 deposit (βˆ’$1 perps taker fee β‡’ $234 balance) β‡’ underwater by ~$30.50 + * (substantial). * - * Net effect the test asserts: BOTH venues carry liquidation activity in the - * one sweep (futures fully closed, perps partially closed), the account lands in + * The key sizing invariant: closing a delta unit only improves the portfolio + * margin *gap* by the maintenance-margin relief `mmRateΒ·mark = 0.05Β·30 = $1.50` + * (realizing the loss debits the balance but drops mmReq by the same amount, so + * only the shock-margin term nets out). Liquidation fees are zero in this harness + * (futures taker fee zeroed; no per-lot liquidation fee applied), so: + * - Full futures capacity = 12Β·$1.50 = $18 < $30.50 deficit β‡’ even closing ALL + * 12 lots leaves the account under MM: the futures leg CANNOT heal it alone. + * - The planner therefore fully closes the futures leg, then takes a SECOND + * iteration on perps. Perps closes by a *continuous* quantity down to the IM + * boundary (deepest close staying at/under IM), reducing ~9.67 of the 11 qty + * and leaving a residual ~1.33-qty long β€” unlike the discrete futures-lot + * granularity. + * - Total capacity = 23Β·$1.50 = $34.50 > $30.50, so the account stays + * recoverable (a residual perps long survives β€” not the bad-debt path). + * + * Net effect the test asserts: BOTH venues carry liquidation activity in the one + * sweep (futures fully closed, perps partially closed), the account lands in * `[MM, IM]`, and it is not fully wiped (the perps leg keeps a residual long). - * The futures taker fee is zeroed (as elsewhere) so the $1/lot open cost doesn't - * shift the sizing. */ export function crossVenueBothLegsCrashFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("83", base.config.tokenDecimals); + // Stage entry at a $40 mark (see `futuresPartialCrashFixtureBuilder`). + const entryMark = parseUnits("40", base.config.tokenDecimals); + await base.setMark(entryMark); + + const aliceDeposit = parseUnits("235", base.config.tokenDecimals); const bobDeposit = parseUnits("5000", base.config.tokenDecimals); - const alicePerpsQty = parseUnits("25", base.config.quantityDecimals); - const aliceFuturesQty = 6; + const alicePerpsQty = parseUnits("11", base.config.quantityDecimals); + const aliceFuturesQty = 12; await setFuturesTakerFee(base, 0n); @@ -750,13 +801,13 @@ export function crossVenueBothLegsCrashFixtureBuilder(rpcUrl: string) { await matchPerpsTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: entryMark, quantity: alicePerpsQty, }); await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: entryMark, deliveryAt: base.config.futuresFirstDeliveryDate, quantity: aliceFuturesQty, }); @@ -766,8 +817,7 @@ export function crossVenueBothLegsCrashFixtureBuilder(rpcUrl: string) { aliceDeposit, alicePerpsQty, aliceFuturesQty, - makeLiquidatable: () => - base.crashOracles(parseUnits("3.00", base.config.oracleDecimals)), + makeLiquidatable: () => base.crashOracles(parseUnits("30", base.config.tokenDecimals)), }; }; } @@ -780,10 +830,10 @@ export function crossVenueBothLegsCrashFixtureBuilder(rpcUrl: string) { * Two parameterised variants are exposed via dedicated builders: * * - `crossVenuePerpsDominantFixtureBuilder` β€” perps `unrealizedLoss` - * dominates futures (ratio β‰ˆ 14:1). The planner should liquidate + * dominates futures (ratio β‰ˆ 100:1). The planner should liquidate * perps first, then futures. * - `crossVenueFuturesDominantFixtureBuilder` β€” futures dominates perps - * (ratio β‰ˆ 1:140). The planner should liquidate futures first. + * (ratio β‰ˆ 12:1). The planner should liquidate futures first. * * Together they prove the planner ranks by *loss size*, not venue order. */ @@ -799,13 +849,13 @@ function crossVenueFixtureBody( await matchPerpsTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, quantity: alicePerpsQty, }); await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, deliveryAt: base.config.futuresFirstDeliveryDate, quantity: aliceFuturesQty, }); @@ -822,9 +872,9 @@ function crossVenueFixtureBody( } /** - * Perps-dominant: alice has a 100-qty perps long ($420 unrealized loss - * after the crash) and a 1-unit futures long ($29.40 loss). The planner - * must liquidate perps first by `unrealizedLoss` ranking. + * Perps-dominant: alice has a 100-qty perps long ($420 unrealized loss after the + * crash to a $0.01 mark) and a 1-unit futures long ($4.20 loss, duration-free). + * The planner must liquidate perps first by `unrealizedLoss` ranking. */ export function crossVenuePerpsDominantFixtureBuilder(rpcUrl: string) { return async (): Promise => { @@ -857,7 +907,10 @@ export function crossVenuePerpsDominantFixtureBuilder(rpcUrl: string) { export function crossVenueOrdersAndPositionsFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); - const aliceDeposit = parseUnits("250", base.config.tokenDecimals); + // Duration-free rescale: the 6-lot futures leg contributes ~$25 of loss + // (was ~$176 with the Γ—7 window), so the deposit drops to keep the combined + // book underwater after the deep crash and fully wiped across both venues. + const aliceDeposit = parseUnits("150", base.config.tokenDecimals); const bobDeposit = parseUnits("5000", base.config.tokenDecimals); const alicePerpsQty = parseUnits("40", base.config.quantityDecimals); const aliceFuturesQty = 6; @@ -868,13 +921,13 @@ export function crossVenueOrdersAndPositionsFixtureBuilder(rpcUrl: string) { await matchPerpsTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, quantity: alicePerpsQty, }); await matchFuturesTrade(base, { buyer: base.accounts.alice, seller: base.accounts.bob, - price: base.config.initialHashprice, + price: base.config.initialMarketPrice, deliveryAt: base.config.futuresFirstDeliveryDate, quantity: aliceFuturesQty, }); @@ -910,9 +963,9 @@ export function crossVenueOrdersAndPositionsFixtureBuilder(rpcUrl: string) { } /** - * Futures-dominant: alice has a 1-qty perps long ($4.20 unrealized loss) - * and a 12-unit futures long ($352.80 loss over the 7-day delivery - * window). The planner must liquidate futures first. + * Futures-dominant: alice has a 1-qty perps long ($4.20 unrealized loss) and a + * 12-unit futures long ($50.40 loss, duration-free: 12 Β· ($4.21 βˆ’ $0.01 mark)). + * The planner must liquidate futures first. * * The futures qty is capped at 12 because `createOrder` loops once per * contract in the matching engine; larger values blow past Hardhat's @@ -922,7 +975,7 @@ export function crossVenueFuturesDominantFixtureBuilder(rpcUrl: string) { return async (): Promise => { const base = await baseFixture(rpcUrl); return crossVenueFixtureBody(base, { - aliceDeposit: parseUnits("300", base.config.tokenDecimals), + aliceDeposit: parseUnits("40", base.config.tokenDecimals), bobDeposit: parseUnits("5000", base.config.tokenDecimals), alicePerpsQty: parseUnits("1", base.config.quantityDecimals), aliceFuturesQty: 12, diff --git a/keeper/tests/oracle/priceFeed.test.ts b/keeper/tests/oracle/priceFeed.test.ts index 7c8f9a5..dcf530c 100644 --- a/keeper/tests/oracle/priceFeed.test.ts +++ b/keeper/tests/oracle/priceFeed.test.ts @@ -8,6 +8,7 @@ import type { Config } from "../../src/config.ts"; const HASHPRICE = "0x000000000000000000000000000000000000aa01" as Address; const BTC_FEED = "0x000000000000000000000000000000000000aa02" as Address; +const PERPS = "0x000000000000000000000000000000000000aa03" as Address; function makeConfig(): Config { return { @@ -16,6 +17,7 @@ function makeConfig(): Config { btcUsdcFeedAddress: BTC_FEED, priceMoveTriggerBps: 0, }, + perps: { address: PERPS }, } as Config; } @@ -44,6 +46,12 @@ function makeChainStub(initialAnswer: bigint, decimals: number): ChainStub { publicClient: { readContract: async ({ functionName }: { functionName: string }) => { if (functionName === "decimals") return decimals; + // Contract-size rebase reads. Returning equal values gives a 1Γ— + // passthrough so these tests assert the pure decimals rebase without a + // contract-size multiplier (the x10 factor is exercised by the venue / + // integration paths that use the real 1e15 / 100e12 constants). + if (functionName === "CONTRACT_SIZE_HPS_DAY") return 100n * 10n ** 12n; + if (functionName === "ORACLE_UNIT_HPS_DAY") return 100n * 10n ** 12n; if (functionName === "latestRoundData") { reads++; return [1n, currentAnswer, 1_000n, 1_000n, 1n] as const; diff --git a/keeper/tests/predict/coordinator.test.ts b/keeper/tests/predict/coordinator.test.ts index 1fea37d..4ae47e7 100644 --- a/keeper/tests/predict/coordinator.test.ts +++ b/keeper/tests/predict/coordinator.test.ts @@ -87,6 +87,11 @@ function buildHarness({ publicClient: { readContract: async ({ functionName }: { functionName: string }) => { if (functionName === "decimals") return 8; + // Contract-size rebase reads (PriceFeed.start). Equal values β†’ 1Γ— + // passthrough, so the streamed price stays $100 and matches the + // `computePortfolioMM` price the harness derives from the same answer. + if (functionName === "CONTRACT_SIZE_HPS_DAY") return 100n * 10n ** 12n; + if (functionName === "ORACLE_UNIT_HPS_DAY") return 100n * 10n ** 12n; if (functionName === "latestRoundData") { return [1n, oracleAnswer, 1_000n, 1_000n, 1n] as const; } @@ -124,8 +129,6 @@ function buildHarness({ return 0n; case "getPositionIds": return []; - case "deliveryDurationDays": - return 30; case "computePortfolioIM": return balance / 2n; case "computePortfolioMM": { diff --git a/keeper/tests/predict/coordinatorAlerts.test.ts b/keeper/tests/predict/coordinatorAlerts.test.ts index 894466e..918ff7b 100644 --- a/keeper/tests/predict/coordinatorAlerts.test.ts +++ b/keeper/tests/predict/coordinatorAlerts.test.ts @@ -64,6 +64,11 @@ function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigi publicClient: { readContract: async ({ functionName }: { functionName: string }) => { if (functionName === "decimals") return 8; + // Contract-size rebase reads (PriceFeed.start). Equal values β†’ 1Γ— + // passthrough, so the streamed price stays $100 and matches the IM/MM + // the harness derives from the same answer. + if (functionName === "CONTRACT_SIZE_HPS_DAY") return 100n * 10n ** 12n; + if (functionName === "ORACLE_UNIT_HPS_DAY") return 100n * 10n ** 12n; if (functionName === "latestRoundData") { return [1n, oracleAnswer, 1_000n, 1_000n, 1n] as const; } @@ -99,8 +104,6 @@ function buildHarness({ balance, perpEntry }: { balance: bigint; perpEntry: bigi return 0n; case "getPositionIds": return []; - case "deliveryDurationDays": - return 30; case "computePortfolioIM": return imAtPriceTokens(currentPrice); case "computePortfolioMM": diff --git a/keeper/tests/predict/mm.test.ts b/keeper/tests/predict/mm.test.ts index e1e56ef..5c28461 100644 --- a/keeper/tests/predict/mm.test.ts +++ b/keeper/tests/predict/mm.test.ts @@ -33,7 +33,7 @@ function emptySnapshot(overrides: Partial = {}): AccountSnapsho user: USER, balance: 0n, perp: { netQty: 0n, entryPrice: 0n, orderMargin: 0n, fundingOwed: 0n }, - futures: { positions: [], orderMargin: 0n, deliveryDays: 0n }, + futures: { positions: [], orderMargin: 0n }, ...overrides, }; } @@ -58,16 +58,15 @@ describe("predict/mm: netDeltaWad", () => { assert.equal(netDeltaWad(snap, PARAMS), -2_000_000_000_000_000_000n); }); - it("adds futures buyer delta scaled by deliveryDays", () => { - // Buyer of 1 contract over 30 days β†’ +30 * 1e18 WAD delta. + it("adds futures buyer delta (Β±1 per contract, no duration factor)", () => { + // Buyer of 1 contract β†’ +1 * 1e18 WAD delta. const snap = emptySnapshot({ futures: { positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], orderMargin: 0n, - deliveryDays: 30n, }, }); - assert.equal(netDeltaWad(snap, PARAMS), 30n * 10n ** 18n); + assert.equal(netDeltaWad(snap, PARAMS), 1n * 10n ** 18n); }); it("subtracts futures seller delta", () => { @@ -75,10 +74,9 @@ describe("predict/mm: netDeltaWad", () => { futures: { positions: [{ id: "0xaa", isBuyer: false, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], orderMargin: 0n, - deliveryDays: 30n, }, }); - assert.equal(netDeltaWad(snap, PARAMS), -30n * 10n ** 18n); + assert.equal(netDeltaWad(snap, PARAMS), -1n * 10n ** 18n); }); it("sums perps + futures legs into one signed delta", () => { @@ -90,10 +88,9 @@ describe("predict/mm: netDeltaWad", () => { { id: "0xbb", isBuyer: false, entryPricePerDay: 60n, deliveryAt: 1_756_416_000n }, ], orderMargin: 0n, - deliveryDays: 30n, }, }); - // Perp +1e18; futures +30e18 - 30e18 = 0 β†’ net = +1e18. + // Perp +1e18; futures +1e18 - 1e18 = 0 β†’ net = +1e18. assert.equal(netDeltaWad(snap, PARAMS), 1n * 10n ** 18n); }); }); @@ -168,16 +165,15 @@ describe("predict/mm: futuresUnrealizedLoss", () => { assert.equal(futuresUnrealizedLoss(emptySnapshot(), 100_000_000n), 0n); }); - it("buyer loses when P drops below entry; loss scales by deliveryDays", () => { + it("buyer loses when P drops below entry (no duration factor)", () => { const snap = emptySnapshot({ futures: { positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], orderMargin: 0n, - deliveryDays: 30n, }, }); - // diffPerDay = P - entry = 40 - 50 = -10. pnl = -10 * 30 = -300. loss = 300. - assert.equal(futuresUnrealizedLoss(snap, 40n), 300n); + // diffPerDay = P - entry = 40 - 50 = -10. pnl = -10. loss = 10. + assert.equal(futuresUnrealizedLoss(snap, 40n), 10n); }); it("seller loses when P rises above entry", () => { @@ -185,28 +181,26 @@ describe("predict/mm: futuresUnrealizedLoss", () => { futures: { positions: [{ id: "0xaa", isBuyer: false, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], orderMargin: 0n, - deliveryDays: 30n, }, }); - assert.equal(futuresUnrealizedLoss(snap, 60n), 300n); + assert.equal(futuresUnrealizedLoss(snap, 60n), 10n); }); it("sums losses across multiple positions; profitable legs do not net out", () => { const snap = emptySnapshot({ futures: { positions: [ - { id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }, // P=40 β†’ loses 300 - { id: "0xbb", isBuyer: false, entryPricePerDay: 30n, deliveryAt: 1_756_416_000n }, // P=40 β†’ loses 300 + { id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }, // P=40 β†’ loses 10 + { id: "0xbb", isBuyer: false, entryPricePerDay: 30n, deliveryAt: 1_756_416_000n }, // P=40 β†’ loses 10 ], orderMargin: 0n, - deliveryDays: 30n, }, }); // Loss is sum of *losing* legs only (consistent with `max(0, -pnl)` per leg // mirroring the on-chain `getFuturesUnrealizedPnl` aggregation, which // would be 0 net but PME treats them piecewise via stress + per-leg PnL). // Here both happen to be losing β€” buyer down, seller up. - assert.equal(futuresUnrealizedLoss(snap, 40n), 600n); + assert.equal(futuresUnrealizedLoss(snap, 40n), 20n); }); }); @@ -215,7 +209,7 @@ describe("predict/mm: mmRequired / mmSurplus / imRequired / imSurplus", () => { const snap = emptySnapshot({ balance: 1_000n, perp: { netQty: 0n, entryPrice: 0n, orderMargin: 100n, fundingOwed: 50n }, - futures: { positions: [], orderMargin: 25n, deliveryDays: 0n }, + futures: { positions: [], orderMargin: 25n }, }); // No delta β†’ no stress, no PnL. orderMargin + funding = 175. assert.equal(mmRequired(snap, PARAMS, 100_000_000n), 175n); diff --git a/keeper/tests/predict/snapshot.test.ts b/keeper/tests/predict/snapshot.test.ts index dc32b26..7af4f55 100644 --- a/keeper/tests/predict/snapshot.test.ts +++ b/keeper/tests/predict/snapshot.test.ts @@ -44,7 +44,6 @@ function makeChain(scripted: { mmShock?: bigint; tokenDecimals?: number; perpQtyDecimals?: number; - deliveryDays?: number; }): Chain { return { publicClient: { @@ -70,8 +69,6 @@ function makeChain(scripted: { return scripted.futuresOrderMargin ?? 0n; case "getPositionIds": return scripted.futuresPositionIds ?? []; - case "deliveryDurationDays": - return scripted.deliveryDays ?? 30; case "getPositionById": { const id = c.args?.[0] as string; const pos = scripted.futuresPositions?.[id]; @@ -114,7 +111,6 @@ describe("predict/snapshot: readAccountSnapshot", () => { assert.equal(snap.perp.netQty, 0n); assert.equal(snap.perp.fundingOwed, 0n); assert.equal(snap.futures.positions.length, 0); - assert.equal(snap.futures.deliveryDays, 30n); }); it("clamps pending funding to >= 0 (PME treats credits as not-owed)", async () => { diff --git a/keeper/tests/predict/solve.test.ts b/keeper/tests/predict/solve.test.ts index 099f788..72e6dba 100644 --- a/keeper/tests/predict/solve.test.ts +++ b/keeper/tests/predict/solve.test.ts @@ -20,7 +20,7 @@ function emptySnapshot(overrides: Partial = {}): AccountSnapsho user: USER, balance: 0n, perp: { netQty: 0n, entryPrice: 0n, orderMargin: 0n, fundingOwed: 0n }, - futures: { positions: [], orderMargin: 0n, deliveryDays: 0n }, + futures: { positions: [], orderMargin: 0n }, ...overrides, }; } @@ -138,19 +138,20 @@ describe("predict/solve: solveLiquidationThresholds", () => { }); it("handles a futures buyer position the same way as a long perp", () => { - // Buyer of 1 contract over 30 days @ $50/day, balance $200. - // Below entry: stress + (entry - P) * 30 days - // delta = 30 * WAD; stress = |delta| * shock * P / WADΒ² β†’ 30 * 0.05 * P / 1 = 1.5 P (per token decimals). - // Hmm β€” let me just verify via mmSurplus at the returned threshold. + // Buyer of 1 contract @ $50/day (delta = 1 * WAD; no duration factor), + // collateral $30. Below entry: mmRequired(P) = stress(P) + (entry - P) + // = 0.05 P + (50 - P) = 50 - 0.95 P (token decimals). + // surplus(P) = 30 - (50 - 0.95 P) = -20 + 0.95 P β†’ crosses 0 β‰ˆ $21.05. const snap = emptySnapshot({ - balance: 200n, + balance: 30_000_000n, futures: { - positions: [{ id: "0xaa", isBuyer: true, entryPricePerDay: 50n, deliveryAt: 1_756_416_000n }], + positions: [ + { id: "0xaa", isBuyer: true, entryPricePerDay: 50_000_000n, deliveryAt: 1_756_416_000n }, + ], orderMargin: 0n, - deliveryDays: 30n, }, }); - const out = solveLiquidationThresholds(snap, PARAMS, 50n); + const out = solveLiquidationThresholds(snap, PARAMS, 50_000_000n); assert.notEqual(out.liqDown, undefined); if (out.liqDown !== undefined) { assertCrossing(snap, PARAMS, out.liqDown, "down"); diff --git a/keeper/tests/predict/solveTarget.test.ts b/keeper/tests/predict/solveTarget.test.ts index b239995..7eb5997 100644 --- a/keeper/tests/predict/solveTarget.test.ts +++ b/keeper/tests/predict/solveTarget.test.ts @@ -28,6 +28,15 @@ const FEE = 1_000_000n; // $1 flat liquidation fee const EXPIRY_A = 1_756_416_000n; const EXPIRY_B = 1_759_008_000n; +// Duration-free contract sizing. Each lot is a single contract that settles +// `entryPricePerDay` of notional (no `Γ— deliveryDays` factor). For a close to +// improve MM surplus the stress it frees (`spotShock Γ— P`) must exceed the flat +// fee, so the moderate-crash price sits well above `20 Γ— FEE` β€” hence the +// $40/$30 magnitudes below rather than the old sub-dollar per-day prices. +const ENTRY_PER_DAY = 40_000_000n; // $40/day entry +const P_MODERATE = 30_000_000n; // $30/day: underwater but recoverable via a subset +const BALANCE = 136_000_000n; // collateral: underwater at P_MODERATE, healable by a partial close + function futuresLot(id: Hex, entryPricePerDay: bigint, isBuyer = true, deliveryAt = EXPIRY_A) { return { id, isBuyer, entryPricePerDay, deliveryAt }; } @@ -37,33 +46,33 @@ function futuresSnapshot(overrides: Partial = {}): AccountSnaps user: USER, balance: 0n, perp: { netQty: 0n, entryPrice: 0n, orderMargin: 0n, fundingOwed: 0n }, - futures: { positions: [], orderMargin: 0n, deliveryDays: 7n }, + futures: { positions: [], orderMargin: 0n }, ...overrides, }; } -/** 12 identical $4.21/day long lots β€” the integration `futuresPartialCrash` shape. */ +/** 12 identical $40/day long lots β€” the integration `futuresPartialCrash` shape. */ function twelveLongLots(): AccountSnapshot { const positions = []; for (let i = 0; i < 12; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n)); + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY)); } - return futuresSnapshot({ balance: 40_000_000n, futures: { positions, orderMargin: 0n, deliveryDays: 7n } }); + return futuresSnapshot({ balance: BALANCE, futures: { positions, orderMargin: 0n } }); } describe("predict/solve: solveFuturesLotsToTarget", () => { it("returns an empty set when the account is already healthy", () => { const snap = futuresSnapshot({ balance: 1_000_000_000n, - futures: { positions: [futuresLot(("0x" + "01".repeat(32)) as Hex, 4_210_000n)], orderMargin: 0n, deliveryDays: 7n }, + futures: { positions: [futuresLot(("0x" + "01".repeat(32)) as Hex, ENTRY_PER_DAY)], orderMargin: 0n }, }); - const ids = solveFuturesLotsToTarget(snap, PARAMS, 4_000_000n, FEE); + const ids = solveFuturesLotsToTarget(snap, PARAMS, P_MODERATE, FEE); assert.equal(ids.length, 0); }); it("closes a strict worst-first subset that lands inside the [MM, IM] band", () => { const snap = twelveLongLots(); - const P = 3_900_000n; // moderate crash β†’ underwater but recoverable + const P = P_MODERATE; // moderate crash β†’ underwater but recoverable // Precondition: the account really is underwater at P. assert.ok(mmSurplus(snap, PARAMS, P) < 0n, "fixture must start underwater"); @@ -80,7 +89,7 @@ describe("predict/solve: solveFuturesLotsToTarget", () => { it("is the DEEPEST in-band subset β€” closing one more worst-first lot breaches IM", () => { const snap = twelveLongLots(); - const P = 3_900_000n; + const P = P_MODERATE; const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); // There is still a lot to add and doing so would push balance over IM. @@ -104,7 +113,7 @@ describe("predict/solve: solveFuturesLotsToTarget", () => { it("degenerate IM == MM: targets minimal healthy (no upper IM bound)", () => { const snap = twelveLongLots(); - const P = 3_900_000n; + const P = P_MODERATE; const degenerate: MMParams = { ...PARAMS, imSpotShock: PARAMS.mmSpotShock }; const ids = solveFuturesLotsToTarget(snap, degenerate, P, FEE); assert.ok(ids.length > 0 && ids.length <= snap.futures.positions.length); @@ -120,13 +129,13 @@ describe("predict/solve: solveFuturesLotsToTarget", () => { // spread the closures across both books. const positions: AccountSnapshot["futures"]["positions"] = []; for (let i = 0; i < 6; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n, true, EXPIRY_A)); + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY, true, EXPIRY_A)); } for (let i = 6; i < 12; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n, true, EXPIRY_B)); + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY, true, EXPIRY_B)); } - const snap = futuresSnapshot({ balance: 40_000_000n, futures: { positions, orderMargin: 0n, deliveryDays: 7n } }); - const P = 3_900_000n; + const snap = futuresSnapshot({ balance: BALANCE, futures: { positions, orderMargin: 0n } }); + const P = P_MODERATE; assert.ok(mmSurplus(snap, PARAMS, P) < 0n, "fixture must start underwater"); const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); @@ -155,13 +164,13 @@ describe("predict/solve: solveFuturesLotsToTarget", () => { // lots as B β€” rather than emptying the smaller book first. const positions: AccountSnapshot["futures"]["positions"] = []; for (let i = 0; i < 8; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n, true, EXPIRY_A)); + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY, true, EXPIRY_A)); } for (let i = 8; i < 12; i++) { - positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, 4_210_000n, true, EXPIRY_B)); + positions.push(futuresLot(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex, ENTRY_PER_DAY, true, EXPIRY_B)); } - const snap = futuresSnapshot({ balance: 40_000_000n, futures: { positions, orderMargin: 0n, deliveryDays: 7n } }); - const P = 3_900_000n; + const snap = futuresSnapshot({ balance: BALANCE, futures: { positions, orderMargin: 0n } }); + const P = P_MODERATE; const ids = solveFuturesLotsToTarget(snap, PARAMS, P, FEE); const byExpiry = (deliveryAt: bigint) => @@ -181,7 +190,7 @@ function perpSnapshot(netQty: bigint, entryPrice: bigint, balance: bigint): Acco user: USER, balance, perp: { netQty, entryPrice, orderMargin: 0n, fundingOwed: 0n }, - futures: { positions: [], orderMargin: 0n, deliveryDays: 0n }, + futures: { positions: [], orderMargin: 0n }, }; } diff --git a/keeper/tests/venues/futures.test.ts b/keeper/tests/venues/futures.test.ts index f1d2862..71d0b36 100644 --- a/keeper/tests/venues/futures.test.ts +++ b/keeper/tests/venues/futures.test.ts @@ -47,12 +47,10 @@ const silentLogger = { } as unknown as ConstructorParameters[2]; const DELIVERY_AT = 1_756_416_000n; // 2025-08-28T18:40:00Z (slice(0,10) β†’ "2025-08-28") -const DELIVERY_DURATION_DAYS = 7n; -/** Reusable stub: deliveryDurationDays + market price + (positionIds | orderIds) reads. */ -function makeReadHandler(deliveryDurationDays: bigint, marketPrice: bigint, listResult: readonly Hex[]) { +/** Reusable stub: market price + (positionIds | orderIds) reads. */ +function makeReadHandler(marketPrice: bigint, listResult: readonly Hex[]) { return (call: ReadCall): unknown => { - if (call.functionName === "deliveryDurationDays") return Number(deliveryDurationDays); if (call.functionName === "getMarketPrice") return marketPrice; if (call.functionName === "getOrderIds" || call.functionName === "getPositionIds") return listResult; throw new Error(`unexpected readContract call: ${call.functionName}`); @@ -71,7 +69,7 @@ describe("futures venue: readOpenOrders", () => { it("returns empty when getOrderIds is empty (no extra multicall)", async () => { let multicallCount = 0; const chain = makeChainStub({ - readContract: makeReadHandler(DELIVERY_DURATION_DAYS, 100n, []), + readContract: makeReadHandler(100n, []), multicall: () => { multicallCount++; return []; @@ -89,7 +87,7 @@ describe("futures venue: readOpenOrders", () => { "0x000000000000000000000000000000000000000000000000000000000000000b", ]; const chain = makeChainStub({ - readContract: makeReadHandler(DELIVERY_DURATION_DAYS, 100n, orderIds), + readContract: makeReadHandler(100n, orderIds), multicall: (calls) => { // One getOrderById per order id, in order. assert.equal(calls.length, 2); @@ -112,7 +110,7 @@ describe("futures venue: readOpenOrders", () => { describe("futures venue: readPositions", () => { it("returns empty when getPositionIds is empty", async () => { const chain = makeChainStub({ - readContract: makeReadHandler(DELIVERY_DURATION_DAYS, 100n, []), + readContract: makeReadHandler(100n, []), multicall: () => [], }); const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); @@ -124,9 +122,9 @@ describe("futures venue: readPositions", () => { const positionIds: Hex[] = ["0x" + "11".repeat(32) as Hex]; const buyPx = 100n; const sellPx = 100n; - const marketPrice = 70n; // long β†’ loses (100-70)*7days = 210 per contract + const marketPrice = 70n; // long β†’ loses (100-70) = 30 per contract (no duration factor) const chain = makeChainStub({ - readContract: makeReadHandler(DELIVERY_DURATION_DAYS, marketPrice, positionIds), + readContract: makeReadHandler(marketPrice, positionIds), multicall: (calls) => { assert.equal(calls.length, 1); assert.equal(calls[0]?.functionName, "getPositionById"); @@ -144,8 +142,8 @@ describe("futures venue: readPositions", () => { const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); const [pos] = await venue.readPositions(BUYER); assert.ok(pos); - assert.equal(pos.unrealizedLoss, (buyPx - marketPrice) * DELIVERY_DURATION_DAYS); - assert.equal(pos.notional, buyPx * DELIVERY_DURATION_DAYS); + assert.equal(pos.unrealizedLoss, buyPx - marketPrice); + assert.equal(pos.notional, buyPx); assert.equal(pos.marketId, deliveryAtMarketId(DELIVERY_AT)); }); @@ -153,9 +151,9 @@ describe("futures venue: readPositions", () => { const positionIds: Hex[] = ["0x" + "22".repeat(32) as Hex]; const sellPx = 100n; const buyPx = 100n; - const marketPrice = 130n; // short β†’ loses (130-100)*7days = 210 per contract + const marketPrice = 130n; // short β†’ loses (130-100) = 30 per contract (no duration factor) const chain = makeChainStub({ - readContract: makeReadHandler(DELIVERY_DURATION_DAYS, marketPrice, positionIds), + readContract: makeReadHandler(marketPrice, positionIds), multicall: () => [ { seller: SELLER, @@ -169,14 +167,14 @@ describe("futures venue: readPositions", () => { const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); const [pos] = await venue.readPositions(SELLER); assert.ok(pos); - assert.equal(pos.unrealizedLoss, (marketPrice - sellPx) * DELIVERY_DURATION_DAYS); - assert.equal(pos.notional, sellPx * DELIVERY_DURATION_DAYS); + assert.equal(pos.unrealizedLoss, marketPrice - sellPx); + assert.equal(pos.notional, sellPx); }); it("reports zero loss when the user is in profit", async () => { const positionIds: Hex[] = ["0x" + "33".repeat(32) as Hex]; const chain = makeChainStub({ - readContract: makeReadHandler(DELIVERY_DURATION_DAYS, 150n, positionIds), + readContract: makeReadHandler(150n, positionIds), multicall: () => [ { seller: SELLER, @@ -192,25 +190,4 @@ describe("futures venue: readPositions", () => { assert.ok(pos); assert.equal(pos.unrealizedLoss, 0n, "buyer with market > entry is in profit"); }); - - it("caches deliveryDurationDays across calls (read once)", async () => { - let durationReads = 0; - const chain = makeChainStub({ - readContract: (call) => { - if (call.functionName === "deliveryDurationDays") { - durationReads++; - return Number(DELIVERY_DURATION_DAYS); - } - if (call.functionName === "getMarketPrice") return 100n; - if (call.functionName === "getPositionIds") return []; - throw new Error(`unexpected ${call.functionName}`); - }, - multicall: () => [], - }); - const venue = new FuturesVenue(chain, makeConfigStub(), silentLogger); - await venue.readPositions(BUYER); - await venue.readPositions(BUYER); - await venue.readPositions(BUYER); - assert.equal(durationReads, 1, "deliveryDurationDays read only once"); - }); }); diff --git a/keeper/tests/venues/reduceToTarget.test.ts b/keeper/tests/venues/reduceToTarget.test.ts index b50c9d4..ce55a4b 100644 --- a/keeper/tests/venues/reduceToTarget.test.ts +++ b/keeper/tests/venues/reduceToTarget.test.ts @@ -77,7 +77,6 @@ function makeChainStub(opts: { 0n, // getPendingFunding 0n, // getFuturesOrderMargin opts.futuresPositionIds, - 7, // deliveryDurationDays ]; } // readAccountSnapshot per-position hydration @@ -104,18 +103,18 @@ function makeChainStub(opts: { describe("futures venue: reduceToTarget", () => { it("sizes a strict worst-first lot subset and submits one liquidatePositions batch", async () => { - // 12 long lots @ $4.21/day, $40 deposit, crash to $3.90 β€” underwater but - // recoverable (mirrors the solver's in-band fixture). + // 12 long lots @ $40/day, $136 deposit, crash to $30 β€” underwater but + // recoverable (mirrors the solver's in-band fixture; no duration factor). const ids: Hex[] = []; for (let i = 0; i < 12; i++) ids.push(`0x${(i + 1).toString(16).padStart(64, "0")}` as Hex); let simulated: ReadCall | undefined; const chain = makeChainStub({ - balance: 40_000_000n, - marketPrice: 3_900_000n, + balance: 136_000_000n, + marketPrice: 30_000_000n, liquidationFee: 1_000_000n, perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, futuresPositionIds: ids, - futuresPosition: { buyer: USER, buyPricePerDay: 4_210_000n, sellPricePerDay: 4_210_000n }, + futuresPosition: { buyer: USER, buyPricePerDay: 40_000_000n, sellPricePerDay: 40_000_000n }, onSimulate: (call) => { simulated = call; }, @@ -133,7 +132,7 @@ describe("futures venue: reduceToTarget", () => { }); it("caps the batch to maxLotsPerLiquidationTx (gas-bounded chunking)", async () => { - // 12 long lots @ $4.21/day, $40 deposit, crash to $1.00 β€” a deep crash the + // 12 long lots @ $40/day, $136 deposit, crash to $1.00 β€” a deep crash the // solver resolves to a FULL close (all 12 ids). With a cap below 12, // `reduceToTarget` must send only the worst-first prefix and report // `positionsClosed` == cap; the planner loop drains the rest next iteration. @@ -143,12 +142,12 @@ describe("futures venue: reduceToTarget", () => { // Uncapped target first, so the assertion is robust to the solver's sizing. let full: Hex[] = []; const chainFull = makeChainStub({ - balance: 40_000_000n, + balance: 136_000_000n, marketPrice: 1_000_000n, liquidationFee: 1_000_000n, perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, futuresPositionIds: ids, - futuresPosition: { buyer: USER, buyPricePerDay: 4_210_000n, sellPricePerDay: 4_210_000n }, + futuresPosition: { buyer: USER, buyPricePerDay: 40_000_000n, sellPricePerDay: 40_000_000n }, onSimulate: (call) => { full = (call.args as [Address, Hex[]])[1]; }, @@ -159,12 +158,12 @@ describe("futures venue: reduceToTarget", () => { const cap = full.length - 1; let chunk: Hex[] = []; const chainCap = makeChainStub({ - balance: 40_000_000n, + balance: 136_000_000n, marketPrice: 1_000_000n, liquidationFee: 1_000_000n, perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, futuresPositionIds: ids, - futuresPosition: { buyer: USER, buyPricePerDay: 4_210_000n, sellPricePerDay: 4_210_000n }, + futuresPosition: { buyer: USER, buyPricePerDay: 40_000_000n, sellPricePerDay: 40_000_000n }, onSimulate: (call) => { chunk = (call.args as [Address, Hex[]])[1]; }, @@ -183,11 +182,11 @@ describe("futures venue: reduceToTarget", () => { let simulateCalled = false; const chain = makeChainStub({ balance: 1_000_000_000n, // fully collateralised - marketPrice: 3_900_000n, + marketPrice: 30_000_000n, liquidationFee: 1_000_000n, perp: { netQuantity: 0n, aggregatedEntryPrice: 0n }, futuresPositionIds: ids, - futuresPosition: { buyer: USER, buyPricePerDay: 4_210_000n, sellPricePerDay: 4_210_000n }, + futuresPosition: { buyer: USER, buyPricePerDay: 40_000_000n, sellPricePerDay: 40_000_000n }, onSimulate: () => { simulateCalled = true; }, diff --git a/market-maker/.gitignore b/market-maker/.gitignore new file mode 100644 index 0000000..404abb2 --- /dev/null +++ b/market-maker/.gitignore @@ -0,0 +1 @@ +coverage/ diff --git a/market-maker/configs/portfolio.local.yml b/market-maker/configs/portfolio.local.yml new file mode 100644 index 0000000..6a2e3d1 --- /dev/null +++ b/market-maker/configs/portfolio.local.yml @@ -0,0 +1,117 @@ +# yaml-language-server: $schema=../schemas/portfolio.json +# Titan Market Maker - Portfolio (perps + all futures expiries) - LOCAL (hardhat). +# +# 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} +logLevel: debug +dryRun: false +cancelOrdersOnShutdown: false + +wallets: + primary: + privateKey: ${PRIVATE_KEY} + +# Single shared signer for the whole portfolio. +wallet: primary + +network: + name: "hardhat" + rpcUrl: "http://127.0.0.1:8545" + ethPriceFeed: ${ETH_PRICE_FEED_ADDRESS:-} + +venues: + - kind: perps + address: ${PERPS_ADDRESS} + maxPositionSize: 10 + pricing: + strategy: effective-spread + minSpreadBps: 20 + volatilityMultiplier: 2.5 + inventorySkewGamma: 1.0 + maxSkewTicks: 5 + sizing: + strategy: linear + baseQuantity: "10000000" # venue-native (perps hashrate base units) + numLevelsPerSide: 3 + + - 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 + 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: 3 + taperRatio: 0.6 + +# Shared portfolio-wide budget across every market. +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 + requoteThresholdTicks: 2 + requoteCooldownSec: 1 + resyncIntervalSec: 60 + levelSpacingTicks: 1 + +collateral: + autoDeposit: false + autoDepositMinAmount: 0 + +oracle: + windowSize: 60 + precisionBits: 48 + historyLookbackMultiplier: 4 + +health: + port: 3001 + +# Centralized submission / nonce recovery. +txCoordinator: + maxCallsPerTx: 50 + confirmationTimeoutSec: 60 + maxReplacements: 2 + replacementFeeBumpPct: 15 + +# Per-market fault isolation. +circuitBreaker: + quarantineThreshold: 3 + baseBackoffSec: 5 + maxBackoffSec: 180 + +rollCheckIntervalSec: 300 +sharedStalenessGraceSec: 30 + +readBatchSize: 100 +writeBatchSize: 20 diff --git a/market-maker/package.json b/market-maker/package.json index 0c74965..31d2cb0 100644 --- a/market-maker/package.json +++ b/market-maker/package.json @@ -8,31 +8,38 @@ }, "scripts": { "test": "pnpm node --test --test-force-exit --test-concurrency=1 'tests/**/*.test.ts'", + "test:coverage": "pnpm node --test --test-force-exit --test-concurrency=1 --experimental-test-coverage --test-coverage-include='src/**' 'tests/**/*.test.ts'", + "test:coverage:lcov": "mkdir -p coverage && pnpm node --test --test-force-exit --test-concurrency=1 --experimental-test-coverage --test-coverage-include='src/**' --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info 'tests/**/*.test.ts'", "gen:schemas": "pnpm node scripts/gen-schemas.ts", "pretypecheck": "pnpm gen:schemas", "typecheck": "tsgo --noEmit", "node": "node --import=amaro/strip --conditions=typescript", "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 perps --config configs/perps.local.yml | pino-pretty", "local:futures": "pnpm futures --config configs/futures.local.yml | pino-pretty", + "local:portfolio": "pnpm portfolio --config configs/portfolio.local.yml | pino-pretty", "dev:perps": "pnpm perps --config configs/perps.dev.yml | pino-pretty", "dev:futures": "pnpm futures --config configs/futures.dev.yml | pino-pretty", + "dev:portfolio": "pnpm portfolio --config configs/portfolio.dev.yml | pino-pretty", "stg:perps": "pnpm perps --config configs/perps.stg.yml", "stg:futures": "pnpm futures --config configs/futures.stg.yml", + "stg:portfolio": "pnpm portfolio --config configs/portfolio.stg.yml", "prd:perps": "pnpm perps --config configs/perps.prd.yml", - "prd:futures": "pnpm futures --config configs/futures.prd.yml" + "prd:futures": "pnpm futures --config configs/futures.prd.yml", + "prd:portfolio": "pnpm portfolio --config configs/portfolio.prd.yml" }, "dependencies": { "@sinclair/typebox": "^0.34.49", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "amaro": "^1.1.9", - "collateral-margin-contracts": "github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts", + "collateral-margin-contracts": "github:Lumerin-protocol/collateral-margin#dev&path:/contracts", "fraction.js": "^5.2.2", - "futures-contracts": "github:Lumerin-protocol/futures-marketplace#dev&path:/contracts", + "futures-contracts": "github:Lumerin-protocol/futures-marketplace#5e28bb346b125d90eb138370a8a78c69487d1c26&path:/contracts", "js-yaml": "^4.1.0", - "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts", + "perps-contracts": "github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts", "pino": "^10.3.1", "viem": "^2.45.3" }, diff --git a/market-maker/pnpm-lock.yaml b/market-maker/pnpm-lock.yaml index 547aaa8..c3b8829 100644 --- a/market-maker/pnpm-lock.yaml +++ b/market-maker/pnpm-lock.yaml @@ -21,20 +21,20 @@ importers: specifier: ^1.1.9 version: 1.1.9 collateral-margin-contracts: - specifier: github:Lumerin-protocol/collateral-margin.git#dev&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc#path:/contracts(typescript@5.9.3) + specifier: github:Lumerin-protocol/collateral-margin#dev&path:/contracts + version: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/d335ea93b2052d8b66d654b3cdad51ce4e47615a#path:/contracts(typescript@5.9.3) fraction.js: specifier: ^5.2.2 version: 5.3.4 futures-contracts: - specifier: github:Lumerin-protocol/futures-marketplace#dev&path:/contracts - version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) + specifier: github:Lumerin-protocol/futures-marketplace#5e28bb346b125d90eb138370a8a78c69487d1c26&path:/contracts + version: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3) js-yaml: specifier: ^4.1.0 version: 4.1.1 perps-contracts: - specifier: github:Lumerin-protocol/derivatives-marketplace#dev&path:/contracts - version: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/ce6471c9e2b6b8058dd89a440c68a6e06ab082fc#path:/contracts(@nomicfoundation/hardhat-ethers@3.1.3(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)))(@types/node@22.19.17)(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)) + specifier: github:Lumerin-protocol/derivatives-marketplace#c8078b9e430df8a5ec8bc64865d692b38cc69d8c&path:/contracts + version: derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts(@nomicfoundation/hardhat-ethers@3.1.3(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)))(@types/node@22.19.17)(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)) pino: specifier: ^10.3.1 version: 10.3.1 @@ -1133,11 +1133,8 @@ packages: brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - brace-expansion@2.1.0: - resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - - brace-expansion@2.1.1: - resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -1239,13 +1236,13 @@ packages: cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} - collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc} + collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/d335ea93b2052d8b66d654b3cdad51ce4e47615a#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/d335ea93b2052d8b66d654b3cdad51ce4e47615a} version: 1.0.0 engines: {node: 24.x} - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79: - resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512: + resolution: {tarball: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512} version: 1.0.0 color-convert@2.0.1: @@ -1331,8 +1328,8 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/ce6471c9e2b6b8058dd89a440c68a6e06ab082fc#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/ce6471c9e2b6b8058dd89a440c68a6e06ab082fc} + derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c} version: 1.0.0 engines: {node: 24.x} @@ -1545,8 +1542,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c#path:/contracts: - resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c} + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts: + resolution: {path: /contracts, tarball: https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26} version: 1.0.0 engines: {node: 24.x} @@ -1676,8 +1673,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - immutable@4.3.8: - resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==} + immutable@4.3.9: + resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==} import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} @@ -1789,14 +1786,18 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - json-stream-stringify@3.1.6: - resolution: {integrity: sha512-x7fpwxOkbhFCaJDJ8vb1fBY3DdSa4AlITaz+HHILQJzdPMnHEFjxPwVUi1ALIbcIxDE0PNe/0i7frnY8QnBQog==} + json-stream-stringify@3.1.7: + resolution: {integrity: sha512-F4MWetLtY42YMaAKw5cV4e47zMD5aOT+tjjQWjX18ACtdkQ5Y/vrcfbcQ107Rh+MXjOCIx4KhW0wPmOvG8iQ5w==} engines: {node: '>=7.10.1'} jsonfile@4.0.0: @@ -2056,8 +2057,8 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} pify@4.0.1: @@ -2372,8 +2373,8 @@ packages: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} - tinyglobby@0.2.16: - resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} tmp@0.0.33: @@ -2559,8 +2560,8 @@ packages: resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} engines: {node: '>=10'} - yargs@16.2.0: - resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + yargs@16.2.2: + resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==} engines: {node: '>=10'} yocto-queue@0.1.0: @@ -3108,7 +3109,7 @@ snapshots: '@changesets/parse@0.4.3': dependencies: '@changesets/types': 6.1.0 - js-yaml: 4.1.1 + js-yaml: 4.3.0 '@changesets/pre@2.0.2': dependencies: @@ -3151,7 +3152,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -4340,11 +4341,7 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.0: - dependencies: - balanced-match: 1.0.2 - - brace-expansion@2.1.1: + brace-expansion@2.1.2: dependencies: balanced-match: 1.0.2 @@ -4469,7 +4466,7 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/b8045fafcf7178928d414269e6d86d797b2e2cdc#path:/contracts(typescript@5.9.3): + collateral-margin-contracts@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/d335ea93b2052d8b66d654b3cdad51ce4e47615a#path:/contracts(typescript@5.9.3): dependencies: '@openzeppelin/contracts': 5.1.0 '@openzeppelin/contracts-upgradeable': 5.1.0(@openzeppelin/contracts@5.1.0) @@ -4481,7 +4478,7 @@ snapshots: - utf-8-validate - zod - collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79: {} + collateral-margin@https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512: {} color-convert@2.0.1: dependencies: @@ -4564,14 +4561,14 @@ snapshots: depd@2.0.0: {} - derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/ce6471c9e2b6b8058dd89a440c68a6e06ab082fc#path:/contracts(@nomicfoundation/hardhat-ethers@3.1.3(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)))(@types/node@22.19.17)(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)): + derivatives-contracts@https://codeload.github.com/Lumerin-protocol/derivatives-marketplace/tar.gz/c8078b9e430df8a5ec8bc64865d692b38cc69d8c#path:/contracts(@nomicfoundation/hardhat-ethers@3.1.3(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)))(@types/node@22.19.17)(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)): dependencies: '@chainlink/contracts': 1.5.0(@types/node@22.19.17)(ethers@5.8.0) '@multicall/multicall3': multicall3@https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 '@openzeppelin/contracts': 5.1.0 '@openzeppelin/contracts-upgradeable': 5.1.0(@openzeppelin/contracts@5.1.0) '@openzeppelin/hardhat-upgrades': 3.9.1(@nomicfoundation/hardhat-ethers@3.1.3(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)))(ethers@5.8.0)(hardhat@2.28.6(typescript@5.9.3)) - collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79 + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/b65adbfeb7e6c4417747bfd3d94b6e89e162ed50 multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 solidity-linked-list: 6.5.0 @@ -4777,9 +4774,9 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.5 fill-range@7.1.1: dependencies: @@ -4847,7 +4844,7 @@ snapshots: function-bind@1.1.2: {} - futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/f3fa176dd95c456a91dccd4feee4e54f610af05c#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): + futures-contracts@https://codeload.github.com/Lumerin-protocol/futures-marketplace/tar.gz/5e28bb346b125d90eb138370a8a78c69487d1c26#path:/contracts(@types/node@22.19.17)(ethers@5.8.0)(typescript@5.9.3): dependencies: '@chainlink/contracts': 1.5.0(@types/node@22.19.17)(ethers@5.8.0) '@noble/curves': 1.9.1 @@ -4856,7 +4853,7 @@ snapshots: '@safe-global/api-kit': 3.0.2(typescript@5.9.3) '@safe-global/protocol-kit': 6.1.2(typescript@5.9.3) '@safe-global/types-kit': 2.0.1(typescript@5.9.3) - collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/ef77952259de3abc1b219e18618ef3fb49ebce79 + collateral-margin: https://codeload.github.com/Lumerin-protocol/collateral-margin/tar.gz/a7f89b8fd167655d1157568230bc6779ce37d512 hashprice-oracle: https://codeload.github.com/Lumerin-protocol/hashprice-oracle/tar.gz/55776ca92c86c69e38e5210e9b63d77d9e4218d8 multicall3: https://codeload.github.com/mds1/multicall3/tar.gz/b667d67ecfa5361a81e8f110234ce242613b0012 solidity-linked-list: 6.5.0 @@ -4949,9 +4946,9 @@ snapshots: find-up: 5.0.0 fp-ts: 1.19.3 fs-extra: 7.0.1 - immutable: 4.3.8 + immutable: 4.3.9 io-ts: 1.10.4 - json-stream-stringify: 3.1.6 + json-stream-stringify: 3.1.7 keccak: 3.0.4 lodash: 4.18.1 micro-eth-signer: 0.14.0 @@ -4965,7 +4962,7 @@ snapshots: solc: 0.8.26(debug@4.4.3) source-map-support: 0.5.21 stacktrace-parser: 0.1.11 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 tsort: 0.0.1 undici: 5.29.0 uuid: 8.3.2 @@ -5048,7 +5045,7 @@ snapshots: ignore@5.3.2: {} - immutable@4.3.8: {} + immutable@4.3.9: {} import-fresh@3.3.1: dependencies: @@ -5140,11 +5137,15 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} - json-stream-stringify@3.1.6: {} + json-stream-stringify@3.1.7: {} jsonfile@4.0.0: optionalDependencies: @@ -5234,11 +5235,11 @@ snapshots: minimatch@5.1.9: dependencies: - brace-expansion: 2.1.1 + brace-expansion: 2.1.2 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.0 + brace-expansion: 2.1.2 minimist@1.2.8: {} @@ -5257,7 +5258,7 @@ snapshots: find-up: 5.0.0 glob: 8.1.0 he: 1.2.0 - js-yaml: 4.1.1 + js-yaml: 4.3.0 log-symbols: 4.1.0 minimatch: 5.1.9 ms: 2.1.3 @@ -5265,7 +5266,7 @@ snapshots: strip-json-comments: 3.1.1 supports-color: 8.1.1 workerpool: 6.5.1 - yargs: 16.2.0 + yargs: 16.2.2 yargs-parser: 20.2.9 yargs-unparser: 2.0.0 @@ -5405,7 +5406,7 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.4: {} + picomatch@4.0.5: {} pify@4.0.1: {} @@ -5710,10 +5711,10 @@ snapshots: dependencies: real-require: 0.2.0 - tinyglobby@0.2.16: + tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tmp@0.0.33: dependencies: @@ -5854,7 +5855,7 @@ snapshots: flat: 5.0.2 is-plain-obj: 2.1.0 - yargs@16.2.0: + yargs@16.2.2: dependencies: cliui: 7.0.4 escalade: 3.2.0 diff --git a/market-maker/schemas/portfolio.json b/market-maker/schemas/portfolio.json new file mode 100644 index 0000000..9ebdf9a --- /dev/null +++ b/market-maker/schemas/portfolio.json @@ -0,0 +1,1482 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Titan Market Maker - Portfolio config", + "additionalProperties": false, + "description": "Titan Market Maker β€” unified portfolio app config.", + "type": "object", + "required": [ + "nodeEnv", + "commitHash", + "logLevel", + "dryRun", + "cancelOrdersOnShutdown", + "wallets", + "wallet", + "network", + "venues", + "risk", + "gas", + "collateral", + "oracle", + "timing", + "health", + "rollCheckIntervalSec", + "sharedStalenessGraceSec", + "readBatchSize", + "writeBatchSize" + ], + "properties": { + "nodeEnv": { + "default": "development", + "type": "string" + }, + "commitHash": { + "default": "unknown", + "type": "string" + }, + "logLevel": { + "default": "info", + "type": "string" + }, + "dryRun": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": false + }, + "cancelOrdersOnShutdown": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": true + }, + "wallets": { + "description": "Named signer wallets; `wallet` selects the portfolio signer.", + "type": "object", + "patternProperties": { + "^(.*)$": { + "additionalProperties": false, + "description": "Named signer wallet. Referenced by venue.wallet.", + "type": "object", + "required": [ + "privateKey" + ], + "properties": { + "privateKey": { + "anyOf": [ + { + "pattern": "^0x[a-fA-F0-9]+$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Hex-encoded ECDSA private key for the signer." + } + } + } + } + }, + "wallet": { + "description": "Key in `wallets` for the single shared signer. All venues submit through this one account/nonce.", + "type": "string" + }, + "network": { + "additionalProperties": false, + "description": "Network connection settings.", + "type": "object", + "required": [ + "name", + "rpcUrl" + ], + "properties": { + "name": { + "description": "Chain id (hardhat, base-sepolia, base, arbitrum). Resolves the viem chain object.", + "type": "string" + }, + "rpcUrl": { + "description": "JSON-RPC endpoint URL for reads and tx submission.", + "type": "string" + }, + "ethPriceFeed": { + "description": "Optional Chainlink ETH/USD aggregator. Required for USD-denominated gas budgets; leave empty for local hardhat.", + "anyOf": [ + { + "const": "", + "type": "string" + }, + { + "anyOf": [ + { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + } + } + }, + "venues": { + "minItems": 1, + "description": "Venues to run in this process (perps and/or futures).", + "type": "array", + "items": { + "anyOf": [ + { + "additionalProperties": false, + "description": "Perps venue in the portfolio.", + "type": "object", + "required": [ + "kind", + "address", + "maxPositionSize", + "pricing", + "sizing" + ], + "properties": { + "kind": { + "const": "perps", + "type": "string" + }, + "address": { + "anyOf": [ + { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Deployed HashPowerPerpsDEX address." + }, + "maxPositionSize": { + "description": "USD. Per-venue net position cap for perps.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "pricing": { + "additionalProperties": false, + "description": "Effective-spread pricing parameters.", + "type": "object", + "required": [ + "strategy", + "minSpreadBps", + "volatilityMultiplier", + "inventorySkewGamma", + "maxSkewTicks" + ], + "properties": { + "strategy": { + "description": "Pricing strategy. Perps lock to 'effective-spread' (symmetric mid-spread).", + "const": "effective-spread", + "type": "string" + }, + "minSpreadBps": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Floor on the half-spread in bps. Quotes never tighten below this." + }, + "volatilityMultiplier": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Multiplier applied to realized volatility when widening the spread." + }, + "inventorySkewGamma": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Inventory skew coefficient. Quotes shift by Ξ³ Γ— (netPos / maxPos) ticks toward unwinding." + }, + "maxSkewTicks": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Cap on absolute ticks a level can be skewed from the symmetric mid." + } + } + }, + "sizing": { + "additionalProperties": false, + "description": "Linear-ladder sizing parameters.", + "type": "object", + "required": [ + "strategy", + "baseQuantity", + "numLevelsPerSide" + ], + "properties": { + "strategy": { + "description": "Sizing strategy. Perps lock to 'linear' (level k receives (k+1) Γ— baseQuantity).", + "const": "linear", + "type": "string" + }, + "baseQuantity": { + "description": "Per-level base size in venue-native units (perps: hashrate base units). Use a string for values > 2^53.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "numLevelsPerSide": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Number of price levels quoted per side." + } + } + } + } + }, + { + "additionalProperties": false, + "description": "Futures venue (one market per selected expiry).", + "type": "object", + "required": [ + "kind", + "address", + "maxPositionSize", + "pricing", + "sizing" + ], + "properties": { + "kind": { + "const": "futures", + "type": "string" + }, + "address": { + "anyOf": [ + { + "pattern": "^0x[a-fA-F0-9]{40}$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Deployed Futures address." + }, + "maxPositionSize": { + "description": "USD. Per-expiry net position cap for futures markets.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "marketSelection": { + "description": "Which futures expiries to quote: 'nearest' N dates, or explicit 'indices' into the nearest-first window.", + "anyOf": [ + { + "additionalProperties": false, + "type": "object", + "required": [ + "mode" + ], + "properties": { + "mode": { + "const": "nearest", + "type": "string" + }, + "count": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + } + }, + { + "additionalProperties": false, + "type": "object", + "required": [ + "mode", + "indices" + ], + "properties": { + "mode": { + "const": "indices", + "type": "string" + }, + "indices": { + "type": "array", + "items": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + } + } + } + ] + }, + "pricing": { + "additionalProperties": false, + "description": "Reservation-price pricing parameters.", + "type": "object", + "required": [ + "strategy", + "riskAversion", + "marginCallTimeSec", + "minSpreadBps", + "volatilityMultiplier", + "maxSkewTicks" + ], + "properties": { + "strategy": { + "description": "Pricing strategy. Futures lock to 'reservation-price' (Avellaneda–Stoikov inventory skew).", + "const": "reservation-price", + "type": "string" + }, + "riskAversion": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Avellaneda–Stoikov risk aversion Ξ³. Higher = stronger inventory skew." + }, + "marginCallTimeSec": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Seconds. Fallback time-to-margin-call when InstrumentContext.deliveryDate is unavailable." + }, + "minSpreadBps": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Floor on the half-spread in bps. Quotes never tighten below this." + }, + "volatilityMultiplier": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Multiplier applied to realized volatility when widening the spread." + }, + "maxSkewTicks": { + "const": 0, + "default": 0, + "description": "Pinned to 0 β€” under reservation-price the skew is encoded in r itself.", + "type": "number" + } + } + }, + "sizing": { + "additionalProperties": false, + "description": "Geometric-taper sizing parameters.", + "type": "object", + "required": [ + "strategy", + "baseQuantity", + "numLevelsPerSide", + "taperRatio" + ], + "properties": { + "strategy": { + "description": "Sizing strategy. Futures lock to 'geometric-taper' (front level largest, decays by taperRatio).", + "const": "geometric-taper", + "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.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "numLevelsPerSide": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Number of price levels quoted per side." + }, + "taperRatio": { + "anyOf": [ + { + "exclusiveMinimum": 0, + "exclusiveMaximum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Geometric decay ratio in (0, 1). Each subsequent level is taperRatio Γ— the previous." + } + } + } + } + } + ] + } + }, + "risk": { + "additionalProperties": false, + "description": "Risk caps, circuit-breakers, and gas-price guards.", + "type": "object", + "required": [ + "maxPositionSize", + "maxUtilizationPct", + "minCollateralBalance", + "maxDailyLossUsd", + "maxGasBudgetPerHourUsd", + "maxGasBudgetPerDayUsd", + "gasSpikeThresholdPct", + "gasPenaltyBps", + "urgentRequoteThresholdTicks" + ], + "properties": { + "maxPositionSize": { + "description": "USD. Hard cap on |net position notional|. Beyond this, only risk-reducing quotes are placed.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxUtilizationPct": { + "anyOf": [ + { + "minimum": 0, + "maximum": 100, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Margin utilization (used IM / vault balance) above which only risk-reducing quotes are placed.", + "default": 80 + }, + "minCollateralBalance": { + "description": "USD. Operational floor; halts quoting when vault balance falls below this.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxDailyLossUsd": { + "description": "USD. Daily PnL circuit-breaker. Halts quoting when realized loss + gas exceeds this since 00:00 UTC.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxGasBudgetPerHourUsd": { + "default": 50, + "description": "USD. Soft throttle: when hourly gas spend exceeds this, requote cooldown triples.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxGasBudgetPerDayUsd": { + "default": 500, + "description": "USD. Hard halt: stops requoting once daily gas spend exceeds this.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "gasSpikeThresholdPct": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Percent of baseline. Quotes pause when current gas price exceeds (baseline Γ— pct/100).", + "default": 200 + }, + "gasPenaltyBps": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Bps to widen spreads by per unit of gas-cost-as-fraction-of-notional (compensates for fill economics).", + "default": 5 + }, + "urgentRequoteThresholdTicks": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Tick distance from oracle at which a stale order is requoted immediately, ignoring cooldown.", + "default": 10 + } + } + }, + "gas": { + "additionalProperties": false, + "description": "Gas-pricing knobs.", + "type": "object", + "required": [ + "gasCapMultiplier" + ], + "properties": { + "gasCapMultiplier": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Multiplier on viem-suggested gas price for the maxFeePerGas cap. Higher = more reliable inclusion at higher cost.", + "default": 2 + } + } + }, + "collateral": { + "additionalProperties": false, + "description": "Collateral vault behaviour.", + "type": "object", + "required": [ + "autoDeposit", + "autoDepositMinAmount" + ], + "properties": { + "autoDeposit": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "If true, sweeps wallet token balance into the vault on each loop iteration (subject to min/max).", + "default": false + }, + "autoDepositMinAmount": { + "default": 0, + "description": "USD. Trigger threshold: deposit fires only when wallet balance β‰₯ this. Dust filter to avoid wasting gas on tiny sweeps.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxCollateralAmount": { + "description": "USD. Optional ceiling on the total vault balance held by this MM. Each auto-deposit brings the vault up to (but not above) this value; the wallet retains anything beyond it. Omit for no ceiling.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^-?\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + } + } + }, + "oracle": { + "additionalProperties": false, + "description": "OracleTracker / volatility-window configuration.", + "type": "object", + "required": [ + "windowSize", + "precisionBits", + "historyLookbackMultiplier" + ], + "properties": { + "windowSize": { + "anyOf": [ + { + "minimum": 3, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "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.", + "default": 60 + }, + "precisionBits": { + "anyOf": [ + { + "minimum": 16, + "maximum": 256, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "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": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "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 + }, + "history": { + "additionalProperties": false, + "description": "Historical price source for Οƒ window backfill.", + "type": "object", + "required": [ + "subgraphUrl" + ], + "properties": { + "subgraphUrl": { + "description": "GraphQL endpoint for the hashprice-oracle subgraph (queries the HashpriceUsd time-series). Empty string is treated as 'no source' so YAML can use ${VAR:-} patterns; omit the entire `history` block for the same effect.", + "type": "string" + } + } + } + } + }, + "timing": { + "additionalProperties": false, + "description": "Loop cadences and requote thresholds.", + "type": "object", + "required": [ + "pollIntervalSec", + "requoteThresholdTicks", + "requoteCooldownSec", + "resyncIntervalSec", + "levelSpacingTicks" + ], + "properties": { + "pollIntervalSec": { + "default": 3, + "description": "Seconds between main-loop iterations (snapshot, quote, execute).", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 0.1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "requoteThresholdTicks": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Tick deviation from current target before a resting order is replaced.", + "default": 2 + }, + "requoteCooldownSec": { + "default": 1, + "description": "Seconds between requote bursts. Tripled when risk is throttled.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "resyncIntervalSec": { + "default": 60, + "description": "Seconds between full BookTracker snapshot refetches (event deltas in between).", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "levelSpacingTicks": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Ticks between successive quote levels. 1 = quote every tick, 5 = every fifth.", + "default": 1 + } + } + }, + "health": { + "additionalProperties": false, + "description": "Health-check HTTP server.", + "type": "object", + "required": [ + "port" + ], + "properties": { + "port": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "TCP port for the /healthz HTTP endpoint.", + "default": 3001 + } + } + }, + "txCoordinator": { + "additionalProperties": false, + "default": {}, + "description": "Centralized submission / nonce recovery.", + "type": "object", + "required": [ + "maxCallsPerTx", + "confirmationTimeoutSec", + "maxReplacements", + "replacementFeeBumpPct" + ], + "properties": { + "maxCallsPerTx": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Max encoded multicall entries per tx before chunking.", + "default": 50 + }, + "confirmationTimeoutSec": { + "default": 60, + "description": "Seconds to wait for a tx receipt before replacing by fee.", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxReplacements": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Replacement-by-fee attempts before escalating to a cancel-tx.", + "default": 2 + }, + "replacementFeeBumpPct": { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Fee bump per replacement attempt, percent.", + "default": 15 + } + } + }, + "circuitBreaker": { + "additionalProperties": false, + "default": {}, + "description": "Per-market circuit-breaker tuning.", + "type": "object", + "required": [ + "quarantineThreshold", + "baseBackoffSec", + "maxBackoffSec" + ], + "properties": { + "quarantineThreshold": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "description": "Consecutive market errors before quarantine.", + "default": 3 + }, + "baseBackoffSec": { + "default": 5, + "description": "Base quarantine backoff (seconds).", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "maxBackoffSec": { + "default": 180, + "description": "Backoff ceiling (seconds).", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + } + } + }, + "rollCheckIntervalSec": { + "default": 300, + "description": "Seconds between futures roll re-checks (add/drop expiries).", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "sharedStalenessGraceSec": { + "default": 30, + "description": "Seconds shared inputs may be stale before new placements are paused (existing orders kept).", + "anyOf": [ + { + "anyOf": [ + { + "pattern": "^\\d+(\\.\\d+)?$", + "type": "string" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + }, + { + "anyOf": [ + { + "minimum": 0, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ] + } + ] + }, + "readBatchSize": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 10 + }, + "writeBatchSize": { + "anyOf": [ + { + "minimum": 1, + "type": "number" + }, + { + "type": "string", + "pattern": "^\\$\\{[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\\}$", + "description": "Environment variable interpolation (resolved at startup)" + } + ], + "default": 20 + } + } +} diff --git a/market-maker/scripts/gen-schemas.ts b/market-maker/scripts/gen-schemas.ts index 7d822b6..e78880f 100644 --- a/market-maker/scripts/gen-schemas.ts +++ b/market-maker/scripts/gen-schemas.ts @@ -11,6 +11,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { perpsRootSchema } from "../src/apps/perps/config.ts"; import { futuresRootSchema } from "../src/apps/futures/config.ts"; +import { portfolioRootSchema } from "../src/apps/portfolio/config.ts"; const here = dirname(fileURLToPath(import.meta.url)); const outDir = resolve(here, "..", "schemas"); @@ -83,6 +84,7 @@ function relaxForEnvInterpolation(node: unknown): unknown { const targets = [ { name: "perps", schema: perpsRootSchema, title: "Titan Market Maker - Perps config" }, { name: "futures", schema: futuresRootSchema, title: "Titan Market Maker - Futures config" }, + { name: "portfolio", schema: portfolioRootSchema, title: "Titan Market Maker - Portfolio config" }, ] as const; for (const t of targets) { diff --git a/market-maker/src/adapters/futures/events.ts b/market-maker/src/adapters/futures/events.ts index 30ead7f..006e799 100644 --- a/market-maker/src/adapters/futures/events.ts +++ b/market-maker/src/adapters/futures/events.ts @@ -6,7 +6,10 @@ import type { } from "../../core/adapter.ts"; import { FuturesAbi } from "futures-contracts/abi/Futures"; -export const FUTURES_INSTRUMENT_ID = "futures"; +/** Instrument id for a futures expiry, e.g. `futures:1893456000`. */ +export function futuresInstrumentId(deliveryDate: bigint): string { + return `futures:${deliveryDate.toString()}`; +} type FuturesLog = Log< bigint, @@ -61,11 +64,12 @@ export class FuturesVenueEvents implements VenueEvents { export function decodeEvent(log: FuturesLog): VenueEvent | null { switch (log.eventName) { case "OrderCreated": { - const { orderId, participant, pricePerDay, isBuy } = log.args; + const { orderId, participant, pricePerDay, deliveryAt, isBuy } = log.args; if ( !orderId || !participant || pricePerDay === undefined || + deliveryAt === undefined || isBuy === undefined ) return null; @@ -75,33 +79,34 @@ export function decodeEvent(log: FuturesLog): VenueEvent | null { participant, price: pricePerDay, side: isBuy ? "buy" : "sell", - size: 1n, // futures orders are always single-contract per OrderCreated event - instrumentId: FUTURES_INSTRUMENT_ID, + size: 1n, // futures orders are single-contract per OrderCreated event + instrumentId: futuresInstrumentId(deliveryAt), + deliveryDate: deliveryAt, }; } case "OrderClosed": { const { orderId } = log.args; if (!orderId) return null; + // OrderClosed carries neither participant nor deliveryAt; per-expiry + // own-order caches resolve ownership + routing by cache membership. return { type: "order-cancelled", orderId, - instrumentId: FUTURES_INSTRUMENT_ID, }; } case "LotCreated": { - const { lotId, seller, buyer } = log.args; - if (!lotId || !seller || !buyer) return null; + const { seller, deliveryAt } = log.args; + if (!seller || deliveryAt === undefined) return null; return { type: "position-changed", participant: seller, - instrumentId: FUTURES_INSTRUMENT_ID, + instrumentId: futuresInstrumentId(deliveryAt), }; } case "LotClosed": return { type: "position-changed", participant: "0x0" as `0x${string}`, - instrumentId: FUTURES_INSTRUMENT_ID, }; default: return null; diff --git a/market-maker/src/adapters/futures/index.ts b/market-maker/src/adapters/futures/index.ts index a65c0a1..6fe911a 100644 --- a/market-maker/src/adapters/futures/index.ts +++ b/market-maker/src/adapters/futures/index.ts @@ -1,7 +1,7 @@ import type pino from "pino"; import type { NetworkClients } from "../../core/client.ts"; import type { VenueAdapter, WalletContext } from "../../core/adapter.ts"; -import { FuturesVenueAdapter } from "./venue.ts"; +import { FuturesVenueAdapter, type FuturesMarketSelection } from "./venue.ts"; export interface CreateFuturesVenueOpts { network: NetworkClients; @@ -10,6 +10,8 @@ export interface CreateFuturesVenueOpts { multicall3Address?: `0x${string}`; readBatchSize: number; writeBatchSize: number; + /** Which delivery dates to quote. Defaults to nearest-only. */ + marketSelection?: FuturesMarketSelection; logger: pino.Logger; } @@ -25,4 +27,5 @@ export async function createFuturesVenue( } export { FuturesVenueAdapter } from "./venue.ts"; -export { FuturesInstrumentAdapter } from "./instrument.ts"; +export type { FuturesMarketSelection, FuturesMarketSet } from "./venue.ts"; +export { FuturesInstrumentAdapter, futuresInstrumentId } from "./instrument.ts"; diff --git a/market-maker/src/adapters/futures/instrument.ts b/market-maker/src/adapters/futures/instrument.ts index 0a6a984..5dfed27 100644 --- a/market-maker/src/adapters/futures/instrument.ts +++ b/market-maker/src/adapters/futures/instrument.ts @@ -16,85 +16,123 @@ import type { import { FuturesAbi } from "futures-contracts/abi/Futures"; import type { FuturesVenueAdapter } from "./venue.ts"; import { FuturesOwnOrders } from "./ownOrders.ts"; +import { futuresInstrumentId } from "./events.ts"; -const FUTURES_INSTRUMENT_ID = "futures"; +export { futuresInstrumentId } from "./events.ts"; +/** + * One futures market = one delivery date (expiry). The venue creates one + * adapter per selected expiry; each owns its own book snapshot, own-order + * cache, and order encoding, all scoped to `deliveryDate`. + * + * Position and margin reads are per-expiry (client-side), while the shared + * portfolio collateral/IM/MM lives on the venue's `CollateralAccount`. + */ export class FuturesInstrumentAdapter implements InstrumentAdapter { - readonly id = FUTURES_INSTRUMENT_ID; + readonly id: string; readonly venue: FuturesVenueAdapter; readonly book: FuturesBook; readonly ownOrders: FuturesOwnOrders; + readonly deliveryDate: bigint; private tickCache: bigint | null = null; - private deliveryDateCache: bigint | null = null; - private deliveryDurationDaysCache: bigint | null = null; + private marginPercentCache: bigint | null = null; - constructor(venue: FuturesVenueAdapter, logger: pino.Logger) { + constructor(venue: FuturesVenueAdapter, deliveryDate: bigint, logger: pino.Logger) { this.venue = venue; + this.deliveryDate = deliveryDate; + this.id = futuresInstrumentId(deliveryDate); this.book = new FuturesBook(this, venue.readBatchSize); - this.ownOrders = new FuturesOwnOrders(venue, logger, venue.readBatchSize); + this.ownOrders = new FuturesOwnOrders( + venue, + deliveryDate, + logger.child({ instrument: this.id }), + venue.readBatchSize, + ); } async getIndexPrice(): Promise { - // Read the raw oracle answer rebased to token decimals β€” `Futures.getMarketPrice` - // would round to the nearest tick, which collapses our reservation-price - // shift onto a tick boundary and forces a 2-tick min spread. The unrounded - // mid lets `roundDownToTick(r) β†’ bidMid` and `roundUpToTick(r) β†’ askMid` - // produce a 1-tick spread naturally. + // Raw oracle answer rebased to token decimals (no tick rounding). All + // futures expiries share the same per-day hashprice oracle, so the index + // is identical across markets; only time-to-expiry (T) differs downstream. return await this.venue.getRawMarketPrice(); } async getPosition(): Promise { - // Futures' net position is summed from all open positions. We use the - // engine view exposed for this purpose: getNetPositionDelta returns - // `Ξ£ qty_i * deliveryDurationDays` Γ— 1e18 in WAD. Convert back to - // contracts by dividing by `deliveryDurationDays * 1e18`. - const [netDeltaWad, durationDays, marketPrice] = await Promise.all([ - this.venue.publicClient.readContract({ - address: this.venue.address, - abi: FuturesAbi, - functionName: "getNetPositionDelta", - args: [this.venue.wallet.account.address], - }), - this.venue.publicClient.readContract({ - address: this.venue.address, - abi: FuturesAbi, - functionName: "deliveryDurationDays", - }), - this.venue.getRawMarketPrice(), - ]); - const days = BigInt(durationDays); - const denom = days * 10n ** 18n; - const netQuantity = denom === 0n ? 0n : netDeltaWad / denom; - return { netQuantity, entryPrice: marketPrice }; - } - - async getContext(): Promise { - const deliveryDates = await this.venue.publicClient.readContract({ + // Per-expiry net position, computed client-side. The engine's + // `getNetPositionDelta` is portfolio-wide (sums all expiries), so we walk + // this expiry's positions instead. Each position is a single matched unit + // (qty=1): buyer is long (+1), seller is short (-1). + const owner = this.venue.wallet.account.address.toLowerCase(); + const positionIds = await this.venue.publicClient.readContract({ address: this.venue.address, abi: FuturesAbi, - functionName: "getDeliveryDates", + functionName: "getPositionsByParticipantDeliveryDate", + args: [this.venue.wallet.account.address, this.deliveryDate], }); - if (deliveryDates.length === 0) - throw new Error("futures contract returned no delivery dates"); - this.deliveryDateCache = deliveryDates[0]; - // Eagerly cache margin inputs so `estimateOrderMargin` can be synchronous. - const { deliveryDurationDays } = await this.venue.getMarginInputs(); - this.deliveryDurationDaysCache = deliveryDurationDays; + if (positionIds.length === 0) { + return { netQuantity: 0n, entryPrice: await this.venue.getRawMarketPrice() }; + } + + const batchSize = this.venue.readBatchSize; + const positions: { + seller: string; + buyer: string; + sellPricePerDay: bigint; + buyPricePerDay: bigint; + }[] = []; + for (let i = 0; i < positionIds.length; i += batchSize) { + const chunk = positionIds.slice(i, i + batchSize); + const results = await this.venue.publicClient.multicall({ + allowFailure: false, + contracts: chunk.map((id) => ({ + address: this.venue.address, + abi: FuturesAbi, + functionName: "getPositionById" as const, + args: [id] as const, + })), + }); + positions.push( + ...(results as { + seller: string; + buyer: string; + sellPricePerDay: bigint; + buyPricePerDay: bigint; + }[]), + ); + } + let net = 0n; + let entrySum = 0n; + let entryCount = 0n; + for (const p of positions) { + if (p.buyer.toLowerCase() === owner) { + net += 1n; + entrySum += p.buyPricePerDay; + entryCount += 1n; + } + if (p.seller.toLowerCase() === owner) { + net -= 1n; + entrySum += p.sellPricePerDay; + entryCount += 1n; + } + } + const entryPrice = + entryCount > 0n ? entrySum / entryCount : await this.venue.getRawMarketPrice(); + return { netQuantity: net, entryPrice }; + } + + async getContext(): Promise { + // Eagerly cache marginPct so `estimateOrderMargin` is synchronous. + const { marginPct } = await this.venue.getMarginInputs(); + this.marginPercentCache = marginPct; return { - deliveryDate: Number(deliveryDates[0]), - contractMultiplier: deliveryDurationDays, + deliveryDate: Number(this.deliveryDate), }; } encodeCreate(intent: OrderIntent): `0x${string}` { - if (this.deliveryDateCache === null) { - throw new Error( - "futures: getContext() must be called before encodeCreate()", - ); - } const qty = Number(intent.size); if (qty <= 0 || qty > 127) { throw new Error(`futures: order size ${qty} must be in (0, 127]`); @@ -105,7 +143,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { return encodeFunctionData({ abi: FuturesAbi, functionName: "createOrder", - args: [intent.price, this.deliveryDateCache, "", signed], + args: [intent.price, this.deliveryDate, "", signed], }); } @@ -118,58 +156,50 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { } /** - * Execute cancels then creates on-chain. Owns the full lifecycle: - * encoding β†’ batching β†’ tx chunking β†’ broadcast β†’ receipt gathering. - * - * Cancels (individual closeOrder calls, max 20 per batch) are placed - * before creates (createOrders calls, max 10 per batch) so margin is - * freed before new risk is added. + * Execute cancels then creates for this expiry. Kept for single-market + * callers and tests; the portfolio runner routes through the shared + * `TxCoordinator` instead, which batches this expiry's calls with the other + * expiries' into one `Futures.multicall`. */ - async executeOrders( - intent: ExecuteOrdersIntent, - ): Promise { + async executeOrders(intent: ExecuteOrdersIntent): Promise { return this.executeOrdersImpl(intent, this.venue.getLogger()); } + /** Build the ordered call list for this expiry: cancels then creates. */ + buildCalls(intent: { cancels: CancelIntent[]; creates: OrderIntent[] }): `0x${string}`[] { + const calls: `0x${string}`[] = []; + for (const c of intent.cancels) calls.push(this.encodeCancel(c)); + for (const c of intent.creates) calls.push(this.encodeCreate(c)); + return calls; + } + // ── Private implementation ────────────────────────────────────────── - /** - * Shared implementation β€” the inner `logger` param makes this testable - * without coupling to the full venue adapter. - */ private async executeOrdersImpl( intent: ExecuteOrdersIntent, logger: pino.Logger, ): Promise { - // 1. Build the ordered call list: cancels first, then creates. - const calls = this.buildCallList(intent); + const batches = this.chunkCalls(intent); if (intent.dryRun) { logger.info( - { - cancels: intent.cancels.length, - creates: intent.creates.length, - }, + { cancels: intent.cancels.length, creates: intent.creates.length }, "DRY RUN: would send multicall batches", ); return { receipts: [], errors: [] }; } - // 2. Chunk into tx-sized groups and broadcast sequentially. const receipts: { gasUsed: bigint; effectiveGasPrice: bigint }[] = []; const errors: Error[] = []; - const totalBatches = calls.length; - - console.log(calls); - for (let batchNum = 0; batchNum < totalBatches; batchNum++) { - const chunk = calls[batchNum]; + for (let batchNum = 0; batchNum < batches.length; batchNum++) { + const chunk = batches[batchNum]; try { const hash = await this.venue.multicall(chunk, { maxFeePerGas: intent.maxFeePerGas, }); - const receipt = await this.venue.publicClient.waitForTransactionReceipt( - { hash }, - ); + const receipt = await this.venue.publicClient.waitForTransactionReceipt({ + hash, + }); receipts.push({ gasUsed: receipt.gasUsed, effectiveGasPrice: receipt.effectiveGasPrice, @@ -177,7 +207,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { logger.info( { calls: chunk.length, - batch: `${batchNum}/${totalBatches}`, + batch: `${batchNum}/${batches.length}`, gas: receipt.gasUsed.toString(), }, "futures multicall chunk executed", @@ -186,100 +216,53 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { const wrapped = err instanceof Error ? err : new Error(String(err)); errors.push(wrapped); logger.error( - { - err: wrapped, - calls: chunk.length, - batch: `${batchNum}/${totalBatches}`, - }, + { err: wrapped, calls: chunk.length, batch: `${batchNum}/${batches.length}` }, "futures multicall chunk failed β€” continuing with next chunk", ); } } - return { receipts, errors }; } - /** Build the ordered call list: cancels (individual closeOrder) then creates (createOrders). */ - private buildCallList(intent: ExecuteOrdersIntent): `0x${string}`[][] { + /** + * Split cancels+creates into tx-sized chunks. Batch size is measured in qty + * count since one cancel costs roughly one qty=1 create. + */ + private chunkCalls(intent: { + cancels: CancelIntent[]; + creates: OrderIntent[]; + }): `0x${string}`[][] { const batchSize = this.venue.writeBatchSize; - - let batchN = 0; - let qtyCount = 0; // we limit batch by qty count, since gas cost of one cancel approx eq one create order qty=1 - const batch: `0x${string}`[][] = []; - function addTx(tx: `0x${string}`, qty: number) { - if (!batch[batchN]) { - batch[batchN] = new Array(); - } - batch[batchN].push(tx); + const batches: `0x${string}`[][] = []; + let current: `0x${string}`[] = []; + let qtyCount = 0; + const push = (tx: `0x${string}`, qty: number) => { + current.push(tx); qtyCount += qty; - if (batch[batchN].length >= batchSize) { - batchN++; + if (current.length >= batchSize || qtyCount >= batchSize) { + batches.push(current); + current = []; qtyCount = 0; } - } - - for (const c of intent.cancels) { - addTx(this.encodeCancel(c), 1); - } - - for (const c of intent.creates) { - addTx(this.encodeCreate(c), Number(c.size)); - } - - return batch; - } - - /** Encode a batch of creates via the `createOrders` contract function. */ - private encodeCreateOrders(intents: OrderIntent[]): `0x${string}` { - if (!this.deliveryDateCache) { - throw new Error("Delivery data cache not filled"); - } - const _deliveryDateCache = this.deliveryDateCache; - return encodeFunctionData({ - abi: FuturesAbi, - functionName: "createOrders", - args: [ - intents.map((i) => ({ - pricePerDay: i.price, - deliveryDate: _deliveryDateCache, - destURL: "", - qty: i.side === "buy" ? Number(i.size) : -Number(i.size), - })), - ], - }); + }; + for (const c of intent.cancels) push(this.encodeCancel(c), 1); + for (const c of intent.creates) push(this.encodeCreate(c), Number(c.size)); + if (current.length > 0) batches.push(current); + return batches; } /** - * Mirrors `Futures.getMaintenanceMarginForPosition` for a single new order: - * IM_added = pricePerDay Γ— deliveryDurationDays Γ— |qty| Γ— marginPct / 100 - * - * `getFuturesOrderMargin` clamps each order's marginal contribution at 0 - * when its mark-to-market PnL exceeds maintenance (a profitable order - * locks no extra margin). We don't mirror that branch here: it would - * make the estimate sign-dependent on the live oracle, and the - * conservative "always charge full maintenance" estimate is fine because - * `engine.canPlaceOrder` is the real authority. We err on the high side - * by O(few percent), which only costs us a tiny slice of quoting capacity. + * IM added by a new order: + * pricePerDay Γ— |qty| Γ— marginPct / 100 (one unit, no duration multiplier) + * Conservative (ignores the profitable-order clamp); the engine's + * `canPlaceOrder` is the real authority. */ estimateOrderMargin(intent: OrderIntent): bigint { - if (this.deliveryDurationDaysCache === null) return 0n; - // marginPct is loaded lazily at first canPlace call; if we don't have it - // yet, return 0 and let the engine gate sort it out on the first tx. - const cachedMarginPct = ( - this.venue as unknown as { marginPercentCache?: bigint } - ).marginPercentCache; - if (!cachedMarginPct) return 0n; - return ( - (intent.price * - this.deliveryDurationDaysCache * - intent.size * - cachedMarginPct) / - 100n - ); + if (this.marginPercentCache === null) return 0n; + return (intent.price * intent.size * this.marginPercentCache) / 100n; } async estimateCreateGas(account: `0x${string}`): Promise { - if (this.deliveryDateCache === null) return 0n; try { return await this.venue.publicClient.estimateContractGas({ address: this.venue.address, @@ -287,7 +270,7 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { functionName: "createOrder", args: [ 1_000_000n, - this.deliveryDateCache, + this.deliveryDate, "", 1 as number & { readonly __int8__: true }, ], @@ -308,17 +291,9 @@ export class FuturesInstrumentAdapter implements InstrumentAdapter { this.tickCache = tick; return tick; } - - /** Internal: nearest delivery date, populated after `getContext()`. */ - getDeliveryDate(): bigint | null { - return this.deliveryDateCache; - } } -/** - * Per-(deliveryDate) book source. The MM is locked to the nearest delivery - * date β€” see venue header for rationale. - */ +/** Per-expiry book source. Reads the ladders for this instrument's delivery date. */ class FuturesBook implements BookSource { readonly matchingMode: MatchingMode = "exact"; private readonly inst: FuturesInstrumentAdapter; @@ -334,12 +309,7 @@ class FuturesBook implements BookSource { async snapshot(opts: { depth?: number } = {}): Promise { const v = this.inst.venue; - const dd = this.inst.getDeliveryDate(); - if (dd === null) { - throw new Error( - "futures: getContext() must be called before book.snapshot()", - ); - } + const dd = this.inst.deliveryDate; const depth = BigInt(opts.depth ?? 200); const [bidPrices, askPrices] = await v.publicClient.multicall({ @@ -360,8 +330,7 @@ class FuturesBook implements BookSource { ], }); - if (bidPrices.length === 0 && askPrices.length === 0) - return { bids: [], asks: [] }; + if (bidPrices.length === 0 && askPrices.length === 0) return { bids: [], asks: [] }; const allCalls = [ ...bidPrices.map((p) => ({ @@ -378,7 +347,6 @@ class FuturesBook implements BookSource { })), ]; - // Chunk to stay under RPC payload / timeout limits. const batchSize = this.readBatchSize; const allResults: bigint[] = []; for (let i = 0; i < allCalls.length; i += batchSize) { @@ -398,13 +366,8 @@ class FuturesBook implements BookSource { price: p, quantity: allResults[bidPrices.length + i], })); - // EnumerableSet returns prices in unspecified order; sort for the consumer. - const bids = bidsRaw.sort((a, b) => - a.price < b.price ? 1 : a.price > b.price ? -1 : 0, - ); - const asks = asksRaw.sort((a, b) => - a.price < b.price ? -1 : a.price > b.price ? 1 : 0, - ); + const bids = bidsRaw.sort((a, b) => (a.price < b.price ? 1 : a.price > b.price ? -1 : 0)); + const asks = asksRaw.sort((a, b) => (a.price < b.price ? -1 : a.price > b.price ? 1 : 0)); return { bids, asks }; } } diff --git a/market-maker/src/adapters/futures/ownOrders.ts b/market-maker/src/adapters/futures/ownOrders.ts index 387034d..5d0ad9a 100644 --- a/market-maker/src/adapters/futures/ownOrders.ts +++ b/market-maker/src/adapters/futures/ownOrders.ts @@ -7,23 +7,24 @@ import type { } from "../../core/adapter.ts"; import { FuturesAbi } from "futures-contracts/abi/Futures"; import type { FuturesVenueAdapter } from "./venue.ts"; -import { FUTURES_INSTRUMENT_ID } from "./events.ts"; +import { futuresInstrumentId } from "./events.ts"; + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; /** - * Cache-backed own-order source for futures. - * - * Why a cache? `Futures.sol` previously had no view returning a participant's - * orders. The new `getOrderIds` view (added alongside this adapter) lets us - * skip the historical event-scan path entirely: + * Cache-backed own-order source for a single futures expiry. * - * 1. `bootstrap()` reads `getOrderIds(wallet)` and `getOrderById(id)` for - * each in one multicall, populating the cache. - * 2. `subscribe()` listens to venue events and applies adds/removes to the - * cache, then forwards the event to the registered callback. - * 3. `list()` returns `Array.from(cache.values())`. + * The contract has no per-participant order view scoped by delivery date, so + * we read all of the wallet's orders and keep only those matching this + * instrument's `deliveryDate`: * - * Idempotency: bootstrap clears the cache before re-populating, so calling - * it twice is safe. + * 1. `bootstrap()` reads `getOrderIds(wallet)` + `getOrderById(id)` and + * caches the orders whose `deliveryAt === deliveryDate`. + * 2. `subscribe()` listens to venue events. `order-created` is filtered by + * participant AND instrumentId (which encodes the expiry). `order-cancelled` + * carries no expiry, so we apply it only if the id is in *this* cache β€” + * that both identifies ownership and routes to the right expiry. + * 3. `list()` returns the cache contents. */ export class FuturesOwnOrders implements OwnOrderSource { private readonly cache = new Map<`0x${string}`, OwnOrder>(); @@ -32,15 +33,20 @@ export class FuturesOwnOrders implements OwnOrderSource { private bootstrapped = false; private readonly venue: FuturesVenueAdapter; + private readonly deliveryDate: bigint; + private readonly instrumentId: string; private readonly logger: pino.Logger; private readonly readBatchSize: number; constructor( venue: FuturesVenueAdapter, + deliveryDate: bigint, logger: pino.Logger, readBatchSize: number, ) { this.venue = venue; + this.deliveryDate = deliveryDate; + this.instrumentId = futuresInstrumentId(deliveryDate); this.logger = logger.child({ component: "futures-own-orders" }); this.readBatchSize = readBatchSize; } @@ -74,10 +80,7 @@ export class FuturesOwnOrders implements OwnOrderSource { if (orderIds.length === 0) { this.bootstrapped = true; - this.logger.info( - { orders: 0 }, - "futures own-orders bootstrapped (empty)", - ); + this.logger.info({ orders: 0 }, "futures own-orders bootstrapped (empty)"); return; } @@ -88,7 +91,6 @@ export class FuturesOwnOrders implements OwnOrderSource { args: [id] as const, })); - // Chunk to stay under RPC payload / timeout limits. const batchSize = this.readBatchSize; const allOrders: unknown[] = []; for (let i = 0; i < allCalls.length; i += batchSize) { @@ -104,25 +106,24 @@ export class FuturesOwnOrders implements OwnOrderSource { const o = allOrders[i] as { participant: string; pricePerDay: bigint; + deliveryAt: bigint; isBuy: boolean; }; - if ( - !o.participant || - o.participant === "0x0000000000000000000000000000000000000000" - ) - continue; + if (!o.participant || o.participant === ZERO_ADDRESS) continue; + // Keep only orders belonging to this expiry. + if (o.deliveryAt !== this.deliveryDate) continue; this.cache.set(orderIds[i], { orderId: orderIds[i], price: o.pricePerDay, side: o.isBuy ? "buy" : "sell", size: 1n, - instrumentId: FUTURES_INSTRUMENT_ID, + instrumentId: this.instrumentId, }); } this.bootstrapped = true; this.logger.info( - { orders: this.cache.size }, + { orders: this.cache.size, deliveryDate: this.deliveryDate.toString() }, "futures own-orders bootstrapped", ); } @@ -132,19 +133,21 @@ export class FuturesOwnOrders implements OwnOrderSource { return this.venue.events.subscribe((evt) => { if (evt.type === "order-created") { if (evt.participant.toLowerCase() !== own) return; + // Route by expiry: the created event carries the instrumentId. + if (evt.instrumentId !== this.instrumentId) return; const order: OwnOrder = { orderId: evt.orderId, price: evt.price, side: evt.side, size: 1n, - instrumentId: FUTURES_INSTRUMENT_ID, + instrumentId: this.instrumentId, }; this.cache.set(evt.orderId, order); this.notify({ type: "added", orderId: evt.orderId, order }); return; } if (evt.type === "order-cancelled") { - // OrderClosed no longer carries participant; identify own orders by cache. + // No expiry on the close event: apply only if this cache owns the id. if (!this.cache.has(evt.orderId)) return; this.cache.delete(evt.orderId); this.notify({ type: "removed", orderId: evt.orderId }); diff --git a/market-maker/src/adapters/futures/venue.ts b/market-maker/src/adapters/futures/venue.ts index 5bc3795..52d0f96 100644 --- a/market-maker/src/adapters/futures/venue.ts +++ b/market-maker/src/adapters/futures/venue.ts @@ -2,9 +2,11 @@ import { encodeFunctionData, erc20Abi } from "viem"; import type { Chain, PublicClient, Transport } from "viem"; import type pino from "pino"; import type { + BatchableCollateralAccount, CollateralAccount, CollateralSnapshot, InstrumentAdapter, + MarginReadPlan, VenueAdapter, VenueEvents, WalletContext, @@ -20,6 +22,17 @@ import { attachTenderlyUrl } from "../../core/tenderly.ts"; import { FuturesInstrumentAdapter } from "./instrument.ts"; import { FuturesVenueEvents } from "./events.ts"; +/** + * How the venue picks which delivery dates to quote out of the rolling window + * returned by `getDeliveryDates()` (ordered nearest-first). + * + * - `nearest`: the first `count` dates (count=1 reproduces the legacy MVP). + * - `indices`: explicit relative offsets into the window (0 = nearest). + */ +export type FuturesMarketSelection = + | { mode: "nearest"; count: number } + | { mode: "indices"; indices: number[] }; + export interface FuturesVenueOptions { network: NetworkClients; wallet: WalletContext; @@ -29,9 +42,21 @@ export interface FuturesVenueOptions { readBatchSize: number; /** Max closeOrder calls per cancellation batch. Default 20. */ writeBatchSize: number; + /** Which delivery dates to quote. Defaults to `{ mode: "nearest", count: 1 }`. */ + marketSelection?: FuturesMarketSelection; logger: pino.Logger; } +/** Outcome of a `resolveMarkets()` roll check. */ +export interface FuturesMarketSet { + /** Instruments the venue currently wants quoted, nearest-first. */ + active: FuturesInstrumentAdapter[]; + /** Instruments newly added since the previous resolve (need bootstrap). */ + added: FuturesInstrumentAdapter[]; + /** Instruments dropped since the previous resolve (matured / rolled off). */ + dropped: FuturesInstrumentAdapter[]; +} + /** * Futures venue: Futures contract, the shared CollateralVault, the * PortfolioMarginEngine. @@ -60,12 +85,15 @@ export class FuturesVenueAdapter implements VenueAdapter { private readonly multicall3Address: `0x${string}`; readonly readBatchSize: number; readonly writeBatchSize: number; - private instrumentSingleton: FuturesInstrumentAdapter | null = null; + private readonly marketSelection: FuturesMarketSelection; + /** deliveryDate β†’ instrument, memoized so each expiry has one adapter. */ + private readonly instruments = new Map(); + /** deliveryDates currently selected (as strings), from the last resolve. */ + private activeKeys: string[] = []; private vaultAddressCache: `0x${string}` | null = null; private engineAddressCache: `0x${string}` | null = null; private collateralTokenCache: `0x${string}` | null = null; - private deliveryDurationDaysCache: bigint | null = null; private marginPercentCache: bigint | null = null; private readonly rawOracle: RawOracleReader; @@ -85,6 +113,7 @@ export class FuturesVenueAdapter implements VenueAdapter { this.multicall3Address = mc3; this.readBatchSize = opts.readBatchSize; this.writeBatchSize = opts.writeBatchSize; + this.marketSelection = opts.marketSelection ?? { mode: "nearest", count: 1 }; this.events = new FuturesVenueEvents(this.publicClient, this.address); this.account = new FuturesCollateralAccount(this); @@ -96,7 +125,7 @@ export class FuturesVenueAdapter implements VenueAdapter { publicClient: this.publicClient, label: "futures", resolve: async () => { - const [oracle, divisor] = await this.publicClient.multicall({ + const [oracle, divisor, contractSizeHpsDay, oracleUnitHpsDay] = await this.publicClient.multicall({ allowFailure: false, contracts: [ { @@ -109,26 +138,113 @@ export class FuturesVenueAdapter implements VenueAdapter { abi: FuturesAbi, functionName: "hashpriceScalingDivisor", }, + { + address: this.address, + abi: FuturesAbi, + functionName: "CONTRACT_SIZE_HPS_DAY", + }, + { + address: this.address, + abi: FuturesAbi, + functionName: "ORACLE_UNIT_HPS_DAY", + }, ], }); - return { oracle, divisor }; + return { oracle, divisor, contractSizeHpsDay, oracleUnitHpsDay }; }, }); } + /** Nearest-expiry instrument. Back-compat / single-market entrypoint. */ async getInstrument(): Promise { - if (!this.instrumentSingleton) { - this.instrumentSingleton = new FuturesInstrumentAdapter( - this, - this.logger, + const dates = await this.readDeliveryDates(); + if (dates.length === 0) throw new Error("futures contract returned no delivery dates"); + return this.instrumentFor(dates[0]); + } + + /** All currently-selected expiries, nearest-first. */ + async listInstruments(): Promise { + const { active } = await this.resolveMarkets(); + return active; + } + + /** + * Re-read the rolling delivery-date window, apply the configured selection, + * and diff against the previously-active set. Instruments are memoized per + * expiry, so `added`/`dropped` let the runner bootstrap new markets and tear + * down matured ones without disturbing the survivors. + */ + async resolveMarkets(): Promise { + const dates = await this.readDeliveryDates(); + const selected = this.selectDates(dates); + const selectedKeys = selected.map((d) => d.toString()); + + const prev = new Set(this.activeKeys); + const next = new Set(selectedKeys); + + const added: FuturesInstrumentAdapter[] = []; + for (const d of selected) { + if (!prev.has(d.toString())) added.push(this.instrumentFor(d)); + } + const dropped: FuturesInstrumentAdapter[] = []; + for (const key of this.activeKeys) { + if (!next.has(key)) { + const inst = this.instruments.get(key); + if (inst) dropped.push(inst); + this.instruments.delete(key); + } + } + + this.activeKeys = selectedKeys; + const active = selected.map((d) => this.instrumentFor(d)); + + if (added.length > 0 || dropped.length > 0) { + this.logger.info( + { + active: active.map((i) => i.deliveryDate.toString()), + added: added.map((i) => i.deliveryDate.toString()), + dropped: dropped.map((i) => i.deliveryDate.toString()), + }, + "futures markets resolved", ); } - return this.instrumentSingleton; + return { active, added, dropped }; + } + + private instrumentFor(deliveryDate: bigint): FuturesInstrumentAdapter { + const key = deliveryDate.toString(); + let inst = this.instruments.get(key); + if (!inst) { + inst = new FuturesInstrumentAdapter(this, deliveryDate, this.logger); + this.instruments.set(key, inst); + } + return inst; + } + + private async readDeliveryDates(): Promise { + const dates = await this.publicClient.readContract({ + address: this.address, + abi: FuturesAbi, + functionName: "getDeliveryDates", + }); + return [...dates]; + } + + private selectDates(dates: bigint[]): bigint[] { + if (dates.length === 0) return []; + if (this.marketSelection.mode === "nearest") { + return dates.slice(0, Math.max(0, this.marketSelection.count)); + } + const out: bigint[] = []; + for (const idx of this.marketSelection.indices) { + if (idx >= 0 && idx < dates.length) out.push(dates[idx]); + } + return out; } async multicall( calls: `0x${string}`[], - opts: { maxFeePerGas?: bigint } = {}, + opts: { maxFeePerGas?: bigint; nonce?: number } = {}, ): Promise<`0x${string}`> { try { return await this.wallet.walletClient.writeContract({ @@ -139,6 +255,7 @@ export class FuturesVenueAdapter implements VenueAdapter { account: this.wallet.account, chain: this.chain, maxFeePerGas: opts.maxFeePerGas, + nonce: opts.nonce, }); } catch (err) { // Attach a Tenderly simulation URL so the failed multicall can be @@ -217,47 +334,23 @@ export class FuturesVenueAdapter implements VenueAdapter { } /** - * Cache delivery-duration-days and marginPercent on the venue. Both are - * static-ish (admin-changeable) so we read them once and reuse for the - * `estimateOrderMargin` formula. + * Cache marginPercent on the venue. It is static-ish (admin-changeable) so we + * read it once and reuse for the `estimateOrderMargin` formula. */ - async getMarginInputs(): Promise<{ - deliveryDurationDays: bigint; - marginPct: bigint; - }> { - if ( - this.deliveryDurationDaysCache !== null && - this.marginPercentCache !== null - ) { - return { - deliveryDurationDays: this.deliveryDurationDaysCache, - marginPct: this.marginPercentCache, - }; + async getMarginInputs(): Promise<{ marginPct: bigint }> { + if (this.marginPercentCache !== null) { + return { marginPct: this.marginPercentCache }; } - const [duration, liqMarginPct] = await this.publicClient.multicall({ - allowFailure: false, - contracts: [ - { - address: this.address, - abi: FuturesAbi, - functionName: "deliveryDurationDays", - }, - { - address: this.address, - abi: FuturesAbi, - functionName: "liquidationMarginPercent", - }, - ], + const liqMarginPct = await this.publicClient.readContract({ + address: this.address, + abi: FuturesAbi, + functionName: "liquidationMarginPercent", }); // Note: `getMarginPercent` on chain adds a breach-penalty term we don't // mirror here β€” we use `liquidationMarginPercent` as a slight over-estimate. // The on-chain check is the real authority; this is just our pre-trade gate. - this.deliveryDurationDaysCache = BigInt(duration); this.marginPercentCache = BigInt(liqMarginPct); - return { - deliveryDurationDays: this.deliveryDurationDaysCache, - marginPct: this.marginPercentCache, - }; + return { marginPct: this.marginPercentCache }; } } @@ -268,88 +361,66 @@ export class FuturesVenueAdapter implements VenueAdapter { * portfolio IM/MM, futures order margin (positive resting margin), futures * unrealized PnL (signed), wallet ERC20 balance, native ETH balance. */ -class FuturesCollateralAccount implements CollateralAccount { +class FuturesCollateralAccount implements BatchableCollateralAccount { private readonly venue: FuturesVenueAdapter; constructor(venue: FuturesVenueAdapter) { this.venue = venue; } - async snapshot(): Promise { + /** + * Decompose the snapshot into shared (portfolio-wide) + venue-specific reads. + * `shared` order matches the perps account so the aggregator can decode one + * shared result slice for every venue: + * [vaultBalance, portfolioIM, portfolioMM, walletTokenBalance, nativeBalance] + */ + async buildMarginReadPlan(): Promise { const owner = this.venue.wallet.account.address; const { vault, engine, token } = await this.venue.resolveAddresses(); const mc3 = this.venue.getMulticall3Address(); - const [ - vaultBalance, - portfolioIM, - portfolioMM, - orderMargin, - unrealizedPnl, - walletTokenBalance, - nativeBalance, - ] = await this.venue.publicClient.multicall({ - allowFailure: false, - contracts: [ - { - address: vault, - abi: CollateralVaultAbi, - functionName: "balanceOf", - args: [owner], - }, - { - address: engine, - abi: PortfolioMarginEngineAbi, - functionName: "computePortfolioIM", - args: [owner], - }, - { - address: engine, - abi: PortfolioMarginEngineAbi, - functionName: "computePortfolioMM", - args: [owner], - }, - { - address: this.venue.address, - abi: FuturesAbi, - functionName: "getFuturesOrderMargin", - args: [owner], - }, - { - address: this.venue.address, - abi: FuturesAbi, - functionName: "getFuturesUnrealizedPnl", - args: [owner], - }, - { - address: token, - abi: erc20Abi, - functionName: "balanceOf", - args: [owner], - }, - { - address: mc3, - abi: Multicall3Abi, - functionName: "getEthBalance", - args: [owner], - }, - ], - }); + const shared = [ + { address: vault, abi: CollateralVaultAbi, functionName: "balanceOf", args: [owner] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioIM", args: [owner] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioMM", args: [owner] }, + { address: token, abi: erc20Abi, functionName: "balanceOf", args: [owner] }, + { address: mc3, abi: Multicall3Abi, functionName: "getEthBalance", args: [owner] }, + ] as MarginReadPlan["shared"]; - return { - vaultBalance, - portfolioIM, - portfolioMM, - venueOrderMargin: orderMargin, - venueUnrealizedPnl: unrealizedPnl, - walletTokenBalance, - nativeBalance, - collateralToken: token, + const venue = [ + { address: this.venue.address, abi: FuturesAbi, functionName: "getFuturesOrderMargin", args: [owner] }, + { address: this.venue.address, abi: FuturesAbi, functionName: "getFuturesUnrealizedPnl", args: [owner] }, + ] as MarginReadPlan["venue"]; + + const decode = (results: readonly unknown[]): CollateralSnapshot => { + const r = results as bigint[]; + const [vaultBalance, portfolioIM, portfolioMM, walletTokenBalance, nativeBalance] = r; + return { + vaultBalance, + portfolioIM, + portfolioMM, + venueOrderMargin: r[5], + venueUnrealizedPnl: r[6], + walletTokenBalance, + nativeBalance, + collateralToken: token, + }; }; + + return { shared, venue, decode }; + } + + async snapshot(): Promise { + const plan = await this.buildMarginReadPlan(); + const results = await this.venue.publicClient.multicall({ + allowFailure: false, + contracts: [...plan.shared, ...plan.venue], + }); + return plan.decode(results); } async imSpotShock(): Promise { - // Futures uses pricePerDay Γ— deliveryDurationDays Γ— marginPct/100, not a - // spot-shock model. Returns 0 to signal "not applicable" β€” adapters don't + // Futures uses pricePerDay Γ— marginPct/100 (one unit, no duration multiplier), + // not a spot-shock model. Returns 0 to signal "not applicable" β€” adapters don't // use this directly; estimateOrderMargin reads from getMarginInputs instead. return 0n; } diff --git a/market-maker/src/adapters/perps/index.ts b/market-maker/src/adapters/perps/index.ts index 055dd06..c101fe1 100644 --- a/market-maker/src/adapters/perps/index.ts +++ b/market-maker/src/adapters/perps/index.ts @@ -29,7 +29,10 @@ export interface CreatePerpsVenueOpts { export async function createPerpsVenue( opts: CreatePerpsVenueOpts, ): Promise { - return new PerpsVenueAdapter(opts); + const venue = new PerpsVenueAdapter(opts); + // Fail fast if the compiled quantity scale drifts from the deployed venue. + await venue.validateQuantityDecimals(); + return venue; } export { PerpsVenueAdapter } from "./venue.ts"; diff --git a/market-maker/src/adapters/perps/venue.ts b/market-maker/src/adapters/perps/venue.ts index 0caae25..abc9f14 100644 --- a/market-maker/src/adapters/perps/venue.ts +++ b/market-maker/src/adapters/perps/venue.ts @@ -2,9 +2,11 @@ import { encodeFunctionData, erc20Abi } from "viem"; import type { Chain, PublicClient, Transport } from "viem"; import type pino from "pino"; import type { + BatchableCollateralAccount, CollateralAccount, CollateralSnapshot, InstrumentAdapter, + MarginReadPlan, VenueAdapter, VenueEvents, WalletContext, @@ -15,6 +17,7 @@ import { CollateralVaultAbi } from "collateral-margin-contracts/abi/CollateralVa import { PortfolioMarginEngineAbi } from "collateral-margin-contracts/abi/PortfolioMarginEngine.ts"; import { Multicall3Abi } from "perps-contracts/abi/Multicall3.ts"; import { depositToVault } from "../../core/vaultDeposit.ts"; +import { QUANTITY_DECIMALS } from "../../core/math.ts"; import { RawOracleReader, chainlinkAggregatorAbi, @@ -106,7 +109,7 @@ export class PerpsVenueAdapter implements VenueAdapter { abi: HashPowerPerpsDEXAbi, functionName: "priceOracle", }); - const [oracleDecimals, tokenDecimals] = + const [oracleDecimals, tokenDecimals, contractSizeHpsDay, oracleUnitHpsDay] = await this.publicClient.multicall({ allowFailure: false, contracts: [ @@ -116,6 +119,16 @@ export class PerpsVenueAdapter implements VenueAdapter { functionName: "decimals", }, { address: token, abi: erc20Abi, functionName: "decimals" }, + { + address: this.address, + abi: HashPowerPerpsDEXAbi, + functionName: "CONTRACT_SIZE_HPS_DAY", + }, + { + address: this.address, + abi: HashPowerPerpsDEXAbi, + functionName: "ORACLE_UNIT_HPS_DAY", + }, ], }); if (tokenDecimals > oracleDecimals) { @@ -126,6 +139,8 @@ export class PerpsVenueAdapter implements VenueAdapter { return { oracle, divisor: 10n ** BigInt(oracleDecimals - tokenDecimals), + contractSizeHpsDay, + oracleUnitHpsDay, }; }, }); @@ -138,9 +153,14 @@ export class PerpsVenueAdapter implements VenueAdapter { return this.instrumentSingleton; } + /** Perps is single-instrument; the list is always one element. */ + async listInstruments(): Promise { + return [await this.getInstrument()]; + } + async multicall( calls: `0x${string}`[], - opts: { maxFeePerGas?: bigint } = {}, + opts: { maxFeePerGas?: bigint; nonce?: number } = {}, ): Promise<`0x${string}`> { try { return await this.wallet.walletClient.writeContract({ @@ -151,6 +171,7 @@ export class PerpsVenueAdapter implements VenueAdapter { account: this.wallet.account, chain: this.chain, maxFeePerGas: opts.maxFeePerGas, + nonce: opts.nonce, }); } catch (err) { // Attach a Tenderly simulation URL so the failed multicall can be @@ -228,6 +249,26 @@ export class PerpsVenueAdapter implements VenueAdapter { return this.rawOracle.read(); } + /** + * Assert the compiled `QUANTITY_DECIMALS` matches the on-chain + * `HashPowerPerpsDEX.QUANTITY_DECIMALS()`. The off-chain sizing/notional math + * hardcodes this scale for performance, so the chain is the source of truth β€” + * a mismatch (e.g. after a venue redeploy) must fail fast at startup rather + * than silently misprice by orders of magnitude. + */ + async validateQuantityDecimals(): Promise { + const onChain = (await this.publicClient.readContract({ + address: this.address, + abi: HashPowerPerpsDEXAbi, + functionName: "QUANTITY_DECIMALS", + })) as number; + if (Number(onChain) !== QUANTITY_DECIMALS) { + throw new Error( + `perps: on-chain QUANTITY_DECIMALS (${onChain}) != market-maker QUANTITY_DECIMALS (${QUANTITY_DECIMALS})`, + ); + } + } + async fetchImSpotShock(): Promise { if (this.imSpotShockCache !== null) return this.imSpotShockCache; const { engine } = await this.resolveAddresses(); @@ -251,93 +292,66 @@ export class PerpsVenueAdapter implements VenueAdapter { * `deposit(amount)` is delegated to the shared `vaultDeposit` helper; the * old `addCollateralWithPermit` path no longer exists on the contract. */ -class PerpsCollateralAccount implements CollateralAccount { +class PerpsCollateralAccount implements BatchableCollateralAccount { private readonly venue: PerpsVenueAdapter; constructor(venue: PerpsVenueAdapter) { this.venue = venue; } - async snapshot(): Promise { + /** + * Decompose the snapshot into shared (portfolio-wide) + venue-specific reads + * so the portfolio aggregator can batch every venue into one multicall. + * `shared` order is canonical across venues: + * [vaultBalance, portfolioIM, portfolioMM, walletTokenBalance, nativeBalance] + */ + async buildMarginReadPlan(): Promise { const owner = this.venue.wallet.account.address; const { vault, engine, token } = await this.venue.resolveAddresses(); const mc3 = await this.venue.getMulticall3Address(); - const [ - vaultBalance, - portfolioIM, - portfolioMM, - orderMargin, - perpsUnrealizedPnl, - pendingFunding, - walletTokenBalance, - nativeBalance, - ] = await this.venue.publicClient.multicall({ - allowFailure: false, - contracts: [ - { - address: vault, - abi: CollateralVaultAbi, - functionName: "balanceOf", - args: [owner], - }, - { - address: engine, - abi: PortfolioMarginEngineAbi, - functionName: "computePortfolioIM", - args: [owner], - }, - { - address: engine, - abi: PortfolioMarginEngineAbi, - functionName: "computePortfolioMM", - args: [owner], - }, - { - address: this.venue.address, - abi: HashPowerPerpsDEXAbi, - functionName: "getOrderMargin", - args: [owner], - }, - { - address: this.venue.address, - abi: HashPowerPerpsDEXAbi, - functionName: "getUnrealizedPnl", - args: [owner], - }, - { - address: this.venue.address, - abi: HashPowerPerpsDEXAbi, - functionName: "getPendingFunding", - args: [owner], - }, - { - address: token, - abi: erc20Abi, - functionName: "balanceOf", - args: [owner], - }, - { - address: mc3, - abi: Multicall3Abi, - functionName: "getEthBalance", - args: [owner], - }, - ], - }); + const shared = [ + { address: vault, abi: CollateralVaultAbi, functionName: "balanceOf", args: [owner] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioIM", args: [owner] }, + { address: engine, abi: PortfolioMarginEngineAbi, functionName: "computePortfolioMM", args: [owner] }, + { address: token, abi: erc20Abi, functionName: "balanceOf", args: [owner] }, + { address: mc3, abi: Multicall3Abi, functionName: "getEthBalance", args: [owner] }, + ] as MarginReadPlan["shared"]; - // Funding owed (positive) reduces effective unrealized PnL. - const venueUnrealizedPnl = perpsUnrealizedPnl - pendingFunding; + const venue = [ + { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getOrderMargin", args: [owner] }, + { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getUnrealizedPnl", args: [owner] }, + { address: this.venue.address, abi: HashPowerPerpsDEXAbi, functionName: "getPendingFunding", args: [owner] }, + ] as MarginReadPlan["venue"]; - return { - vaultBalance, - portfolioIM, - portfolioMM, - venueOrderMargin: orderMargin, - venueUnrealizedPnl, - walletTokenBalance, - nativeBalance, - collateralToken: token, + const decode = (results: readonly unknown[]): CollateralSnapshot => { + const r = results as bigint[]; + const [vaultBalance, portfolioIM, portfolioMM, walletTokenBalance, nativeBalance] = r; + const orderMargin = r[5]; + const perpsUnrealizedPnl = r[6]; + const pendingFunding = r[7]; + // Funding owed (positive) reduces effective unrealized PnL. + return { + vaultBalance, + portfolioIM, + portfolioMM, + venueOrderMargin: orderMargin, + venueUnrealizedPnl: perpsUnrealizedPnl - pendingFunding, + walletTokenBalance, + nativeBalance, + collateralToken: token, + }; }; + + return { shared, venue, decode }; + } + + async snapshot(): Promise { + const plan = await this.buildMarginReadPlan(); + const results = await this.venue.publicClient.multicall({ + allowFailure: false, + contracts: [...plan.shared, ...plan.venue], + }); + return plan.decode(results); } imSpotShock(): Promise { diff --git a/market-maker/src/apps/futures/config.ts b/market-maker/src/apps/futures/config.ts index d497e77..9072a45 100644 --- a/market-maker/src/apps/futures/config.ts +++ b/market-maker/src/apps/futures/config.ts @@ -54,7 +54,7 @@ const futuresVenueSchema = Type.Object( }, ); -const futuresPricingSchema = Type.Object( +export const futuresPricingSchema = Type.Object( { strategy: Type.Literal("reservation-price", { description: @@ -93,7 +93,7 @@ const futuresPricingSchema = Type.Object( // `baseQuantity` is venue-native (futures: contract base units). Bigint // expressed as a decimal string; numbers accepted but use strings if values // exceed Number.MAX_SAFE_INTEGER. -const futuresSizingSchema = Type.Object( +export const futuresSizingSchema = Type.Object( { strategy: Type.Literal("geometric-taper", { description: diff --git a/market-maker/src/apps/perps/config.ts b/market-maker/src/apps/perps/config.ts index 4957fc5..0d0ec83 100644 --- a/market-maker/src/apps/perps/config.ts +++ b/market-maker/src/apps/perps/config.ts @@ -54,7 +54,7 @@ const perpsVenueSchema = Type.Object( }, ); -const perpsPricingSchema = Type.Object( +export const perpsPricingSchema = Type.Object( { strategy: Type.Literal("effective-spread", { description: @@ -87,7 +87,7 @@ const perpsPricingSchema = Type.Object( // `baseQuantity` is venue-native (perps: hashrate base units). It's a bigint // expressed as a decimal string; numbers are accepted but use strings if // values exceed Number.MAX_SAFE_INTEGER. -const perpsSizingSchema = Type.Object( +export const perpsSizingSchema = Type.Object( { strategy: Type.Literal("linear", { description: diff --git a/market-maker/src/apps/portfolio/config.ts b/market-maker/src/apps/portfolio/config.ts new file mode 100644 index 0000000..aaf246c --- /dev/null +++ b/market-maker/src/apps/portfolio/config.ts @@ -0,0 +1,361 @@ +import { type Static, type TSchema, Type } from "@sinclair/typebox"; +import { + type ParsedCollateralConfig, + type ParsedOracleConfig, + type ParsedRiskConfig, + type ParsedTimingConfig, + TypeEthAddress, + collateralSchema, + configBigint, + gasSchema, + healthSchema, + loadConfigFromFile, + networkSchema, + oracleSchema, + parseCollateralConfig, + parseOracleConfig, + parseRiskConfig, + parseTimingConfig, + riskSchema, + timingSchema, + walletSchema, + USD_DECIMALS, +} from "../../core/config/base.ts"; +import { parseUsd, secondsToMs } from "../../core/config/units.ts"; +import { perpsPricingSchema, perpsSizingSchema } from "../perps/config.ts"; +import { futuresPricingSchema, futuresSizingSchema } from "../futures/config.ts"; +import type { FuturesMarketSelection } from "../../adapters/futures/index.ts"; +import { ConfigError } from "../../core/errors.ts"; + +/** + * Unified portfolio app config. + * + * A single process runs one signer wallet against N markets across venues + * (perps + all selected futures expiries). Collateral, gas, risk budget, and + * loop timing are shared; each venue carries its own pricing/sizing and a + * per-venue position cap. The futures venue additionally declares how many + * expiries to quote (`marketSelection`). + */ +const Closed = { additionalProperties: false }; + +const TypeUsdAmount = (opts?: { default?: string | number; description?: string }) => + Type.Union([Type.String({ pattern: "^-?\\d+(\\.\\d+)?$" }), Type.Number()], opts); + +const TypeSeconds = (opts?: { minimum?: number; default?: number; description?: string }) => { + const { minimum, ...rest } = opts ?? {}; + return Type.Union( + [Type.String({ pattern: "^\\d+(\\.\\d+)?$" }), Type.Number({ minimum })], + rest as Record, + ); +}; + +const marketSelectionSchema = Type.Union( + [ + Type.Object( + { + // `count` is optional (defaulted to 1 in parseVenue). It can't carry a + // schema `default` here: AJV skips defaults inside anyOf/oneOf branches + // and, in strict mode, errors ("default is ignored for: …count"). + mode: Type.Literal("nearest"), + count: Type.Optional(Type.Integer({ minimum: 1 })), + }, + Closed, + ), + Type.Object( + { + mode: Type.Literal("indices"), + indices: Type.Array(Type.Integer({ minimum: 0 })), + }, + Closed, + ), + ], + { + description: + "Which futures expiries to quote: 'nearest' N dates, or explicit 'indices' into the nearest-first window.", + }, +); + +const perpsVenueSchema = Type.Object( + { + kind: Type.Literal("perps"), + address: TypeEthAddress({ description: "Deployed HashPowerPerpsDEX address." }), + maxPositionSize: TypeUsdAmount({ + description: "USD. Per-venue net position cap for perps.", + }), + pricing: perpsPricingSchema, + sizing: perpsSizingSchema, + }, + { ...Closed, description: "Perps venue in the portfolio." }, +); + +const futuresVenueSchema = Type.Object( + { + kind: Type.Literal("futures"), + address: TypeEthAddress({ description: "Deployed Futures address." }), + maxPositionSize: TypeUsdAmount({ + description: "USD. Per-expiry net position cap for futures markets.", + }), + marketSelection: Type.Optional(marketSelectionSchema), + pricing: futuresPricingSchema, + sizing: futuresSizingSchema, + }, + { ...Closed, description: "Futures venue (one market per selected expiry)." }, +); + +const txCoordinatorSchema = Type.Object( + { + maxCallsPerTx: Type.Integer({ + minimum: 1, + default: 50, + description: "Max encoded multicall entries per tx before chunking.", + }), + confirmationTimeoutSec: TypeSeconds({ + minimum: 1, + default: 60, + description: "Seconds to wait for a tx receipt before replacing by fee.", + }), + maxReplacements: Type.Integer({ + minimum: 0, + default: 2, + description: "Replacement-by-fee attempts before escalating to a cancel-tx.", + }), + replacementFeeBumpPct: Type.Number({ + minimum: 0, + default: 15, + description: "Fee bump per replacement attempt, percent.", + }), + }, + { ...Closed, default: {}, description: "Centralized submission / nonce recovery." }, +); + +const circuitBreakerSchema = Type.Object( + { + quarantineThreshold: Type.Integer({ + minimum: 1, + default: 3, + description: "Consecutive market errors before quarantine.", + }), + baseBackoffSec: TypeSeconds({ + minimum: 1, + default: 5, + description: "Base quarantine backoff (seconds).", + }), + maxBackoffSec: TypeSeconds({ + minimum: 1, + default: 180, + description: "Backoff ceiling (seconds).", + }), + }, + { ...Closed, default: {}, description: "Per-market circuit-breaker tuning." }, +); + +export const portfolioRootSchema = Type.Object( + { + nodeEnv: Type.String({ default: "development" }), + commitHash: Type.String({ default: "unknown" }), + logLevel: Type.String({ default: "info" }), + dryRun: Type.Boolean({ default: false }), + cancelOrdersOnShutdown: Type.Boolean({ default: true }), + wallets: Type.Record(Type.String(), walletSchema, { + description: "Named signer wallets; `wallet` selects the portfolio signer.", + }), + wallet: Type.String({ + description: + "Key in `wallets` for the single shared signer. All venues submit through this one account/nonce.", + }), + network: networkSchema, + venues: Type.Array(Type.Union([perpsVenueSchema, futuresVenueSchema]), { + minItems: 1, + description: "Venues to run in this process (perps and/or futures).", + }), + risk: riskSchema, + gas: gasSchema, + collateral: collateralSchema, + oracle: oracleSchema, + timing: timingSchema, + health: healthSchema, + txCoordinator: Type.Optional(txCoordinatorSchema), + circuitBreaker: Type.Optional(circuitBreakerSchema), + rollCheckIntervalSec: TypeSeconds({ + minimum: 1, + default: 300, + description: "Seconds between futures roll re-checks (add/drop expiries).", + }), + sharedStalenessGraceSec: TypeSeconds({ + minimum: 0, + default: 30, + description: + "Seconds shared inputs may be stale before new placements are paused (existing orders kept).", + }), + readBatchSize: Type.Number({ minimum: 1, default: 10 }), + writeBatchSize: Type.Number({ minimum: 1, default: 20 }), + }, + { ...Closed, description: "Titan Market Maker β€” unified portfolio app config." }, +); + +/** + * AJV (strict mode) rejects a `default` that sits inside an `anyOf`/`oneOf` + * branch because it can't decide which branch applies before validating, so + * the default would be silently ignored. The reused perps/futures pricing + * schemas legitimately carry defaults (e.g. futures `maxSkewTicks`), but once + * they're nested in the `venues` union those defaults become "ignored". We + * keep `portfolioRootSchema` (with defaults) for type inference + editor JSON + * schema, and validate against a clone with combinator-nested defaults removed. + */ +function stripCombinatorDefaults(schema: TSchema): TSchema { + const COMBINATORS = new Set(["anyOf", "oneOf", "allOf", "if", "then", "else"]); + const clone = structuredClone(schema) as unknown; + const walk = (node: unknown, inCombinator: boolean): void => { + if (Array.isArray(node)) { + for (const n of node) walk(n, inCombinator); + return; + } + if (!node || typeof node !== "object") return; + const obj = node as Record; + if (inCombinator && "default" in obj) delete obj.default; + for (const [key, value] of Object.entries(obj)) { + walk(value, inCombinator || COMBINATORS.has(key)); + } + }; + walk(clone, false); + return clone as TSchema; +} + +const portfolioValidationSchema = stripCombinatorDefaults(portfolioRootSchema); + +type RawPortfolioConfig = Static; +type RawVenue = RawPortfolioConfig["venues"][number]; +type RawPerpsVenue = Extract; +type RawFuturesVenue = Extract; + +export interface ParsedPerpsVenue { + kind: "perps"; + address: `0x${string}`; + maxPositionSize: bigint; + pricing: RawPerpsVenue["pricing"]; + sizing: Omit & { baseQuantity: bigint }; +} + +export interface ParsedFuturesVenue { + kind: "futures"; + address: `0x${string}`; + maxPositionSize: bigint; + marketSelection: FuturesMarketSelection; + pricing: RawFuturesVenue["pricing"]; + sizing: Omit & { baseQuantity: bigint }; +} + +export type ParsedVenue = ParsedPerpsVenue | ParsedFuturesVenue; + +export interface ParsedTxCoordinatorConfig { + maxCallsPerTx: number; + confirmationTimeoutMs: number; + maxReplacements: number; + replacementFeeBumpPct: number; +} + +export interface ParsedCircuitBreakerConfig { + quarantineThreshold: number; + baseBackoffMs: number; + maxBackoffMs: number; +} + +export type PortfolioMakerConfig = Omit< + RawPortfolioConfig, + "risk" | "timing" | "collateral" | "oracle" | "venues" | "txCoordinator" | "circuitBreaker" | "rollCheckIntervalSec" | "sharedStalenessGraceSec" +> & { + risk: ParsedRiskConfig; + timing: ParsedTimingConfig; + collateral: ParsedCollateralConfig; + oracle: ParsedOracleConfig; + venues: ParsedVenue[]; + txCoordinator: ParsedTxCoordinatorConfig; + circuitBreaker: ParsedCircuitBreakerConfig; + rollCheckIntervalMs: number; + sharedStalenessGraceMs: number; +}; + +function parseMarketSelection(raw: RawFuturesVenue["marketSelection"]): FuturesMarketSelection { + if (!raw) return { mode: "nearest", count: 1 }; + if (raw.mode === "nearest") return { mode: "nearest", count: raw.count ?? 1 }; + return { mode: "indices", indices: raw.indices }; +} + +function parseVenue(raw: RawVenue): ParsedVenue { + if (raw.kind === "perps") { + return { + kind: "perps", + address: raw.address, + maxPositionSize: parseUsd(raw.maxPositionSize, USD_DECIMALS, "venue.maxPositionSize"), + pricing: raw.pricing, + sizing: { + ...raw.sizing, + baseQuantity: configBigint(String(raw.sizing.baseQuantity), "venue.sizing.baseQuantity"), + }, + }; + } + return { + kind: "futures", + address: raw.address, + maxPositionSize: parseUsd(raw.maxPositionSize, USD_DECIMALS, "venue.maxPositionSize"), + marketSelection: parseMarketSelection(raw.marketSelection), + pricing: raw.pricing, + sizing: { + ...raw.sizing, + baseQuantity: configBigint(String(raw.sizing.baseQuantity), "venue.sizing.baseQuantity"), + }, + }; +} + +export function loadPortfolioConfig( + opts: { path?: string; env?: NodeJS.ProcessEnv } = {}, +): PortfolioMakerConfig { + return loadConfigFromFile({ + schema: portfolioValidationSchema, + path: opts.path, + env: opts.env, + parse: (raw) => { + const tx = raw.txCoordinator ?? { + maxCallsPerTx: 50, + confirmationTimeoutSec: 60, + maxReplacements: 2, + replacementFeeBumpPct: 15, + }; + const cb = raw.circuitBreaker ?? { + quarantineThreshold: 3, + baseBackoffSec: 5, + maxBackoffSec: 180, + }; + return { + ...raw, + risk: parseRiskConfig(raw.risk), + timing: parseTimingConfig(raw.timing), + collateral: parseCollateralConfig(raw.collateral), + oracle: parseOracleConfig(raw.oracle), + venues: raw.venues.map(parseVenue), + txCoordinator: { + maxCallsPerTx: tx.maxCallsPerTx, + confirmationTimeoutMs: secondsToMs(tx.confirmationTimeoutSec, "txCoordinator.confirmationTimeoutSec"), + maxReplacements: tx.maxReplacements, + replacementFeeBumpPct: tx.replacementFeeBumpPct, + }, + circuitBreaker: { + quarantineThreshold: cb.quarantineThreshold, + baseBackoffMs: secondsToMs(cb.baseBackoffSec, "circuitBreaker.baseBackoffSec"), + maxBackoffMs: secondsToMs(cb.maxBackoffSec, "circuitBreaker.maxBackoffSec"), + }, + rollCheckIntervalMs: secondsToMs(raw.rollCheckIntervalSec, "rollCheckIntervalSec"), + sharedStalenessGraceMs: secondsToMs(raw.sharedStalenessGraceSec, "sharedStalenessGraceSec"), + }; + }, + validate: (cfg) => { + if (!cfg.wallets[cfg.wallet]) { + throw new ConfigError(`wallet "${cfg.wallet}" not in wallets map`); + } + const kinds = cfg.venues.map((v) => v.kind); + if (new Set(kinds).size !== kinds.length) { + throw new ConfigError("duplicate venue kind; declare at most one perps and one futures venue"); + } + }, + }); +} diff --git a/market-maker/src/apps/portfolio/main.ts b/market-maker/src/apps/portfolio/main.ts new file mode 100644 index 0000000..dc6ce68 --- /dev/null +++ b/market-maker/src/apps/portfolio/main.ts @@ -0,0 +1,318 @@ +import pino from "pino"; +import { loadDotenvFiles } from "../../core/env.ts"; +import { createNetworkClients } from "../../core/client.ts"; +import { WalletRegistry } from "../../core/wallet.ts"; +import { OracleTracker } from "../../core/oracleTracker.ts"; +import { HashpriceOracleSubgraphSource } from "../../core/historicalPriceSource.ts"; +import { GasTracker } from "../../core/gasTracker.ts"; +import { InventoryManager } from "../../core/inventoryManager.ts"; +import { CollateralTracker } from "../../core/collateralTracker.ts"; +import { PortfolioCollateralAccount } from "../../core/portfolioCollateral.ts"; +import { RiskManager } from "../../core/riskManager.ts"; +import { BookTracker } from "../../core/bookTracker.ts"; +import { Quoter, type QuoterConfig } from "../../core/quoter.ts"; +import { OrderExecutor } from "../../core/orderExecutor.ts"; +import { MarketRuntime } from "../../core/marketRuntime.ts"; +import { NonceManager } from "../../core/nonceManager.ts"; +import { TxCoordinator } from "../../core/txCoordinator.ts"; +import { PortfolioHealthCheck } from "../../core/portfolioHealth.ts"; +import { runPortfolioLoop, type RollFn } from "../../core/portfolioRunner.ts"; +import { serializeError } from "../../core/errSerializer.ts"; +import { sanitiseConfig } from "../../core/config/base.ts"; +import { createPerpsVenue } from "../../adapters/perps/index.ts"; +import { FuturesVenueAdapter } from "../../adapters/futures/index.ts"; +import type { InstrumentAdapter, VenueAdapter, WalletContext } from "../../core/adapter.ts"; +import type { NetworkClients } from "../../core/client.ts"; +import { + loadPortfolioConfig, + type ParsedFuturesVenue, + type ParsedPerpsVenue, + type ParsedVenue, + type PortfolioMakerConfig, +} from "./config.ts"; + +/** Shared context passed to every market factory. */ +interface BuildContext { + config: PortfolioMakerConfig; + gas: GasTracker; + risk: RiskManager; + logger: pino.Logger; + historyUrl?: string; +} + +function buildOracle(instrument: InstrumentAdapter, ctx: BuildContext): OracleTracker { + const history = ctx.historyUrl + ? new HashpriceOracleSubgraphSource({ url: ctx.historyUrl, logger: ctx.logger }) + : undefined; + return new OracleTracker(instrument, ctx.logger, { + windowSize: ctx.config.oracle.windowSize, + precisionBits: ctx.config.oracle.precisionBits, + historyLookbackMultiplier: ctx.config.oracle.historyLookbackMultiplier, + history, + pollIntervalMs: ctx.config.timing.pollIntervalMs, + }); +} + +function quoterPricing(venue: ParsedVenue, gasPenaltyBps: number): QuoterConfig["pricing"] { + if (venue.kind === "perps") { + const p = (venue as ParsedPerpsVenue).pricing; + return { + strategy: "effective-spread", + minSpreadBps: p.minSpreadBps, + volatilityMultiplier: p.volatilityMultiplier, + inventorySkewGamma: p.inventorySkewGamma, + gasPenaltyBps, + }; + } + const p = (venue as ParsedFuturesVenue).pricing; + return { + strategy: "reservation-price", + riskAversion: p.riskAversion, + marginCallTimeSeconds: p.marginCallTimeSec, + minSpreadBps: p.minSpreadBps, + volatilityMultiplier: p.volatilityMultiplier, + gasPenaltyBps, + }; +} + +function quoterSizing(venue: ParsedVenue): QuoterConfig["sizing"] { + if (venue.kind === "perps") { + const s = (venue as ParsedPerpsVenue).sizing; + return { + strategy: "linear", + baseQuantity: s.baseQuantity, + numLevelsPerSide: s.numLevelsPerSide, + }; + } + const s = (venue as ParsedFuturesVenue).sizing; + return { + strategy: "geometric-taper", + baseQuantity: s.baseQuantity, + numLevelsPerSide: s.numLevelsPerSide, + taperRatio: s.taperRatio, + }; +} + +/** Build a fully-wired (but not-yet-started) market for an instrument. */ +function buildMarket( + instrument: InstrumentAdapter, + venue: ParsedVenue, + ctx: BuildContext, +): MarketRuntime { + const { config, gas, risk, logger } = ctx; + const oracle = buildOracle(instrument, ctx); + const inventory = new InventoryManager( + instrument, + { maxPositionSize: venue.maxPositionSize }, + logger, + ); + const book = new BookTracker( + instrument, + { resyncIntervalMs: config.timing.resyncIntervalMs, snapshotDepth: 200 }, + logger, + ); + const quoter = new Quoter( + instrument, + { + pricing: quoterPricing(venue, config.risk.gasPenaltyBps), + sizing: quoterSizing(venue), + maxSkewTicks: venue.pricing.maxSkewTicks, + levelSpacingTicks: config.timing.levelSpacingTicks, + volHorizonSec: config.timing.pollIntervalMs / 1000, + }, + oracle, + gas, + inventory, + risk, + logger, + ); + const executor = new OrderExecutor( + instrument, + { + requoteCooldownMs: config.timing.requoteCooldownMs, + requoteThresholdTicks: config.timing.requoteThresholdTicks, + urgentRequoteThresholdTicks: config.risk.urgentRequoteThresholdTicks, + dryRun: config.dryRun, + }, + quoter, + book, + gas, + risk, + oracle, + logger, + ); + return new MarketRuntime({ + instrument, + oracle, + book, + inventory, + quoter, + executor, + breaker: config.circuitBreaker, + logger, + }); +} + +async function main(): Promise { + loadDotenvFiles(import.meta.dirname); + const config = loadPortfolioConfig(); + const logger = pino({ level: config.logLevel, serializers: { err: serializeError } }); + logger.info( + { venues: config.venues.map((v) => v.kind), dryRun: config.dryRun }, + "starting portfolio mm", + ); + + const network: NetworkClients = createNetworkClients(config.network.name, config.network.rpcUrl); + const wallets = new WalletRegistry(config.wallets, network.chain, network.transport); + const wallet: WalletContext = wallets.get(config.wallet); + + // ── Venues ──────────────────────────────────────────────────────────── + const venueAdapters: VenueAdapter[] = []; + let futuresVenue: FuturesVenueAdapter | null = null; + let futuresCfg: ParsedFuturesVenue | null = null; + + for (const v of config.venues) { + if (v.kind === "perps") { + venueAdapters.push( + await createPerpsVenue({ + network, + wallet, + address: v.address, + readBatchSize: config.readBatchSize, + cancelBatchSize: config.writeBatchSize, + createBatchSize: config.writeBatchSize, + logger, + }), + ); + } else { + const fv = new FuturesVenueAdapter({ + network, + wallet, + address: v.address, + readBatchSize: config.readBatchSize, + writeBatchSize: config.writeBatchSize, + marketSelection: v.marketSelection, + logger, + }); + futuresVenue = fv; + futuresCfg = v; + venueAdapters.push(fv); + } + } + + // ── Shared portfolio layer ────────────────────────────────────────────── + const gas = new GasTracker( + network.publicClient, + { + ethPriceFeedAddress: + config.network.ethPriceFeed === "" ? undefined : config.network.ethPriceFeed, + gasSpikeThresholdPct: config.risk.gasSpikeThresholdPct, + gasCapMultiplier: config.gas.gasCapMultiplier, + }, + logger, + ); + const collateralAccount = new PortfolioCollateralAccount( + venueAdapters.map((v) => v.account), + network.publicClient, + ); + const collateral = new CollateralTracker( + collateralAccount, + { + autoDeposit: config.collateral.autoDeposit, + autoDepositMinAmount: config.collateral.autoDepositMinAmount, + maxCollateralAmount: config.collateral.maxCollateralAmount, + }, + logger, + ); + const risk = new RiskManager( + { + maxPositionSize: config.risk.maxPositionSize, + maxUtilizationPct: config.risk.maxUtilizationPct, + minCollateralBalance: config.risk.minCollateralBalance, + maxDailyLossUsd: config.risk.maxDailyLossUsd, + maxGasBudgetPerHourUsd: config.risk.maxGasBudgetPerHourUsd, + maxGasBudgetPerDayUsd: config.risk.maxGasBudgetPerDayUsd, + }, + null, + collateral, + gas, + // RiskManager keeps an oracle ref for future use but never reads it; + // supply a throwaway so the portfolio (no single price source) type-checks. + undefined as unknown as OracleTracker, + logger, + ); + + const ctx: BuildContext = { config, gas, risk, logger, historyUrl: config.oracle.history?.subgraphUrl }; + + // ── Build initial market set ──────────────────────────────────────────── + const markets: MarketRuntime[] = []; + for (let i = 0; i < config.venues.length; i++) { + const vCfg = config.venues[i]; + const adapter = venueAdapters[i]; + const instruments = await adapter.listInstruments(); + for (const instrument of instruments) { + markets.push(buildMarket(instrument, vCfg, ctx)); + } + } + logger.info({ count: markets.length }, "built initial markets"); + + // ── Centralized submission ─────────────────────────────────────────────── + const nonce = new NonceManager( + network.publicClient, + wallet.walletClient, + wallet.account, + network.chain, + { + confirmationTimeoutMs: config.txCoordinator.confirmationTimeoutMs, + maxReplacements: config.txCoordinator.maxReplacements, + replacementFeeBumpPct: config.txCoordinator.replacementFeeBumpPct, + }, + logger, + ); + const coordinator = new TxCoordinator( + nonce, + { maxCallsPerTx: config.txCoordinator.maxCallsPerTx }, + logger, + ); + + const health = new PortfolioHealthCheck({ + port: config.health.port, + appName: "portfolio-mm", + configSummary: sanitiseConfig(config), + collateral, + gas, + risk, + logger, + }); + health.walletAddress = wallet.account.address; + + // ── Roll: reconcile futures expiries against the live venue selection ──── + const onRoll: RollFn | undefined = + futuresVenue && futuresCfg + ? async () => { + const { added, dropped } = await futuresVenue.resolveMarkets(); + return { + add: added.map((inst) => buildMarket(inst, futuresCfg, ctx)), + removeIds: dropped.map((inst) => inst.id), + }; + } + : undefined; + + await runPortfolioLoop({ + pollIntervalMs: config.timing.pollIntervalMs, + rollCheckIntervalMs: config.rollCheckIntervalMs, + sharedStalenessGraceMs: config.sharedStalenessGraceMs, + cancelOrdersOnShutdown: config.cancelOrdersOnShutdown, + dryRun: config.dryRun, + markets, + gas, + collateral, + risk, + coordinator, + health, + logger, + onRoll, + }); +} + +main(); diff --git a/market-maker/src/core/adapter.ts b/market-maker/src/core/adapter.ts index a412769..e6eaf50 100644 --- a/market-maker/src/core/adapter.ts +++ b/market-maker/src/core/adapter.ts @@ -1,6 +1,7 @@ import type { Account, Chain, + ContractFunctionParameters, PublicClient, Transport, WalletClient, @@ -137,13 +138,44 @@ export interface CollateralAccount { canPlace(additionalIM: bigint): Promise; } +/** + * A `snapshot()` decomposed into its underlying multicall reads so the + * portfolio account can batch every venue into a single RPC round trip. + * + * `shared` reads are portfolio-wide (vault balance, IM/MM, wallet token, native + * balance) and therefore **identical across venues** for a given wallet β€” the + * aggregator reads them once. `venue` reads are venue-specific (order margin, + * unrealized PnL). `decode` reconstructs the snapshot from the concatenated + * results in `[...shared, ...venue]` order. + */ +export interface MarginReadPlan { + shared: ContractFunctionParameters[]; + venue: ContractFunctionParameters[]; + decode(results: readonly unknown[]): CollateralSnapshot; +} + +/** + * A collateral account that can expose its reads for batched aggregation. + * Implemented by the concrete venue accounts (perps, futures); the portfolio + * aggregator uses it to fuse all venues' reads into one multicall. + */ +export interface BatchableCollateralAccount extends CollateralAccount { + buildMarginReadPlan(): Promise; +} + +export function isBatchableCollateralAccount( + account: CollateralAccount, +): account is BatchableCollateralAccount { + return ( + typeof (account as BatchableCollateralAccount).buildMarginReadPlan === "function" + ); +} + // ─── Instrument context (venue-specific hints for pricing) ────────────────── export interface InstrumentContext { /** Unix seconds of delivery / expiry, if any. */ deliveryDate?: number; - /** Contract multiplier (e.g. futures' deliveryDurationDays). */ - contractMultiplier?: bigint; /** Strike price (options). */ strike?: bigint; /** Call vs put (options). */ @@ -214,6 +246,8 @@ export type VenueEvent = side: Side; size: bigint; instrumentId?: string; + /** Futures expiry (unix seconds) the order belongs to; undefined for perps. */ + deliveryDate?: bigint; } | { type: "order-updated"; @@ -291,10 +325,10 @@ export interface InstrumentAdapter { * * Mirrors the on-chain margin computation for the venue: * - Perps: imSpotShock Γ— notional / 1e18 - * - Futures: pricePerDay Γ— deliveryDurationDays Γ— marginPct / 100 + * - Futures: pricePerDay Γ— marginPct / 100 (one unit, no duration multiplier) * * Adapter computes synchronously from already-cached state (imSpotShock, - * deliveryDurationDays). Returns 0n if it can't be estimated yet. + * marginPct). Returns 0n if it can't be estimated yet. */ estimateOrderMargin(intent: OrderIntent): bigint; @@ -327,15 +361,29 @@ export interface VenueAdapter { readonly events: VenueEvents; readonly account: CollateralAccount; - /** The MM's instrument on this venue. */ + /** + * The MM's primary instrument on this venue. For single-instrument venues + * (perps) this is the only book; for multi-instrument venues (futures across + * expiries) it is the nearest one. Kept for back-compat and single-market + * callers; prefer {@link listInstruments} for the portfolio runner. + */ getInstrument(): Promise; + /** + * All instruments this venue currently wants quoted. Perps returns a single + * element; futures returns one `InstrumentAdapter` per selected delivery + * date. The set can change over time (futures roll) β€” callers re-invoke to + * pick up added/dropped markets. + */ + listInstruments(): Promise; + /** * Batch cancels/creates in one tx. Returns tx hash. Implementations route - * through the venue contract's multicall function. + * through the venue contract's multicall function. `nonce` is supplied by the + * shared NonceManager when the portfolio runner sequences multi-venue txs. */ multicall( calls: `0x${string}`[], - opts: { maxFeePerGas?: bigint }, + opts: { maxFeePerGas?: bigint; nonce?: number }, ): Promise<`0x${string}`>; } diff --git a/market-maker/src/core/circuitBreaker.ts b/market-maker/src/core/circuitBreaker.ts new file mode 100644 index 0000000..0377e07 --- /dev/null +++ b/market-maker/src/core/circuitBreaker.ts @@ -0,0 +1,63 @@ +export type BreakerState = "active" | "degraded" | "quarantined"; + +export interface CircuitBreakerConfig { + /** Consecutive errors before a market is quarantined. Default 3. */ + quarantineThreshold?: number; + /** Base backoff once quarantined (ms). Default 5s. */ + baseBackoffMs?: number; + /** Backoff ceiling (ms). Default 3min. */ + maxBackoffMs?: number; +} + +/** + * Per-market fault isolation. Tracks consecutive failures and, past a + * threshold, quarantines the market with exponential backoff so a persistently + * failing expiry stops consuming cycles while its healthy siblings keep + * quoting. A single success clears it back to `active`. + */ +export class CircuitBreaker { + state: BreakerState = "active"; + consecutiveErrors = 0; + lastError: unknown = null; + + private nextRetryAt = 0; + private readonly threshold: number; + private readonly baseBackoffMs: number; + private readonly maxBackoffMs: number; + + constructor(cfg: CircuitBreakerConfig = {}) { + this.threshold = cfg.quarantineThreshold ?? 3; + this.baseBackoffMs = cfg.baseBackoffMs ?? 5_000; + this.maxBackoffMs = cfg.maxBackoffMs ?? 180_000; + } + + recordSuccess(): void { + this.state = "active"; + this.consecutiveErrors = 0; + this.lastError = null; + this.nextRetryAt = 0; + } + + recordError(err: unknown, now: number = Date.now()): void { + this.consecutiveErrors++; + this.lastError = err; + if (this.consecutiveErrors >= this.threshold) { + this.state = "quarantined"; + this.nextRetryAt = now + this.backoff(); + } else { + this.state = "degraded"; + } + } + + /** Whether the guarded work may run this cycle. */ + canAttempt(now: number = Date.now()): boolean { + if (this.state !== "quarantined") return true; + return now >= this.nextRetryAt; + } + + private backoff(): number { + const over = this.consecutiveErrors - this.threshold; + const ms = this.baseBackoffMs * 2 ** Math.max(0, over); + return Math.min(ms, this.maxBackoffMs); + } +} diff --git a/market-maker/src/core/inventoryManager.ts b/market-maker/src/core/inventoryManager.ts index 00045dd..d499ae9 100644 --- a/market-maker/src/core/inventoryManager.ts +++ b/market-maker/src/core/inventoryManager.ts @@ -64,4 +64,9 @@ export class InventoryManager { get absPosition(): bigint { return bigAbs(this.netQuantity); } + + /** Configured position cap for this market (venue-native units). */ + get maxPositionSize(): bigint { + return this.cfg.maxPositionSize; + } } diff --git a/market-maker/src/core/marketRuntime.ts b/market-maker/src/core/marketRuntime.ts new file mode 100644 index 0000000..acaf777 --- /dev/null +++ b/market-maker/src/core/marketRuntime.ts @@ -0,0 +1,179 @@ +import type pino from "pino"; +import type Fraction from "fraction.js"; +import type { InstrumentAdapter } from "./adapter.ts"; +import type { BookTracker } from "./bookTracker.ts"; +import type { InventoryManager } from "./inventoryManager.ts"; +import type { OracleTracker } from "./oracleTracker.ts"; +import type { Quoter } from "./quoter.ts"; +import type { OrderExecutor } from "./orderExecutor.ts"; +import type { MarketIntents } from "./txCoordinator.ts"; +import { CircuitBreaker, type CircuitBreakerConfig } from "./circuitBreaker.ts"; +import { toErrorInfo } from "./errSerializer.ts"; +import type { ErrorInfo } from "./errors.ts"; + +export interface MarketRuntimeDeps { + instrument: InstrumentAdapter; + oracle: OracleTracker; + book: BookTracker; + inventory: InventoryManager; + quoter: Quoter; + executor: OrderExecutor; + breaker?: CircuitBreakerConfig; + logger: pino.Logger; +} + +/** + * One quoting unit (perps, or a single futures expiry) bundling its book, + * inventory, quoter, and executor behind a circuit breaker. Every on-chain + * touch is guarded so a fault in this market is recorded and skipped without + * disturbing sibling markets. Planning is decoupled from submission: `plan()` + * yields `MarketIntents` for the shared `TxCoordinator`. + */ +export class MarketRuntime { + readonly instrument: InstrumentAdapter; + readonly oracle: OracleTracker; + readonly book: BookTracker; + readonly inventory: InventoryManager; + readonly quoter: Quoter; + readonly executor: OrderExecutor; + readonly breaker: CircuitBreaker; + + private readonly logger: pino.Logger; + private initialized = false; + + constructor(deps: MarketRuntimeDeps) { + this.instrument = deps.instrument; + this.oracle = deps.oracle; + this.book = deps.book; + this.inventory = deps.inventory; + this.quoter = deps.quoter; + this.executor = deps.executor; + this.breaker = new CircuitBreaker(deps.breaker); + this.logger = deps.logger.child({ component: "market", instrument: deps.instrument.id }); + } + + get id(): string { + return this.instrument.id; + } + + /** + * Initialize this market (own-order bootstrap, book start, quoter init). + * On failure the market is quarantined but the error is swallowed so the + * process can still start with healthy markets. Returns whether init + * succeeded. + */ + async start(): Promise { + try { + await this.oracle.initialize(); + await this.instrument.ownOrders.bootstrap(); + await this.book.start(); + await this.quoter.initialize(); + this.breaker.recordSuccess(); + this.initialized = true; + this.logger.info("market initialized"); + return true; + } catch (err) { + this.breaker.recordError(err); + this.logger.error({ err }, "market init failed; quarantined"); + return false; + } + } + + /** Refresh book + inventory. Guarded by the circuit breaker. */ + async update(now: number = Date.now()): Promise { + if (!this.breaker.canAttempt(now)) return; + try { + // Late init for markets that were quarantined at startup. + if (!this.initialized) { + await this.oracle.initialize(); + await this.instrument.ownOrders.bootstrap(); + await this.book.start(); + await this.quoter.initialize(); + this.initialized = true; + } + await this.oracle.update(); + await this.book.refresh(); + await this.inventory.update(); + this.breaker.recordSuccess(); + } catch (err) { + this.breaker.recordError(err, now); + this.logger.error( + { err, state: this.breaker.state, consecutive: this.breaker.consecutiveErrors }, + "market update failed", + ); + } + } + + /** + * Compute this market's desired quotes and diff them against resting orders. + * Returns `null` when the market is quarantined, uninitialized, or no + * requote is warranted this cycle. Never throws. + */ + plan(now: number = Date.now()): MarketIntents | null { + if (!this.initialized || !this.breaker.canAttempt(now)) return null; + try { + const desired = this.quoter.computeQuotes(); + const planned = this.executor.plan(desired); + if (!planned) return null; + return { + instrument: this.instrument, + cancels: planned.cancels.map((o) => ({ orderId: o.orderId })), + creates: planned.creates, + }; + } catch (err) { + this.breaker.recordError(err, now); + this.logger.error({ err }, "market plan failed"); + return null; + } + } + + /** Bookkeeping after a successful submission for this market. */ + recordRequote(placed: number, cancelled: number): void { + this.executor.recordRequote(placed, cancelled); + } + + /** Cancel every resting order for this market (shutdown / quarantine). */ + async cancelAll(): Promise { + try { + await this.executor.cancelAll(); + } catch (err) { + this.logger.error({ err }, "cancelAll failed"); + } + } + + stop(): void { + this.book.stop(); + } + + /** Snapshot for /health. */ + healthState(): { + id: string; + breaker: string; + consecutiveErrors: number; + lastError: ErrorInfo | null; + oraclePrice: string; + volatilityPerSecond: number; + netPosition: string; + bestBid: string; + bestAsk: string; + ownOrders: number; + } { + return { + id: this.id, + breaker: this.breaker.state, + consecutiveErrors: this.breaker.consecutiveErrors, + lastError: this.breaker.lastError ? toErrorInfo(this.breaker.lastError) : null, + oraclePrice: this.oracle.currentPrice.toString(), + volatilityPerSecond: fractionToNumber(this.oracle.volatilityPerSecond), + netPosition: this.inventory.netQuantity.toString(), + bestBid: this.book.bestBid.toString(), + bestAsk: this.book.bestAsk.toString(), + ownOrders: this.book.ownOrders.size, + }; + } +} + +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 bf94ade..0a43080 100644 --- a/market-maker/src/core/math.ts +++ b/market-maker/src/core/math.ts @@ -8,6 +8,10 @@ import Fraction from "fraction.js"; import { ln, sqrt } from "./rational.ts"; +// Perps quantity scale. Mirrors `HashPowerPerpsDEX.QUANTITY_DECIMALS()` (an on-chain +// `uint8 public constant`). The value is hardcoded here so the hot-path sizing/notional +// math stays synchronous, but it is the CHAIN that is authoritative: the perps venue +// asserts this matches on-chain at startup (`validateQuantityDecimals`) and aborts on drift. export const QUANTITY_DECIMALS = 6; export const QUANTITY_SCALE = 10n ** BigInt(QUANTITY_DECIMALS); export const BPS_SCALE = 10_000n; diff --git a/market-maker/src/core/nonceManager.ts b/market-maker/src/core/nonceManager.ts new file mode 100644 index 0000000..9216997 --- /dev/null +++ b/market-maker/src/core/nonceManager.ts @@ -0,0 +1,194 @@ +import type { Account, Chain, Hex, PublicClient, WalletClient } from "viem"; +import type pino from "pino"; + +export interface NonceManagerConfig { + /** How long to wait for a tx receipt before treating it as stuck. Default 60s. */ + confirmationTimeoutMs?: number; + /** Max replacement-by-fee attempts before escalating to a cancel-tx. Default 2. */ + maxReplacements?: number; + /** Fee bump per replacement attempt, in percent. Default 15%. */ + replacementFeeBumpPct?: number; +} + +/** Broadcasts one logical tx at the given nonce/fee and returns its hash. */ +export type Broadcast = (params: { + nonce: number; + maxFeePerGas: bigint; +}) => Promise; + +export interface TxOutcome { + gasUsed: bigint; + effectiveGasPrice: bigint; +} + +/** + * Owns the shared wallet's nonce for a single-wallet, multi-venue process. + * + * All submissions are **serialized** through an internal queue so nonces are + * assigned in a single deterministic order across venues β€” a perps tx and a + * futures tx in the same cycle get consecutive nonces and never race. + * + * Stuck-tx recovery keeps one wedged venue tx from starving the other: + * 1. Broadcast at nonce N; await the receipt with a timeout. + * 2. On timeout, resubmit the **same nonce** with a bumped fee + * (replacement-by-fee β€” at most one of original/replacement can land, so + * this is safe even for non-idempotent creates). + * 3. After `maxReplacements`, escalate to a `cancel-tx` (0-value self-send at + * nonce N with an aggressive fee) to free the nonce, then advance. + */ +export class NonceManager { + private next: number | null = null; + private queue: Promise = Promise.resolve(); + + private readonly confirmationTimeoutMs: number; + private readonly maxReplacements: number; + private readonly bumpPct: number; + + private readonly publicClient: PublicClient; + private readonly walletClient: WalletClient; + private readonly account: Account; + private readonly chain: Chain; + private readonly logger: pino.Logger; + + constructor( + publicClient: PublicClient, + walletClient: WalletClient, + account: Account, + chain: Chain, + cfg: NonceManagerConfig, + logger: pino.Logger, + ) { + this.publicClient = publicClient; + this.walletClient = walletClient; + this.account = account; + this.chain = chain; + this.confirmationTimeoutMs = cfg.confirmationTimeoutMs ?? 60_000; + this.maxReplacements = cfg.maxReplacements ?? 2; + this.bumpPct = cfg.replacementFeeBumpPct ?? 15; + this.logger = logger.child({ component: "nonce" }); + } + + /** + * Submit one logical tx. Resolves with the receipt's gas figures, or throws + * if the tx could not be landed even after replacement + cancel escalation. + * Serialized against every other in-flight `submit`. + */ + submit(broadcast: Broadcast, opts: { maxFeePerGas: bigint; label: string }): Promise { + return this.enqueue(() => this.submitInner(broadcast, opts)); + } + + /** Force a nonce re-read from chain on the next submit (after a desync). */ + resetNonce(): void { + this.next = null; + } + + private enqueue(fn: () => Promise): Promise { + const run = this.queue.then(fn, fn); + // Keep the chain alive regardless of individual outcomes. + this.queue = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private async nextNonce(): Promise { + if (this.next === null) { + this.next = await this.publicClient.getTransactionCount({ + address: this.account.address, + blockTag: "pending", + }); + } + return this.next; + } + + private async submitInner( + broadcast: Broadcast, + opts: { maxFeePerGas: bigint; label: string }, + ): Promise { + const nonce = await this.nextNonce(); + let fee = opts.maxFeePerGas; + + for (let attempt = 0; attempt <= this.maxReplacements; attempt++) { + try { + const hash = await broadcast({ nonce, maxFeePerGas: fee }); + const receipt = await this.waitWithTimeout(hash); + if (receipt) { + this.next = nonce + 1; + return { + gasUsed: receipt.gasUsed, + effectiveGasPrice: receipt.effectiveGasPrice, + }; + } + // Timeout: bump fee and resubmit the same nonce. + fee = this.bump(fee); + this.logger.warn( + { label: opts.label, nonce, attempt, maxFeePerGas: fee.toString() }, + "tx confirmation timed out; replacing by fee", + ); + } catch (err) { + // A submission error (revert-on-send, RPC error). Fee-bump-and-retry a + // couple of times; a persistent failure likely means the nonce is + // wedged, so unstick it below. + this.logger.error( + { err, label: opts.label, nonce, attempt }, + "tx submission failed", + ); + if (attempt >= this.maxReplacements) { + await this.tryCancelTx(nonce, fee); + this.next = nonce + 1; + this.resetNonce(); // resync from chain next time in case of desync + throw err instanceof Error ? err : new Error(String(err)); + } + fee = this.bump(fee); + } + } + + // Exhausted replacements on repeated timeout: free the nonce and advance. + await this.tryCancelTx(nonce, fee); + this.next = nonce + 1; + throw new Error( + `tx "${opts.label}" stuck at nonce ${nonce} after ${this.maxReplacements} replacements`, + ); + } + + private bump(fee: bigint): bigint { + return (fee * BigInt(100 + this.bumpPct)) / 100n; + } + + private async waitWithTimeout( + hash: Hex, + ): Promise<{ gasUsed: bigint; effectiveGasPrice: bigint } | null> { + const timeout = new Promise((resolve) => + setTimeout(() => resolve(null), this.confirmationTimeoutMs), + ); + const receipt = this.publicClient + .waitForTransactionReceipt({ hash }) + .then((r) => ({ gasUsed: r.gasUsed, effectiveGasPrice: r.effectiveGasPrice })) + .catch(() => null); + return Promise.race([receipt, timeout]); + } + + /** + * Replace a stuck tx with a 0-value self-send at the same nonce to free it. + * Best-effort: logged and swallowed on failure (the caller advances anyway). + */ + private async tryCancelTx(nonce: number, fee: bigint): Promise { + try { + const aggressive = this.bump(fee); + const hash = await this.walletClient.sendTransaction({ + account: this.account, + chain: this.chain, + to: this.account.address, + value: 0n, + nonce, + maxFeePerGas: aggressive, + maxPriorityFeePerGas: aggressive, + }); + await this.waitWithTimeout(hash); + this.logger.warn({ nonce, hash }, "sent cancel-tx to unstick nonce"); + } catch (err) { + this.logger.error({ err, nonce }, "cancel-tx failed; will resync nonce"); + } + } +} diff --git a/market-maker/src/core/orderExecutor.ts b/market-maker/src/core/orderExecutor.ts index 40f88f7..8b4f53e 100644 --- a/market-maker/src/core/orderExecutor.ts +++ b/market-maker/src/core/orderExecutor.ts @@ -70,10 +70,16 @@ export class OrderExecutor { }); } - async reconcile(desired: OrderIntent[]): Promise { - if (!this.shouldRequote(desired)) { - return; - } + /** + * Compute the diff (stale cancels + missing creates) for `desired` without + * submitting anything. Returns `null` when no requote should happen this + * cycle (cooldown, no drift, or gas-spike deferral). The portfolio runner + * feeds the returned intents to the shared `TxCoordinator`, which runs the + * aggregate pre-trade gate β€” so `plan()` deliberately does NOT call + * `canPlaceOrders` (that would under-count across markets). + */ + plan(desired: OrderIntent[]): { cancels: OwnOrder[]; creates: OrderIntent[] } | null { + if (!this.shouldRequote(desired)) return null; if (this.gas.isGasSpiking) { const drift = this.priceDriftTicks(); @@ -86,18 +92,38 @@ export class OrderExecutor { }, "requote skipped: gas spike, drift below urgent threshold", ); - return; + return null; } this.logger.warn({ drift }, "proceeding with requote despite gas spike"); } - const ordersToCancel = this.findStaleOrders(desired); - const ordersToPlace = this.findNewOrders(desired); - - if (ordersToCancel.length === 0 && ordersToPlace.length === 0) { + const cancels = this.findStaleOrders(desired); + const creates = this.findNewOrders(desired); + if (cancels.length === 0 && creates.length === 0) { this.logger.debug("no order changes needed"); - return; + return null; } + return { cancels, creates }; + } + + /** + * Update timing/stat bookkeeping after a submission (whether via this + * executor's own `reconcile` or the shared coordinator). Idempotent within a + * cycle; safe to call once per successful submit. + */ + recordRequote(placed: number, cancelled: number): void { + this.stats.ordersCancelled += cancelled; + this.stats.ordersPlaced += placed; + this.lastRequoteAt = Date.now(); + this.lastQuoteMidPrice = this.oracle.currentPrice; + this.stats.reconcileCount++; + } + + async reconcile(desired: OrderIntent[]): Promise { + const planned = this.plan(desired); + if (!planned) return; + const ordersToCancel = planned.cancels; + const ordersToPlace = planned.creates; // Pre-trade engine gate: ask whether the new orders' total IM still fits // the wallet's portfolio IM budget. If not, only cancel; don't add risk. diff --git a/market-maker/src/core/portfolioCollateral.ts b/market-maker/src/core/portfolioCollateral.ts new file mode 100644 index 0000000..b5daf39 --- /dev/null +++ b/market-maker/src/core/portfolioCollateral.ts @@ -0,0 +1,95 @@ +import type { PublicClient } from "viem"; +import { + type CollateralAccount, + type CollateralSnapshot, + isBatchableCollateralAccount, +} from "./adapter.ts"; + +/** + * Composes one or more venue `CollateralAccount`s into a single portfolio view + * for the shared `CollateralTracker`. + * + * Vault balance and portfolio IM/MM are per-wallet on the shared engine, so + * they are identical across venues. Venue-specific order margin and unrealized + * PnL are summed. The pre-trade gate, deposit, and IM-shock hit the shared + * vault/engine and delegate to the first account. + * + * Fast path: when every account exposes a `buildMarginReadPlan()` (the concrete + * perps/futures accounts do), all venues are fused into ONE multicall β€” the + * shared vault/IM/MM/wallet/native reads happen exactly once and only the + * per-venue order-margin/PnL reads scale with venue count. Falls back to + * per-account `snapshot()` (one RPC each) for any non-batchable account. + */ +export class PortfolioCollateralAccount implements CollateralAccount { + private readonly accounts: CollateralAccount[]; + private readonly publicClient: PublicClient; + + constructor(accounts: CollateralAccount[], publicClient: PublicClient) { + if (accounts.length === 0) { + throw new Error("PortfolioCollateralAccount requires at least one account"); + } + this.accounts = accounts; + this.publicClient = publicClient; + } + + async snapshot(): Promise { + if (this.accounts.every(isBatchableCollateralAccount)) { + return this.batchedSnapshot(); + } + return this.perAccountSnapshot(); + } + + /** Single multicall across all venues; shared reads counted once. */ + private async batchedSnapshot(): Promise { + const batchable = this.accounts.filter(isBatchableCollateralAccount); + const plans = await Promise.all(batchable.map((a) => a.buildMarginReadPlan())); + + // Shared reads are identical across venues (same wallet/vault/engine/token), + // so we take them from the first plan and read them just once. + const shared = plans[0].shared; + const contracts = [...shared, ...plans.flatMap((p) => p.venue)]; + const results = await this.publicClient.multicall({ allowFailure: false, contracts }); + + const sharedResults = results.slice(0, shared.length); + let offset = shared.length; + let venueOrderMargin = 0n; + let venueUnrealizedPnl = 0n; + let primary: CollateralSnapshot | null = null; + + for (const plan of plans) { + const venueResults = results.slice(offset, offset + plan.venue.length); + offset += plan.venue.length; + const snap = plan.decode([...sharedResults, ...venueResults]); + if (!primary) primary = snap; + venueOrderMargin += snap.venueOrderMargin; + venueUnrealizedPnl += snap.venueUnrealizedPnl; + } + + return { ...(primary as CollateralSnapshot), venueOrderMargin, venueUnrealizedPnl }; + } + + /** Fallback: one snapshot RPC per account. */ + private async perAccountSnapshot(): Promise { + const snaps = await Promise.all(this.accounts.map((a) => a.snapshot())); + const primary = snaps[0]; + let venueOrderMargin = 0n; + let venueUnrealizedPnl = 0n; + for (const s of snaps) { + venueOrderMargin += s.venueOrderMargin; + venueUnrealizedPnl += s.venueUnrealizedPnl; + } + return { ...primary, venueOrderMargin, venueUnrealizedPnl }; + } + + imSpotShock(): Promise { + return this.accounts[0].imSpotShock(); + } + + deposit(amount: bigint): Promise { + return this.accounts[0].deposit(amount); + } + + canPlace(additionalIM: bigint): Promise { + return this.accounts[0].canPlace(additionalIM); + } +} diff --git a/market-maker/src/core/portfolioHealth.ts b/market-maker/src/core/portfolioHealth.ts new file mode 100644 index 0000000..ff1a7cf --- /dev/null +++ b/market-maker/src/core/portfolioHealth.ts @@ -0,0 +1,166 @@ +import { createServer } from "node:http"; +import type { Server, ServerResponse } from "node:http"; +import type pino from "pino"; +import type Fraction from "fraction.js"; +import type { CollateralTracker } from "./collateralTracker.ts"; +import type { GasTracker } from "./gasTracker.ts"; +import type { RiskManager } from "./riskManager.ts"; +import type { MarketRuntime } from "./marketRuntime.ts"; +import type { ErrorInfo } from "./errors.ts"; + +export interface PortfolioHealthOptions { + port: number; + appName: string; + configSummary: Record; + collateral: CollateralTracker; + gas: GasTracker; + risk: RiskManager; + logger: pino.Logger; +} + +/** + * Portfolio-aware /health endpoint. Exposes one shared collateral/gas/risk + * block plus a per-market breakdown (circuit-breaker state, last error, + * position, top of book) so ops can see partial degradation rather than an + * all-or-nothing status. + */ +export class PortfolioHealthCheck { + private server: Server | null = null; + private startedAt = Date.now(); + + tickCount = 0; + lastTickAt = 0; + walletAddress = ""; + status: "initializing" | "init-error" | "running" | "error" | "stopped" = "initializing"; + lastError: ErrorInfo | null = null; + paused = false; + + /** Live market set provider, wired by the runner. */ + markets: () => MarketRuntime[] = () => []; + onStop: (() => Promise) | null = null; + onStart: (() => Promise) | null = null; + + private readonly opts: PortfolioHealthOptions; + + constructor(opts: PortfolioHealthOptions) { + this.opts = opts; + } + + start(): Promise { + return new Promise((resolve) => { + this.startedAt = Date.now(); + this.server = createServer((req, res) => { + try { + if (req.method === "POST" && req.url === "/stop") return this.handleStop(res); + if (req.method === "POST" && req.url === "/start") return this.handleStart(res); + if (req.method === "GET" && req.url === "/health") return this.handleHealth(res); + res.writeHead(404); + res.end(); + } catch (err) { + this.opts.logger.error({ err }, "server error"); + res.writeHead(500); + res.end(); + } + }); + const { logger, port } = this.opts; + this.server.listen(port, () => { + logger.info({ url: `http://localhost:${port}/health` }, "health endpoint started"); + resolve(); + }); + }); + } + + stop(): Promise { + return new Promise((resolve, reject) => { + if (!this.server) return resolve(); + this.server.close((err) => { + this.server = null; + if (err) reject(err); + else resolve(); + }); + }); + } + + private handleHealth(res: ServerResponse): void { + const { collateral, gas, risk } = this.opts; + const body = JSON.stringify( + { + app: this.opts.appName, + status: this.status, + walletAddress: this.walletAddress, + lastError: this.lastError, + uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000), + config: this.opts.configSummary, + collateral: { + vaultBalance: collateral.vaultBalance.toString(), + portfolioIM: collateral.portfolioIM.toString(), + portfolioMM: collateral.portfolioMM.toString(), + venueOrderMargin: collateral.venueOrderMargin.toString(), + venueUnrealizedPnl: collateral.venueUnrealizedPnl.toString(), + walletTokenBalance: collateral.walletTokenBalance.toString(), + nativeBalance: collateral.nativeBalance.toString(), + utilizationPct: collateral.utilizationPct, + }, + gas: { + gasGwei: (Number(gas.currentGasPrice) / 1e9).toFixed(2), + gasSpiking: gas.isGasSpiking, + gasSpikePct: fractionToNumber(gas.gasSpikePct).toFixed(0), + }, + risk: { + throttled: risk.throttled, + throttleReason: risk.throttleReason, + cumulativeGasCostUsd: risk.cumulativeGasCostUsd.toString(), + }, + markets: this.markets().map((m) => m.healthState()), + stats: { tickCount: this.tickCount, lastTickAt: this.lastTickAt }, + }, + bigIntReplacer, + ); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(body); + } + + private handleStop(res: ServerResponse): void { + if (this.paused) return this.respondOk(res); + this.paused = true; + this.status = "stopped"; + this.lastError = null; + if (!this.onStop) return this.respondOk(res); + this.onStop() + .then(() => this.respondOk(res)) + .catch((err) => { + this.opts.logger.error({ err }, "onStop callback failed"); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "stop callback failed" })); + }); + } + + private handleStart(res: ServerResponse): void { + if (!this.paused) return this.respondOk(res); + this.paused = false; + this.status = "running"; + this.lastError = null; + if (!this.onStart) return this.respondOk(res); + this.onStart() + .then(() => this.respondOk(res)) + .catch((err) => { + this.opts.logger.error({ err }, "onStart callback failed"); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "start callback failed" })); + }); + } + + private respondOk(res: ServerResponse): void { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, status: this.status })); + } +} + +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/portfolioRunner.ts b/market-maker/src/core/portfolioRunner.ts new file mode 100644 index 0000000..63fc81c --- /dev/null +++ b/market-maker/src/core/portfolioRunner.ts @@ -0,0 +1,363 @@ +import type pino from "pino"; +import type { GasTracker } from "./gasTracker.ts"; +import type { CollateralTracker } from "./collateralTracker.ts"; +import type { RiskManager } from "./riskManager.ts"; +import type { MarketRuntime } from "./marketRuntime.ts"; +import type { MarketIntents, TxCoordinator } from "./txCoordinator.ts"; +import type { PortfolioHealthCheck } from "./portfolioHealth.ts"; +import type { ErrorInfo } from "./errors.ts"; +import { toErrorInfo } from "./errSerializer.ts"; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const BASE_ERROR_DELAY_MS = 5_000; +const MAX_ERROR_DELAY_MS = 3 * 60_000; + +/** + * Reconciles the live market set with the venues' current selection. Called + * periodically for the futures roll. Returns markets to add (already built, + * not yet started) and ids to remove (matured / rolled off). + */ +export type RollFn = ( + current: MarketRuntime[], +) => Promise<{ add: MarketRuntime[]; removeIds: string[] }>; + +export interface PortfolioRunnerOpts { + pollIntervalMs: number; + /** How often to re-check the venue market set for the roll. */ + rollCheckIntervalMs: number; + cancelOrdersOnShutdown?: boolean; + /** + * Grace window: if shared inputs (gas/collateral) can't be refreshed for + * longer than this, stop placing new orders (existing orders are left in + * place). Default 30s. + */ + sharedStalenessGraceMs?: number; + dryRun: boolean; + + markets: MarketRuntime[]; + gas: GasTracker; + collateral: CollateralTracker; + risk: RiskManager; + coordinator: TxCoordinator; + health: PortfolioHealthCheck; + logger: pino.Logger; + + onRoll?: RollFn; +} + +/** Shared dependencies one tick reads/acts on. */ +export interface PortfolioTickDeps { + gas: GasTracker; + collateral: CollateralTracker; + risk: RiskManager; + coordinator: TxCoordinator; + health: PortfolioHealthCheck; + logger: pino.Logger; + dryRun: boolean; + /** Shared-input staleness grace (ms) before pausing new placements. */ + graceMs: number; + rollCheckIntervalMs: number; + onRoll?: RollFn; +} + +/** Loop-carried state threaded through successive ticks. */ +export interface PortfolioTickState { + markets: MarketRuntime[]; + lastSharedOkAt: number; + pauseNew: boolean; + lastRollAt: number; +} + +export interface PortfolioTickResult { + state: PortfolioTickState; + /** True when a confirmed portfolio breach forced a full cancel this tick. */ + halted: boolean; +} + +/** + * One iteration of the portfolio loop, extracted so the resilience logic is + * unit-testable without process signals or an infinite loop. Mutates the + * shared trackers/health as needed and returns the next loop-carried state. + * + * Staging (each stage's failure is contained to its own blast radius): + * 1. roll β€” reconcile the futures market set (add/drop expiries). + * 2. shared inputs (gas/collateral) β€” on failure past `graceMs`, set + * `pauseNew` (stop placing; keep existing orders). Never throws. + * 3. per-market update β€” each behind its own circuit breaker. + * 4. risk gate β€” only on FRESH shared data; a confirmed breach cancels all + * and returns `halted`. Stale data only pauses new placements. + * 5. plan + submit via the coordinator (aggregate gate + per-venue isolation). + */ +export async function runPortfolioTick( + now: number, + deps: PortfolioTickDeps, + prev: PortfolioTickState, +): Promise { + const { gas, collateral, risk, coordinator, health, logger, dryRun, graceMs } = deps; + const state: PortfolioTickState = { ...prev }; + // The single error surfaced by THIS tick (shared-input or submit). A tick + // that ends with this null and fresh inputs is healthy and clears /health. + let tickError: ErrorInfo | null = null; + + // Stage 1: roll. + if (deps.onRoll && now - state.lastRollAt > deps.rollCheckIntervalMs) { + state.lastRollAt = now; + state.markets = await applyRoll(state.markets, deps.onRoll, logger); + } + + // Stage 2: shared inputs (gas + collateral; oracles are per-market). + let sharedOk = true; + try { + await gas.update(); + await collateral.update(); + state.lastSharedOkAt = now; + state.pauseNew = false; + try { + await collateral.maybeTopUp(); + } catch (err) { + logger.error({ err }, "collateral top-up failed"); + } + } catch (err) { + sharedOk = false; + tickError = toErrorInfo(err); + logger.error({ err }, "shared input update failed"); + if (now - state.lastSharedOkAt > graceMs && !state.pauseNew) { + state.pauseNew = true; + logger.warn( + { staleMs: now - state.lastSharedOkAt }, + "shared inputs stale past grace; pausing new placements (existing orders kept)", + ); + } + } + + // Stage 3: per-market update (each isolated by its circuit breaker). + for (const m of state.markets) await m.update(now); + + // Stage 4: portfolio risk gate β€” only act on fresh data. A confirmed breach + // cancels everything; stale data only pauses new placements. + if (sharedOk) { + const ok = risk.check(); + if (!ok) { + health.status = "error"; + health.lastError = risk.haltReason; + await Promise.all(state.markets.map((m) => m.cancelAll())).catch((err) => + logger.error({ err }, "cancelAll after halt failed"), + ); + return { state, halted: true }; + } + } + + // Stage 5: plan per market, then submit via the coordinator. + const intents: MarketIntents[] = []; + for (const m of state.markets) { + const p = m.plan(now); + if (p) intents.push(state.pauseNew ? { ...p, creates: [] } : p); + } + const active = intents.filter((i) => i.cancels.length > 0 || i.creates.length > 0); + + if (active.length > 0) { + const res = await coordinator.submit(active, { + maxFeePerGas: gas.cappedGasPrice(), + dryRun, + canPlace: (im) => collateral.canPlace(im), + }); + for (const receipt of res.receipts) { + risk.recordGasCost(gasCostUsd(receipt, gas.ethPriceUsd)); + } + // Per-market timing bookkeeping (best-effort; failed venues re-plan next + // tick via the on-chain-diff resync). + for (const i of active) { + const m = state.markets.find((mk) => mk.instrument === i.instrument); + m?.recordRequote(i.creates.length, i.cancels.length); + } + if (res.errors.length > 0) tickError = toErrorInfo(res.errors[0]); + } + + health.status = "running"; + // A tick that saw an error (stale shared inputs or a submit revert) surfaces + // it; a fully clean tick with fresh inputs clears any stale error so /health + // recovers even during continuous active quoting. + if (tickError) health.lastError = tickError; + else if (sharedOk) health.lastError = null; + return { state, halted: false }; +} + +/** + * Single-process portfolio loop over N markets across venues. + * + * Staged so a fault's blast radius matches its domain: + * - shared-update stage (gas/collateral): failure β†’ fail-safe pause of new + * placements past a grace window; existing orders untouched. + * - per-market stage: each market updates its own oracle/book/inventory and + * plans behind its own circuit breaker; one market's failure never stops + * the others. + * - submit stage: intents handed to the TxCoordinator, which isolates venues + * and runs the single aggregate pre-trade gate. + * The loop itself never dies β€” errors log, back off, and retry. + */ +export async function runPortfolioLoop(opts: PortfolioRunnerOpts): Promise { + const { + pollIntervalMs, + rollCheckIntervalMs, + dryRun, + gas, + collateral, + risk, + coordinator, + health, + logger, + onRoll, + } = opts; + const cancelOrdersOnShutdown = opts.cancelOrdersOnShutdown ?? true; + const graceMs = opts.sharedStalenessGraceMs ?? 30_000; + let markets = [...opts.markets]; + + health.markets = () => markets; + health.onStop = async () => { + logger.info("stop requested via API, cancelling orders"); + await Promise.all(markets.map((m) => m.cancelAll())); + for (const m of markets) m.stop(); + }; + health.onStart = async () => { + logger.info("start requested via API, re-initializing markets"); + await Promise.all(markets.map((m) => m.start())); + }; + + await health.start(); + + // ── Bootstrap: shared trackers (retry) + each market (isolated) ────────── + for (let attempt = 1; ; attempt++) { + try { + await gas.update(); + await collateral.update(); + risk.initialize(); + break; + } catch (err) { + health.status = "init-error"; + health.lastError = toErrorInfo(err); + const delay = Math.min(BASE_ERROR_DELAY_MS * 2 ** (attempt - 1), MAX_ERROR_DELAY_MS); + logger.warn({ err, attempt, retryInMs: delay }, "shared init failed, retrying"); + await sleep(delay); + } + } + // Markets init independently β€” a bad expiry is quarantined, others proceed. + await Promise.all(markets.map((m) => m.start())); + health.status = "running"; + health.lastError = null; + logger.info({ markets: markets.map((m) => m.id) }, "portfolio init complete"); + + // ── Shutdown ───────────────────────────────────────────────────────────── + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + logger.info({ cancelOrdersOnShutdown }, "shutting down…"); + if (cancelOrdersOnShutdown) { + await Promise.all(markets.map((m) => m.cancelAll())).catch((err) => + logger.error({ err }, "failed to cancel orders during shutdown"), + ); + } + for (const m of markets) m.stop(); + await health.stop(); + process.exit(0); + }; + process.on("SIGINT", () => void shutdown()); + process.on("SIGTERM", () => void shutdown()); + + // ── Main loop ────────────────────────────────────────────────────────── + const tickDeps: PortfolioTickDeps = { + gas, + collateral, + risk, + coordinator, + health, + logger, + dryRun, + graceMs, + rollCheckIntervalMs, + onRoll, + }; + let consecutiveErrors = 0; + let state: PortfolioTickState = { + markets, + lastSharedOkAt: Date.now(), + pauseNew: false, + lastRollAt: 0, + }; + + while (!shuttingDown) { + if (health.paused) { + await sleep(pollIntervalMs); + continue; + } + + try { + const result = await runPortfolioTick(Date.now(), tickDeps, state); + state = result.state; + markets = state.markets; // keep health.markets() closure in sync + consecutiveErrors = result.halted ? consecutiveErrors + 1 : 0; + } catch (err) { + consecutiveErrors++; + health.status = "error"; + health.lastError = toErrorInfo(err); + logger.error({ err }, "tick error"); + } + + await afterTick(health, consecutiveErrors, pollIntervalMs); + } +} + +async function afterTick( + health: PortfolioHealthCheck, + consecutiveErrors: number, + pollIntervalMs = 0, +): Promise { + health.tickCount++; + health.lastTickAt = Date.now(); + const delay = + consecutiveErrors > 0 + ? Math.min(BASE_ERROR_DELAY_MS * 2 ** consecutiveErrors, MAX_ERROR_DELAY_MS) + : pollIntervalMs; + if (delay > 0) await sleep(delay); +} + +/** Apply a roll: start added markets, cancel+stop removed ones, splice the set. */ +export async function applyRoll( + current: MarketRuntime[], + onRoll: RollFn, + logger: pino.Logger, +): Promise { + let next = current; + try { + const { add, removeIds } = await onRoll(current); + if (add.length === 0 && removeIds.length === 0) return current; + + const removeSet = new Set(removeIds); + const removed = current.filter((m) => removeSet.has(m.id)); + await Promise.all( + removed.map(async (m) => { + await m.cancelAll(); + m.stop(); + }), + ); + await Promise.all(add.map((m) => m.start())); + + next = current.filter((m) => !removeSet.has(m.id)).concat(add); + logger.info( + { added: add.map((m) => m.id), removed: [...removeSet] }, + "market set rolled", + ); + } catch (err) { + logger.error({ err }, "roll failed; keeping current market set"); + } + return next; +} + +export function gasCostUsd( + receipt: { gasUsed: bigint; effectiveGasPrice: bigint }, + ethPriceUsd: bigint, +): bigint { + if (ethPriceUsd === 0n) return 0n; + return (receipt.gasUsed * receipt.effectiveGasPrice * ethPriceUsd) / 10n ** 18n; +} diff --git a/market-maker/src/core/quoter.ts b/market-maker/src/core/quoter.ts index a9f76e9..e5fb467 100644 --- a/market-maker/src/core/quoter.ts +++ b/market-maker/src/core/quoter.ts @@ -130,7 +130,10 @@ export class Quoter { }); const { bidMid, askMid, spreadBps } = midQuote; - const { quoteBid, quoteAsk } = this.risk.allowedSides(); + const { quoteBid, quoteAsk } = this.risk.allowedSides( + this.inventory, + this.inventory.maxPositionSize, + ); const intents: OrderIntent[] = []; const spacing = BigInt(this.cfg.levelSpacingTicks) * this.tick; diff --git a/market-maker/src/core/rawOracle.ts b/market-maker/src/core/rawOracle.ts index f3743b3..ff3421d 100644 --- a/market-maker/src/core/rawOracle.ts +++ b/market-maker/src/core/rawOracle.ts @@ -7,16 +7,17 @@ * 2-tick floor on the symmetric bid/ask layout. * * `RawOracleReader` reads the underlying Chainlink aggregator directly and - * applies the same `10^(oracle.decimals βˆ’ token.decimals)` rebase the contract - * does, but skips the tick rounding. The MM gets a unit-precision mid that - * lands between ticks ~99% of the time, so `roundDownToTick(r) β†’ bidMid` and + * applies the same `10^(oracle.decimals βˆ’ token.decimals)` rebase AND the same + * contract-size multiplier (`contractSizeHpsDay / ORACLE_UNIT_HPS_DAY`) the venue does, + * but skips the tick rounding. The MM gets a unit-precision mid that lands + * between ticks ~99% of the time, so `roundDownToTick(r) β†’ bidMid` and * `roundUpToTick(r) β†’ askMid` produce a 1-tick spread without any extra * pricing-strategy plumbing. * - * The two venues differ only in *how* the (oracle address, scaling divisor) - * pair is discovered. Each adapter supplies that as a `resolve()` callback; - * the reader caches the result for the lifetime of the process (both values - * change only on `setOracle`-style admin txs). + * The two venues differ only in *how* the (oracle address, scaling divisor, + * contract-size multiplier) tuple is discovered. Each adapter supplies that as a + * `resolve()` callback; the reader caches the result for the lifetime of the + * process (all three change only on `setOracle`/`setContractSize`-style admin txs). */ import type { PublicClient } from "viem"; @@ -49,6 +50,10 @@ export interface RawOracleConfig { oracle: `0x${string}`; /** 10^(oracle.decimals βˆ’ token.decimals); used to rebase the answer to token decimals. */ divisor: bigint; + /** Contract size in hashes/sΒ·day (`contractSizeHpsDay`). Numerator of the unit rebase. */ + contractSizeHpsDay: bigint; + /** The oracle's quote basis in hashes/sΒ·day (`ORACLE_UNIT_HPS_DAY`). Denominator of the unit rebase. */ + oracleUnitHpsDay: bigint; } export class RawOracleReader { @@ -83,7 +88,9 @@ export class RawOracleReader { if (answer <= 0n) { throw new Error(`${this.label}: oracle returned non-positive answer (${answer.toString()})`); } - return answer / this.cache.divisor; + // Mirror the venue's `getMarketPrice()`: rebase decimals first, then apply the + // contract-size multiplier (contractSizeHpsDay / ORACLE_UNIT_HPS_DAY). + return ((answer / this.cache.divisor) * this.cache.contractSizeHpsDay) / this.cache.oracleUnitHpsDay; } /** Drop cached (oracle, divisor) β€” next `read()` will re-resolve. */ diff --git a/market-maker/src/core/riskManager.ts b/market-maker/src/core/riskManager.ts index 0210b9f..6c06be2 100644 --- a/market-maker/src/core/riskManager.ts +++ b/market-maker/src/core/riskManager.ts @@ -73,7 +73,12 @@ export class RiskManager { private startOfDayTimestamp = 0; private readonly cfg: RiskManagerConfig; - private readonly inventory: InventoryManager; + /** + * Default inventory for single-market callers. Null in the portfolio process, + * where `allowedSides` is always called with the per-market inventory since + * position units differ across venues (perps hashrate vs futures contracts). + */ + private readonly inventory: InventoryManager | null; private readonly collateral: CollateralTracker; private readonly gas: GasTracker; private readonly oracle: OracleTracker; @@ -81,7 +86,7 @@ export class RiskManager { constructor( cfg: RiskManagerConfig, - inventory: InventoryManager, + inventory: InventoryManager | null, collateral: CollateralTracker, gas: GasTracker, oracle: OracleTracker, @@ -186,12 +191,19 @@ export class RiskManager { } /** - * Sides allowed to quote. Respects position cap and stops quoting at high - * utilization (only the side that reduces exposure is allowed). + * Sides allowed to quote for a market. Utilization is portfolio-wide (shared + * collateral), while the direction and the position cap are per-market: + * pass the market's inventory + cap. Single-market callers may omit both to + * fall back to the injected defaults. */ - allowedSides(): { quoteBid: boolean; quoteAsk: boolean } { - const maxPos = this.cfg.maxPositionSize; - const net = this.inventory.netQuantity; + allowedSides( + inventory?: InventoryManager, + maxPositionSize?: bigint, + ): { quoteBid: boolean; quoteAsk: boolean } { + const inv = inventory ?? this.inventory; + if (!inv) return { quoteBid: false, quoteAsk: false }; + const maxPos = maxPositionSize ?? this.cfg.maxPositionSize; + const net = inv.netQuantity; if (this.collateral.utilizationPct > this.cfg.maxUtilizationPct) { if (net > 0n) return { quoteBid: false, quoteAsk: true }; diff --git a/market-maker/src/core/txCoordinator.ts b/market-maker/src/core/txCoordinator.ts new file mode 100644 index 0000000..89e747f --- /dev/null +++ b/market-maker/src/core/txCoordinator.ts @@ -0,0 +1,168 @@ +import type pino from "pino"; +import type { + CancelIntent, + InstrumentAdapter, + OrderIntent, + VenueAdapter, +} from "./adapter.ts"; +import type { NonceManager, TxOutcome } from "./nonceManager.ts"; + +/** One market's desired order changes for a cycle. */ +export interface MarketIntents { + instrument: InstrumentAdapter; + cancels: CancelIntent[]; + creates: OrderIntent[]; +} + +export interface TxCoordinatorConfig { + /** Max encoded calls per on-chain tx before chunking. Default 50. */ + maxCallsPerTx?: number; +} + +export interface SubmitOptions { + maxFeePerGas: bigint; + dryRun: boolean; + /** + * Portfolio pre-trade gate. Returns whether `additionalIM` (summed across + * every market's creates) still fits under the wallet's IM budget. This is + * the single aggregate `engine.canPlaceOrder` check β€” never per market. + */ + canPlace: (additionalIM: bigint) => Promise; +} + +export interface SubmitResult { + receipts: TxOutcome[]; + /** Non-fatal per-venue errors; other venues still submitted. */ + errors: Error[]; + ordersPlaced: number; + ordersCancelled: number; + /** True if the aggregate gate denied placements (creates were dropped). */ + gateDenied: boolean; +} + +/** + * Centralized ordered submission for the single-wallet portfolio process. + * + * Responsibilities: + * 1. Aggregate pre-trade gate over ALL markets' creates (one canPlaceOrder). + * 2. Group intents by venue β€” expiries on the same Futures contract merge + * into one `Futures.multicall`; perps is its own contract (β‰₯2 txs total). + * 3. Cancels-before-creates within each venue batch (free margin first). + * 4. Submit each venue independently via the shared NonceManager: a revert or + * timeout on one venue is recorded and never blocks the other. + */ +export class TxCoordinator { + private readonly nonce: NonceManager; + private readonly maxCallsPerTx: number; + private readonly logger: pino.Logger; + + constructor(nonce: NonceManager, cfg: TxCoordinatorConfig, logger: pino.Logger) { + this.nonce = nonce; + this.maxCallsPerTx = cfg.maxCallsPerTx ?? 50; + this.logger = logger.child({ component: "tx-coordinator" }); + } + + async submit(all: MarketIntents[], opts: SubmitOptions): Promise { + const result: SubmitResult = { + receipts: [], + errors: [], + ordersPlaced: 0, + ordersCancelled: 0, + gateDenied: false, + }; + + // 1. Aggregate pre-trade gate across every market's creates. + let additionalIM = 0n; + let totalCreates = 0; + for (const m of all) { + totalCreates += m.creates.length; + for (const c of m.creates) additionalIM += m.instrument.estimateOrderMargin(c); + } + let allowCreates = true; + if (totalCreates > 0 && additionalIM > 0n) { + allowCreates = await opts.canPlace(additionalIM); + if (!allowCreates) { + result.gateDenied = true; + this.logger.warn( + { additionalIM: additionalIM.toString(), wouldPlace: totalCreates }, + "aggregate canPlaceOrder denied; cancelling stale only", + ); + } + } + + // 2. Group by venue (identity). Expiries share their futures venue. + const byVenue = new Map(); + for (const m of all) { + const venue = m.instrument.venue; + const list = byVenue.get(venue); + if (list) list.push(m); + else byVenue.set(venue, [m]); + } + + // 3. Build + submit per venue, isolated. + for (const [venue, markets] of byVenue) { + const calls: `0x${string}`[] = []; + let cancelCount = 0; + let placeCount = 0; + + // Cancels first (all markets), then creates (all markets). + for (const m of markets) { + for (const c of m.cancels) { + calls.push(m.instrument.encodeCancel(c)); + cancelCount++; + } + } + if (allowCreates) { + for (const m of markets) { + for (const c of m.creates) { + calls.push(m.instrument.encodeCreate(c)); + placeCount++; + } + } + } + + if (calls.length === 0) continue; + + if (opts.dryRun) { + this.logger.info( + { venue: venue.kind, cancels: cancelCount, creates: placeCount, calls: calls.length }, + "DRY RUN: would submit venue batch", + ); + result.ordersCancelled += cancelCount; + result.ordersPlaced += placeCount; + continue; + } + + try { + const chunks = this.chunk(calls); + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const outcome = await this.nonce.submit( + ({ nonce, maxFeePerGas }) => venue.multicall(chunk, { maxFeePerGas, nonce }), + { maxFeePerGas: opts.maxFeePerGas, label: `${venue.kind}#${i}` }, + ); + result.receipts.push(outcome); + } + result.ordersCancelled += cancelCount; + result.ordersPlaced += placeCount; + } catch (err) { + const wrapped = err instanceof Error ? err : new Error(String(err)); + result.errors.push(wrapped); + this.logger.error( + { err: wrapped, venue: venue.kind }, + "venue submission failed β€” other venues unaffected", + ); + } + } + + return result; + } + + private chunk(calls: `0x${string}`[]): `0x${string}`[][] { + const out: `0x${string}`[][] = []; + for (let i = 0; i < calls.length; i += this.maxCallsPerTx) { + out.push(calls.slice(i, i + this.maxCallsPerTx)); + } + return out; + } +} diff --git a/market-maker/tests/apps/portfolio/config.test.ts b/market-maker/tests/apps/portfolio/config.test.ts new file mode 100644 index 0000000..f0f58bc --- /dev/null +++ b/market-maker/tests/apps/portfolio/config.test.ts @@ -0,0 +1,159 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { writeFileSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { loadPortfolioConfig, type ParsedFuturesVenue } from "../../../src/apps/portfolio/config.ts"; + +function writeTmp(dir: string, name: string, content: string): string { + const path = join(dir, name); + writeFileSync(path, content, "utf8"); + return path; +} + +const VALID_YAML = ` +wallet: default +wallets: + default: + privateKey: "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" +network: + name: arbitrum + rpcUrl: "https://arb1.arbitrum.io/rpc" +venues: + - kind: perps + address: "0x1111111111111111111111111111111111111111" + maxPositionSize: 10 + pricing: + strategy: effective-spread + minSpreadBps: 15 + volatilityMultiplier: 2.0 + inventorySkewGamma: 0.5 + maxSkewTicks: 20 + sizing: + strategy: linear + baseQuantity: "500000000" + numLevelsPerSide: 4 + - kind: futures + address: "0x2222222222222222222222222222222222222222" + maxPositionSize: 5 + 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 + baseQuantity: "500000000" + numLevelsPerSide: 4 + taperRatio: 0.6 +risk: + maxPositionSize: 50 + maxUtilizationPct: 80 + minCollateralBalance: 10 + maxDailyLossUsd: 500 +gas: + gasCapMultiplier: 2.0 +timing: {} +collateral: {} +oracle: {} +health: + port: 8080 +`; + +let tmpDir: string; +beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "mm-pf-cfg-")); +}); + +describe("loadPortfolioConfig", () => { + it("parses a valid multi-venue config", () => { + const path = writeTmp(tmpDir, "test.yml", VALID_YAML); + const cfg = loadPortfolioConfig({ path }); + assert.equal(cfg.wallet, "default"); + assert.equal(cfg.venues.length, 2); + assert.equal(cfg.venues[0].kind, "perps"); + assert.equal(cfg.venues[1].kind, "futures"); + // USD parsed to 6-decimal bigint. + assert.equal(cfg.venues[0].maxPositionSize, 10_000_000n); + // Futures market selection + baseQuantity bigint. + const fut = cfg.venues[1] as ParsedFuturesVenue; + assert.deepEqual(fut.marketSelection, { mode: "nearest", count: 3 }); + assert.equal(fut.sizing.baseQuantity, 500_000_000n); + }); + + it("defaults futures marketSelection to nearest-1 when omitted", () => { + const yaml = VALID_YAML.replace( + ` marketSelection: + mode: nearest + count: 3 +`, + "", + ); + const path = writeTmp(tmpDir, "test.yml", yaml); + const cfg = loadPortfolioConfig({ path }); + const fut = cfg.venues[1] as ParsedFuturesVenue; + assert.deepEqual(fut.marketSelection, { mode: "nearest", count: 1 }); + }); + + it("applies txCoordinator and circuitBreaker defaults", () => { + const path = writeTmp(tmpDir, "test.yml", VALID_YAML); + const cfg = loadPortfolioConfig({ path }); + assert.equal(cfg.txCoordinator.maxCallsPerTx, 50); + assert.equal(cfg.txCoordinator.confirmationTimeoutMs, 60_000); + assert.equal(cfg.circuitBreaker.quarantineThreshold, 3); + assert.equal(cfg.rollCheckIntervalMs, 300_000); + assert.equal(cfg.sharedStalenessGraceMs, 30_000); + }); + + it("throws when the shared wallet is not declared", () => { + const yaml = VALID_YAML.replace("wallet: default", "wallet: ghost"); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadPortfolioConfig({ path }), /ghost/); + }); + + it("rejects duplicate venue kinds", () => { + const yaml = VALID_YAML.replace('kind: futures\n address: "0x2222222222222222222222222222222222222222"', 'kind: perps\n address: "0x2222222222222222222222222222222222222222"') + .replace( + ` marketSelection: + mode: nearest + count: 3 +`, + "", + ) + .replace( + ` strategy: reservation-price + riskAversion: 0.001 + marginCallTimeSec: 3600 + minSpreadBps: 15 + volatilityMultiplier: 2.5 + maxSkewTicks: 0`, + ` strategy: effective-spread + minSpreadBps: 15 + volatilityMultiplier: 2.0 + inventorySkewGamma: 0.5 + maxSkewTicks: 20`, + ) + .replace( + ` strategy: geometric-taper + baseQuantity: "500000000" + numLevelsPerSide: 4 + taperRatio: 0.6`, + ` strategy: linear + baseQuantity: "500000000" + numLevelsPerSide: 4`, + ); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadPortfolioConfig({ path }), /duplicate venue kind/); + }); + + it("rejects an empty venues array", () => { + const yaml = VALID_YAML.replace(/venues:[\s\S]*?risk:/, "venues: []\nrisk:"); + const path = writeTmp(tmpDir, "test.yml", yaml); + assert.throws(() => loadPortfolioConfig({ path }), /Config validation failed/); + }); +}); diff --git a/market-maker/tests/core/circuitBreaker.test.ts b/market-maker/tests/core/circuitBreaker.test.ts new file mode 100644 index 0000000..529a8e4 --- /dev/null +++ b/market-maker/tests/core/circuitBreaker.test.ts @@ -0,0 +1,55 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { CircuitBreaker } from "../../src/core/circuitBreaker.ts"; + +describe("CircuitBreaker", () => { + it("starts active and stays active below the threshold", () => { + const cb = new CircuitBreaker({ quarantineThreshold: 3 }); + assert.equal(cb.state, "active"); + cb.recordError(new Error("x")); + assert.equal(cb.state, "degraded"); + assert.equal(cb.consecutiveErrors, 1); + assert.equal(cb.canAttempt(), true); + }); + + it("quarantines at the threshold and blocks until backoff elapses", () => { + const now = 1_000_000; + const cb = new CircuitBreaker({ quarantineThreshold: 3, baseBackoffMs: 5_000 }); + cb.recordError(new Error("a"), now); + cb.recordError(new Error("b"), now); + cb.recordError(new Error("c"), now); + assert.equal(cb.state, "quarantined"); + assert.equal(cb.canAttempt(now), false); + assert.equal(cb.canAttempt(now + 4_999), false); + assert.equal(cb.canAttempt(now + 5_000), true); + }); + + it("applies exponential backoff capped at maxBackoffMs", () => { + const now = 0; + const cb = new CircuitBreaker({ + quarantineThreshold: 1, + baseBackoffMs: 1_000, + maxBackoffMs: 4_000, + }); + cb.recordError(new Error("1"), now); // over=0 -> 1000ms + assert.equal(cb.canAttempt(now + 999), false); + assert.equal(cb.canAttempt(now + 1_000), true); + cb.recordError(new Error("2"), now); // over=1 -> 2000ms + assert.equal(cb.canAttempt(now + 2_000), true); + cb.recordError(new Error("3"), now); // over=2 -> 4000ms + cb.recordError(new Error("4"), now); // over=3 -> 8000ms, capped to 4000ms + assert.equal(cb.canAttempt(now + 4_000), true); + }); + + it("recovers to active on a single success", () => { + const cb = new CircuitBreaker({ quarantineThreshold: 2 }); + cb.recordError(new Error("a")); + cb.recordError(new Error("b")); + assert.equal(cb.state, "quarantined"); + cb.recordSuccess(); + assert.equal(cb.state, "active"); + assert.equal(cb.consecutiveErrors, 0); + assert.equal(cb.lastError, null); + assert.equal(cb.canAttempt(), true); + }); +}); diff --git a/market-maker/tests/core/config/base.test.ts b/market-maker/tests/core/config/base.test.ts new file mode 100644 index 0000000..b63c0d9 --- /dev/null +++ b/market-maker/tests/core/config/base.test.ts @@ -0,0 +1,90 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Hex } from "viem"; +import { + configBigint, + expandEnv, + sanitiseConfig, +} from "../../../src/core/config/base.ts"; + +// Anvil account #0 β€” deterministic, not a real secret. +const TEST_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as Hex; +const TEST_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +describe("sanitiseConfig", () => { + it("redacts private keys, derives addresses, and masks RPC secrets", () => { + const cfg = { + wallets: { maker: { privateKey: TEST_KEY } }, + network: { rpcUrl: "https://eth-mainnet.g.alchemy.com/v2/SUPER_SECRET?k=1" }, + }; + const s = sanitiseConfig(cfg); + const wallets = s.wallets as Record; + + assert.equal(wallets.maker.privateKey, "[REDACTED]"); + assert.equal(wallets.maker.address, TEST_ADDRESS); + assert.equal( + (s.network as { rpcUrl: string }).rpcUrl, + "https://eth-mainnet.g.alchemy.com/[redacted]", + ); + // The original config is not mutated (deep clone). + assert.equal(cfg.wallets.maker.privateKey, TEST_KEY); + assert.equal(cfg.network.rpcUrl, "https://eth-mainnet.g.alchemy.com/v2/SUPER_SECRET?k=1"); + }); + + it("marks an unparseable private key as [invalid] and leaves a bare host untouched", () => { + const s = sanitiseConfig({ + wallets: { bad: { privateKey: "0xdeadbeef" as Hex } }, + network: { rpcUrl: "http://localhost:8545" }, + }); + const wallets = s.wallets as Record; + assert.equal(wallets.bad.address, "[invalid]"); + // No path or query β†’ host preserved, nothing to redact. + assert.equal((s.network as { rpcUrl: string }).rpcUrl, "http://localhost:8545"); + }); + + it("reports an unparseable RPC URL as [invalid url]", () => { + const s = sanitiseConfig({ + wallets: {}, + network: { rpcUrl: "not a url" }, + }); + assert.equal((s.network as { rpcUrl: string }).rpcUrl, "[invalid url]"); + }); +}); + +describe("configBigint", () => { + it("parses a numeric string", () => { + assert.equal(configBigint("1500000", "risk.maxPositionSize"), 1_500_000n); + }); + + it("throws a ConfigError with the field name on a bad value", () => { + assert.throws( + () => configBigint("12.5", "risk.cap"), + /Invalid bigint value for risk\.cap/, + ); + }); +}); + +describe("expandEnv", () => { + const env = { FOO: "bar", EMPTY: "" } as unknown as NodeJS.ProcessEnv; + + it("interpolates variables recursively through objects and arrays", () => { + const out = expandEnv({ a: "${FOO}", b: ["x", "${FOO}"], c: 3 }, env); + assert.deepEqual(out, { a: "bar", b: ["x", "bar"], c: 3 }); + }); + + it("uses the :- default when the variable is unset or empty", () => { + assert.equal(expandEnv("${MISSING:-fallback}", env), "fallback"); + assert.equal(expandEnv("${EMPTY:-fallback}", env), "fallback"); + }); + + it("throws when a required variable is unset and has no default", () => { + assert.throws(() => expandEnv("${MISSING}", env), /Environment variable "MISSING" is not set/); + }); + + it("passes through non-string leaves untouched", () => { + assert.equal(expandEnv(42, env), 42); + assert.equal(expandEnv(true, env), true); + assert.equal(expandEnv(null, env), null); + }); +}); diff --git a/market-maker/tests/core/gasTracker.test.ts b/market-maker/tests/core/gasTracker.test.ts index a8736c3..a2431bf 100644 --- a/market-maker/tests/core/gasTracker.test.ts +++ b/market-maker/tests/core/gasTracker.test.ts @@ -113,6 +113,81 @@ describe("GasTracker cost calculations", () => { }); }); +describe("GasTracker ETH price feed", () => { + function feedClient(opts: { + answer: bigint; + decimals: number; + multicallThrows?: boolean; + }): PublicClient { + return { + getGasPrice: async () => 1_000_000_000n, + multicall: async () => { + if (opts.multicallThrows) throw new Error("feed down"); + return [[0n, opts.answer, 0n, 0n, 0n], opts.decimals]; + }, + } as unknown as PublicClient; + } + + const feedCfg = makeConfig({ + ethPriceFeedAddress: "0x0000000000000000000000000000000000000fee", + }); + + it("scales an 8-decimal feed answer down to 6-decimal USDC terms", async () => { + const tracker = new GasTracker( + feedClient({ answer: 2000_00000000n, decimals: 8 }), + feedCfg, + makeLogger(), + ); + await tracker.update(); + assert.equal(tracker.ethPriceUsd, 2000_000000n); // $2000 at 6 dp + }); + + it("scales a low-decimal feed answer up to 6-decimal USDC terms", async () => { + const tracker = new GasTracker( + feedClient({ answer: 2000_00n, decimals: 2 }), + feedCfg, + makeLogger(), + ); + await tracker.update(); + assert.equal(tracker.ethPriceUsd, 2000_000000n); + }); + + it("ignores a non-positive feed answer", async () => { + const tracker = new GasTracker( + feedClient({ answer: 0n, decimals: 8 }), + feedCfg, + makeLogger(), + ); + await tracker.update(); + assert.equal(tracker.ethPriceUsd, 0n); + }); + + it("swallows a feed read failure and leaves ethPriceUsd untouched", async () => { + const tracker = new GasTracker( + feedClient({ answer: 0n, decimals: 8, multicallThrows: true }), + feedCfg, + makeLogger(), + ); + await assert.doesNotReject(tracker.update()); + assert.equal(tracker.ethPriceUsd, 0n); + }); + + it("skips the feed entirely when no address is configured", async () => { + let called = false; + const client = { + getGasPrice: async () => 1_000_000_000n, + multicall: async () => { + called = true; + return []; + }, + } as unknown as PublicClient; + const tracker = new GasTracker(client, makeConfig(), makeLogger()); + await tracker.update(); + assert.equal(called, false); + assert.equal(tracker.ethPriceUsd, 0n); + }); +}); + describe("GasTracker.cappedGasPrice", () => { it("uses cap when current < cap (median * multiplier)", () => { const tracker = new GasTracker( diff --git a/market-maker/tests/core/marketRuntime.test.ts b/market-maker/tests/core/marketRuntime.test.ts new file mode 100644 index 0000000..d623f08 --- /dev/null +++ b/market-maker/tests/core/marketRuntime.test.ts @@ -0,0 +1,193 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import Fraction from "fraction.js"; +import { MarketRuntime, type MarketRuntimeDeps } from "../../src/core/marketRuntime.ts"; +import type { InstrumentAdapter } from "../../src/core/adapter.ts"; +import type { OracleTracker } from "../../src/core/oracleTracker.ts"; +import type { BookTracker } from "../../src/core/bookTracker.ts"; +import type { InventoryManager } from "../../src/core/inventoryManager.ts"; +import type { Quoter } from "../../src/core/quoter.ts"; +import type { OrderExecutor } from "../../src/core/orderExecutor.ts"; + +const noop = () => {}; +function makeLogger(): never { + return { + child: () => ({ info: noop, warn: noop, error: noop, debug: noop }), + } as never; +} + +interface Knobs { + refreshFails: boolean; + plan: { cancels: { orderId: `0x${string}` }[]; creates: unknown[] } | null; +} + +function makeDeps(knobs: Knobs): { deps: MarketRuntimeDeps; knobs: Knobs; recorded: number[] } { + const recorded: number[] = []; + const instrument = { + id: "futures@1700000000", + ownOrders: { bootstrap: async () => {} }, + } as unknown as InstrumentAdapter; + const oracle = { + initialize: async () => {}, + update: async () => {}, + currentPrice: 100n, + volatilityPerSecond: new Fraction(0n), + } as unknown as OracleTracker; + const book = { + start: async () => {}, + refresh: async () => { + if (knobs.refreshFails) throw new Error("rpc down"); + }, + stop: noop, + bestBid: 99n, + bestAsk: 101n, + ownOrders: new Map(), + } as unknown as BookTracker; + const inventory = { update: async () => {}, netQuantity: 0n } as unknown as InventoryManager; + const quoter = { + initialize: async () => {}, + computeQuotes: () => [], + } as unknown as Quoter; + const executor = { + plan: () => knobs.plan, + recordRequote: (p: number, _c: number) => recorded.push(p), + cancelAll: async () => {}, + } as unknown as OrderExecutor; + return { + deps: { + instrument, + oracle, + book, + inventory, + quoter, + executor, + breaker: { quarantineThreshold: 2, baseBackoffMs: 1_000 }, + logger: makeLogger(), + }, + knobs, + recorded, + }; +} + +describe("MarketRuntime", () => { + it("starts healthy and reports an active breaker", async () => { + const { deps } = makeDeps({ refreshFails: false, plan: null }); + const m = new MarketRuntime(deps); + assert.equal(await m.start(), true); + assert.equal(m.breaker.state, "active"); + assert.equal(m.healthState().breaker, "active"); + assert.equal(m.healthState().id, "futures@1700000000"); + }); + + it("quarantines after repeated update failures and skips planning", async () => { + const { deps, knobs } = makeDeps({ refreshFails: false, plan: { cancels: [], creates: [] } }); + const m = new MarketRuntime(deps); + await m.start(); + + knobs.refreshFails = true; + const now = 10_000; + await m.update(now); + assert.equal(m.breaker.state, "degraded"); + await m.update(now); + assert.equal(m.breaker.state, "quarantined"); + + // Quarantined β†’ plan is skipped even though a diff exists. + assert.equal(m.plan(now), null); + // ...and update is a no-op until backoff elapses. + assert.equal(m.breaker.canAttempt(now + 500), false); + assert.equal(m.breaker.canAttempt(now + 1_000), true); + }); + + it("recovers to active after a successful update", async () => { + const { deps, knobs } = makeDeps({ refreshFails: true, plan: null }); + const m = new MarketRuntime(deps); + await m.start(); + await m.update(0); + assert.equal(m.breaker.state, "degraded"); + knobs.refreshFails = false; + await m.update(1); + assert.equal(m.breaker.state, "active"); + assert.equal(m.breaker.consecutiveErrors, 0); + }); + + it("plan() emits MarketIntents mapping cancels to orderIds", async () => { + const { deps } = makeDeps({ + refreshFails: false, + plan: { cancels: [{ orderId: "0xabc" }], creates: [{ side: "buy", price: 1n, size: 1n }] }, + }); + const m = new MarketRuntime(deps); + await m.start(); + const intents = m.plan(0); + assert.ok(intents); + assert.deepEqual(intents.cancels, [{ orderId: "0xabc" }]); + assert.equal(intents.creates.length, 1); + assert.equal(intents.instrument, deps.instrument); + }); + + it("start() swallows init failures and quarantines instead of throwing", async () => { + const { deps } = makeDeps({ refreshFails: false, plan: null }); + (deps.book as unknown as { start: () => Promise }).start = async () => { + throw new Error("init boom"); + }; + const m = new MarketRuntime(deps); + assert.equal(await m.start(), false); + assert.equal(m.breaker.consecutiveErrors, 1); + }); + + it("lazily initializes on the first update() for a market that never started", async () => { + const { deps } = makeDeps({ refreshFails: false, plan: { cancels: [], creates: [] } }); + let bootstrapped = 0; + (deps.instrument as unknown as { ownOrders: { bootstrap: () => Promise } }).ownOrders = { + bootstrap: async () => { + bootstrapped++; + }, + }; + const m = new MarketRuntime(deps); + + // Never called start(); the first update must run the late-init path. + await m.update(0); + assert.equal(bootstrapped, 1, "own-order bootstrap ran during late init"); + assert.equal(m.breaker.state, "active"); + assert.ok(m.plan(0), "plans normally once lazily initialized"); + }); + + it("recordRequote delegates to the executor", () => { + const { deps, recorded } = makeDeps({ refreshFails: false, plan: null }); + const m = new MarketRuntime(deps); + m.recordRequote(3, 1); + assert.deepEqual(recorded, [3]); + }); + + it("cancelAll swallows executor failures", async () => { + const { deps } = makeDeps({ refreshFails: false, plan: null }); + (deps.executor as unknown as { cancelAll: () => Promise }).cancelAll = async () => { + throw new Error("cancel boom"); + }; + const m = new MarketRuntime(deps); + await m.start(); + await assert.doesNotReject(m.cancelAll()); + }); + + it("stop() stops the book tracker", async () => { + const { deps } = makeDeps({ refreshFails: false, plan: null }); + let stopped = 0; + (deps.book as unknown as { stop: () => void }).stop = () => { + stopped++; + }; + const m = new MarketRuntime(deps); + await m.start(); + m.stop(); + assert.equal(stopped, 1); + }); + + it("plan() returns null and records an error when quoting throws", async () => { + const { deps } = makeDeps({ refreshFails: false, plan: { cancels: [], creates: [] } }); + (deps.quoter as unknown as { computeQuotes: () => never }).computeQuotes = () => { + throw new Error("quote boom"); + }; + const m = new MarketRuntime(deps); + await m.start(); + assert.equal(m.plan(0), null); + assert.equal(m.breaker.consecutiveErrors, 1); + }); +}); diff --git a/market-maker/tests/core/nonceManager.test.ts b/market-maker/tests/core/nonceManager.test.ts new file mode 100644 index 0000000..60a8e6e --- /dev/null +++ b/market-maker/tests/core/nonceManager.test.ts @@ -0,0 +1,265 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { Account, Chain, PublicClient, WalletClient } from "viem"; +import { NonceManager } from "../../src/core/nonceManager.ts"; + +const noop = () => {}; +function makeLogger(): never { + return { + child: () => ({ info: noop, warn: noop, error: noop, debug: noop }), + } as never; +} + +const account = { + address: "0x1111111111111111111111111111111111111111", +} as unknown as Account; +const chain = { id: 31337 } as Chain; +const RECEIPT = { gasUsed: 21_000n, effectiveGasPrice: 1_000_000_000n }; + +function pending(): Promise { + return new Promise(() => {}); +} + +interface Mocks { + publicClient: PublicClient; + walletClient: WalletClient; + cancelCalls: { nonce: number }[]; +} + +function makeMocks(opts: { + startNonce: number; + receiptFor: (hash: string) => Promise; +}): Mocks { + const cancelCalls: { nonce: number }[] = []; + const publicClient = { + getTransactionCount: async () => opts.startNonce, + waitForTransactionReceipt: ({ hash }: { hash: string }) => opts.receiptFor(hash), + } as unknown as PublicClient; + const walletClient = { + sendTransaction: async ({ nonce }: { nonce: number }) => { + cancelCalls.push({ nonce }); + return "0xcancel" as const; + }, + } as unknown as WalletClient; + return { publicClient, walletClient, cancelCalls }; +} + +describe("NonceManager", () => { + it("assigns sequential nonces across submits and returns receipts", async () => { + const mocks = makeMocks({ startNonce: 5, receiptFor: async () => RECEIPT }); + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + {}, + makeLogger(), + ); + + const seenNonces: number[] = []; + const broadcast = ({ nonce }: { nonce: number }) => { + seenNonces.push(nonce); + return Promise.resolve(`0x${nonce.toString(16)}` as `0x${string}`); + }; + + const a = await nm.submit(broadcast, { maxFeePerGas: 1n, label: "a" }); + const b = await nm.submit(broadcast, { maxFeePerGas: 1n, label: "b" }); + + assert.deepEqual(seenNonces, [5, 6]); + assert.equal(a.gasUsed, RECEIPT.gasUsed); + assert.equal(b.gasUsed, RECEIPT.gasUsed); + }); + + it("serializes concurrent submits so nonces never collide", async () => { + const mocks = makeMocks({ startNonce: 0, receiptFor: async () => RECEIPT }); + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + {}, + makeLogger(), + ); + const seen: number[] = []; + const broadcast = ({ nonce }: { nonce: number }) => { + seen.push(nonce); + return Promise.resolve("0xaa" as const); + }; + await Promise.all([ + nm.submit(broadcast, { maxFeePerGas: 1n, label: "1" }), + nm.submit(broadcast, { maxFeePerGas: 1n, label: "2" }), + nm.submit(broadcast, { maxFeePerGas: 1n, label: "3" }), + ]); + assert.deepEqual(seen, [0, 1, 2]); + }); + + it("replaces by fee on confirmation timeout, reusing the same nonce", async () => { + // First broadcast's receipt never lands; second one confirms. + const mocks = makeMocks({ + startNonce: 9, + receiptFor: (hash) => (hash === "0xfirst" ? pending() : Promise.resolve(RECEIPT)), + }); + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + { confirmationTimeoutMs: 20, maxReplacements: 2, replacementFeeBumpPct: 10 }, + makeLogger(), + ); + + const attempts: { nonce: number; fee: bigint }[] = []; + const broadcast = ({ nonce, maxFeePerGas }: { nonce: number; maxFeePerGas: bigint }) => { + attempts.push({ nonce, fee: maxFeePerGas }); + return Promise.resolve((attempts.length === 1 ? "0xfirst" : "0xsecond") as `0x${string}`); + }; + + const outcome = await nm.submit(broadcast, { maxFeePerGas: 100n, label: "rbf" }); + assert.equal(outcome.gasUsed, RECEIPT.gasUsed); + assert.equal(attempts.length, 2); + assert.equal(attempts[0].nonce, 9); + assert.equal(attempts[1].nonce, 9); // same nonce + assert.equal(attempts[1].fee, 110n); // +10% + assert.equal(mocks.cancelCalls.length, 0); + }); + + it("escalates to a cancel-tx and advances after exhausting replacements", async () => { + const mocks = makeMocks({ startNonce: 3, receiptFor: () => pending() }); + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + { confirmationTimeoutMs: 10, maxReplacements: 1 }, + makeLogger(), + ); + + const broadcast = () => Promise.resolve("0xstuck" as const); + await assert.rejects(nm.submit(broadcast, { maxFeePerGas: 100n, label: "stuck" }), /stuck at nonce 3/); + assert.equal(mocks.cancelCalls.length, 1); + assert.equal(mocks.cancelCalls[0].nonce, 3); + + // Nonce advanced past the wedged one for the next submit. + const seen: number[] = []; + const ok = ({ nonce }: { nonce: number }) => { + seen.push(nonce); + // receiptFor is still "pending" for all hashes, so return a landing one: + return Promise.resolve("0xok" as const); + }; + // Swap receiptFor to resolve now. + (mocks.publicClient as unknown as { waitForTransactionReceipt: unknown }).waitForTransactionReceipt = + async () => RECEIPT; + await nm.submit(ok, { maxFeePerGas: 1n, label: "next" }); + assert.deepEqual(seen, [4]); + }); + + it("fee-bumps and retries a transient submission error, then confirms", async () => { + const mocks = makeMocks({ startNonce: 7, receiptFor: async () => RECEIPT }); + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + { maxReplacements: 2, replacementFeeBumpPct: 20 }, + makeLogger(), + ); + + const attempts: { nonce: number; fee: bigint }[] = []; + const broadcast = ({ nonce, maxFeePerGas }: { nonce: number; maxFeePerGas: bigint }) => { + attempts.push({ nonce, fee: maxFeePerGas }); + if (attempts.length === 1) return Promise.reject(new Error("nonce too low")); + return Promise.resolve("0xok" as const); + }; + + const outcome = await nm.submit(broadcast, { maxFeePerGas: 100n, label: "flaky" }); + assert.equal(outcome.gasUsed, RECEIPT.gasUsed); + assert.equal(attempts.length, 2); + assert.equal(attempts[1].nonce, 7, "same nonce reused on retry"); + assert.equal(attempts[1].fee, 120n, "fee bumped +20% after the error"); + assert.equal(mocks.cancelCalls.length, 0, "no cancel-tx for a recovered submit"); + }); + + it("escalates to a cancel-tx and re-reads the nonce after a persistent send error", async () => { + const mocks = makeMocks({ startNonce: 2, receiptFor: async () => RECEIPT }); + let counts = 0; + (mocks.publicClient as unknown as { getTransactionCount: () => Promise }).getTransactionCount = + async () => { + counts++; + return counts === 1 ? 2 : 50; // desync: chain jumps ahead after reset + }; + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + { maxReplacements: 1 }, + makeLogger(), + ); + + const broadcast = () => Promise.reject(new Error("execution reverted")); + await assert.rejects(nm.submit(broadcast, { maxFeePerGas: 100n, label: "dead" }), /execution reverted/); + assert.equal(mocks.cancelCalls.length, 1, "cancel-tx sent to free the wedged nonce"); + assert.equal(mocks.cancelCalls[0].nonce, 2); + + // resetNonce() forced a re-read; next submit picks up the chain's value. + const seen: number[] = []; + await nm.submit( + ({ nonce }: { nonce: number }) => { + seen.push(nonce); + return Promise.resolve("0xok" as const); + }, + { maxFeePerGas: 1n, label: "after" }, + ); + assert.deepEqual(seen, [50], "nonce re-read from chain after desync"); + }); + + it("swallows a failing cancel-tx and still advances", async () => { + const mocks = makeMocks({ startNonce: 8, receiptFor: () => pending() }); + (mocks.walletClient as unknown as { sendTransaction: () => Promise }).sendTransaction = + async () => { + throw new Error("cancel broadcast failed"); + }; + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + { confirmationTimeoutMs: 10, maxReplacements: 0 }, + makeLogger(), + ); + // Timeout with 0 replacements β†’ straight to cancel escalation, which fails + // internally but must not propagate; the stuck error is what surfaces. + await assert.rejects( + nm.submit(() => Promise.resolve("0xstuck" as const), { maxFeePerGas: 1n, label: "wedged" }), + /stuck at nonce 8/, + ); + }); + + it("resetNonce() forces a fresh chain read on the next submit", async () => { + let counts = 0; + const mocks = makeMocks({ startNonce: 0, receiptFor: async () => RECEIPT }); + (mocks.publicClient as unknown as { getTransactionCount: () => Promise }).getTransactionCount = + async () => { + counts++; + return counts === 1 ? 10 : 20; + }; + const nm = new NonceManager( + mocks.publicClient, + mocks.walletClient, + account, + chain, + {}, + makeLogger(), + ); + const seen: number[] = []; + const broadcast = ({ nonce }: { nonce: number }) => { + seen.push(nonce); + return Promise.resolve("0xok" as const); + }; + + await nm.submit(broadcast, { maxFeePerGas: 1n, label: "1" }); + nm.resetNonce(); + await nm.submit(broadcast, { maxFeePerGas: 1n, label: "2" }); + assert.deepEqual(seen, [10, 20], "second submit re-read the nonce from chain"); + }); +}); diff --git a/market-maker/tests/core/orderExecutor.test.ts b/market-maker/tests/core/orderExecutor.test.ts index 4c67c9f..52e6b64 100644 --- a/market-maker/tests/core/orderExecutor.test.ts +++ b/market-maker/tests/core/orderExecutor.test.ts @@ -230,3 +230,164 @@ describe("OrderExecutor requote guards (regression)", () => { assert.equal(deps.placedIntents.length, 0, "no unnecessary placements"); }); }); + +// ── plan() / cooldown / gas-spike deferral ───────────────────────────────── + +describe("OrderExecutor.plan", () => { + function spikingGas(): GasTracker { + return { + isGasSpiking: true, + gasSpikePct: 300, + cappedGasPrice: () => 1_000_000_000n, + ethPriceUsd: 0n, + } as unknown as GasTracker; + } + + it("defers a requote during a gas spike when drift is below the urgent threshold", () => { + const deps = makeDeps({ gas: spikingGas() }); + const executor = makeExecutor(deps); + // Anchor the last quote at the current oracle price β†’ drift == 0 ticks. + executor.recordRequote(0, 0); + // A count deficit (empty book vs 2 desired) makes a requote warranted… + const planned = executor.plan([desiredBuy(95_000_000n), desiredSell(96_000_000n)]); + // …but the gas-spike guard defers it because drift (0) < urgent (10). + assert.equal(planned, null); + }); + + it("requotes anyway during a gas spike when drift exceeds the urgent threshold", () => { + const deps = makeDeps({ gas: spikingGas() }); + const executor = makeExecutor(deps); + // No recordRequote β†’ lastQuoteMid is 0 β†’ drift is infinite β†’ proceed. + const planned = executor.plan([desiredBuy(95_000_000n), desiredSell(96_000_000n)]); + assert.ok(planned); + assert.equal(planned.creates.length, 2); + }); + + it("returns null while inside the requote cooldown", () => { + const deps = makeDeps(); + const executor = new OrderExecutor( + deps.instrument, + makeConfig({ requoteCooldownMs: 60_000 }), + deps.quoter, + deps.book, + deps.gas, + deps.risk, + deps.oracle, + makeLogger(), + ); + executor.recordRequote(0, 0); // sets lastRequoteAt = now + assert.equal(executor.plan([desiredBuy(95_000_000n)]), null); + }); + + it("returns null when there is no deficit, no stale order, and no drift", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + seedOrder(deps.book, 1, "buy", 95_000_000n); + seedOrder(deps.book, 2, "sell", 96_000_000n); + executor.recordRequote(0, 0); // lastQuoteMid = oracle.currentPrice β†’ drift 0 + assert.equal( + executor.plan([desiredBuy(95_000_000n), desiredSell(96_000_000n)]), + null, + ); + }); + + it("records requote stats and timing", () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + executor.recordRequote(3, 2); + assert.equal(executor.stats.ordersPlaced, 3); + assert.equal(executor.stats.ordersCancelled, 2); + assert.equal(executor.stats.reconcileCount, 1); + }); +}); + +// ── limit-mode (perps) stale detection ───────────────────────────────────── + +describe("OrderExecutor limit-mode stale detection", () => { + function limitDeps(): TestDeps { + const deps = makeDeps(); + (deps.instrument as unknown as { book: { matchingMode: MatchingMode } }).book.matchingMode = + "limit"; + return deps; + } + + it("keeps orders at-least-as-aggressive as the grid, cancels worse ones", () => { + const deps = limitDeps(); + const executor = makeExecutor(deps); + seedOrder(deps.book, 1, "buy", 96_000_000n); // better than worst bid β†’ keep + seedOrder(deps.book, 2, "buy", 93_000_000n); // worse than worst bid β†’ stale + seedOrder(deps.book, 3, "sell", 95_000_000n); // better than worst ask β†’ keep + seedOrder(deps.book, 4, "sell", 98_000_000n); // worse than worst ask β†’ stale + + const planned = executor.plan([ + desiredBuy(95_000_000n), + desiredBuy(94_000_000n), // worst desired bid + desiredSell(96_000_000n), + desiredSell(97_000_000n), // worst desired ask + ]); + assert.ok(planned); + const cancelled = new Set(planned.cancels.map((o) => o.orderId)); + assert.ok(cancelled.has(makeOrderId(2)) && cancelled.has(makeOrderId(4)), "worse cancelled"); + assert.ok(!cancelled.has(makeOrderId(1)) && !cancelled.has(makeOrderId(3)), "better kept"); + }); + + it("treats every resting order on a side as stale when that side is absent from the grid", () => { + const deps = limitDeps(); + const executor = makeExecutor(deps); + seedOrder(deps.book, 1, "buy", 96_000_000n); + seedOrder(deps.book, 2, "buy", 93_000_000n); + seedOrder(deps.book, 3, "sell", 95_000_000n); // 95 > 96? no β†’ keep + seedOrder(deps.book, 4, "sell", 98_000_000n); // 98 > 96? yes β†’ stale + + // Desired has only an ask side β†’ no desired bid β†’ all resting buys stale. + const planned = executor.plan([desiredSell(96_000_000n)]); + assert.ok(planned); + const cancelled = new Set(planned.cancels.map((o) => o.orderId)); + assert.ok(cancelled.has(makeOrderId(1)) && cancelled.has(makeOrderId(2)), "all bids stale"); + assert.ok(cancelled.has(makeOrderId(4)), "worse ask stale"); + assert.ok(!cancelled.has(makeOrderId(3)), "aggressive ask kept"); + }); +}); + +// ── reconcile() gate + cancelAll ─────────────────────────────────────────── + +describe("OrderExecutor reconcile gate and cancelAll", () => { + it("cancels stale orders but places nothing when the engine denies placement", async () => { + const deps = makeDeps({ + risk: { + throttled: false, + recordGasCost: noop, + canPlaceOrders: async () => false, + } as unknown as RiskManager, + }); + seedOrder(deps.book, 1, "buy", 99_000_000n); // stale vs desired buy@95 + const executor = makeExecutor(deps); + + await executor.reconcile([desiredBuy(95_000_000n), desiredSell(96_000_000n)]); + + assert.equal(deps.placedIntents.length, 0, "denied creates are dropped"); + assert.equal(deps.cancelledOrderIds.length, 1, "stale order still cancelled"); + assert.equal(executor.stats.ordersPlaced, 0); + assert.equal(executor.stats.ordersCancelled, 1); + }); + + it("cancelAll cancels every resting order and records the count", async () => { + const deps = makeDeps(); + seedOrder(deps.book, 1, "buy", 95_000_000n); + seedOrder(deps.book, 2, "sell", 96_000_000n); + const executor = makeExecutor(deps); + + await executor.cancelAll(); + + assert.equal(deps.cancelledOrderIds.length, 2); + assert.equal(deps.placedIntents.length, 0); + assert.equal(executor.stats.ordersCancelled, 2); + }); + + it("cancelAll is a no-op on an empty book", async () => { + const deps = makeDeps(); + const executor = makeExecutor(deps); + await executor.cancelAll(); + assert.equal(deps.cancelledOrderIds.length, 0); + }); +}); diff --git a/market-maker/tests/core/portfolioCollateral.test.ts b/market-maker/tests/core/portfolioCollateral.test.ts new file mode 100644 index 0000000..e570f5e --- /dev/null +++ b/market-maker/tests/core/portfolioCollateral.test.ts @@ -0,0 +1,151 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import type { PublicClient } from "viem"; +import { PortfolioCollateralAccount } from "../../src/core/portfolioCollateral.ts"; +import type { + BatchableCollateralAccount, + CollateralAccount, + CollateralSnapshot, + MarginReadPlan, +} from "../../src/core/adapter.ts"; + +/** A multicall mock that echoes each contract's `args[0]` back as its result. */ +function makeMulticallSpy(): { publicClient: PublicClient; state: { calls: number } } { + const state = { calls: 0 }; + const publicClient = { + multicall: async ({ contracts }: { contracts: { args: unknown[] }[] }) => { + state.calls++; + return contracts.map((c) => c.args[0]); + }, + } as unknown as PublicClient; + return { publicClient, state }; +} + +function reads(values: bigint[]): MarginReadPlan["shared"] { + return values.map((v) => ({ + address: "0x0000000000000000000000000000000000000001", + abi: [], + functionName: "x", + args: [v], + })) as unknown as MarginReadPlan["shared"]; +} + +const SHARED = [100n, 200n, 300n, 400n, 500n]; // vault, IM, MM, wallet, native + +function decodeShared(results: readonly unknown[]): Omit { + const r = results as bigint[]; + return { + vaultBalance: r[0], + portfolioIM: r[1], + portfolioMM: r[2], + walletTokenBalance: r[3], + nativeBalance: r[4], + collateralToken: "0x00000000000000000000000000000000000000aa", + }; +} + +function makeBatchable( + sharedValues: bigint[], + orderMargin: bigint, + pnl: bigint, +): BatchableCollateralAccount { + const buildMarginReadPlan = async (): Promise => ({ + shared: reads(sharedValues), + venue: reads([orderMargin, pnl]), + decode: (results) => ({ + ...decodeShared(results), + venueOrderMargin: (results as bigint[])[5], + venueUnrealizedPnl: (results as bigint[])[6], + }), + }); + return { + buildMarginReadPlan, + snapshot: async () => { + const plan = await buildMarginReadPlan(); + return plan.decode([...sharedValues, orderMargin, pnl]); + }, + imSpotShock: async () => 0n, + deposit: async () => {}, + canPlace: async () => true, + }; +} + +describe("PortfolioCollateralAccount", () => { + it("batches all venues into one multicall, reading shared state once", async () => { + const spy = makeMulticallSpy(); + const a = makeBatchable(SHARED, 11n, 22n); + // b's shared values are ignored (aggregator reads shared from the first plan). + const b = makeBatchable([9n, 9n, 9n, 9n, 9n], 33n, 44n); + const acct = new PortfolioCollateralAccount([a, b], spy.publicClient); + + const snap = await acct.snapshot(); + + assert.equal(spy.state.calls, 1); // single RPC round trip + assert.equal(snap.vaultBalance, 100n); // shared from first plan + assert.equal(snap.portfolioIM, 200n); + assert.equal(snap.venueOrderMargin, 44n); // 11 + 33 + assert.equal(snap.venueUnrealizedPnl, 66n); // 22 + 44 + }); + + it("falls back to per-account snapshot when an account is not batchable", async () => { + const spy = makeMulticallSpy(); + const legacy: CollateralAccount = { + snapshot: async () => ({ + vaultBalance: 1_000n, + portfolioIM: 50n, + portfolioMM: 25n, + venueOrderMargin: 7n, + venueUnrealizedPnl: -3n, + walletTokenBalance: 0n, + nativeBalance: 0n, + collateralToken: "0x00000000000000000000000000000000000000aa", + }), + imSpotShock: async () => 0n, + deposit: async () => {}, + canPlace: async () => true, + }; + const batchable = makeBatchable(SHARED, 5n, 5n); + const acct = new PortfolioCollateralAccount([legacy, batchable], spy.publicClient); + + const snap = await acct.snapshot(); + assert.equal(spy.state.calls, 0); // no batched multicall; each account snapshots itself + assert.equal(snap.vaultBalance, 1_000n); // primary = first (legacy) + assert.equal(snap.venueOrderMargin, 12n); // 7 + 5 + assert.equal(snap.venueUnrealizedPnl, 2n); // -3 + 5 + }); + + it("rejects construction with no accounts", () => { + const spy = makeMulticallSpy(); + assert.throws( + () => new PortfolioCollateralAccount([], spy.publicClient), + /at least one account/, + ); + }); + + it("delegates the shared-vault operations to the first account", async () => { + const spy = makeMulticallSpy(); + const calls: string[] = []; + const primary: CollateralAccount = { + snapshot: async () => makeBatchable(SHARED, 0n, 0n).snapshot(), + imSpotShock: async () => { + calls.push("shock"); + return 42n; + }, + deposit: async (amount) => { + calls.push(`deposit:${amount}`); + }, + canPlace: async (im) => { + calls.push(`canPlace:${im}`); + return im < 100n; + }, + }; + const secondary = makeBatchable(SHARED, 1n, 1n); + const acct = new PortfolioCollateralAccount([primary, secondary], spy.publicClient); + + assert.equal(await acct.imSpotShock(), 42n); + await acct.deposit(7n); + assert.equal(await acct.canPlace(50n), true); + assert.equal(await acct.canPlace(150n), false); + assert.deepEqual(calls, ["shock", "deposit:7", "canPlace:50", "canPlace:150"]); + }); +}); diff --git a/market-maker/tests/core/portfolioRunner.test.ts b/market-maker/tests/core/portfolioRunner.test.ts new file mode 100644 index 0000000..cd1cba6 --- /dev/null +++ b/market-maker/tests/core/portfolioRunner.test.ts @@ -0,0 +1,395 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + applyRoll, + gasCostUsd, + runPortfolioTick, + type PortfolioTickDeps, + type PortfolioTickState, +} from "../../src/core/portfolioRunner.ts"; +import type { MarketRuntime } from "../../src/core/marketRuntime.ts"; +import type { InstrumentAdapter } from "../../src/core/adapter.ts"; +import type { MarketIntents, SubmitResult } from "../../src/core/txCoordinator.ts"; + +const noop = () => {}; +function makeLogger(): never { + return { + child: () => makeLogger(), + info: noop, + warn: noop, + error: noop, + debug: noop, + } as never; +} + +const RECEIPT = { gasUsed: 100_000n, effectiveGasPrice: 2_000_000_000n }; + +interface FakeMarket { + runtime: MarketRuntime; + calls: { + updated: number; + planned: number; + cancelAll: number; + started: number; + stopped: number; + requotes: { placed: number; cancelled: number }[]; + }; +} + +/** + * A stand-in `MarketRuntime`. `plan` returns fixed intents so we can drive the + * runner's staging (pause/halt/roll/attribution) without any on-chain wiring. + */ +function makeMarket( + id: string, + planResult: { cancels: number; creates: number } | null, +): FakeMarket { + const instrument = { id } as unknown as InstrumentAdapter; + const calls: FakeMarket["calls"] = { + updated: 0, + planned: 0, + cancelAll: 0, + started: 0, + stopped: 0, + requotes: [], + }; + const runtime = { + id, + instrument, + update: async () => { + calls.updated++; + }, + plan: (): MarketIntents | null => { + calls.planned++; + if (!planResult) return null; + return { + instrument, + cancels: Array.from({ length: planResult.cancels }, (_, i) => ({ + orderId: `0x${i.toString(16).padStart(64, "0")}` as `0x${string}`, + })), + creates: Array.from({ length: planResult.creates }, () => ({ + side: "buy" as const, + price: 1n, + size: 1n, + })), + }; + }, + cancelAll: async () => { + calls.cancelAll++; + }, + start: async () => { + calls.started++; + return true; + }, + stop: () => { + calls.stopped++; + }, + recordRequote: (placed: number, cancelled: number) => { + calls.requotes.push({ placed, cancelled }); + }, + } as unknown as MarketRuntime; + return { runtime, calls }; +} + +interface DepOverrides { + sharedThrows?: boolean; + riskOk?: boolean; + submit?: (all: MarketIntents[]) => Promise; + graceMs?: number; + rollCheckIntervalMs?: number; + onRoll?: PortfolioTickDeps["onRoll"]; +} + +interface Tracked { + deps: PortfolioTickDeps; + health: { status: string; lastError: unknown }; + spies: { + gasUpdates: number; + collateralUpdates: number; + topUps: number; + riskChecks: number; + recordedGas: bigint[]; + submits: MarketIntents[][]; + }; +} + +function makeDeps(over: DepOverrides = {}): Tracked { + const health = { status: "init", lastError: null as unknown }; + const spies: Tracked["spies"] = { + gasUpdates: 0, + collateralUpdates: 0, + topUps: 0, + riskChecks: 0, + recordedGas: [], + submits: [], + }; + + const defaultSubmit = async (all: MarketIntents[]): Promise => ({ + receipts: [RECEIPT], + errors: [], + ordersPlaced: all.reduce((n, i) => n + i.creates.length, 0), + ordersCancelled: all.reduce((n, i) => n + i.cancels.length, 0), + gateDenied: false, + }); + + const deps: PortfolioTickDeps = { + gas: { + update: async () => { + spies.gasUpdates++; + if (over.sharedThrows) throw new Error("gas rpc down"); + }, + cappedGasPrice: () => 3_000_000_000n, + ethPriceUsd: 2_000_000_000n, + } as unknown as PortfolioTickDeps["gas"], + collateral: { + update: async () => { + spies.collateralUpdates++; + }, + maybeTopUp: async () => { + spies.topUps++; + }, + canPlace: async () => true, + } as unknown as PortfolioTickDeps["collateral"], + risk: { + check: () => { + spies.riskChecks++; + return over.riskOk ?? true; + }, + haltReason: { message: "drawdown breach" }, + recordGasCost: (usd: bigint) => spies.recordedGas.push(usd), + } as unknown as PortfolioTickDeps["risk"], + coordinator: { + submit: async (all: MarketIntents[]) => { + spies.submits.push(all); + return (over.submit ?? defaultSubmit)(all); + }, + } as unknown as PortfolioTickDeps["coordinator"], + health: health as unknown as PortfolioTickDeps["health"], + logger: makeLogger(), + dryRun: false, + graceMs: over.graceMs ?? 30_000, + rollCheckIntervalMs: over.rollCheckIntervalMs ?? 60_000, + onRoll: over.onRoll, + }; + return { deps, health, spies }; +} + +function makeState( + markets: MarketRuntime[], + over: Partial = {}, +): PortfolioTickState { + return { + markets, + lastSharedOkAt: 0, + pauseNew: false, + lastRollAt: 0, + ...over, + }; +} + +describe("runPortfolioTick", () => { + it("happy path: updates, plans, submits, records gas and requotes", async () => { + const m = makeMarket("perps", { cancels: 1, creates: 2 }); + const { deps, health, spies } = makeDeps(); + const now = 1_000; + + const res = await runPortfolioTick(now, deps, makeState([m.runtime], { lastSharedOkAt: now })); + + assert.equal(res.halted, false); + assert.equal(spies.gasUpdates, 1); + assert.equal(spies.collateralUpdates, 1); + assert.equal(spies.topUps, 1); + assert.equal(m.calls.updated, 1); + assert.equal(spies.submits.length, 1); + assert.equal(spies.submits[0][0].creates.length, 2, "creates kept when fresh"); + // Gas recorded per receipt, requote attributed to the planning market. + assert.deepEqual(spies.recordedGas, [gasCostUsd(RECEIPT, 2_000_000_000n)]); + assert.deepEqual(m.calls.requotes, [{ placed: 2, cancelled: 1 }]); + assert.equal(health.status, "running"); + assert.equal(health.lastError, null, "clears stale error after a clean tick"); + assert.equal(res.state.pauseNew, false); + }); + + it("skips submission and clears a stale error on an idle tick", async () => { + const m = makeMarket("perps", null); + const { deps, health, spies } = makeDeps(); + health.lastError = { message: "stale from an earlier tick" }; + const res = await runPortfolioTick(1_000, deps, makeState([m.runtime], { lastSharedOkAt: 1_000 })); + assert.equal(spies.submits.length, 0); + assert.equal(res.halted, false); + assert.equal(health.lastError, null, "idle fresh tick clears the prior error"); + }); + + it("clears a stale error after a clean active (submitting) tick", async () => { + const m = makeMarket("perps", { cancels: 1, creates: 2 }); + const { deps, health } = makeDeps(); + health.lastError = { message: "revert from a previous tick" }; + await runPortfolioTick(1_000, deps, makeState([m.runtime], { lastSharedOkAt: 1_000 })); + assert.equal(health.lastError, null, "recovered active tick must clear the stale error"); + }); + + it("surfaces a shared-input failure onto health even within the grace window", async () => { + const m = makeMarket("perps", { cancels: 1, creates: 0 }); + const { deps, health } = makeDeps({ sharedThrows: true, graceMs: 30_000 }); + await runPortfolioTick(5_000, deps, makeState([m.runtime], { lastSharedOkAt: 0 })); + assert.equal((health.lastError as { message: string }).message, "gas rpc down"); + }); + + it("keeps placing within the staleness grace window on a shared-input failure", async () => { + const m = makeMarket("perps", { cancels: 1, creates: 2 }); + const { deps, spies } = makeDeps({ sharedThrows: true, graceMs: 30_000 }); + // Failure happened only 5s after the last good refresh β†’ still in grace. + const res = await runPortfolioTick(5_000, deps, makeState([m.runtime], { lastSharedOkAt: 0 })); + + assert.equal(res.state.pauseNew, false, "not paused inside grace"); + assert.equal(spies.riskChecks, 0, "risk gate skipped on stale shared data"); + assert.equal(spies.submits.length, 1); + assert.equal(spies.submits[0][0].creates.length, 2, "creates still allowed in grace"); + }); + + it("pauses new placements (keeps cancels) when shared inputs are stale past grace", async () => { + const withWork = makeMarket("perps", { cancels: 1, creates: 2 }); + const cancelsOnly = makeMarket("futures", { cancels: 3, creates: 0 }); + const createsOnly = makeMarket("futures2", { cancels: 0, creates: 4 }); + const { deps, spies } = makeDeps({ sharedThrows: true, graceMs: 10_000 }); + + const res = await runPortfolioTick( + 100_000, + deps, + makeState([withWork.runtime, cancelsOnly.runtime, createsOnly.runtime], { lastSharedOkAt: 0 }), + ); + + assert.equal(res.state.pauseNew, true); + const submitted = spies.submits[0]; + // createsOnly is filtered out (no cancels, creates stripped); others keep cancels only. + assert.equal(submitted.length, 2); + for (const intent of submitted) assert.equal(intent.creates.length, 0, "creates stripped"); + assert.equal(submitted.reduce((n, i) => n + i.cancels.length, 0), 4); + }); + + it("halts and cancels every market on a confirmed risk breach with fresh data", async () => { + const a = makeMarket("perps", { cancels: 0, creates: 2 }); + const b = makeMarket("futures", { cancels: 0, creates: 2 }); + const { deps, health, spies } = makeDeps({ riskOk: false }); + + const res = await runPortfolioTick( + 1_000, + deps, + makeState([a.runtime, b.runtime], { lastSharedOkAt: 1_000 }), + ); + + assert.equal(res.halted, true); + assert.equal(a.calls.cancelAll, 1); + assert.equal(b.calls.cancelAll, 1); + assert.equal(spies.submits.length, 0, "no submissions after a halt"); + assert.equal(health.status, "error"); + assert.deepEqual(health.lastError, { message: "drawdown breach" }); + }); + + it("does not halt on a would-be breach when shared data is stale", async () => { + const a = makeMarket("perps", { cancels: 1, creates: 0 }); + const { deps, spies } = makeDeps({ sharedThrows: true, riskOk: false, graceMs: 10_000 }); + + const res = await runPortfolioTick(1_000, deps, makeState([a.runtime], { lastSharedOkAt: 0 })); + + assert.equal(res.halted, false, "stale data must not trigger a halt"); + assert.equal(spies.riskChecks, 0); + assert.equal(a.calls.cancelAll, 0); + }); + + it("surfaces a submission error onto health without halting", async () => { + const m = makeMarket("perps", { cancels: 0, creates: 1 }); + const { deps, health } = makeDeps({ + submit: async () => ({ + receipts: [], + errors: [new Error("venue revert")], + ordersPlaced: 0, + ordersCancelled: 0, + gateDenied: false, + }), + }); + + const res = await runPortfolioTick(1_000, deps, makeState([m.runtime], { lastSharedOkAt: 1_000 })); + assert.equal(res.halted, false); + assert.equal(health.status, "running"); + assert.equal((health.lastError as { message: string }).message, "venue revert"); + }); + + it("runs the roll when the interval has elapsed and swaps the market set", async () => { + const stay = makeMarket("futures@1", { cancels: 0, creates: 0 }); + const dropped = makeMarket("futures@2", { cancels: 0, creates: 0 }); + const added = makeMarket("futures@3", { cancels: 0, creates: 0 }); + const { deps } = makeDeps({ + rollCheckIntervalMs: 1_000, + onRoll: async () => ({ add: [added.runtime], removeIds: ["futures@2"] }), + }); + + const res = await runPortfolioTick( + 5_000, + deps, + makeState([stay.runtime, dropped.runtime], { lastSharedOkAt: 5_000, lastRollAt: 0 }), + ); + + assert.deepEqual( + res.state.markets.map((mk) => mk.id), + ["futures@1", "futures@3"], + ); + assert.equal(dropped.calls.cancelAll, 1); + assert.equal(dropped.calls.stopped, 1); + assert.equal(added.calls.started, 1); + assert.equal(res.state.lastRollAt, 5_000); + }); +}); + +describe("applyRoll", () => { + it("cancels+stops removed markets, starts added ones, and splices the set", async () => { + const keep = makeMarket("a", null); + const drop = makeMarket("b", null); + const add = makeMarket("c", null); + const next = await applyRoll( + [keep.runtime, drop.runtime], + async () => ({ add: [add.runtime], removeIds: ["b"] }), + makeLogger(), + ); + assert.deepEqual(next.map((m) => m.id), ["a", "c"]); + assert.equal(drop.calls.cancelAll, 1); + assert.equal(drop.calls.stopped, 1); + assert.equal(add.calls.started, 1); + assert.equal(keep.calls.cancelAll, 0); + }); + + it("is a no-op that keeps the same set when there is nothing to roll", async () => { + const keep = makeMarket("a", null); + const current = [keep.runtime]; + const next = await applyRoll(current, async () => ({ add: [], removeIds: [] }), makeLogger()); + assert.equal(next, current, "returns the same reference"); + assert.equal(keep.calls.started, 0); + assert.equal(keep.calls.stopped, 0); + }); + + it("keeps the current set when the roll callback throws", async () => { + const keep = makeMarket("a", null); + const current = [keep.runtime]; + const next = await applyRoll( + current, + async () => { + throw new Error("venue read failed"); + }, + makeLogger(), + ); + assert.equal(next, current); + assert.equal(keep.calls.cancelAll, 0); + }); +}); + +describe("gasCostUsd", () => { + it("returns 0 when the ETH price is unknown", () => { + assert.equal(gasCostUsd({ gasUsed: 21_000n, effectiveGasPrice: 1n }, 0n), 0n); + }); + + it("scales gasUsed * price * ethUsd down by 1e18", () => { + // 100000 gas * 2 gwei * $2000 (8-dp) / 1e18 + const cost = gasCostUsd({ gasUsed: 100_000n, effectiveGasPrice: 2_000_000_000n }, 2_000_000_000n); + assert.equal(cost, (100_000n * 2_000_000_000n * 2_000_000_000n) / 10n ** 18n); + }); +}); diff --git a/market-maker/tests/core/riskManager.test.ts b/market-maker/tests/core/riskManager.test.ts index 94a79d8..7870c1c 100644 --- a/market-maker/tests/core/riskManager.test.ts +++ b/market-maker/tests/core/riskManager.test.ts @@ -169,6 +169,38 @@ describe("RiskManager", () => { assert.deepEqual(r.allowedSides(), { quoteBid: false, quoteAsk: false }); }); + it("allowedSides: uses the per-market inventory and cap over the shared ones", () => { + // Shared inventory is neutral with a large cap; the per-market override is + // at its own (smaller) long cap, so bids must be blocked for THIS market. + const r = new RiskManager( + makeConfig({ maxPositionSize: 1_000_000_000n }), + makeInventory({ netQuantity: 0n }), + makeCollateral({ utilizationPct: 10 }), + dummyGas, + dummyOracle, + makeLogger(), + ); + const perMarket = makeInventory({ netQuantity: 5_000_000n }); + assert.deepEqual(r.allowedSides(perMarket, 5_000_000n), { + quoteBid: false, // net == cap β†’ cannot add more long + quoteAsk: true, + }); + // Sanity: without the override it would use the shared neutral inventory. + assert.deepEqual(r.allowedSides(), { quoteBid: true, quoteAsk: true }); + }); + + it("allowedSides: blocks both sides when there is no inventory to reason about", () => { + const r = new RiskManager( + makeConfig(), + null, + makeCollateral(), + dummyGas, + dummyOracle, + makeLogger(), + ); + assert.deepEqual(r.allowedSides(), { quoteBid: false, quoteAsk: false }); + }); + it("throttles when hourly gas budget exceeded", () => { const r = new RiskManager( makeConfig({ maxGasBudgetPerHourUsd: 10_000_000n }), diff --git a/market-maker/tests/core/txCoordinator.test.ts b/market-maker/tests/core/txCoordinator.test.ts new file mode 100644 index 0000000..e4d7d35 --- /dev/null +++ b/market-maker/tests/core/txCoordinator.test.ts @@ -0,0 +1,163 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { TxCoordinator, type MarketIntents } from "../../src/core/txCoordinator.ts"; +import type { NonceManager } from "../../src/core/nonceManager.ts"; +import type { InstrumentAdapter, VenueAdapter } from "../../src/core/adapter.ts"; + +const noop = () => {}; +function makeLogger(): never { + return { + child: () => ({ info: noop, warn: noop, error: noop, debug: noop }), + } as never; +} + +const RECEIPT = { gasUsed: 21_000n, effectiveGasPrice: 1n }; + +/** Fake NonceManager: runs the broadcast (triggering venue.multicall) once. */ +function makeNonce(): { nm: NonceManager; submitCount: () => number } { + let count = 0; + const nm = { + submit: async ( + broadcast: (p: { nonce: number; maxFeePerGas: bigint }) => Promise<`0x${string}`>, + opts: { maxFeePerGas: bigint }, + ) => { + count++; + await broadcast({ nonce: count, maxFeePerGas: opts.maxFeePerGas }); + return RECEIPT; + }, + } as unknown as NonceManager; + return { nm, submitCount: () => count }; +} + +function makeVenue(kind: "perps" | "futures", opts: { fail?: boolean } = {}) { + const batches: `0x${string}`[][] = []; + const venue = { + kind, + multicall: async (calls: `0x${string}`[]) => { + if (opts.fail) throw new Error(`${kind} boom`); + batches.push(calls); + return "0xhash" as const; + }, + } as unknown as VenueAdapter; + return { venue, batches }; +} + +function makeMarket( + venue: VenueAdapter, + id: string, + cancels: string[], + creates: { price: bigint; im: bigint }[], +): MarketIntents { + const instrument = { + id, + venue, + encodeCancel: (c: { orderId: `0x${string}` }) => `0xC${c.orderId.slice(2)}` as `0x${string}`, + encodeCreate: (o: { price: bigint }) => `0xO${o.price.toString()}` as `0x${string}`, + estimateOrderMargin: (o: { price: bigint }) => + creates.find((c) => c.price === o.price)?.im ?? 0n, + } as unknown as InstrumentAdapter; + return { + instrument, + cancels: cancels.map((o) => ({ orderId: o as `0x${string}` })), + creates: creates.map((c) => ({ side: "buy" as const, price: c.price, size: 1n })), + }; +} + +describe("TxCoordinator", () => { + it("runs one aggregate gate summing IM across all markets' creates", async () => { + const { nm } = makeNonce(); + const seen: bigint[] = []; + const coord = new TxCoordinator(nm, {}, makeLogger()); + const { venue } = makeVenue("futures"); + const markets = [ + makeMarket(venue, "f1", [], [{ price: 1n, im: 300n }]), + makeMarket(venue, "f2", [], [{ price: 2n, im: 400n }]), + ]; + await coord.submit(markets, { + maxFeePerGas: 1n, + dryRun: false, + canPlace: async (im) => { + seen.push(im); + return true; + }, + }); + assert.deepEqual(seen, [700n]); // 300 + 400, one call + }); + + it("drops creates but keeps cancels when the gate denies", async () => { + const { nm, submitCount } = makeNonce(); + const coord = new TxCoordinator(nm, {}, makeLogger()); + const { venue, batches } = makeVenue("futures"); + const markets = [makeMarket(venue, "f1", ["0xdead"], [{ price: 1n, im: 300n }])]; + const res = await coord.submit(markets, { + maxFeePerGas: 1n, + dryRun: false, + canPlace: async () => false, + }); + assert.equal(res.gateDenied, true); + assert.equal(res.ordersPlaced, 0); + assert.equal(res.ordersCancelled, 1); + assert.equal(submitCount(), 1); + assert.deepEqual(batches[0], ["0xCdead"]); // only the cancel encoded + }); + + it("merges same-venue markets into one batch, cancels before creates", async () => { + const { nm, submitCount } = makeNonce(); + const coord = new TxCoordinator(nm, {}, makeLogger()); + const { venue, batches } = makeVenue("futures"); + const markets = [ + makeMarket(venue, "f1", ["0xa"], [{ price: 1n, im: 0n }]), + makeMarket(venue, "f2", ["0xb"], [{ price: 2n, im: 0n }]), + ]; + await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); + assert.equal(submitCount(), 1); // one venue β†’ one tx + assert.deepEqual(batches[0], ["0xCa", "0xCb", "0xO1", "0xO2"]); + }); + + it("isolates venue failures: one venue's revert doesn't block the other", async () => { + const { nm } = makeNonce(); + const coord = new TxCoordinator(nm, {}, makeLogger()); + const perps = makeVenue("perps", { fail: true }); + const futures = makeVenue("futures"); + const markets = [ + makeMarket(perps.venue, "p", ["0x1"], []), + makeMarket(futures.venue, "f", ["0x2"], []), + ]; + const res = await coord.submit(markets, { + maxFeePerGas: 1n, + dryRun: false, + canPlace: async () => true, + }); + assert.equal(res.errors.length, 1); + assert.equal(res.receipts.length, 1); // futures still submitted + assert.equal(futures.batches.length, 1); + }); + + it("chunks a venue batch that exceeds maxCallsPerTx", async () => { + const { nm, submitCount } = makeNonce(); + const coord = new TxCoordinator(nm, { maxCallsPerTx: 2 }, makeLogger()); + const { venue, batches } = makeVenue("futures"); + const markets = [ + makeMarket(venue, "f1", ["0xa", "0xb", "0xc", "0xd", "0xe"], []), + ]; + await coord.submit(markets, { maxFeePerGas: 1n, dryRun: false, canPlace: async () => true }); + assert.equal(submitCount(), 3); // 5 calls / 2 = 3 chunks + assert.deepEqual(batches.map((b) => b.length), [2, 2, 1]); + }); + + it("dry run submits nothing but reports intended counts", async () => { + const { nm, submitCount } = makeNonce(); + const coord = new TxCoordinator(nm, {}, makeLogger()); + const { venue, batches } = makeVenue("futures"); + const markets = [makeMarket(venue, "f1", ["0xa"], [{ price: 1n, im: 0n }])]; + const res = await coord.submit(markets, { + maxFeePerGas: 1n, + dryRun: true, + canPlace: async () => true, + }); + assert.equal(submitCount(), 0); + assert.equal(batches.length, 0); + assert.equal(res.ordersCancelled, 1); + assert.equal(res.ordersPlaced, 1); + }); +}); diff --git a/points-indexer/package.json b/points-indexer/package.json index 4fa4feb..18f3d1a 100644 --- a/points-indexer/package.json +++ b/points-indexer/package.json @@ -16,7 +16,7 @@ "remove-local": "graph remove --node http://localhost:8020/ points", "deploy-local": "graph deploy --node http://localhost:8020/ --ipfs http://localhost:5001 --version-label 0 points", "setup-local": "pnpm prepare-local && pnpm codegen && pnpm build && pnpm create-local && pnpm deploy-local", - "test": "graph test", + "test": "graph test -v 0.6.0", "test:integration": "hardhat test nodejs", "test:integration:debug": "MATCHSTICK_VERBOSE=true hardhat test nodejs", "indexer": "docker compose --env-file ../.env up",