Repository Public
1/**2 * Paper trading account: a simulated cross-margin account filled against the order books it is given.3 *4 * Deterministic: it never reads the clock and never draws a random number; every timestamp is the `now` passed in and every5 * id comes from a counter in the state. The whole account is `state`, a plain JSON-serialisable object (`serialize` /6 * `PaperAccount.restore`); continuing from a restored state gives exactly the same results. All arithmetic is on `Dec`.7 *8 * Simulation rules (simplifications are deliberate and listed here):9 * - Market and IOC orders walk the opposite side of the supplied book, best level first, never beyond the order's price10 * (limit price, or worst acceptable price of a market order). What cannot be filled is cancelled. Taker fee.11 * - A GTC limit order fills its crossing part as a taker, then rests. A resting order is filled by a LATER book (`onBook`,12 * strictly newer `ts` for that instrument) whose opposite side crosses its price: at the order's own price, for at most13 * the crossing depth (shared between resting orders, oldest first). Maker fee. Queue position is not modelled.14 * - `alo` (post only) is rejected if it would cross.15 * - Stop / take-profit orders wait as 'pending'; `onTicker` turns them 'triggered' on the MARK price; the next `onBook`16 * executes them (market variants as takers bounded by their worst price, limit variants as limit orders).17 * - Reduce-only: rejected at submission when there is nothing to reduce or when it is larger than the position; a resting or18 * triggered reduce-only order is clipped to the position when it executes and cancelled when the position is gone.19 * - Margin: one leverage per instrument (config). An order that adds exposure needs free collateral ≥ initial margin of the20 * added size at the order price + its fee; resting orders reserve nothing and are checked again when they fill.21 * - Funding: amount = −size·mark·rate for a long, +size·mark·rate for a short (a positive rate is paid by longs), on the22 * last mark seen. Idempotent per (instrument, ts).23 * - Liquidation (simulated, flagged): when account equity < Σ maintenance margin, every open order is cancelled and every24 * position is closed at its last mark with `liquidation: true` fills. Real venues liquidate through their book or a25 * backstop, often partially; this is a pessimistic, simplified stand-in. Collateral can end negative (bad debt is shown,26 * not hidden).27 */28import { z } from 'zod';29import { D, type Dec, type DecStr } from './decimal.ts';30import { applyOrderToPosition, CALC_SCALE, initialMargin, liquidationPriceCross, signedSize, unrealizedPnl } from './margin.ts';31import {32 BookSchema, DecStrSchema, FillSchema, FundingPaymentSchema, OrderRequestSchema, OrderSchema, SideSchema, TickerSchema, VenueIdSchema,33 type AccountState, type Book, type Fill, type FundingPayment, type Order, type OrderRequest, type Position, type Ticker, type VenueId,34} from './models.ts';3536const NonNegDec = DecStrSchema.refine((x) => !x.startsWith('-'), 'must not be negative');37const PosDec = NonNegDec.refine((x) => /[1-9]/.test(x), 'must be positive');3839export const PaperConfigSchema = z.object({40 /** fee rates as fractions of the notional (0.00045 = 4.5 bps); the maker rate may be negative (rebate) */41 takerFeeRate: NonNegDec,42 makerFeeRate: DecStrSchema,43 /** fee rate charged on simulated liquidation fills */44 liquidationFeeRate: NonNegDec,45 feeAsset: z.string().min(1),46 /** leverage used for initial margin, and per-instrument overrides */47 leverage: PosDec,48 leverageByInstrument: z.record(z.string(), PosDec),49 /** flat maintenance margin fraction of the notional at mark */50 maintenanceMarginFraction: NonNegDec,51});52export type PaperConfig = z.infer<typeof PaperConfigSchema>;5354export const PaperPositionSchema = z.object({55 instrument: z.string().min(1), side: SideSchema, size: PosDec, entryPrice: PosDec, markPrice: PosDec,56 realizedPnl: DecStrSchema, fundingPaid: DecStrSchema,57});58export type PaperPosition = z.infer<typeof PaperPositionSchema>;5960export const PaperStateSchema = z.object({61 version: z.literal(1),62 venue: VenueIdSchema,63 account: z.string().min(1),64 /** cash balance: deposits + realised PnL − fees + funding; excludes unrealized PnL */65 collateral: DecStrSchema,66 config: PaperConfigSchema,67 positions: z.array(PaperPositionSchema),68 /** every order ever submitted, oldest first */69 orders: z.array(OrderSchema),70 fills: z.array(FillSchema),71 funding: z.array(FundingPaymentSchema),72 /** last mark and last book timestamp seen per instrument */73 marks: z.record(z.string(), PosDec),74 lastBookTs: z.record(z.string(), z.number().int().nonnegative()),75 /** cumulative figures */76 realizedPnl: DecStrSchema,77 feesPaid: DecStrSchema,78 fundingNet: DecStrSchema,79 liquidations: z.number().int().nonnegative(),80 /** id counter */81 seq: z.number().int().nonnegative(),82});83export type PaperState = z.infer<typeof PaperStateSchema>;8485export const DEFAULT_PAPER_CONFIG: PaperConfig = {86 takerFeeRate: '0.00045', makerFeeRate: '0.00015', liquidationFeeRate: '0', feeAsset: 'USDC',87 leverage: '5', leverageByInstrument: {}, maintenanceMarginFraction: '0.01',88};8990export interface PaperResult {91 /** the order concerned (submit / cancel); null for market-data events */92 order: Order | null;93 /** fills created by this call, liquidation fills included */94 fills: Fill[];95 /** orders whose state changed in this call */96 orders: Order[];97 funding: FundingPayment[];98 /** true when this call triggered a simulated liquidation */99 liquidated: boolean;100}101102type Level = { price: Dec; size: Dec };103const OPEN_STATUSES: readonly Order['status'][] = ['pending', 'triggered', 'open', 'partially_filled'];104const isOpen = (o: Order): boolean => OPEN_STATUSES.includes(o.status);105const isTriggerType = (t: Order['type']): boolean => t !== 'limit' && t !== 'market';106const isMarketLike = (t: Order['type']): boolean => t === 'market' || t === 'stop_market' || t === 'take_profit_market';107108export class PaperAccount {109 /** the whole account; plain and serialisable */110 readonly state: PaperState;111 /** derived index of `state.fills` ids (rebuilt on restore) */112 readonly fillIds: Set<string>;113114 constructor(state: PaperState) {115 this.state = PaperStateSchema.parse(state);116 this.fillIds = new Set(this.state.fills.map((f) => f.id));117 }118119 static create(o: { venue: VenueId; account: string; collateral: DecStr; config?: Partial<PaperConfig> }): PaperAccount {120 return new PaperAccount({121 version: 1, venue: o.venue, account: o.account, collateral: D.str(D.from(o.collateral)),122 config: { ...DEFAULT_PAPER_CONFIG, ...(o.config ?? {}) },123 positions: [], orders: [], fills: [], funding: [], marks: {}, lastBookTs: {},124 realizedPnl: '0', feesPaid: '0', fundingNet: '0', liquidations: 0, seq: 0,125 });126 }127128 /** rebuild an account from `serialize()` output (or the parsed object); validates everything */129 static restore(snapshot: string | unknown): PaperAccount {130 return new PaperAccount(PaperStateSchema.parse(typeof snapshot === 'string' ? JSON.parse(snapshot) : snapshot));131 }132133 serialize(): string { return JSON.stringify(this.state); }134135 // ---------------------------------------------------------------- reads136137 openOrders(): Order[] { return this.state.orders.filter(isOpen).map((o) => ({ ...o })); }138 order(clientId: string): Order | null { const o = this.state.orders.find((x) => x.clientId === clientId); return o ? { ...o } : null; }139140 accountState(now: number): AccountState {141 const equity = this.equity(), used = this.marginUsed(), free = D.sub(equity, used);142 return {143 venue: this.state.venue, account: this.state.account, equity: D.str(equity), collateral: this.state.collateral,144 freeCollateral: D.str(free), marginUsed: D.str(used), maintenanceMargin: D.str(this.maintenance()),145 withdrawable: D.str(D.max(D.zero, D.min(free, D.from(this.state.collateral)))), ts: now, source: 'paper',146 };147 }148149 /** positions in the normalised model; `liquidationPrice` is an ESTIMATE (cross, single-position approximation) */150 positions(): Position[] {151 const mmf = D.from(this.state.config.maintenanceMarginFraction);152 return this.state.positions.map((p) => {153 const size = D.from(p.size), entry = D.from(p.entryPrice), mark = D.from(p.markPrice), lev = this.leverage(p.instrument);154 let otherPnl = D.zero, otherMm = D.zero;155 for (const q of this.state.positions) {156 if (q === p) continue;157 otherPnl = D.add(otherPnl, unrealizedPnl(q.side, D.from(q.size), D.from(q.entryPrice), D.from(q.markPrice)));158 otherMm = D.add(otherMm, D.mul(D.mul(D.from(q.size), D.from(q.markPrice)), mmf));159 }160 const liq = liquidationPriceCross({ side: p.side, size, entryPrice: entry, accountCollateral: D.from(this.state.collateral), maintenanceFraction: mmf, otherUnrealizedPnl: otherPnl, otherMaintenanceMargin: otherMm }).value;161 return {162 instrument: p.instrument, side: p.side, size: p.size, entryPrice: p.entryPrice, markPrice: p.markPrice,163 liquidationPrice: liq === null ? null : D.str(liq), leverage: D.str(lev), marginMode: 'cross',164 margin: D.str(initialMargin(D.mul(size, mark), lev)), unrealizedPnl: D.str(unrealizedPnl(p.side, size, entry, mark)),165 realizedPnl: p.realizedPnl, fundingPaid: p.fundingPaid, source: 'paper',166 };167 });168 }169170 // ---------------------------------------------------------------- commands171172 /**173 * Submit an order against the current book and ticker. Idempotent by `clientId`: submitting a client id again returns the174 * existing order and does nothing. A refused order is recorded with status 'rejected' and its reason.175 */176 submit(req: OrderRequest, book: Book, ticker: Ticker, now: number): PaperResult {177 const r = OrderRequestSchema.parse(req), b = BookSchema.parse(book), t = TickerSchema.parse(ticker);178 const res = this.result();179 const existing = this.state.orders.find((o) => o.clientId === r.clientId);180 if (existing) { res.order = { ...existing }; return res; }181182 this.state.seq += 1;183 const order: Order = { ...r, venueOrderId: `paper-o-${this.state.seq}`, status: 'pending', filled: '0', avgFillPrice: null, createdAt: now, updatedAt: now, rejectReason: null };184 this.state.orders.push(order);185 const reject = (reason: string): PaperResult => { this.close(order, 'rejected', reason, now, res); res.order = { ...order }; return res; };186187 if (b.instrument !== r.instrument || t.instrument !== r.instrument) return reject('book or ticker is for another instrument');188 if (!D.isPos(D.from(r.size))) return reject('size must be positive');189 if (r.price === null) return reject('a price is required (limit price, or worst acceptable price of a market order)');190 if (!D.isPos(D.from(r.price))) return reject('price must be positive');191 if (!D.isPos(D.from(t.mark))) return reject('mark price must be positive');192 if (isTriggerType(r.type)) {193 if (r.triggerPrice === null || !D.isPos(D.from(r.triggerPrice))) return reject(`${r.type} needs a positive trigger price`);194 } else if (r.triggerPrice !== null) return reject(`${r.type} takes no trigger price`);195 if (r.type === 'market' && r.tif === 'alo') return reject('a market order cannot be post-only');196197 this.setMark(r.instrument, D.from(t.mark));198 this.state.lastBookTs[r.instrument] = Math.max(this.state.lastBookTs[r.instrument] ?? 0, b.ts);199200 if (isTriggerType(r.type)) {201 // waits for the mark to reach the trigger; it may fire on this very ticker and then executes on the next book202 this.checkTriggers(r.instrument, D.from(t.mark), now, res);203 } else {204 this.activate(order, this.levels(b), now, res, true);205 }206 this.liquidateIfNeeded(now, res);207 res.order = { ...order };208 return res;209 }210211 /** cancel an open order by client id; a no-op (order returned unchanged) when it is already closed; null when unknown */212 cancel(clientId: string, now: number): PaperResult {213 const res = this.result();214 const order = this.state.orders.find((o) => o.clientId === clientId);215 if (!order) return res;216 if (isOpen(order)) this.close(order, 'cancelled', 'cancelled by the user', now, res);217 res.order = { ...order };218 return res;219 }220221 /** a new book: executes triggered orders and fills resting orders it crosses. Books not newer than the last one seen are ignored. */222 onBook(book: Book, now: number): PaperResult {223 const b = BookSchema.parse(book);224 const res = this.result();225 if (b.ts <= (this.state.lastBookTs[b.instrument] ?? -1)) return res;226 this.state.lastBookTs[b.instrument] = b.ts;227 const levels = this.levels(b);228 for (const order of this.state.orders) {229 if (order.instrument !== b.instrument) continue;230 if (order.status === 'triggered') this.activate(order, levels, now, res, false);231 else if (order.status === 'open' || order.status === 'partially_filled') this.fillResting(order, levels, now, res);232 }233 this.liquidateIfNeeded(now, res);234 return res;235 }236237 /** a new ticker: updates the mark, fires stop / take-profit triggers, checks liquidation */238 onTicker(ticker: Ticker, now: number): PaperResult {239 const t = TickerSchema.parse(ticker);240 const res = this.result();241 const mark = D.from(t.mark);242 if (!D.isPos(mark)) throw new RangeError('mark price must be positive');243 this.setMark(t.instrument, mark);244 this.checkTriggers(t.instrument, mark, now, res);245 this.liquidateIfNeeded(now, res);246 return res;247 }248249 /**250 * Apply a funding rate (per funding interval) at `now`: to one instrument, or to every position when `instrument` is omitted251 * (only sensible for a single-instrument account, since rates are per instrument). Idempotent per (instrument, now).252 */253 applyFunding(rate: DecStr, now: number, instrument?: string): PaperResult {254 const res = this.result();255 const rt = D.from(rate);256 for (const p of this.state.positions) {257 if (instrument !== undefined && p.instrument !== instrument) continue;258 if (this.state.funding.some((f) => f.instrument === p.instrument && f.ts === now)) continue;259 const payment = D.mul(D.mul(D.from(p.size), D.from(p.markPrice)), rt); // what a long pays260 const amount = p.side === 'long' ? D.neg(payment) : payment; // signed for the account: negative = paid261 this.state.collateral = D.str(D.add(D.from(this.state.collateral), amount));262 this.state.fundingNet = D.str(D.add(D.from(this.state.fundingNet), amount));263 p.fundingPaid = D.str(D.sub(D.from(p.fundingPaid), amount));264 const f: FundingPayment = { instrument: p.instrument, amount: D.str(amount), rate: D.str(rt), size: p.size, ts: now };265 this.state.funding.push(f); res.funding.push({ ...f });266 }267 this.liquidateIfNeeded(now, res);268 return res;269 }270271 /**272 * Apply a fill to the account: position, realised PnL, fee, order progress. Idempotent by fill id — a fill already applied is273 * ignored and `false` is returned. `closedPnl` is recomputed from the account's own position.274 */275 applyFill(fill: Fill): boolean {276 const f = FillSchema.parse(fill);277 if (this.fillIds.has(f.id)) return false;278 const size = D.from(f.size), price = D.from(f.price), fee = D.from(f.fee);279 if (!D.isPos(size) || !D.isPos(price)) throw new RangeError('fill size and price must be positive');280281 const idx = this.state.positions.findIndex((p) => p.instrument === f.instrument);282 const cur = idx < 0 ? null : this.state.positions[idx]!;283 const eff = applyOrderToPosition(cur ? { side: cur.side, size: D.from(cur.size), entryPrice: D.from(cur.entryPrice) } : null, { side: f.side, size, price });284 const mark = this.state.marks[f.instrument] ?? f.price;285 if (eff.position === null) { if (idx >= 0) this.state.positions.splice(idx, 1); }286 else {287 const flipped = eff.kind === 'flip';288 const next: PaperPosition = {289 instrument: f.instrument, side: eff.position.side, size: D.str(eff.position.size), entryPrice: D.str(eff.position.entryPrice), markPrice: mark,290 realizedPnl: D.str(D.add(cur && !flipped ? D.from(cur.realizedPnl) : D.zero, flipped ? D.zero : eff.realizedPnl)),291 fundingPaid: cur && !flipped ? cur.fundingPaid : '0',292 };293 if (idx >= 0) this.state.positions[idx] = next; else this.state.positions.push(next);294 }295 this.state.collateral = D.str(D.sub(D.add(D.from(this.state.collateral), eff.realizedPnl), fee));296 this.state.realizedPnl = D.str(D.add(D.from(this.state.realizedPnl), eff.realizedPnl));297 this.state.feesPaid = D.str(D.add(D.from(this.state.feesPaid), fee));298299 const order = f.orderId === null ? undefined : this.state.orders.find((o) => o.venueOrderId === f.orderId);300 if (order) {301 const filled = D.from(order.filled), total = D.add(filled, size);302 const cost = D.add(D.mul(order.avgFillPrice === null ? D.zero : D.from(order.avgFillPrice), filled), D.mul(price, size));303 order.filled = D.str(total);304 order.avgFillPrice = D.str(D.div(cost, total, CALC_SCALE, 'half_even'));305 order.updatedAt = f.ts;306 if (D.gte(total, D.from(order.size))) order.status = 'filled';307 else if (order.status === 'open') order.status = 'partially_filled';308 }309 this.state.fills.push({ ...f, closedPnl: D.isZero(eff.closedSize) ? null : D.str(eff.realizedPnl) });310 this.fillIds.add(f.id);311 return true;312 }313314 // ---------------------------------------------------------------- internals315316 result(): PaperResult { return { order: null, fills: [], orders: [], funding: [], liquidated: false }; }317318 leverage(instrument: string): Dec { return D.from(this.state.config.leverageByInstrument[instrument] ?? this.state.config.leverage); }319320 equity(): Dec {321 return this.state.positions.reduce((acc, p) => D.add(acc, unrealizedPnl(p.side, D.from(p.size), D.from(p.entryPrice), D.from(p.markPrice))), D.from(this.state.collateral));322 }323324 marginUsed(): Dec {325 return this.state.positions.reduce((acc, p) => D.add(acc, initialMargin(D.mul(D.from(p.size), D.from(p.markPrice)), this.leverage(p.instrument))), D.zero);326 }327328 maintenance(): Dec {329 const mmf = D.from(this.state.config.maintenanceMarginFraction);330 return this.state.positions.reduce((acc, p) => D.add(acc, D.mul(D.mul(D.from(p.size), D.from(p.markPrice)), mmf)), D.zero);331 }332333 signedPosition(instrument: string): Dec {334 const p = this.state.positions.find((x) => x.instrument === instrument);335 return p ? signedSize({ side: p.side, size: D.from(p.size) }) : D.zero;336 }337338 setMark(instrument: string, mark: Dec): void {339 const s = D.str(mark);340 this.state.marks[instrument] = s;341 const p = this.state.positions.find((x) => x.instrument === instrument);342 if (p) p.markPrice = s;343 }344345 /** mutable copy of a book: depth consumed by one order is not offered to the next one */346 levels(b: Book): { bids: Level[]; asks: Level[] } {347 const side = (ls: [DecStr, DecStr][]): Level[] => ls.map(([p, s]) => ({ price: D.from(p), size: D.from(s) })).filter((l) => D.isPos(l.price) && D.isPos(l.size));348 return { bids: side(b.bids), asks: side(b.asks) };349 }350351 close(order: Order, status: 'cancelled' | 'rejected', reason: string, now: number, res: PaperResult): void {352 order.status = status; order.rejectReason = reason; order.updatedAt = now;353 this.touch(order, res);354 }355356 touch(order: Order, res: PaperResult): void {357 const i = res.orders.findIndex((o) => o.venueOrderId === order.venueOrderId);358 if (i >= 0) res.orders[i] = { ...order }; else res.orders.push({ ...order });359 }360361 /** null when the order may add `size` at `price`; otherwise the reason it may not */362 cannotAfford(order: Order, size: Dec, price: Dec, feeRate: Dec): string | null {363 const before = this.signedPosition(order.instrument);364 const after = order.side === 'buy' ? D.add(before, size) : D.sub(before, size);365 const added = D.sub(D.abs(after), D.abs(before));366 if (!D.isPos(added)) return null; // reduces exposure: always allowed367 const fee = D.max(D.zero, D.mul(D.mul(size, price), feeRate));368 const need = D.add(initialMargin(D.mul(added, price), this.leverage(order.instrument)), fee);369 const free = D.sub(this.equity(), this.marginUsed());370 return D.lt(free, need) ? `insufficient collateral: needs ${D.str(need)}, free ${D.str(free)}` : null;371 }372373 /** quantity a reduce-only order may still execute: its remainder clipped to the opposite position */374 reducible(order: Order, remaining: Dec): Dec {375 const pos = this.signedPosition(order.instrument);376 const opposite = order.side === 'buy' ? D.isNeg(pos) : D.isPos(pos);377 return opposite ? D.min(remaining, D.abs(pos)) : D.zero;378 }379380 fill(order: Order, price: Dec, size: Dec, feeRate: Dec, now: number, res: PaperResult): void {381 this.state.seq += 1;382 const f: Fill = {383 instrument: order.instrument, orderId: order.venueOrderId, clientId: order.clientId, side: order.side, price: D.str(price), size: D.str(size),384 fee: D.str(D.mul(D.mul(price, size), feeRate)), feeAsset: this.state.config.feeAsset, closedPnl: null, ts: now, id: `paper-f-${this.state.seq}`, liquidation: false,385 };386 this.applyFill(f);387 res.fills.push({ ...this.state.fills[this.state.fills.length - 1]! });388 }389390 /** a non-resting order becomes active: submitted limit/market order, or a triggered stop / take-profit */391 activate(order: Order, levels: { bids: Level[]; asks: Level[] }, now: number, res: PaperResult, atSubmit: boolean): void {392 const refuse = (reason: string): void => this.close(order, atSubmit ? 'rejected' : 'cancelled', reason, now, res);393 const limit = D.from(order.price!);394 const opp = order.side === 'buy' ? levels.asks : levels.bids;395 const crosses = (l: Level): boolean => (order.side === 'buy' ? D.lte(l.price, limit) : D.gte(l.price, limit));396 let remaining = D.sub(D.from(order.size), D.from(order.filled));397398 if (order.reduceOnly) {399 const can = this.reducible(order, remaining);400 if (D.isZero(can)) return refuse('reduce-only: no position to reduce');401 if (D.lt(can, remaining)) {402 if (atSubmit) return refuse('reduce-only: order is larger than the position');403 remaining = can; // a triggered order is clipped to what is left404 }405 }406 if (order.tif === 'alo') {407 if (opp.length > 0 && crosses(opp[0]!)) return refuse('post-only order would cross the book');408 const why = this.cannotAfford(order, remaining, limit, D.max(D.zero, D.from(this.state.config.makerFeeRate)));409 if (why) return refuse(why);410 order.status = 'open'; order.updatedAt = now; this.touch(order, res);411 return;412 }413 const taker = D.from(this.state.config.takerFeeRate);414 const why = this.cannotAfford(order, remaining, limit, taker);415 if (why) return refuse(why);416417 for (const l of opp) {418 if (D.isZero(remaining) || !crosses(l)) break;419 if (D.isZero(l.size)) continue;420 const take = D.min(remaining, l.size);421 this.fill(order, l.price, take, taker, now, res);422 l.size = D.sub(l.size, take);423 remaining = D.sub(remaining, take);424 }425 order.updatedAt = now;426 if (D.gte(D.from(order.filled), D.from(order.size))) order.status = 'filled';427 else if (isMarketLike(order.type) || order.tif === 'ioc' || (order.reduceOnly && D.isZero(remaining))) {428 order.status = 'cancelled';429 order.rejectReason = order.reduceOnly && D.isZero(remaining) ? 'reduce-only: remainder cancelled, position closed' : 'unfilled remainder cancelled: no liquidity within the price bound';430 } else order.status = D.isZero(D.from(order.filled)) ? 'open' : 'partially_filled';431 this.touch(order, res);432 }433434 /** a resting limit order against a newer book: filled at its own price for at most the crossing depth */435 fillResting(order: Order, levels: { bids: Level[]; asks: Level[] }, now: number, res: PaperResult): void {436 const limit = D.from(order.price!);437 const opp = order.side === 'buy' ? levels.asks : levels.bids;438 const crosses = (l: Level): boolean => (order.side === 'buy' ? D.lte(l.price, limit) : D.gte(l.price, limit));439 let remaining = D.sub(D.from(order.size), D.from(order.filled));440 if (order.reduceOnly) {441 remaining = this.reducible(order, remaining);442 if (D.isZero(remaining)) return this.close(order, 'cancelled', 'reduce-only: no position left to reduce', now, res);443 }444 let depth = D.zero;445 for (const l of opp) { if (!crosses(l)) break; depth = D.add(depth, l.size); }446 let take = D.min(remaining, depth);447 if (D.isZero(take)) return;448 const maker = D.from(this.state.config.makerFeeRate);449 const why = this.cannotAfford(order, take, limit, maker);450 if (why) return this.close(order, 'cancelled', why, now, res);451 const size = take;452 for (const l of opp) { // consume the depth this fill used453 if (D.isZero(take) || !crosses(l)) break;454 const used = D.min(take, l.size);455 l.size = D.sub(l.size, used); take = D.sub(take, used);456 }457 this.fill(order, limit, size, maker, now, res);458 if (order.status !== 'filled') {459 order.status = 'partially_filled';460 if (order.reduceOnly && D.isZero(this.reducible(order, D.sub(D.from(order.size), D.from(order.filled))))) {461 order.status = 'cancelled'; order.rejectReason = 'reduce-only: remainder cancelled, position closed';462 }463 }464 order.updatedAt = now;465 this.touch(order, res);466 }467468 /** stop: buy fires when mark ≥ trigger, sell when mark ≤ trigger. take-profit: sell fires when mark ≥ trigger, buy when mark ≤ trigger. */469 checkTriggers(instrument: string, mark: Dec, now: number, res: PaperResult): void {470 for (const order of this.state.orders) {471 if (order.status !== 'pending' || order.instrument !== instrument || !isTriggerType(order.type) || order.triggerPrice === null) continue;472 const trig = D.from(order.triggerPrice);473 const stop = order.type === 'stop_market' || order.type === 'stop_limit';474 const up = stop ? order.side === 'buy' : order.side === 'sell';475 if (up ? D.gte(mark, trig) : D.lte(mark, trig)) { order.status = 'triggered'; order.updatedAt = now; this.touch(order, res); }476 }477 }478479 /** simulated liquidation of the whole (cross) account when equity < maintenance margin */480 liquidateIfNeeded(now: number, res: PaperResult): void {481 if (this.state.positions.length === 0 || D.gte(this.equity(), this.maintenance())) return;482 for (const o of this.state.orders) if (isOpen(o)) this.close(o, 'cancelled', 'account liquidated (simulated)', now, res);483 const rate = D.from(this.state.config.liquidationFeeRate);484 for (const p of [...this.state.positions]) {485 this.state.seq += 1;486 const price = D.from(p.markPrice), size = D.from(p.size);487 const f: Fill = {488 instrument: p.instrument, orderId: null, clientId: null, side: p.side === 'long' ? 'sell' : 'buy', price: p.markPrice, size: p.size,489 fee: D.str(D.mul(D.mul(price, size), rate)), feeAsset: this.state.config.feeAsset, closedPnl: null, ts: now, id: `paper-liq-${this.state.seq}`, liquidation: true,490 };491 this.applyFill(f);492 res.fills.push({ ...this.state.fills[this.state.fills.length - 1]! });493 }494 this.state.liquidations += 1;495 res.liquidated = true;496 }497}498