hft-js
Version:
High-Frequency Trading in Node.js
1,241 lines (1,240 loc) • 52.1 kB
JavaScript
/*
* 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, {} from "napi-ctp";
import { CTPProvider } from "./provider.js";
import { isValidPrice, parseSymbol } from "./utils.js";
export class Trader extends CTPProvider {
traderApi;
tradingDay;
frontId;
sessionId;
orderRef;
accountsQueryTime;
positionDetailsChanged;
fastQueryLastTick;
userInfo;
receivers;
accounts;
positionDetails;
instruments;
positions;
orders;
trades;
marginRates;
commRates;
placeOrders;
cancelOrders;
marketOrdersQueue;
priceLimit;
orderStatistics;
marginRatesQueue;
commRatesQueue;
accountsQueue;
positionDetailsQueue;
constructor(flowTdPath, frontTdAddrs, userInfo, options) {
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) {
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(ctp.TraderEvent.RspAuthenticate, (_, options) => {
if (this._isErrorResp(lifecycle, options, "login-error")) {
return;
}
this._withRetry(() => this.traderApi.reqUserLogin(this.userInfo));
});
this.traderApi.on(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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) {
if (!this.traderApi) {
return;
}
this.traderApi.close();
this.traderApi = undefined;
lifecycle.onClose();
}
addOrderReceiver(receiver) {
if (!this.receivers.includes(receiver)) {
this.receivers.push(receiver);
}
}
removeOrderReceiver(receiver) {
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) {
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, receiver) {
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, receiver) {
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, receiver) {
const [instrumentId, exchangeId] = parseSymbol(symbol);
const instrument = this.instruments.get(instrumentId);
receiver.onInstrument(instrument && instrument.ExchangeID === exchangeId
? this._toInstrumentData(instrument)
: undefined);
}
queryPosition(symbol, receiver) {
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, type) {
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) {
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) {
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) {
const positions = [];
this.positions.forEach((position) => positions.push(this._toPositionData(position)));
receiver.onPositions(positions);
}
queryOrders(receiver) {
const orders = [];
this.orders.forEach((order) => {
orders.push(this._toOrderData(order));
});
receiver.onOrders(orders);
}
_placeLimitOrder(symbol, offset, side, volume, price, receiver) {
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;
});
}
_clearAllMarketOrders() {
this.marketOrdersQueue.forEach((queue) => {
const orders = queue.toArray();
orders.forEach((order) => {
order.receiver.onPlaceOrderError("Request Error");
});
});
this.marketOrdersQueue.clear();
}
_placeMarketOrder(symbol, offset, side, volume, receiver) {
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, offset, side, volume, price, flag, receiver) {
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, receiver) {
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();
});
}
_toSymbol(instrumentId) {
const instrument = this.instruments.get(instrumentId);
if (!instrument) {
return undefined;
}
return `${instrument.InstrumentID}.${instrument.ExchangeID}`;
}
_calcOrderId(orderOrTrade) {
const { ExchangeID, TraderID, OrderLocalID } = orderOrTrade;
return `${ExchangeID}:${TraderID}:${OrderLocalID}`;
}
_calcReceiptId(order) {
return `${order.FrontID}:${order.SessionID}:${parseInt(order.OrderRef)}`;
}
_calcOrderStatus(order, traded) {
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";
}
}
_calcOrderFlag(orderPriceType) {
switch (orderPriceType) {
case ctp.OrderPriceTypeType.LimitPrice:
return "limit";
default:
return "market";
}
}
_calcSideType(direction) {
switch (direction) {
case ctp.DirectionType.Buy:
return "long";
case ctp.DirectionType.Sell:
return "short";
}
}
_toDirection(side) {
switch (side) {
case "long":
return ctp.DirectionType.Buy;
case "short":
return ctp.DirectionType.Sell;
}
}
_calcOffsetType(offset) {
switch (offset) {
case ctp.OffsetFlagType.Open:
return "open";
case ctp.OffsetFlagType.CloseToday:
return "close-today";
default:
return "close";
}
}
_toOffsetFlag(offset) {
switch (offset) {
case "open":
return ctp.OffsetFlagType.Open;
case "close":
return ctp.OffsetFlagType.Close;
case "close-today":
return ctp.OffsetFlagType.CloseToday;
}
}
_calcProductType(productClass) {
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}`);
}
}
_calcOptionsType(optionsType) {
switch (optionsType) {
case ctp.OptionsTypeType.CallOptions:
return "call";
case ctp.OptionsTypeType.PutOptions:
return "put";
default:
return undefined;
}
}
_ensurePositionInfo(symbol) {
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;
}
_ensureOrderStatistic(symbol) {
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;
}
_calcPosition(symbol, side, offset, volume) {
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;
}
}
_recordPending(symbol, side, offset, volume) {
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;
}
}
_recoverPending(symbol, side, offset, volume) {
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;
}
}
_freezePosition(symbol, side, offset, volume) {
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;
}
}
_unfreezePosition(symbol, side, offset, volume) {
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;
}
}
_toTradeData(trade) {
return Object.freeze({
id: trade.TradeID,
date: parseInt(trade.TradeDate),
time: this._parseTime(trade.TradeTime),
price: trade.Price,
volume: trade.Volume,
});
}
_toOrderData(order) {
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),
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,
});
}
_toInstrumentData(instrument) {
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),
});
}
_toCommissionRate(symbol, commRate) {
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,
}),
});
}
_toMarginRate(symbol, marginRate) {
return Object.freeze({
symbol: symbol,
long: Object.freeze({
ratio: marginRate.LongMarginRatioByMoney,
amount: marginRate.LongMarginRatioByVolume,
}),
short: Object.freeze({
ratio: marginRate.ShortMarginRatioByMoney,
amount: marginRate.ShortMarginRatioByVolume,
}),
});
}
_toTradingAccount(account) {
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,
});
}
_toPositionDetail(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,
});
}
_toPositionData(position) {
return Object.freeze({
symbol: position.symbol,