From 16217fa1e4edd5e30423ff35eedeb55471c0c455 Mon Sep 17 00:00:00 2001 From: Malcolm Frank Date: Sun, 2 Aug 2026 17:08:36 -0400 Subject: [PATCH 1/2] Add documented paper fill-pricing model: fees, funding, liquidation estimate, provenance (PAPER-REALISM-001) Issue #39: execution/paperEngine.ts already enforced real risk limits (kill switch, position/leverage/loss limits) but paper fills didn't model simulated fees/funding, a liquidation estimate, or record price provenance -- undermining the credibility of any backtest-vs-live-practice comparison the product wants to offer. - execution/fillModel.ts (new): versioned (FILL_MODEL_VERSION) pure functions -- computeFee, estimateLiquidationPrice (standard isolated- margin formula with a documented flat maintenance-margin-ratio simplification), computeFundingCost (pro-rated by elapsed time relative to Hyperliquid's real hourly funding interval, standard perp convention). - fills table: every fill now records priceSource, sourceTimestamp, fillModelVersion, referencePrice, slippageAmount, feeAmount, marketType, simulated. The six provenance fields are nullable -- fills predating this feature never had them computed, and backfilling a synthetic value would fabricate evidence that doesn't exist (same reasoning signals.signal_score already follows for SIGNAL-SCORE-001; the shared dev DB had pre-existing fills rows confirming this wasn't hypothetical). - positions table: leverage (quantity-weighted-averaged the same way entryPrice is), liquidationPriceEstimate, feesPaid, fundingPaid, lastFundingChargedAt. closePosition now settles accumulated fees (including a real exit fee) and funding into realizedPnl -- which flows straight into analytics/'s existing DATA-015 sample-size-tiered performance metrics with no changes needed there. - accruePaperFunding (new, run every 5 minutes from server.ts): charges open positions real Hyperliquid funding via fetchFundingHistory -- deliberately NOT getFundingRate, whose type: 'fundingRate' endpoint was verified directly against live Hyperliquid mainnet during implementation to currently return a real HTTP 422 (a discrepancy an earlier issue's audit had flagged but left unfixed as out of scope). Building this issue's real, recurring cost calculation on the broken endpoint would have meant funding silently never accruing in practice -- confirmed fetchFundingHistory (already used successfully by BACKTEST-001) works correctly instead. - docs/architecture/paper-execution.md documents every assumption: perp-only (no spot ingestion exists), reduce-only out of scope (not represented anywhere in this codebase's order model), the fee/funding/ liquidation-estimate formulas, and what's structurally out of scope. Verified end-to-end against live Hyperliquid mainnet and a real Postgres DB, not just unit tests: submitted a real order and confirmed the fill recorded genuine provenance (real slippage/fee amounts matching hand computation), confirmed the resulting position's leverage/liquidation- estimate/feesPaid, closed it and confirmed realizedPnl correctly nets out entry+exit fees, confirmed GET /api/analytics/performance still works correctly (insufficient tier at sample size 1, no regression), and ran accruePaperFunding directly against real open positions -- discovering and fixing the getFundingRate/fetchFundingHistory issue in the process. Full suite: server 217/217 (211 baseline + fillModel + new paperEngine coverage), client 35/35. Both builds clean. --- client/src/features/execution/types.ts | 26 + docs/architecture/current-state.md | 8 + docs/architecture/paper-execution.md | 120 ++ server/drizzle/0009_wide_korath.sql | 13 + server/drizzle/meta/0009_snapshot.json | 1399 ++++++++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema.ts | 45 +- server/src/execution/fillModel.test.ts | 63 + server/src/execution/fillModel.ts | 60 + server/src/execution/paperEngine.test.ts | 244 +++- server/src/execution/paperEngine.ts | 152 ++- server/src/server.ts | 7 +- 12 files changed, 2119 insertions(+), 25 deletions(-) create mode 100644 docs/architecture/paper-execution.md create mode 100644 server/drizzle/0009_wide_korath.sql create mode 100644 server/drizzle/meta/0009_snapshot.json create mode 100644 server/src/execution/fillModel.test.ts create mode 100644 server/src/execution/fillModel.ts diff --git a/client/src/features/execution/types.ts b/client/src/features/execution/types.ts index e64a2d2..e2f7b1e 100644 --- a/client/src/features/execution/types.ts +++ b/client/src/features/execution/types.ts @@ -32,11 +32,30 @@ export interface Order { updatedAt: string; } +export type PriceSource = 'hyperliquid' | 'coingecko'; + +/** + * Matches the server's fills table exactly (server/src/db/schema.ts). + * Provenance fields (PAPER-REALISM-001) so a fill's pricing basis is + * always traceable: which source priced it, when, by which fill-model + * version, and how much slippage/fee were simulated on top of the + * reference price. `simulated` is always `true` -- this platform has no + * live-execution path. + */ export interface Fill { id: string; orderId: string; price: string; quantity: string; + /** Null only for fills recorded before PAPER-REALISM-001 shipped -- every fill from here forward always populates these. */ + priceSource: PriceSource | null; + sourceTimestamp: string | null; + fillModelVersion: string | null; + referencePrice: string | null; + slippageAmount: string | null; + feeAmount: string | null; + marketType: string; + simulated: boolean; createdAt: string; } @@ -52,6 +71,13 @@ export interface Position { status: PositionStatus; environment: Environment; realizedPnl: string | null; + /** Quantity-weighted-averaged the same way entryPrice is, across every fill that added to this position. */ + leverage: string; + /** A simulated estimate (flat maintenance-margin assumption) -- never an exact liquidation price. Null only if never computed (shouldn't happen for any position opened after PAPER-REALISM-001). */ + liquidationPriceEstimate: string | null; + feesPaid: string; + fundingPaid: string; + lastFundingChargedAt: string | null; createdAt: string; updatedAt: string; closedAt: string | null; diff --git a/docs/architecture/current-state.md b/docs/architecture/current-state.md index 9e164ff..753cec3 100644 --- a/docs/architecture/current-state.md +++ b/docs/architecture/current-state.md @@ -54,6 +54,14 @@ Mounted routers: left open.). - `/api/execution` → `execution/` — paper-trading orders/positions with idempotency (`(user_id, idempotency_key)` unique constraint) and risk gating (PR #14). + `PAPER-REALISM-001` (issue #39) added a documented, versioned fill-pricing + model (`execution/fillModel.ts`): simulated fees (charged at entry and exit), + real Hyperliquid funding accrual (`accruePaperFunding`, run every 5 minutes, + using `fetchFundingHistory` -- not `getFundingRate`, verified broken against + live Hyperliquid during implementation), and a per-position liquidation-price + estimate. Every fill now records price source/timestamp, fill-model version, + reference price, slippage, and fee (nullable for fills predating this + feature). See `docs/architecture/paper-execution.md`. Inline (not yet router-extracted) endpoints on `server.ts` directly: - `GET /api/markets`, `GET /api/market-data/health`, `GET /api/markets/:symbol/candles` diff --git a/docs/architecture/paper-execution.md b/docs/architecture/paper-execution.md new file mode 100644 index 0000000..5ca9488 --- /dev/null +++ b/docs/architecture/paper-execution.md @@ -0,0 +1,120 @@ +# Paper Execution Realism (PAPER-REALISM-001, issue #39) + +## What it is + +`execution/paperEngine.ts` already enforced real risk limits (kill switch, +position/leverage/loss limits) before this issue -- what it lacked was a +documented, provenance-tracked fill-pricing model: simulated fees, funding, +a liquidation-price estimate, and a record of exactly what priced every +fill and how. This issue closes that gap without touching the risk-gating +logic itself (`checkTrustworthySource`, kill switches, position/leverage +limits are all unchanged). + +## What it is not + +**Every number here is simulated.** This platform has no path to a real +order, exchange, or wallet signature -- `fills.simulated` is `true` on +every row, recorded explicitly in the data itself, not just implied by +this being the only execution path that exists. UI copy describing these +numbers as "simulated using Hyperliquid market data and documented +paper-fill assumptions" is `DISCLOSURE-001`'s scope, not duplicated here. + +## Instrument scope: perp only + +This platform has no spot-market ingestion (`DATA-HL-001`'s scope +explicitly stopped at perp). `fills.marketType` and `positions`' implicit +market type are always `'perp'` today -- the field exists for +forward-compatibility, not because spot is actually modeled. "Reduce-only" +order behavior is not represented anywhere in this codebase's order model +(`schemas/execution.ts` has no such flag), so it is out of scope here too, +per the issue's own "where represented" qualifier. + +## Fill provenance + +Every fill now records: + +- `priceSource` / `sourceTimestamp` -- which market snapshot priced it and + when that snapshot was last updated (Hyperliquid or CoinGecko-fallback, + matching `DATA-HL-001`/`DATA-RECOVERY-001`'s existing source labeling). +- `fillModelVersion` -- `execution/fillModel.ts`'s `FILL_MODEL_VERSION`, + versioned the same way `technical-analysis.ts`'s `RULE_VERSION` and + `signals/signalScore.ts`'s `SCORE_MODEL_VERSION` already are. +- `referencePrice` -- the market price before slippage. +- `slippageAmount` -- `|fillPrice - referencePrice|`, using the existing + `applySlippage` function (unchanged). +- `feeAmount` -- see Fees below. +- `marketType`, `simulated` -- see above. + +These six fields are **nullable**, not required: fills recorded before +this feature shipped never had this provenance computed, and backfilling +a synthetic value for them would fabricate evidence that doesn't exist -- +the same reasoning `signals.signal_score` follows for `SIGNAL-SCORE-001`. +Every fill recorded from this point forward always populates all of them. + +## Fees + +A flat, documented taker-fee assumption (`DEFAULT_FEE_BPS`, 5bps of +notional), charged once at entry and once at exit -- not Hyperliquid's +real tiered, volume-dependent fee schedule. `positions.feesPaid` is a +running total (the entry fee at open, plus each fee from any subsequent +same-direction fill that adds to the position), settled into +`realizedPnl` when the position closes (including the exit fee, added at +that point). + +## Funding + +Real Hyperliquid funding rates, fetched via `fetchFundingHistory` (the +documented `fundingHistory` endpoint) -- **not** `getFundingRate` +(`type: 'fundingRate'`), which was verified directly against live +Hyperliquid mainnet during implementation to currently return a real +HTTP 422. That endpoint's brokenness had been flagged but left unfixed by +an earlier issue's audit as out of scope to re-verify; building this +issue's real, recurring cost calculation on top of it would have meant +funding silently never accruing in practice. `fetchFundingHistory` was +independently verified working (`fetchFundingHistory('BTC', ...)` returns +real, recent entries). + +A periodic accrual (`accruePaperFunding`, run every 5 minutes from +`server.ts`) charges each open position the most recent real funding rate +for its asset, pro-rated by elapsed wall-clock time relative to +Hyperliquid's real hourly funding interval (`FUNDING_INTERVAL_MS`) -- not +a fixed per-cycle charge regardless of how long the position was actually +open. A position is never charged more than once within +`FUNDING_MIN_ACCRUAL_INTERVAL_MS` (5 minutes), and is simply skipped (not +charged a fabricated rate) if no funding entry is available in the lookback +window. `positions.fundingPaid` is a running total, settled into +`realizedPnl` at close, same as fees. + +Standard perp convention: a positive funding rate is paid by longs to +shorts (`computeFundingCost` in `fillModel.ts`). + +## Liquidation estimate + +`estimateLiquidationPrice(entryPrice, leverage, side)`: + +``` +LONG: entryPrice * (1 - 1/leverage + MAINTENANCE_MARGIN_RATIO) +SHORT: entryPrice * (1 + 1/leverage - MAINTENANCE_MARGIN_RATIO) +``` + +`MAINTENANCE_MARGIN_RATIO` (0.5%) is a single flat ratio applied uniformly +across every asset -- a deliberate simplification of Hyperliquid's real +per-asset, tiered maintenance-margin schedule. Named and stored as an +**estimate** for exactly this reason, and because it also ignores funding +accrued so far and any cross-margin balance, both of which a real +liquidation price depends on. Recomputed whenever a position's leverage or +entry price changes (a subsequent same-direction fill), using the same +quantity-weighted averaging `entryPrice` already uses. + +## What's out of scope + +- **Spot instrument modeling** -- no spot ingestion exists (`DATA-HL-001`). +- **Reduce-only orders** -- not represented anywhere in this codebase's + order model. +- **Cross-margin portfolio simulation** -- explicitly a non-goal; each + position's liquidation estimate and funding are computed independently. +- **A synthetic exit fill row on `closePosition`** -- closing a position + updates the position directly (fee/funding subtracted into + `realizedPnl`) without creating a new row in `fills`, matching this + codebase's existing structural pattern (`closePosition` never created a + fill before this issue either). diff --git a/server/drizzle/0009_wide_korath.sql b/server/drizzle/0009_wide_korath.sql new file mode 100644 index 0000000..d61c112 --- /dev/null +++ b/server/drizzle/0009_wide_korath.sql @@ -0,0 +1,13 @@ +ALTER TABLE "fills" ADD COLUMN "price_source" "market_snapshot_source";--> statement-breakpoint +ALTER TABLE "fills" ADD COLUMN "source_timestamp" timestamp;--> statement-breakpoint +ALTER TABLE "fills" ADD COLUMN "fill_model_version" varchar(16);--> statement-breakpoint +ALTER TABLE "fills" ADD COLUMN "reference_price" numeric;--> statement-breakpoint +ALTER TABLE "fills" ADD COLUMN "slippage_amount" numeric;--> statement-breakpoint +ALTER TABLE "fills" ADD COLUMN "fee_amount" numeric;--> statement-breakpoint +ALTER TABLE "fills" ADD COLUMN "market_type" varchar(10) DEFAULT 'perp' NOT NULL;--> statement-breakpoint +ALTER TABLE "fills" ADD COLUMN "simulated" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "positions" ADD COLUMN "leverage" numeric DEFAULT '1' NOT NULL;--> statement-breakpoint +ALTER TABLE "positions" ADD COLUMN "liquidation_price_estimate" numeric;--> statement-breakpoint +ALTER TABLE "positions" ADD COLUMN "fees_paid" numeric DEFAULT '0' NOT NULL;--> statement-breakpoint +ALTER TABLE "positions" ADD COLUMN "funding_paid" numeric DEFAULT '0' NOT NULL;--> statement-breakpoint +ALTER TABLE "positions" ADD COLUMN "last_funding_charged_at" timestamp; \ No newline at end of file diff --git a/server/drizzle/meta/0009_snapshot.json b/server/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..d5c008a --- /dev/null +++ b/server/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1399 @@ +{ + "id": "e45f07a9-4e27-48a8-8e3f-baf3c9717b1d", + "prevId": "6883a4dd-d81c-48e8-b547-8da67920a076", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_nonces": { + "name": "auth_nonces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "address": { + "name": "address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "chain": { + "name": "chain", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_nonces_address_chain_idx": { + "name": "auth_nonces_address_chain_idx", + "columns": [ + { + "expression": "address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.candles": { + "name": "candles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "venue": { + "name": "venue", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'hyperliquid'" + }, + "symbol": { + "name": "symbol", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "market_type": { + "name": "market_type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'perp'" + }, + "interval": { + "name": "interval", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "open_time": { + "name": "open_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "close_time": { + "name": "close_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "source_timestamp": { + "name": "source_timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "open": { + "name": "open", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "high": { + "name": "high", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "low": { + "name": "low", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "close": { + "name": "close", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "volume": { + "name": "volume", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "closed": { + "name": "closed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "candles_symbol_interval_open_time_idx": { + "name": "candles_symbol_interval_open_time_idx", + "columns": [ + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "interval", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "open_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fills": { + "name": "fills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "price_source": { + "name": "price_source", + "type": "market_snapshot_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "source_timestamp": { + "name": "source_timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "fill_model_version": { + "name": "fill_model_version", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "reference_price": { + "name": "reference_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "slippage_amount": { + "name": "slippage_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "fee_amount": { + "name": "fee_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "market_type": { + "name": "market_type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'perp'" + }, + "simulated": { + "name": "simulated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "fills_order_id_idx": { + "name": "fills_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fills_order_id_orders_id_fk": { + "name": "fills_order_id_orders_id_fk", + "tableFrom": "fills", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.markets": { + "name": "markets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "symbol": { + "name": "symbol", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "volume": { + "name": "volume", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "change_24h": { + "name": "change_24h", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "market_snapshot_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'hyperliquid'" + }, + "sz_decimals": { + "name": "sz_decimals", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_leverage": { + "name": "max_leverage", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "markets_symbol_unique": { + "name": "markets_symbol_unique", + "nullsNotDistinct": false, + "columns": [ + "symbol" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "side": { + "name": "side", + "type": "order_side", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "order_type": { + "name": "order_type", + "type": "order_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "limit_price": { + "name": "limit_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "leverage": { + "name": "leverage", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "order_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'SUBMITTED'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment": { + "name": "environment", + "type": "execution_environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "orders_user_id_idx": { + "name": "orders_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_user_idempotency_idx": { + "name": "orders_user_idempotency_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.performance": { + "name": "performance", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "signal_id": { + "name": "signal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pnl": { + "name": "pnl", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "is_open": { + "name": "is_open", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "performance_user_id_idx": { + "name": "performance_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "performance_user_id_users_id_fk": { + "name": "performance_user_id_users_id_fk", + "tableFrom": "performance", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "performance_signal_id_signals_id_fk": { + "name": "performance_signal_id_signals_id_fk", + "tableFrom": "performance", + "tableTo": "signals", + "columnsFrom": [ + "signal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.positions": { + "name": "positions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset": { + "name": "asset", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "side": { + "name": "side", + "type": "order_side", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "entry_price": { + "name": "entry_price", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "stop_loss": { + "name": "stop_loss", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "take_profit": { + "name": "take_profit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "position_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "environment": { + "name": "environment", + "type": "execution_environment", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "realized_pnl": { + "name": "realized_pnl", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "leverage": { + "name": "leverage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "liquidation_price_estimate": { + "name": "liquidation_price_estimate", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "fees_paid": { + "name": "fees_paid", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "funding_paid": { + "name": "funding_paid", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_funding_charged_at": { + "name": "last_funding_charged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "positions_user_id_idx": { + "name": "positions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "positions_user_asset_idx": { + "name": "positions_user_asset_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "positions_user_id_users_id_fk": { + "name": "positions_user_id_users_id_fk", + "tableFrom": "positions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.price_history": { + "name": "price_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "symbol": { + "name": "symbol", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "price_history_symbol_timestamp_idx": { + "name": "price_history_symbol_timestamp_idx", + "columns": [ + { + "expression": "symbol", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.risk_limits": { + "name": "risk_limits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "max_position_size": { + "name": "max_position_size", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "max_leverage": { + "name": "max_leverage", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "max_open_positions": { + "name": "max_open_positions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_daily_loss_percent": { + "name": "max_daily_loss_percent", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "kill_switch_enabled": { + "name": "kill_switch_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "risk_limits_user_id_users_id_fk": { + "name": "risk_limits_user_id_users_id_fk", + "tableFrom": "risk_limits", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "risk_limits_user_id_unique": { + "name": "risk_limits_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signals": { + "name": "signals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset": { + "name": "asset", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "signal_type": { + "name": "signal_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "signal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ACTIVE'" + }, + "rule_alignment_score": { + "name": "rule_alignment_score", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "rule_version": { + "name": "rule_version", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entry_price": { + "name": "entry_price", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "stop_loss": { + "name": "stop_loss", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "take_profit": { + "name": "take_profit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "risk_reward_ratio": { + "name": "risk_reward_ratio", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "indicator_snapshot": { + "name": "indicator_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "data_from": { + "name": "data_from", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "data_to": { + "name": "data_to", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "bar_count": { + "name": "bar_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data_quality": { + "name": "data_quality", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "signal_score": { + "name": "signal_score", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "signals_status_created_at_idx": { + "name": "signals_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "address": { + "name": "address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "chain": { + "name": "chain", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "builder_code": { + "name": "builder_code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "user_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'wallet'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_address_unique": { + "name": "users_address_unique", + "nullsNotDistinct": false, + "columns": [ + "address" + ] + }, + "users_builder_code_unique": { + "name": "users_builder_code_unique", + "nullsNotDistinct": false, + "columns": [ + "builder_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.execution_environment": { + "name": "execution_environment", + "schema": "public", + "values": [ + "paper", + "testnet", + "production" + ] + }, + "public.market_snapshot_source": { + "name": "market_snapshot_source", + "schema": "public", + "values": [ + "hyperliquid", + "coingecko" + ] + }, + "public.order_side": { + "name": "order_side", + "schema": "public", + "values": [ + "LONG", + "SHORT" + ] + }, + "public.order_status": { + "name": "order_status", + "schema": "public", + "values": [ + "PENDING_CONFIRMATION", + "SUBMITTED", + "ACKNOWLEDGED", + "PARTIALLY_FILLED", + "FILLED", + "CANCEL_PENDING", + "CANCELLED", + "REJECTED", + "FAILED" + ] + }, + "public.order_type": { + "name": "order_type", + "schema": "public", + "values": [ + "MARKET", + "LIMIT" + ] + }, + "public.position_status": { + "name": "position_status", + "schema": "public", + "values": [ + "OPEN", + "CLOSED", + "LIQUIDATED" + ] + }, + "public.signal_status": { + "name": "signal_status", + "schema": "public", + "values": [ + "DRAFT", + "PUBLISHED", + "ACTIVE", + "TRIGGERED", + "EXPIRED", + "CANCELLED", + "INVALIDATED" + ] + }, + "public.user_kind": { + "name": "user_kind", + "schema": "public", + "values": [ + "wallet", + "guest" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index e126cde..0551cd7 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1785599715888, "tag": "0008_bright_lake", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1785704239802, + "tag": "0009_wide_korath", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 59de877..e79a88b 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -387,7 +387,28 @@ export const orders = pgTable( }), ); -/** A (partial or full) execution of an order. Paper fills are always full-quantity today (see paperEngine.ts). */ +/** + * A (partial or full) execution of an order. Paper fills are always + * full-quantity today (see paperEngine.ts). + * + * PAPER-REALISM-001 provenance fields: every fill records exactly what + * priced it and how, so a fill can never be mistaken for a real execution + * or have its P&L basis silently disputed later. `simulated` is always + * `true` today -- recorded explicitly (not just implied by this being the + * only execution path that exists) so the structural non-goal of live + * trading is visible in the data itself, not only in code that could + * change. `marketType` is always `'perp'` today (no spot ingestion exists + * yet, see DATA-HL-001) -- present for forward-compatibility, not because + * spot is actually modeled. + * + * `priceSource`/`sourceTimestamp`/`fillModelVersion`/`referencePrice`/ + * `slippageAmount`/`feeAmount` are nullable, not required: fills created + * before this feature shipped never had this provenance computed, and + * backfilling a synthetic value for them would fabricate evidence that + * doesn't exist (the same reasoning `signals.signal_score` follows for + * SIGNAL-SCORE-001). Every fill recorded from here forward always + * populates all of them -- see `execution/paperEngine.ts`'s `fillOrder`. + */ export const fills = pgTable( 'fills', { @@ -397,6 +418,14 @@ export const fills = pgTable( .references(() => orders.id, { onDelete: 'cascade' }), price: numeric('price').notNull(), quantity: numeric('quantity').notNull(), + priceSource: marketSnapshotSourceEnum('price_source'), + sourceTimestamp: timestamp('source_timestamp'), + fillModelVersion: varchar('fill_model_version', { length: 16 }), + referencePrice: numeric('reference_price'), + slippageAmount: numeric('slippage_amount'), + feeAmount: numeric('fee_amount'), + marketType: varchar('market_type', { length: 10 }).notNull().default('perp'), + simulated: boolean('simulated').notNull().default(true), createdAt: timestamp('created_at').defaultNow().notNull(), }, (table) => ({ @@ -431,6 +460,20 @@ export const positions = pgTable( status: positionStatusEnum('status').default('OPEN').notNull(), environment: executionEnvironmentEnum('environment').notNull(), realizedPnl: numeric('realized_pnl'), + /** + * PAPER-REALISM-001: leverage the position was opened at (quantity- + * weighted-averaged the same way entryPrice already is, if a + * subsequent same-direction order adds to it). Feeds + * `liquidationPriceEstimate`, recomputed on every fill that changes + * the position. `feesPaid`/`fundingPaid` are running totals, + * subtracted from the raw price-based P&L at close time -- see + * `execution/paperEngine.ts`'s `closePosition`. + */ + leverage: numeric('leverage').notNull().default('1'), + liquidationPriceEstimate: numeric('liquidation_price_estimate'), + feesPaid: numeric('fees_paid').notNull().default('0'), + fundingPaid: numeric('funding_paid').notNull().default('0'), + lastFundingChargedAt: timestamp('last_funding_charged_at'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), closedAt: timestamp('closed_at'), diff --git a/server/src/execution/fillModel.test.ts b/server/src/execution/fillModel.test.ts new file mode 100644 index 0000000..755ac4d --- /dev/null +++ b/server/src/execution/fillModel.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { computeFee, estimateLiquidationPrice, computeFundingCost, FUNDING_INTERVAL_MS, DEFAULT_FEE_BPS } from './fillModel'; + +describe('computeFee', () => { + it('computes fee as a fraction of notional', () => { + expect(computeFee(1000, 10)).toBeCloseTo(1, 6); // 0.1% of 1000 + }); + + it('defaults to DEFAULT_FEE_BPS when not specified', () => { + expect(computeFee(1000)).toBeCloseTo(computeFee(1000, DEFAULT_FEE_BPS), 6); + }); + + it('is zero at zero notional', () => { + expect(computeFee(0, 10)).toBe(0); + }); +}); + +describe('estimateLiquidationPrice', () => { + it('is below entry price for a LONG, and the gap narrows as leverage decreases', () => { + const highLev = estimateLiquidationPrice(100, 10, 'LONG'); + const lowLev = estimateLiquidationPrice(100, 2, 'LONG'); + expect(highLev).toBeLessThan(100); + expect(lowLev).toBeLessThan(100); + // Higher leverage means liquidation is closer to entry (less room to move against the position). + expect(highLev).toBeGreaterThan(lowLev); + }); + + it('is above entry price for a SHORT, and the gap narrows as leverage decreases', () => { + const highLev = estimateLiquidationPrice(100, 10, 'SHORT'); + const lowLev = estimateLiquidationPrice(100, 2, 'SHORT'); + expect(highLev).toBeGreaterThan(100); + expect(lowLev).toBeGreaterThan(100); + expect(highLev).toBeLessThan(lowLev); + }); + + it('still returns a small nonzero maintenance-margin buffer at leverage 1, not exactly 0', () => { + const result = estimateLiquidationPrice(100, 1, 'LONG'); + expect(result).toBeCloseTo(100 * 0.005, 6); + expect(result).toBeGreaterThan(0); + }); +}); + +describe('computeFundingCost', () => { + it('a LONG position pays (positive cost) when funding rate is positive', () => { + const cost = computeFundingCost(1000, 0.0001, 'LONG', FUNDING_INTERVAL_MS); + expect(cost).toBeCloseTo(1000 * 0.0001, 6); + }); + + it('a SHORT position receives (negative cost) when funding rate is positive', () => { + const cost = computeFundingCost(1000, 0.0001, 'SHORT', FUNDING_INTERVAL_MS); + expect(cost).toBeCloseTo(-1000 * 0.0001, 6); + }); + + it('pro-rates linearly by elapsed time relative to the real funding interval', () => { + const full = computeFundingCost(1000, 0.0001, 'LONG', FUNDING_INTERVAL_MS); + const half = computeFundingCost(1000, 0.0001, 'LONG', FUNDING_INTERVAL_MS / 2); + expect(half).toBeCloseTo(full / 2, 6); + }); + + it('is zero at zero elapsed time', () => { + expect(computeFundingCost(1000, 0.0001, 'LONG', 0)).toBe(0); + }); +}); diff --git a/server/src/execution/fillModel.ts b/server/src/execution/fillModel.ts new file mode 100644 index 0000000..d386461 --- /dev/null +++ b/server/src/execution/fillModel.ts @@ -0,0 +1,60 @@ +import type { Side } from './slippage'; + +/** + * The paper fill-pricing model (PAPER-REALISM-001): fee and liquidation- + * estimate math, versioned so every recorded fill can be traced back to + * exactly which model produced it (mirrors `technical-analysis.ts`'s + * `RULE_VERSION` / `signals/signalScore.ts`'s `SCORE_MODEL_VERSION` + * precedent, not a new versioning convention). + * + * Everything here is **simulated**, applied to paper positions only -- + * this module has no path to a real order, exchange, or wallet signature. + */ +export const FILL_MODEL_VERSION = 'v1'; + +/** Round-trip-equivalent taker fee, in basis points of notional, charged once at entry and once at exit. A documented assumption, not Hyperliquid's real (tiered, volume-dependent) fee schedule. */ +export const DEFAULT_FEE_BPS = 5; + +/** + * A single flat maintenance-margin ratio applied uniformly across assets -- + * a deliberate simplification of Hyperliquid's real per-asset, tiered + * maintenance margin schedule. Named and returned as an *estimate* for + * exactly this reason: it also ignores funding accrued so far and any + * cross-margin balance, both of which a real liquidation price depends on. + */ +export const MAINTENANCE_MARGIN_RATIO = 0.005; + +/** Hyperliquid's real funding interval -- used to pro-rate accrued funding by elapsed wall-clock time rather than charging a full period's rate regardless of how long a position was actually open for. */ +export const FUNDING_INTERVAL_MS = 60 * 60_000; + +export function computeFee(notional: number, feeBps: number = DEFAULT_FEE_BPS): number { + return notional * (feeBps / 10_000); +} + +/** + * Estimated liquidation price for an isolated-margin position: + * LONG: entryPrice * (1 - 1/leverage + maintenanceMarginRatio) + * SHORT: entryPrice * (1 + 1/leverage - maintenanceMarginRatio) + * + * At leverage 1 this still returns a (small) nonzero price rather than 0 -- + * a maintenance-margin buffer applies even to unleveraged positions in this + * model, which is the mathematically honest behavior of the formula, not a + * special case to work around. + */ +export function estimateLiquidationPrice(entryPrice: number, leverage: number, side: Side): number { + const factor = 1 / leverage; + return side === 'LONG' + ? entryPrice * (1 - factor + MAINTENANCE_MARGIN_RATIO) + : entryPrice * (1 + factor - MAINTENANCE_MARGIN_RATIO); +} + +/** + * Funding cost for one accrual event, pro-rated by elapsed time relative to + * Hyperliquid's real hourly funding interval. Standard perp convention: a + * positive funding rate is paid by longs to shorts. + */ +export function computeFundingCost(notional: number, fundingRate: number, side: Side, elapsedMs: number): number { + const periods = elapsedMs / FUNDING_INTERVAL_MS; + const cost = notional * fundingRate * periods; + return side === 'LONG' ? cost : -cost; +} diff --git a/server/src/execution/paperEngine.test.ts b/server/src/execution/paperEngine.test.ts index 22aa082..fd43133 100644 --- a/server/src/execution/paperEngine.test.ts +++ b/server/src/execution/paperEngine.test.ts @@ -4,17 +4,53 @@ import { NotFoundError, ForbiddenError } from './errors'; const selectMock = vi.fn(); const updateMock = vi.fn(); +const insertMock = vi.fn(); +const fetchFundingHistoryMock = vi.fn(); vi.mock('../db/index', () => ({ db: { select: (...args: unknown[]) => selectMock(...args), update: (...args: unknown[]) => updateMock(...args), + insert: (...args: unknown[]) => insertMock(...args), }, })); +vi.mock('../hyperliquid-real', () => ({ + fetchFundingHistory: (...args: unknown[]) => fetchFundingHistoryMock(...args), +})); + // vitest hoists `vi.mock` above imports, so `./paperEngine` picks up the // mocked `../db/index`. -import { cancelOrder, closePosition } from './paperEngine'; +import { cancelOrder, closePosition, submitOrder, accruePaperFunding } from './paperEngine'; + +const BASE_ORDER_ROW = { + id: 'order-1', + userId: 'user-a', + asset: 'BTC', + side: 'LONG' as const, + orderType: 'MARKET' as const, + quantity: '1', + limitPrice: null, + leverage: '2', + status: 'PENDING', +}; + +const RISK_LIMITS_ROW = { + userId: 'user-a', + maxPositionSize: '100000', + maxLeverage: '10', + maxOpenPositions: 5, + maxDailyLossPercent: '5', + killSwitchEnabled: false, +}; + +function marketRow() { + // updatedAt must be "now", not a fixed past timestamp -- evaluateTrade's + // stale-data check compares it against the real wall clock and would + // otherwise reject every order in this fixture as stale before it ever + // reaches fillOrder. + return { symbol: 'BTC', price: '100', source: 'hyperliquid' as const, updatedAt: new Date() }; +} /** * Regression coverage for SEC-017: user A must never be able to cancel user @@ -31,6 +67,7 @@ describe('cancelOrder ownership', () => { beforeEach(() => { selectMock.mockReset(); updateMock.mockReset(); + insertMock.mockReset(); }); it('throws NotFoundError when the order does not exist', async () => { @@ -58,6 +95,7 @@ describe('closePosition ownership', () => { beforeEach(() => { selectMock.mockReset(); updateMock.mockReset(); + insertMock.mockReset(); }); it('throws NotFoundError when the position does not exist', async () => { @@ -77,7 +115,17 @@ describe('closePosition ownership', () => { selectMock .mockReturnValueOnce( dbChain([ - { id: 'position-1', userId: 'user-a', status: 'OPEN', asset: 'BTC', side: 'LONG', entryPrice: '100', quantity: '1' }, + { + id: 'position-1', + userId: 'user-a', + status: 'OPEN', + asset: 'BTC', + side: 'LONG', + entryPrice: '100', + quantity: '1', + feesPaid: '0', + fundingPaid: '0', + }, ]), ) .mockReturnValueOnce(dbChain([{ symbol: 'BTC', price: '110' }])); @@ -88,4 +136,196 @@ describe('closePosition ownership', () => { expect(result).toMatchObject({ id: 'position-1', status: 'CLOSED' }); expect(updateMock).toHaveBeenCalledTimes(1); }); + + it("realizedPnl (PAPER-REALISM-001) subtracts the exit fee, fees already accrued from entry, and funding paid over the position's life -- not just the raw price move", async () => { + selectMock + .mockReturnValueOnce( + dbChain([ + { + id: 'position-1', + userId: 'user-a', + status: 'OPEN', + asset: 'BTC', + side: 'LONG', + entryPrice: '100', + quantity: '10', + feesPaid: '2', // already paid at entry + fundingPaid: '3', // accrued over the holding period + }, + ]), + ) + .mockReturnValueOnce(dbChain([{ symbol: 'BTC', price: '110' }])); + + // dbChain's generic proxy discards arguments passed to chained calls + // like `.set(...)`, so it can't be used to inspect what closePosition + // actually computed -- this captures the real payload directly. + let capturedSet: Record | undefined; + updateMock.mockImplementation(() => ({ + set: (payload: Record) => { + capturedSet = payload; + return dbChain([{ id: 'position-1', status: 'CLOSED', ...payload }]); + }, + })); + + await closePosition('user-a', 'position-1'); + + expect(capturedSet).toBeDefined(); + // Exiting a LONG applies SHORT-direction slippage to the exit fill + // (110 * (1 - 5bps)), matching applySlippage's default -- so the + // expected values are computed the same way closePosition itself does, + // not approximated. + const exitPrice = 110 * (1 - 5 / 10_000); + const grossPnl = (exitPrice - 100) * 10; + const exitFee = exitPrice * 10 * (5 / 10_000); + const expectedFeesPaid = 2 + exitFee; + const expectedRealizedPnl = grossPnl - expectedFeesPaid - 3; + + const realizedPnl = parseFloat(capturedSet!.realizedPnl as string); + const feesPaid = parseFloat(capturedSet!.feesPaid as string); + expect(feesPaid).toBeCloseTo(expectedFeesPaid, 6); + expect(realizedPnl).toBeCloseTo(expectedRealizedPnl, 6); + }); +}); + +describe('fill provenance and position tracking (PAPER-REALISM-001)', () => { + beforeEach(() => { + selectMock.mockReset(); + updateMock.mockReset(); + insertMock.mockReset(); + }); + + it('records price source, source timestamp, fill-model version, reference price, slippage, and fee on a new fill', async () => { + let capturedFillValues: Record | undefined; + const market = marketRow(); + selectMock + .mockReturnValueOnce(dbChain([])) // isUserHalted + .mockReturnValueOnce(dbChain([market])) // getMarketSnapshot + .mockReturnValueOnce(dbChain([RISK_LIMITS_ROW])) // getOrCreateRiskLimits + .mockReturnValueOnce(dbChain([{ value: 0 }])) // countOpenPositions + .mockReturnValueOnce(dbChain([])) // getOpenPosition (direction-conflict check) + .mockReturnValueOnce(dbChain([])) // getOpenPosition again, inside fillOrder + .mockReturnValueOnce(dbChain([{ id: 'fill-1' }])); // select(fills) at the end of fillOrder + updateMock.mockReturnValueOnce(dbChain([{ ...BASE_ORDER_ROW, status: 'FILLED' }])); + insertMock + .mockImplementationOnce(() => ({ values: () => dbChain([BASE_ORDER_ROW]) })) // insert(orders) + .mockImplementationOnce(() => ({ + values: (payload: Record) => { + capturedFillValues = payload; + return dbChain([{}]); + }, + })) // insert(fills) + .mockImplementationOnce(() => ({ values: () => dbChain([{}]) })); // insert(positions) + + await submitOrder('user-a', { + asset: 'BTC', + side: 'LONG', + orderType: 'MARKET', + quantity: 1, + leverage: 2, + idempotencyKey: 'key-1', + }); + + expect(capturedFillValues).toBeDefined(); + expect(capturedFillValues!.priceSource).toBe('hyperliquid'); + expect(capturedFillValues!.sourceTimestamp).toEqual(market.updatedAt); + expect(capturedFillValues!.fillModelVersion).toBe('v1'); + expect(capturedFillValues!.referencePrice).toBe('100'); + expect(parseFloat(capturedFillValues!.slippageAmount as string)).toBeGreaterThan(0); // MARKET order -- slippage applied + expect(parseFloat(capturedFillValues!.feeAmount as string)).toBeGreaterThan(0); + }); +}); + +describe('accruePaperFunding', () => { + beforeEach(() => { + selectMock.mockReset(); + updateMock.mockReset(); + fetchFundingHistoryMock.mockReset(); + }); + + it('charges a LONG position funding pro-rated by elapsed time, using the real current funding rate', async () => { + const openedAt = new Date('2026-08-01T00:00:00.000Z'); + const now = new Date(openedAt.getTime() + 60 * 60_000); // exactly one funding interval later + selectMock.mockReturnValueOnce( + dbChain([ + { + id: 'position-1', + asset: 'BTC', + side: 'LONG', + entryPrice: '100', + quantity: '10', + fundingPaid: '0', + createdAt: openedAt, + lastFundingChargedAt: null, + }, + ]), + ); + fetchFundingHistoryMock.mockResolvedValue([{ time: 0, coin: 'BTC', fundingRate: '0.0001', premium: '0' }]); + let capturedSet: Record | undefined; + updateMock.mockImplementation(() => ({ + set: (payload: Record) => { + capturedSet = payload; + return dbChain([{}]); + }, + })); + + await accruePaperFunding(now); + + expect(capturedSet).toBeDefined(); + // notional 1000 * rate 0.0001 * exactly 1 funding interval elapsed = 0.1, LONG pays positive. + expect(parseFloat(capturedSet!.fundingPaid as string)).toBeCloseTo(0.1, 6); + }); + + it('skips a position without fabricating a charge when the funding-history endpoint fails', async () => { + const openedAt = new Date('2026-08-01T00:00:00.000Z'); + const now = new Date(openedAt.getTime() + 60 * 60_000); + selectMock.mockReturnValueOnce( + dbChain([ + { id: 'position-1', asset: 'BTC', side: 'LONG', entryPrice: '100', quantity: '10', fundingPaid: '0', createdAt: openedAt, lastFundingChargedAt: null }, + ]), + ); + fetchFundingHistoryMock.mockRejectedValue(new Error('endpoint unavailable')); + + await accruePaperFunding(now); + + expect(updateMock).not.toHaveBeenCalled(); + }); + + it('skips a position without fabricating a charge when no funding entry is available in the lookback window', async () => { + const openedAt = new Date('2026-08-01T00:00:00.000Z'); + const now = new Date(openedAt.getTime() + 60 * 60_000); + selectMock.mockReturnValueOnce( + dbChain([ + { id: 'position-1', asset: 'BTC', side: 'LONG', entryPrice: '100', quantity: '10', fundingPaid: '0', createdAt: openedAt, lastFundingChargedAt: null }, + ]), + ); + fetchFundingHistoryMock.mockResolvedValue([]); + + await accruePaperFunding(now); + + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("does not charge again before FUNDING_MIN_ACCRUAL_INTERVAL_MS has elapsed since the position's last charge", async () => { + const lastCharged = new Date('2026-08-01T00:00:00.000Z'); + const now = new Date(lastCharged.getTime() + 60_000); // only 1 minute later + selectMock.mockReturnValueOnce( + dbChain([ + { + id: 'position-1', + asset: 'BTC', + side: 'LONG', + entryPrice: '100', + quantity: '10', + fundingPaid: '0', + createdAt: lastCharged, + lastFundingChargedAt: lastCharged, + }, + ]), + ); + + await accruePaperFunding(now); + + expect(fetchFundingHistoryMock).not.toHaveBeenCalled(); + expect(updateMock).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/execution/paperEngine.ts b/server/src/execution/paperEngine.ts index c28ef6f..2cdd5a8 100644 --- a/server/src/execution/paperEngine.ts +++ b/server/src/execution/paperEngine.ts @@ -10,12 +10,16 @@ import { getOrCreateRiskLimits } from '../risk/userLimits'; import { applySlippage } from './slippage'; import { isMarketable } from './marketability'; import { calculateUnrealizedPnl, weightedAverageEntryPrice } from './pnl'; +import { FILL_MODEL_VERSION, computeFee, computeFundingCost, estimateLiquidationPrice } from './fillModel'; import { isOrderTerminal, type OrderStatus } from './stateMachine'; import { getMarketSnapshot, countOpenPositions, getOpenPosition } from './queries'; import { NotFoundError, ForbiddenError, ExecutionModeNotSupportedError, isUniqueViolation } from './errors'; import { incrementCounter } from '../observability/metrics'; +import { fetchFundingHistory } from '../hyperliquid-real'; import type { SubmitOrderRequest } from '../schemas/execution'; +type MarketSnapshot = NonNullable>>; + /** Price deviation and staleness bounds applied to every order, on top of the caller's own risk_limits. */ const MAX_PRICE_DEVIATION_PERCENT = 1; @@ -46,38 +50,67 @@ async function rejectOrder(orderId: string, reason: string): Promise { + if (env.EXECUTION_MODE !== 'paper' || isGloballyHalted()) return; + + const openPositions = await db.select().from(positions).where(eq(positions.status, 'OPEN')); + for (const position of openPositions) { + const lastCharged = position.lastFundingChargedAt ?? position.createdAt; + const elapsedMs = now.getTime() - lastCharged.getTime(); + if (elapsedMs < FUNDING_MIN_ACCRUAL_INTERVAL_MS) continue; + + let fundingRate: number; + try { + const history = await fetchFundingHistory(position.asset, now.getTime() - FUNDING_LOOKBACK_MS, now.getTime()); + const latest = history[history.length - 1]; + if (!latest) continue; + fundingRate = parseFloat(latest.fundingRate); + } catch { + continue; + } + + const notional = parseFloat(position.entryPrice) * parseFloat(position.quantity); + const cost = computeFundingCost(notional, fundingRate, position.side, elapsedMs); + + await db + .update(positions) + .set({ + fundingPaid: (parseFloat(position.fundingPaid) + cost).toString(), + lastFundingChargedAt: now, + updatedAt: now, + }) + .where(eq(positions.id, position.id)); + } +} + /** * Periodic sweep for resting limit orders: checks every ACKNOWLEDGED * limit order against the current market price and fills any that have @@ -262,6 +372,6 @@ export async function sweepLimitOrders(): Promise { const existingPosition = await getOpenPosition(order.userId, order.asset); if (existingPosition && existingPosition.side !== order.side) continue; // still blocked -- leave resting - await fillOrder(order, parseFloat(order.limitPrice!)); + await fillOrder(order, parseFloat(order.limitPrice!), market); } } diff --git a/server/src/server.ts b/server/src/server.ts index 271edd3..85b2dde 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -26,7 +26,7 @@ import { authRouter } from './auth/router'; import { riskRouter } from './risk/router'; import { executionRouter } from './execution/router'; import { analyticsRouter } from './analytics/router'; -import { sweepLimitOrders } from './execution/paperEngine'; +import { sweepLimitOrders, accruePaperFunding } from './execution/paperEngine'; import { apiLimiter } from './middleware/rateLimit'; import { runIngestionCycle, @@ -217,6 +217,11 @@ setInterval(() => { log('error', 'limit_order_sweep_failed', { error: err instanceof Error ? err.message : String(err) }), ); }, 10_000); +setInterval(() => { + accruePaperFunding().catch((err) => + log('error', 'paper_funding_accrual_failed', { error: err instanceof Error ? err.message : String(err) }), + ); +}, 5 * 60_000); /** * REST API routes From 4341997f4d2ebc034fc7c09b6558d00f2e03ada6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:15:40 +0000 Subject: [PATCH 2/2] Merge remote-tracking branch 'origin/main' into feat/paper-realism (resolve conflicts) --- server/package-lock.json | 485 --------------------------------------- 1 file changed, 485 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index c1db300..c846c3c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -4176,448 +4176,6 @@ } } }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, "node_modules/vitest/node_modules/@vitest/mocker": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", @@ -4644,49 +4202,6 @@ } } }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, "node_modules/vitest/node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",