SPB Git

Repository Public

paper.ts 26.9 KB 498 lines typescript

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
Files main
Menu

Theme