Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions client/src/features/execution/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions docs/architecture/current-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
- `/api/backtests` → `backtest/` — deterministic historical backtesting engine
(`BACKTEST-001`, issue #38). `POST /` fetches historical Hyperliquid candles
(and funding history, if enabled) for the requested symbols/interval/range
Expand Down
120 changes: 120 additions & 0 deletions docs/architecture/paper-execution.md
Original file line number Diff line number Diff line change
@@ -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).
13 changes: 13 additions & 0 deletions server/drizzle/0010_wide_korath.sql
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve leverage for pre-migration positions

For every position created before this migration, the new non-null column is populated as leverage 1 regardless of the leverage recorded on its originating orders. Those still-open positions are immediately exposed through the API with false leverage, and a later same-direction fill uses that fabricated value to calculate the liquidation estimate; existing positions should be backfilled from their fills/orders or represented as unknown until they can be derived.

Useful? React with 👍 / 👎.

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;
2 changes: 1 addition & 1 deletion server/drizzle/meta/0009_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -1571,4 +1571,4 @@
"schemas": {},
"tables": {}
}
}
}
Loading
Loading