UNPKG

hft-js

Version:

High-Frequency Trading in Node.js

1,875 lines (1,574 loc) 51.1 kB
/* * trader.ts * * Copyright (c) 2025 Xiongfei Shi * * Author: Xiongfei Shi <xiongfei.shi(a)icloud.com> * License: Apache-2.0 * * https://github.com/shixiongfei/hft.js */ import Denque from "denque"; import ctp, { type Trader as TraderApi } from "napi-ctp"; import type { DepthMarketDataField, DirectionType, InputOrderActionField, InputOrderField, InstrumentCommissionRateField, InstrumentField, InstrumentMarginRateField, InvestorPositionDetailField, InvestorPositionField, OffsetFlagType, OptionsTypeType, OrderField, OrderPriceTypeType, ProductClassType, RspAuthenticateField, RspUserLoginField, SettlementInfoConfirmField, TradeField, TradingAccountField, } from "@napi-ctp/types"; import { CTPProvider } from "./provider.js"; import { isValidPrice, parseSymbol } from "./utils.js"; import type { CommissionRate, InstrumentData, MarginRate, OffsetType, OptionsType, OrderData, OrderFlag, OrderStatistic, OrderStatus, PositionData, PositionDetail, PriceRange, ProductType, SideType, TickData, TradeData, TradingAccount, Writeable, } from "./typedef.js"; import type { ICancelOrderResultReceiver, ICommissionRateReceiver, IInstrumentReceiver, IInstrumentsReceiver, ILifecycleListener, IMarginRateReceiver, IOrderReceiver, IOrdersReceiver, IPlaceOrderResultReceiver, IPositionDetailsReceiver, IPositionReceiver, IPositionsReceiver, ITraderProvider, ITradingAccountsReceiver, } from "./interfaces.js"; type PositionInfo = Writeable<PositionData>; type OrderStat = Writeable<OrderStatistic>; type MarginRateQuery = { symbol: string; receiver: IMarginRateReceiver; }; type CommissionRateQuery = { symbol: string; receiver: ICommissionRateReceiver; }; type MarketOrder = { symbol: string; offset: OffsetType; side: SideType; volume: number; receiver: IPlaceOrderResultReceiver; }; export type CTPUserInfo = { BrokerID: string; UserID: string; Password: string; InvestorID: string; UserProductInfo: string; AuthCode: string; AppID: string; }; export type FastQueryLastTickFunc = ( instrumentId: string, ) => TickData | undefined; export type TraderOptions = { fastQueryLastTick?: FastQueryLastTickFunc; }; export class Trader extends CTPProvider implements ITraderProvider { private traderApi?: TraderApi; private tradingDay: number; private frontId: number; private sessionId: number; private orderRef: number; private accountsQueryTime: number; private positionDetailsChanged: boolean; private readonly fastQueryLastTick?: FastQueryLastTickFunc; private readonly userInfo: CTPUserInfo; private readonly receivers: IOrderReceiver[]; private readonly accounts: TradingAccountField[]; private readonly positionDetails: InvestorPositionDetailField[]; private readonly instruments: Map<string, InstrumentField>; private readonly positions: Map<string, PositionInfo>; private readonly orders: Map<string, OrderField>; private readonly trades: Map<string, TradeField[]>; private readonly marginRates: Map<string, InstrumentMarginRateField>; private readonly commRates: Map<string, InstrumentCommissionRateField>; private readonly placeOrders: Map<number, IPlaceOrderResultReceiver>; private readonly cancelOrders: Map<number, ICancelOrderResultReceiver>; private readonly marketOrdersQueue: Map<string, Denque<MarketOrder>>; private readonly priceLimit: Map<string, PriceRange>; private readonly orderStatistics: Map<string, OrderStat>; private readonly marginRatesQueue: Denque<MarginRateQuery>; private readonly commRatesQueue: Denque<CommissionRateQuery>; private readonly accountsQueue: Denque<ITradingAccountsReceiver>; private readonly positionDetailsQueue: Denque<IPositionDetailsReceiver>; constructor( flowTdPath: string, frontTdAddrs: string | string[], userInfo: CTPUserInfo, options?: TraderOptions, ) { super(flowTdPath, frontTdAddrs); this.tradingDay = 0; this.frontId = 0; this.sessionId = 0; this.orderRef = 0; this.accountsQueryTime = 0; this.positionDetailsChanged = true; this.userInfo = userInfo; this.receivers = []; this.accounts = []; this.positionDetails = []; this.instruments = new Map(); this.positions = new Map(); this.orders = new Map(); this.trades = new Map(); this.marginRates = new Map(); this.commRates = new Map(); this.placeOrders = new Map(); this.cancelOrders = new Map(); this.marketOrdersQueue = new Map(); this.priceLimit = new Map(); this.orderStatistics = new Map(); this.marginRatesQueue = new Denque(); this.commRatesQueue = new Denque(); this.accountsQueue = new Denque(); this.positionDetailsQueue = new Denque(); if (options?.fastQueryLastTick) { this.fastQueryLastTick = options.fastQueryLastTick; } } open(lifecycle: ILifecycleListener) { if (this.traderApi) { return true; } this.traderApi = ctp.createTrader(this.flowPath, this.frontAddrs); this.traderApi.on(ctp.TraderEvent.FrontConnected, () => { this._withRetry(() => this.traderApi!.reqAuthenticate(this.userInfo)); }); this.traderApi.on(ctp.TraderEvent.FrontDisconnected, () => { this._clearAllMarketOrders(); this.placeOrders.clear(); this.cancelOrders.clear(); }); this.traderApi.on<RspAuthenticateField>( ctp.TraderEvent.RspAuthenticate, (_, options) => { if (this._isErrorResp(lifecycle, options, "login-error")) { return; } this._withRetry(() => this.traderApi!.reqUserLogin(this.userInfo)); }, ); this.traderApi.on<RspUserLoginField>( ctp.TraderEvent.RspUserLogin, (rspUserLogin, options) => { if (this._isErrorResp(lifecycle, options, "login-error")) { return; } this.frontId = rspUserLogin.FrontID; this.sessionId = rspUserLogin.SessionID; this.orderRef = parseInt(rspUserLogin.MaxOrderRef); const tradingDay = parseInt(this.traderApi!.getTradingDay()); if (this.tradingDay !== tradingDay) { this.marginRates.clear(); this.commRates.clear(); this.orderStatistics.clear(); this.priceLimit.clear(); this.tradingDay = tradingDay; } this._withRetry(() => this.traderApi!.reqSettlementInfoConfirm(this.userInfo), ); }, ); this.traderApi.on<SettlementInfoConfirmField>( ctp.TraderEvent.RspSettlementInfoConfirm, (_, options) => { if (this._isErrorResp(lifecycle, options, "login-error")) { return; } this.orders.clear(); this._withRetry(() => this.traderApi!.reqQryOrder(this.userInfo)); }, ); this.traderApi.on<OrderField>( ctp.TraderEvent.RspQryOrder, (order, options) => { if (this._isErrorResp(lifecycle, options, "query-order-error")) { return; } if (order) { const orderId = this._calcOrderId(order); this.orders.set(orderId, order); } if (options.isLast) { this.trades.clear(); this._withRetry(() => this.traderApi!.reqQryTrade(this.userInfo)); } }, ); this.traderApi.on<TradeField>( ctp.TraderEvent.RspQryTrade, (trade, options) => { if (this._isErrorResp(lifecycle, options, "query-trade-error")) { return; } if (trade) { const orderId = this._calcOrderId(trade); const trades = this.trades.get(orderId); if (trades) { trades.push(trade); } else { this.trades.set(orderId, [trade]); } } if (options.isLast) { this.instruments.clear(); this._withRetry(() => this.traderApi!.reqQryInstrument()); } }, ); this.traderApi.on<InstrumentField>( ctp.TraderEvent.RspQryInstrument, (instrument, options) => { if (this._isErrorResp(lifecycle, options, "query-instrument-error")) { return; } if (instrument) { if ( instrument.ProductClass === ctp.ProductClassType.Futures || instrument.ProductClass === ctp.ProductClassType.Options ) { this.instruments.set(instrument.InstrumentID, instrument); } } if (options.isLast) { this.positions.clear(); this._withRetry(() => this.traderApi!.reqQryInvestorPosition(this.userInfo), ); } }, ); let fired = false; this.traderApi.on<InvestorPositionField>( ctp.TraderEvent.RspQryInvestorPosition, (position, options) => { if (this._isErrorResp(lifecycle, options, "query-positions-error")) { return; } if (position) { const symbol = this._toSymbol(position.InstrumentID); if (symbol) { let posInfo = this._ensurePositionInfo(symbol); const ExchangeSH = ["SHFE", "INE"]; switch (position.PosiDirection) { case ctp.PosiDirectionType.Long: if (position.PositionDate === ctp.PositionDateType.Today) { if (ExchangeSH.includes(position.ExchangeID)) { posInfo.today.long.position += position.TodayPosition; } else { posInfo.today.long.position += position.Position; } } else { posInfo.history.long.position += position.Position - position.TodayPosition; } break; case ctp.PosiDirectionType.Short: if (position.PositionDate === ctp.PositionDateType.Today) { if (ExchangeSH.includes(position.ExchangeID)) { posInfo.today.short.position += position.TodayPosition; } else { posInfo.today.short.position += position.Position; } } else { posInfo.history.short.position += position.Position - position.TodayPosition; } break; } } } if (options.isLast) { if (!fired) { fired = true; lifecycle.onOpen(); } if (this.accountsQueue.size() > 0) { this._withRetry(() => this.traderApi!.reqQryTradingAccount(this.userInfo), ); } if (this.positionDetailsQueue.size() > 0) { this._withRetry(() => this.traderApi!.reqQryInvestorPositionDetail(this.userInfo), ); } this._processMarginRatesQueue(); this._processCommissionRatesQueue(); } }, ); this.traderApi.on<OrderField>(ctp.TraderEvent.RtnOrder, (order) => { const orderId = this._calcOrderId(order); const current = this.orders.get(orderId); if (current) { if ( order.OrderSubmitStatus === current.OrderSubmitStatus && order.OrderStatus === current.OrderStatus ) { return; } } this.orders.set(orderId, order); switch (this._calcOrderStatus(order)) { case "submitted": { const orderData = this._toOrderData(order); const symbol = this._toSymbol(order.InstrumentID); if (symbol) { if (orderData.offset === "open") { this._recordPending( symbol, orderData.side, orderData.offset, orderData.volume, ); } else { this._freezePosition( symbol, orderData.side, orderData.offset, orderData.volume, ); } const statistic = this._ensureOrderStatistic(symbol); statistic.entrusts += 1; } this.receivers.forEach((receiver) => receiver.onEntrust(orderData)); } break; case "filled": { const symbol = this._toSymbol(order.InstrumentID); if (symbol) { const statistic = this._ensureOrderStatistic(symbol); statistic.filleds += 1; } } break; case "canceled": { const orderData = this._toOrderData(order); const symbol = this._toSymbol(order.InstrumentID); if (symbol) { if (orderData.offset === "open") { this._recoverPending( symbol, orderData.side, orderData.offset, orderData.volume, ); } else { this._unfreezePosition( symbol, orderData.side, orderData.offset, orderData.volume, ); } const statistic = this._ensureOrderStatistic(symbol); statistic.cancels += 1; } this.receivers.forEach((receiver) => receiver.onCancel(orderData)); } break; case "rejected": { const orderData = this._toOrderData(order); const symbol = this._toSymbol(order.InstrumentID); if (symbol) { const statistic = this._ensureOrderStatistic(symbol); statistic.rejects += 1; } this.receivers.forEach((receiver) => receiver.onReject(orderData)); } break; } }); this.traderApi.on<TradeField>(ctp.TraderEvent.RtnTrade, (trade) => { const orderId = this._calcOrderId(trade); const trades = this.trades.get(orderId); if (trades) { trades.push(trade); } else { this.trades.set(orderId, [trade]); } this.positionDetailsChanged = true; const order = this.orders.get(orderId); if (order) { const orderData = this._toOrderData(order); const tradeData = this._toTradeData(trade); const symbol = this._toSymbol(order.InstrumentID); if (symbol) { this._calcPosition( symbol, orderData.side, orderData.offset, tradeData.volume, ); } this.receivers.forEach((receiver) => receiver.onTrade(orderData, tradeData), ); } }); this.traderApi.on<InstrumentMarginRateField>( ctp.TraderEvent.RspQryInstrumentMarginRate, (marginRate, options) => { const query = this.marginRatesQueue.shift(); if (this._isErrorResp(lifecycle, options, "query-margin-rate-error")) { if (query) { query.receiver.onMarginRate(undefined); } return; } if (marginRate) { this.marginRates.set(marginRate.InstrumentID, marginRate); if (query) { query.receiver.onMarginRate( this._toMarginRate(query.symbol, marginRate), ); } } this._processMarginRatesQueue(); }, ); this.traderApi.on<InstrumentCommissionRateField>( ctp.TraderEvent.RspQryInstrumentCommissionRate, (commRate, options) => { const query = this.commRatesQueue.shift(); if ( this._isErrorResp(lifecycle, options, "query-commission-rate-error") ) { if (query) { query.receiver.onCommissionRate(undefined); } return; } if (commRate) { this.commRates.set(commRate.InstrumentID, commRate); if (query) { query.receiver.onCommissionRate( this._toCommissionRate(query.symbol, commRate), ); } } this._processCommissionRatesQueue(); }, ); this.traderApi.on<TradingAccountField>( ctp.TraderEvent.RspQryTradingAccount, (account, options) => { if (this._isErrorResp(lifecycle, options, "query-accounts-error")) { const receivers = this.accountsQueue.toArray(); receivers.forEach((receiver) => receiver.onTradingAccounts(undefined), ); this.accountsQueue.clear(); return; } if (account) { this.accounts.push(account); } if (options.isLast) { const accounts = this.accounts.map(this._toTradingAccount, this); const receivers = this.accountsQueue.toArray(); receivers.forEach((receiver) => receiver.onTradingAccounts(accounts)); this.accountsQueue.clear(); this.accountsQueryTime = Date.now(); } }, ); this.traderApi.on<InvestorPositionDetailField>( ctp.TraderEvent.RspQryInvestorPositionDetail, (positionDetail, options) => { if ( this._isErrorResp(lifecycle, options, "query-position-details-error") ) { const receivers = this.positionDetailsQueue.toArray(); receivers.forEach((receiver) => receiver.onPositionDetails(undefined), ); this.positionDetailsQueue.clear(); return; } if (positionDetail) { this.positionDetails.push(positionDetail); } if (options.isLast) { const positionDetails = this.positionDetails.map( this._toPositionDetail, this, ); const receivers = this.positionDetailsQueue.toArray(); this.positionDetailsChanged = false; receivers.forEach((receiver) => receiver.onPositionDetails(positionDetails), ); this.positionDetailsQueue.clear(); } }, ); this.traderApi.on<InputOrderField>( ctp.TraderEvent.RspOrderInsert, (order, options) => { if (options.rspInfo && order && options.requestId && options.isLast) { const receiver = this.placeOrders.get(options.requestId); if (receiver) { this.placeOrders.delete(options.requestId); receiver.onPlaceOrderError( `${options.rspInfo.ErrorID}: ${options.rspInfo.ErrorMsg}`, ); } } }, ); this.traderApi.on<InputOrderActionField>( ctp.TraderEvent.RspOrderAction, (order, options) => { if (options.rspInfo && order && options.requestId && options.isLast) { const receiver = this.cancelOrders.get(options.requestId); if (receiver) { this.cancelOrders.delete(options.requestId); receiver.onCancelOrderError( `${options.rspInfo.ErrorID}: ${options.rspInfo.ErrorMsg}`, ); } } }, ); this.traderApi.on<DepthMarketDataField>( ctp.TraderEvent.RspQryDepthMarketData, (depthMarketData, options) => { if ( this._isErrorResp(lifecycle, options, "query-depth-market-data-error") ) { this._clearAllMarketOrders(); return; } const isLimitPrice = !isValidPrice(depthMarketData.BandingUpperPrice) || !isValidPrice(depthMarketData.BandingLowerPrice); if (isLimitPrice) { this.priceLimit.set(depthMarketData.InstrumentID, { upper: depthMarketData.UpperLimitPrice, lower: depthMarketData.LowerLimitPrice, }); } const queue = this.marketOrdersQueue.get(depthMarketData.InstrumentID); if (!queue) { return; } if (!queue.isEmpty()) { const upperPrice = isLimitPrice ? depthMarketData.UpperLimitPrice : depthMarketData.BandingUpperPrice; const lowerPrice = isLimitPrice ? depthMarketData.LowerLimitPrice : depthMarketData.BandingLowerPrice; const orders = queue.toArray(); orders.forEach((order) => { switch (order.side) { case "long": this._placeLimitOrder( order.symbol, order.offset, order.side, order.volume, upperPrice, order.receiver, ); break; case "short": this._placeLimitOrder( order.symbol, order.offset, order.side, order.volume, lowerPrice, order.receiver, ); break; } }); } this.marketOrdersQueue.delete(depthMarketData.InstrumentID); }, ); return true; } close(lifecycle: ILifecycleListener) { if (!this.traderApi) { return; } this.traderApi.close(); this.traderApi = undefined; lifecycle.onClose(); } addOrderReceiver(receiver: IOrderReceiver) { if (!this.receivers.includes(receiver)) { this.receivers.push(receiver); } } removeOrderReceiver(receiver: IOrderReceiver) { const index = this.receivers.indexOf(receiver); if (index < 0) { return; } this.receivers.splice(index, 1); } getTradingDay() { return this.tradingDay; } getOrderStatistics() { const statistics = Array.from(this.orderStatistics.values()); return statistics.map((stat) => Object.freeze({ ...stat })); } getOrderStatistic(symbol: string) { const statistic = this.orderStatistics.get(symbol); if (!statistic) { return Object.freeze({ symbol: symbol, places: 0, entrusts: 0, filleds: 0, cancels: 0, rejects: 0, }); } return Object.freeze({ ...statistic }); } queryCommissionRate(symbol: string, receiver: ICommissionRateReceiver) { const [instrumentId] = parseSymbol(symbol); const commRate = this.commRates.get(instrumentId); if (commRate) { receiver.onCommissionRate(this._toCommissionRate(symbol, commRate)); return; } this.commRatesQueue.push({ symbol, receiver }); if (this.commRatesQueue.size() === 1) { this._withRetry(() => this.traderApi?.reqQryInstrumentCommissionRate({ ...this.userInfo, InstrumentID: instrumentId, }), ); } } queryMarginRate(symbol: string, receiver: IMarginRateReceiver) { const [instrumentId] = parseSymbol(symbol); const marginRate = this.marginRates.get(instrumentId); if (marginRate) { receiver.onMarginRate(this._toMarginRate(symbol, marginRate)); return; } this.marginRatesQueue.push({ symbol, receiver }); if (this.marginRatesQueue.size() === 1) { this._withRetry(() => this.traderApi?.reqQryInstrumentMarginRate({ ...this.userInfo, HedgeFlag: ctp.HedgeFlagType.Speculation, InstrumentID: instrumentId, }), ); } } queryInstrument(symbol: string, receiver: IInstrumentReceiver) { const [instrumentId, exchangeId] = parseSymbol(symbol); const instrument = this.instruments.get(instrumentId); receiver.onInstrument( instrument && instrument.ExchangeID === exchangeId ? this._toInstrumentData(instrument) : undefined, ); } queryPosition(symbol: string, receiver: IPositionReceiver) { const position = this.positions.get(symbol); if (position) { receiver.onPosition(this._toPositionData(position)); return; } const [instrumentId] = parseSymbol(symbol); if (!this.instruments.has(instrumentId)) { receiver.onPosition(undefined); return; } receiver.onPosition( Object.freeze({ symbol: symbol, today: Object.freeze({ long: Object.freeze({ position: 0, frozen: 0 }), short: Object.freeze({ position: 0, frozen: 0 }), }), history: Object.freeze({ long: Object.freeze({ position: 0, frozen: 0 }), short: Object.freeze({ position: 0, frozen: 0 }), }), pending: Object.freeze({ long: 0, short: 0 }), }), ); } queryInstruments(receiver: IInstrumentsReceiver, type?: ProductType) { const instruments = Array.from(this.instruments.values()); switch (type) { case "futures": receiver.onInstruments( instruments .filter( (instrument) => instrument.ProductClass === ctp.ProductClassType.Futures, ) .map(this._toInstrumentData, this), ); break; case "options": receiver.onInstruments( instruments .filter( (instrument) => instrument.ProductClass === ctp.ProductClassType.Options, ) .map(this._toInstrumentData, this), ); break; default: receiver.onInstruments(instruments.map(this._toInstrumentData, this)); break; } } queryTradingAccounts(receiver: ITradingAccountsReceiver) { if (this.accountsQueue.size() > 0) { this.accountsQueue.push(receiver); return; } const elapsed = Date.now() - this.accountsQueryTime; if (elapsed < 3000) { receiver.onTradingAccounts( this.accounts.map(this._toTradingAccount, this), ); return; } this.accountsQueue.push(receiver); this.accounts.splice(0, this.accounts.length); this._withRetry(() => this.traderApi?.reqQryTradingAccount(this.userInfo)); } queryPositionDetails(receiver: IPositionDetailsReceiver) { if (this.positionDetailsQueue.size() > 0) { this.positionDetailsQueue.push(receiver); return; } if (!this.positionDetailsChanged) { receiver.onPositionDetails( this.positionDetails.map(this._toPositionDetail, this), ); return; } this.positionDetailsQueue.push(receiver); this.positionDetails.splice(0, this.positionDetails.length); this._withRetry(() => this.traderApi?.reqQryInvestorPositionDetail(this.userInfo), ); } queryPositions(receiver: IPositionsReceiver) { const positions: PositionData[] = []; this.positions.forEach((position) => positions.push(this._toPositionData(position)), ); receiver.onPositions(positions); } queryOrders(receiver: IOrdersReceiver) { const orders: OrderData[] = []; this.orders.forEach((order) => { orders.push(this._toOrderData(order)); }); receiver.onOrders(orders); } private _placeLimitOrder( symbol: string, offset: OffsetType, side: SideType, volume: number, price: number, receiver: IPlaceOrderResultReceiver, ) { const [instrumentId, exchangeId] = parseSymbol(symbol); const instrument = this.instruments.get(instrumentId); if (!instrument) { receiver.onPlaceOrderError("Instrument Not Found"); return; } if (exchangeId !== instrument.ExchangeID) { receiver.onPlaceOrderError("Exchange Id Error"); return; } let orderRef = 0; this._withRetry(() => { orderRef = ++this.orderRef; return this.traderApi?.reqOrderInsert({ ...this.userInfo, OrderRef: `${orderRef}`, InstrumentID: instrumentId, ExchangeID: instrument.ExchangeID, LimitPrice: price, VolumeTotalOriginal: volume, VolumeCondition: ctp.VolumeConditionType.AV, TimeCondition: ctp.TimeConditionType.GFD, Direction: this._toDirection(side), OrderPriceType: ctp.OrderPriceTypeType.LimitPrice, CombOffsetFlag: this._toOffsetFlag(offset), CombHedgeFlag: ctp.HedgeFlagType.Speculation, ContingentCondition: ctp.ContingentConditionType.Immediately, ForceCloseReason: ctp.ForceCloseReasonType.NotForceClose, IsAutoSuspend: 0, UserForceClose: 0, }); }).then((requestId) => { if (!requestId || requestId < 0) { receiver.onPlaceOrderError("Request Error"); return; } const statistic = this._ensureOrderStatistic(symbol); statistic.places += 1; this.placeOrders.set(requestId, receiver); const receiptId = `${this.frontId}:${this.sessionId}:${orderRef}`; receiver.onPlaceOrderSent(receiptId); return receiptId; }); } private _clearAllMarketOrders() { this.marketOrdersQueue.forEach((queue) => { const orders = queue.toArray(); orders.forEach((order) => { order.receiver.onPlaceOrderError("Request Error"); }); }); this.marketOrdersQueue.clear(); } private _placeMarketOrder( symbol: string, offset: OffsetType, side: SideType, volume: number, receiver: IPlaceOrderResultReceiver, ) { const [instrumentId, exchangeId] = parseSymbol(symbol); const instrument = this.instruments.get(instrumentId); if (!instrument) { receiver.onPlaceOrderError("Instrument Not Found"); return; } if (exchangeId !== instrument.ExchangeID) { receiver.onPlaceOrderError("Exchange Id Error"); return; } const priceRange = this.priceLimit.get(instrumentId); if (priceRange) { switch (side) { case "long": this._placeLimitOrder( symbol, offset, side, volume, priceRange.upper, receiver, ); break; case "short": this._placeLimitOrder( symbol, offset, side, volume, priceRange.lower, receiver, ); break; } return; } if (this.fastQueryLastTick) { const lastTick = this.fastQueryLastTick(instrumentId); if (lastTick && lastTick.tradingDay === this.tradingDay) { const isLimitPrice = !isValidPrice(lastTick.bandings.upper) || !isValidPrice(lastTick.bandings.lower); if (isLimitPrice) { this.priceLimit.set(instrumentId, { ...lastTick.limits }); } const upperPrice = isLimitPrice ? lastTick.limits.upper : lastTick.bandings.upper; const lowerPrice = isLimitPrice ? lastTick.limits.lower : lastTick.bandings.lower; switch (side) { case "long": this._placeLimitOrder( symbol, offset, side, volume, upperPrice, receiver, ); break; case "short": this._placeLimitOrder( symbol, offset, side, volume, lowerPrice, receiver, ); break; } return; } } let queue = this.marketOrdersQueue.get(instrumentId); if (queue) { queue.push({ symbol, offset, side, volume, receiver }); return; } queue = new Denque(); this.marketOrdersQueue.set(instrumentId, queue); this._withRetry(() => this.traderApi?.reqQryDepthMarketData({ ExchangeID: exchangeId, InstrumentID: instrumentId, }), ).then((requestId) => { if (!requestId || requestId < 0) { const orders = queue.toArray(); orders.forEach((order) => { order.receiver.onPlaceOrderError("Request Error"); }); this.marketOrdersQueue.delete(instrumentId); return; } queue.push({ symbol, offset, side, volume, receiver }); }); } placeOrder( symbol: string, offset: OffsetType, side: SideType, volume: number, price: number, flag: OrderFlag, receiver: IPlaceOrderResultReceiver, ) { if (volume <= 0) { receiver.onPlaceOrderError("Invalid Volume"); return; } switch (flag) { case "limit": return this._placeLimitOrder( symbol, offset, side, volume, price, receiver, ); case "market": return this._placeMarketOrder(symbol, offset, side, volume, receiver); } } cancelOrder(order: OrderData, receiver: ICancelOrderResultReceiver) { const current = this.orders.get(order.id); if (!current) { receiver.onCancelOrderError("Order Not Found"); return; } if (order.cancelTime) { receiver.onCancelOrderError("Already Canceled"); return; } this._withRetry(() => this.traderApi?.reqOrderAction({ ...this.userInfo, InstrumentID: current.InstrumentID, FrontID: current.FrontID, SessionID: current.SessionID, OrderRef: current.OrderRef, ExchangeID: current.ExchangeID, OrderSysID: current.OrderSysID, ActionFlag: ctp.ActionFlagType.Delete, }), ).then((requestId) => { if (!requestId || requestId < 0) { receiver.onCancelOrderError("Request Error"); return; } this.cancelOrders.set(requestId, receiver); receiver.onCancelOrderSent(); }); } private _toSymbol(instrumentId: string) { const instrument = this.instruments.get(instrumentId); if (!instrument) { return undefined; } return `${instrument.InstrumentID}.${instrument.ExchangeID}`; } private _calcOrderId(orderOrTrade: OrderField | TradeField) { const { ExchangeID, TraderID, OrderLocalID } = orderOrTrade; return `${ExchangeID}:${TraderID}:${OrderLocalID}`; } private _calcReceiptId(order: OrderField | InputOrderActionField) { return `${order.FrontID}:${order.SessionID}:${parseInt(order.OrderRef)}`; } private _calcOrderStatus(order: OrderField, traded?: number): OrderStatus { switch (order.OrderStatus) { case ctp.OrderStatusType.Unknown: return "submitted"; case ctp.OrderStatusType.AllTraded: return "filled"; case ctp.OrderStatusType.Canceled: switch (order.OrderSubmitStatus) { case ctp.OrderSubmitStatusType.InsertRejected: case ctp.OrderSubmitStatusType.CancelRejected: case ctp.OrderSubmitStatusType.ModifyRejected: return "rejected"; default: return "canceled"; } default: return traded && order.VolumeTotalOriginal === traded ? "filled" : "partially-filled"; } } private _calcOrderFlag(orderPriceType: OrderPriceTypeType): OrderFlag { switch (orderPriceType) { case ctp.OrderPriceTypeType.LimitPrice: return "limit"; default: return "market"; } } private _calcSideType(direction: DirectionType): SideType { switch (direction) { case ctp.DirectionType.Buy: return "long"; case ctp.DirectionType.Sell: return "short"; } } private _toDirection(side: SideType) { switch (side) { case "long": return ctp.DirectionType.Buy; case "short": return ctp.DirectionType.Sell; } } private _calcOffsetType(offset: OffsetFlagType): OffsetType { switch (offset) { case ctp.OffsetFlagType.Open: return "open"; case ctp.OffsetFlagType.CloseToday: return "close-today"; default: return "close"; } } private _toOffsetFlag(offset: OffsetType) { switch (offset) { case "open": return ctp.OffsetFlagType.Open; case "close": return ctp.OffsetFlagType.Close; case "close-today": return ctp.OffsetFlagType.CloseToday; } } private _calcProductType(productClass: ProductClassType): ProductType { switch (productClass) { case ctp.ProductClassType.Futures: return "futures"; case ctp.ProductClassType.Options: return "options"; case ctp.ProductClassType.Spot: return "spot"; case ctp.ProductClassType.SpotOption: return "spot-options"; default: throw new Error(`Unsupported product class: ${productClass}`); } } private _calcOptionsType( optionsType: OptionsTypeType, ): OptionsType | undefined { switch (optionsType) { case ctp.OptionsTypeType.CallOptions: return "call"; case ctp.OptionsTypeType.PutOptions: return "put"; default: return undefined; } } private _ensurePositionInfo(symbol: string): PositionInfo { let position = this.positions.get(symbol); if (!position) { position = { symbol: symbol, today: { long: { position: 0, frozen: 0 }, short: { position: 0, frozen: 0 }, }, history: { long: { position: 0, frozen: 0 }, short: { position: 0, frozen: 0 }, }, pending: { long: 0, short: 0 }, }; this.positions.set(symbol, position); } return position; } private _ensureOrderStatistic(symbol: string): OrderStat { let statistic = this.orderStatistics.get(symbol); if (!statistic) { statistic = { symbol: symbol, places: 0, entrusts: 0, filleds: 0, cancels: 0, rejects: 0, }; this.orderStatistics.set(symbol, statistic); } return statistic; } private _calcPosition( symbol: string, side: SideType, offset: OffsetType, volume: number, ) { const position = this._ensurePositionInfo(symbol); switch (offset) { case "open": switch (side) { case "long": position.today.long.position += volume; if (position.pending.long >= volume) { position.pending.long -= volume; } else { position.pending.long = 0; } break; case "short": position.today.short.position += volume; if (position.pending.short >= volume) { position.pending.short -= volume; } else { position.pending.short = 0; } break; } break; case "close": switch (side) { case "long": if (position.history.short.position >= volume) { position.history.short.position -= volume; } else { const rest = volume - position.history.short.position; position.history.short.position -= position.history.short.position; if (rest > 0) { if (position.today.short.position >= rest) { position.today.short.position -= rest; } else { position.today.short.position = 0; } } } if (position.history.short.frozen >= volume) { position.history.short.frozen -= volume; } else { const rest = volume - position.history.short.frozen; position.history.short.frozen -= position.history.short.frozen; if (rest > 0) { if (position.today.short.frozen >= rest) { position.today.short.frozen -= rest; } else { position.today.short.frozen = 0; } } } break; case "short": if (position.history.long.position >= volume) { position.history.long.position -= volume; } else { const rest = volume - position.history.long.position; position.history.long.position -= position.history.long.position; if (rest > 0) { if (position.today.long.position >= rest) { position.today.long.position -= rest; } else { position.today.long.position = 0; } } } if (position.history.long.frozen >= volume) { position.history.long.frozen -= volume; } else { const rest = volume - position.history.long.frozen; position.history.long.frozen -= position.history.long.frozen; if (rest > 0) { if (position.today.long.frozen >= rest) { position.today.long.frozen -= rest; } else { position.today.long.frozen = 0; } } } break; } break; case "close-today": switch (side) { case "long": if (position.today.short.position >= volume) { position.today.short.position -= volume; } else { position.today.short.position = 0; } if (position.today.short.frozen >= volume) { position.today.short.frozen -= volume; } else { position.today.short.frozen = 0; } break; case "short": if (position.today.long.position >= volume) { position.today.long.position -= volume; } else { position.today.long.position = 0; } if (position.today.long.frozen >= volume) { position.today.long.frozen -= volume; } else { position.today.long.frozen = 0; } break; } break; } } private _recordPending( symbol: string, side: SideType, offset: OffsetType, volume: number, ) { if (offset !== "open") { return; } const position = this._ensurePositionInfo(symbol); switch (side) { case "long": position.pending.long += volume; break; case "short": position.pending.short += volume; break; } } private _recoverPending( symbol: string, side: SideType, offset: OffsetType, volume: number, ) { if (offset !== "open") { return; } const position = this.positions.get(symbol); if (!position) { return; } switch (side) { case "long": position.pending.long -= volume; break; case "short": position.pending.short -= volume; break; } } private _freezePosition( symbol: string, side: SideType, offset: OffsetType, volume: number, ) { const position = this.positions.get(symbol); if (!position) { return; } switch (offset) { case "close": switch (side) { case "long": position.history.short.frozen += volume; break; case "short": position.history.long.frozen += volume; break; } break; case "close-today": switch (side) { case "long": position.today.short.frozen += volume; break; case "short": position.today.long.frozen += volume; break; } break; } } private _unfreezePosition( symbol: string, side: SideType, offset: OffsetType, volume: number, ) { const position = this.positions.get(symbol); if (!position) { return; } switch (offset) { case "close": switch (side) { case "long": if (position.history.short.frozen >= volume) { position.history.short.frozen -= volume; } else { position.history.short.frozen = 0; } break; case "short": if (position.history.long.frozen >= volume) { position.history.long.frozen -= volume; } else { position.history.long.frozen = 0; } break; } break; case "close-today": switch (side) { case "long": if (position.today.short.frozen >= volume) { position.today.short.frozen -= volume; } else { position.today.short.frozen = 0; } break; case "short": if (position.today.long.frozen >= volume) { position.today.long.frozen -= volume; } else { position.today.long.frozen = 0; } break; } break; } } private _toTradeData(trade: TradeField): TradeData { return Object.freeze({ id: trade.TradeID, date: parseInt(trade.TradeDate), time: this._parseTime(trade.TradeTime), price: trade.Price, volume: trade.Volume, }); } private _toOrderData(order: OrderField): OrderData { const orderId = this._calcOrderId(order); const trades = this.trades.get(orderId) ?? []; const traded = trades .map((trade) => trade.Volume) .reduce((a, b) => a + b, 0); return Object.freeze({ id: orderId, receiptId: this._calcReceiptId(order), symbol: `${order.InstrumentID}.${order.ExchangeID}`, date: parseInt(order.InsertDate), time: this._parseTime(order.InsertTime), flag: this._calcOrderFlag(order.OrderPriceType), side: this._calcSideType(order.Direction), offset: this._calcOffsetType(order.CombOffsetFlag as OffsetFlagType), price: order.LimitPrice, volume: order.VolumeTotalOriginal, traded: traded, status: this._calcOrderStatus(order, traded), trades: trades.map(this._toTradeData, this), cancelTime: order.CancelTime !== "" ? this._parseTime(order.CancelTime) : undefined, }); } private _toInstrumentData(instrument: InstrumentField): InstrumentData { return Object.freeze({ symbol: `${instrument.InstrumentID}.${instrument.ExchangeID}`, id: instrument.InstrumentID, name: instrument.InstrumentName, exchangeId: instrument.ExchangeID, productId: instrument.ProductID, productType: this._calcProductType(instrument.ProductClass), deliveryTime: instrument.DeliveryYear * 100 + instrument.DeliveryMonth, createDate: parseInt(instrument.CreateDate), openDate: parseInt(instrument.OpenDate), expireDate: parseInt(instrument.ExpireDate), multiple: instrument.VolumeMultiple, priceTick: instrument.PriceTick, maxLimitOrderVolume: instrument.MaxLimitOrderVolume, minLimitOrderVolume: instrument.MinLimitOrderVolume, strikePrice: instrument.StrikePrice, optionsType: this._calcOptionsType(instrument.OptionsType), }); } private _toCommissionRate( symbol: string, commRate: InstrumentCommissionRateField, ): CommissionRate { return Object.freeze({ symbol: symbol, open: Object.freeze({ ratio: commRate.OpenRatioByMoney, amount: commRate.OpenRatioByVolume, }), close: Object.freeze({ ratio: commRate.CloseRatioByMoney, amount: commRate.CloseRatioByVolume, }), closeToday: Object.freeze({ ratio: commRate.CloseTodayRatioByMoney, amount: commRate.CloseTodayRatioByVolume, }), }); } private _toMarginRate( symbol: string, marginRate: InstrumentMarginRateField, ): MarginRate { return Object.freeze({ symbol: symbol, long: Object.freeze({ ratio: marginRate.LongMarginRatioByMoney, amount: marginRate.LongMarginRatioByVolume, }), short: Object.freeze({ ratio: marginRate.ShortMarginRatioByMoney, amount: marginRate.ShortMarginRatioByVolume, }), }); } private _toTradingAccount(account: TradingAccountField): TradingAccount { return Object.freeze({ id: account.AccountID, currency: account.CurrencyID, preBalance: account.PreBalance - account.Withdraw + account.Deposit, preMargin: account.PreMargin, balance: account.Balance, cash: account.Available, margin: account.CurrMargin, commission: account.Commission, frozenMargin: account.FrozenMargin, frozenCash: account.FrozenCash, frozenCommission: account.FrozenCommission, }); } private _toPositionDetail( positionDetail: InvestorPositionDetailField, ): PositionDetail { return Object.freeze({ symbol: this._toSymbol(positionDetail.InstrumentID)!, date: parseInt(positionDetail.OpenDate), side: this._calcSideType(positionDetail.Direction), price: positionDetail.OpenPrice, volume: positionDetail.Volume, margin: positionDetail.Margin, }); } private _toPositionData(position: PositionInfo): PositionData { return Object.freeze({ symbol: position.symbol, today: Object.freeze({ long: Object.freeze({ ...position.today.long }), short: Object.freeze({ ...position.today.short }), }), history: Object.freeze({ long: Object.freeze({ ...position.history.long }), short: Object.freeze({ ...position.history.short }), }), pending: Object.freeze({ ...position.pending }), }); } private _processMarginRatesQueue() { while (!this.marginRatesQueue.isEmpty()) { const nextQuery = this.marginRatesQueue.peekFront()!; const [instrumentId] = parseSymbol(nextQuery.symbol); const marginRate = this.marginRates.get(instrumentId); if (marginRate) { nextQuery.receiver.onMarginRate( this._toMarginRate(nextQuery.symbol, marginRate), ); this.marginRatesQueue.shift(); } else { this._withRetry(() => this.t