hft-js 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/market.ts ADDED
@@ -0,0 +1,370 @@
1
+ /*
2
+ * market.ts
3
+ *
4
+ * Copyright (c) 2025 Xiongfei Shi
5
+ *
6
+ * Author: Xiongfei Shi <xiongfei.shi(a)icloud.com>
7
+ * License: Apache-2.0
8
+ *
9
+ * https://github.com/shixiongfei/hft.js
10
+ */
11
+
12
+ import ctp from "napi-ctp";
13
+ import { CTPProvider, CTPUserInfo } from "./provider.js";
14
+ import { InstrumentData, OrderBook, TickData } from "./typedef.js";
15
+ import {
16
+ ILifecycleListener,
17
+ IMarketProvider,
18
+ IMarketRecorderReceiver,
19
+ IMarketRecorderSymbols,
20
+ ITickReceiver,
21
+ } from "./interfaces.js";
22
+
23
+ const isValidPrice = (x: number) => x !== Number.MAX_VALUE && x !== 0;
24
+ const isValidVolume = (x: number) => x !== Number.MAX_VALUE && x !== 0;
25
+
26
+ export class Market extends CTPProvider implements IMarketProvider {
27
+ private marketApi?: ctp.MarketData;
28
+ private recorder?: IMarketRecorderReceiver;
29
+ private recorderSymbols?: IMarketRecorderSymbols;
30
+ private readonly recordings: Set<string>;
31
+ private readonly symbols: Map<string, string>;
32
+ private readonly subscribers: Map<string, ITickReceiver[]>;
33
+
34
+ constructor(
35
+ flowMdPath: string,
36
+ frontMdAddrs: string | string[],
37
+ userInfo: CTPUserInfo,
38
+ ) {
39
+ super(flowMdPath, frontMdAddrs, userInfo);
40
+ this.recordings = new Set();
41
+ this.symbols = new Map();
42
+ this.subscribers = new Map();
43
+ }
44
+
45
+ hasRecorder() {
46
+ return !!this.recorder;
47
+ }
48
+
49
+ setRecorder(
50
+ receiver: IMarketRecorderReceiver,
51
+ symbols: IMarketRecorderSymbols,
52
+ ) {
53
+ this.recorder = receiver;
54
+ this.recorderSymbols = symbols;
55
+ }
56
+
57
+ open(lifecycle: ILifecycleListener) {
58
+ if (this.marketApi) {
59
+ return true;
60
+ }
61
+
62
+ this.marketApi = ctp.createMarketData(this.flowPath, this.frontAddrs);
63
+
64
+ this.marketApi.on(ctp.MarketDataEvent.FrontConnected, () => {
65
+ this._withRetry(() => this.marketApi!.reqUserLogin(this.userInfo));
66
+ });
67
+
68
+ let fired = false;
69
+
70
+ this.marketApi.on<ctp.RspUserLoginField>(
71
+ ctp.MarketDataEvent.RspUserLogin,
72
+ (_, options) => {
73
+ if (this._isErrorResp(lifecycle, options, "login-error")) {
74
+ return;
75
+ }
76
+
77
+ const instrumentIds = new Set([
78
+ ...Array.from(this.recordings),
79
+ ...Object.keys(this.subscribers),
80
+ ]);
81
+
82
+ if (instrumentIds.size > 0) {
83
+ this._withRetry(() =>
84
+ this.marketApi!.subscribeMarketData(Array.from(instrumentIds)),
85
+ );
86
+ }
87
+
88
+ if (!fired) {
89
+ fired = true;
90
+ lifecycle.onOpen();
91
+ }
92
+ },
93
+ );
94
+
95
+ this.marketApi.on<ctp.DepthMarketDataField>(
96
+ ctp.MarketDataEvent.RtnDepthMarketData,
97
+ (depthMarketData) => {
98
+ const instrumentId = depthMarketData.InstrumentID;
99
+
100
+ if (this.recorder && this.recordings.has(instrumentId)) {
101
+ this.recorder.onMarketData(depthMarketData);
102
+ }
103
+
104
+ const symbol = this.symbols.get(instrumentId);
105
+
106
+ if (!symbol) {
107
+ return;
108
+ }
109
+
110
+ const receivers = this.subscribers.get(instrumentId);
111
+
112
+ if (!receivers || receivers.length === 0) {
113
+ return;
114
+ }
115
+
116
+ const orderBook: OrderBook = {
117
+ asks: { price: [], volume: [] },
118
+ bids: { price: [], volume: [] },
119
+ };
120
+
121
+ if (
122
+ isValidPrice(depthMarketData.AskPrice1) &&
123
+ isValidVolume(depthMarketData.AskVolume1)
124
+ ) {
125
+ orderBook.asks.price.push(depthMarketData.AskPrice1);
126
+ orderBook.asks.volume.push(depthMarketData.AskVolume1);
127
+
128
+ if (
129
+ isValidPrice(depthMarketData.AskPrice2) &&
130
+ isValidVolume(depthMarketData.AskVolume2)
131
+ ) {
132
+ orderBook.asks.price.push(depthMarketData.AskPrice2);
133
+ orderBook.asks.volume.push(depthMarketData.AskVolume2);
134
+
135
+ if (
136
+ isValidPrice(depthMarketData.AskPrice3) &&
137
+ isValidVolume(depthMarketData.AskVolume3)
138
+ ) {
139
+ orderBook.asks.price.push(depthMarketData.AskPrice3);
140
+ orderBook.asks.volume.push(depthMarketData.AskVolume3);
141
+
142
+ if (
143
+ isValidPrice(depthMarketData.AskPrice4) &&
144
+ isValidVolume(depthMarketData.AskVolume4)
145
+ ) {
146
+ orderBook.asks.price.push(depthMarketData.AskPrice4);
147
+ orderBook.asks.volume.push(depthMarketData.AskVolume4);
148
+
149
+ if (
150
+ isValidPrice(depthMarketData.AskPrice5) &&
151
+ isValidVolume(depthMarketData.AskVolume5)
152
+ ) {
153
+ orderBook.asks.price.push(depthMarketData.AskPrice5);
154
+ orderBook.asks.volume.push(depthMarketData.AskVolume5);
155
+ }
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ if (
162
+ isValidPrice(depthMarketData.BidPrice1) &&
163
+ isValidVolume(depthMarketData.BidVolume1)
164
+ ) {
165
+ orderBook.bids.price.push(depthMarketData.BidPrice1);
166
+ orderBook.bids.volume.push(depthMarketData.BidVolume1);
167
+
168
+ if (
169
+ isValidPrice(depthMarketData.BidPrice2) &&
170
+ isValidVolume(depthMarketData.BidVolume2)
171
+ ) {
172
+ orderBook.bids.price.push(depthMarketData.BidPrice2);
173
+ orderBook.bids.volume.push(depthMarketData.BidVolume2);
174
+
175
+ if (
176
+ isValidPrice(depthMarketData.BidPrice3) &&
177
+ isValidVolume(depthMarketData.BidVolume3)
178
+ ) {
179
+ orderBook.bids.price.push(depthMarketData.BidPrice3);
180
+ orderBook.bids.volume.push(depthMarketData.BidVolume3);
181
+
182
+ if (
183
+ isValidPrice(depthMarketData.BidPrice4) &&
184
+ isValidVolume(depthMarketData.BidVolume4)
185
+ ) {
186
+ orderBook.bids.price.push(depthMarketData.BidPrice4);
187
+ orderBook.bids.volume.push(depthMarketData.BidVolume4);
188
+
189
+ if (
190
+ isValidPrice(depthMarketData.BidPrice5) &&
191
+ isValidVolume(depthMarketData.BidVolume5)
192
+ ) {
193
+ orderBook.bids.price.push(depthMarketData.BidPrice5);
194
+ orderBook.bids.volume.push(depthMarketData.BidVolume5);
195
+ }
196
+ }
197
+ }
198
+ }
199
+ }
200
+
201
+ const time = this._parseTime(depthMarketData.UpdateTime);
202
+
203
+ const tick: TickData = Object.freeze({
204
+ symbol: symbol,
205
+ date: parseInt(depthMarketData.ActionDay),
206
+ time: time + depthMarketData.UpdateMillisec / 1000,
207
+ tradingDay: parseInt(depthMarketData.TradingDay),
208
+ preOpenInterest: depthMarketData.PreOpenInterest,
209
+ preClose: depthMarketData.PreClosePrice,
210
+ openInterest: depthMarketData.OpenInterest,
211
+ openPrice: depthMarketData.OpenPrice,
212
+ highPrice: depthMarketData.HighestPrice,
213
+ lowPrice: depthMarketData.LowestPrice,
214
+ lastPrice: depthMarketData.LastPrice,
215
+ volume: depthMarketData.Volume,
216
+ amount: depthMarketData.Turnover,
217
+ limits: Object.freeze({
218
+ upper: depthMarketData.UpperLimitPrice,
219
+ lower: depthMarketData.LowerLimitPrice,
220
+ }),
221
+ bandings: Object.freeze({
222
+ upper: depthMarketData.BandingUpperPrice,
223
+ lower: depthMarketData.BandingLowerPrice,
224
+ }),
225
+ orderBook: Object.freeze(orderBook),
226
+ });
227
+
228
+ receivers.forEach((receiver) => receiver.onTick(tick));
229
+ },
230
+ );
231
+
232
+ return true;
233
+ }
234
+
235
+ close(lifecycle: ILifecycleListener) {
236
+ if (!this.marketApi) {
237
+ return;
238
+ }
239
+
240
+ this.marketApi.close();
241
+ this.marketApi = undefined;
242
+
243
+ lifecycle.onClose();
244
+ }
245
+
246
+ startRecorder(instrument: InstrumentData[]) {
247
+ if (!this.recorderSymbols) {
248
+ return;
249
+ }
250
+
251
+ const symbols = this.recorderSymbols(instrument);
252
+ const instrumentIds = new Set<string>();
253
+
254
+ symbols.forEach((symbol) => {
255
+ const [instrumentId] = this._parseSymbol(symbol);
256
+
257
+ if (this.recordings.has(instrumentId)) {
258
+ return;
259
+ }
260
+
261
+ this.recordings.add(instrumentId);
262
+
263
+ if (!this.subscribers.has(instrumentId)) {
264
+ this.symbols.set(instrumentId, symbol);
265
+ instrumentIds.add(instrumentId);
266
+ }
267
+ });
268
+
269
+ if (instrumentIds.size > 0) {
270
+ this._withRetry(() =>
271
+ this.marketApi?.subscribeMarketData(Array.from(instrumentIds)),
272
+ );
273
+ }
274
+ }
275
+
276
+ stopRecorder() {
277
+ if (this.recordings.size === 0) {
278
+ return;
279
+ }
280
+
281
+ const instrumentIds = new Set<string>();
282
+
283
+ this.recordings.forEach((instrumentId) => {
284
+ if (!this.subscribers.has(instrumentId)) {
285
+ this.symbols.delete(instrumentId);
286
+ instrumentIds.add(instrumentId);
287
+ }
288
+ });
289
+
290
+ this.recordings.clear();
291
+
292
+ if (instrumentIds.size > 0) {
293
+ this._withRetry(() =>
294
+ this.marketApi?.unsubscribeMarketData(Array.from(instrumentIds)),
295
+ );
296
+ }
297
+ }
298
+
299
+ subscribe(symbols: string[], receiver: ITickReceiver) {
300
+ const instrumentIds = new Set<string>();
301
+
302
+ symbols.forEach((symbol) => {
303
+ const [instrumentId] = this._parseSymbol(symbol);
304
+ const receivers = this.subscribers.get(instrumentId);
305
+
306
+ if (receivers) {
307
+ if (!receivers.includes(receiver)) {
308
+ receivers.push(receiver);
309
+ }
310
+ } else {
311
+ this.subscribers.set(instrumentId, [receiver]);
312
+
313
+ if (!this.recordings.has(instrumentId)) {
314
+ this.symbols.set(instrumentId, symbol);
315
+ instrumentIds.add(instrumentId);
316
+ }
317
+ }
318
+ });
319
+
320
+ if (instrumentIds.size > 0) {
321
+ this._withRetry(() =>
322
+ this.marketApi?.subscribeMarketData(Array.from(instrumentIds)),
323
+ );
324
+ }
325
+ }
326
+
327
+ unsubscribe(symbols: string[], receiver: ITickReceiver) {
328
+ const instrumentIds = new Set<string>();
329
+
330
+ symbols.forEach((symbol) => {
331
+ const [instrumentId] = this._parseSymbol(symbol);
332
+ const receivers = this.subscribers.get(instrumentId);
333
+
334
+ if (!receivers) {
335
+ return;
336
+ }
337
+
338
+ if (receivers.length > 0) {
339
+ const index = receivers.indexOf(receiver);
340
+
341
+ if (index < 0) {
342
+ return;
343
+ }
344
+
345
+ receivers.splice(index, 1);
346
+ }
347
+
348
+ if (receivers.length === 0) {
349
+ this.subscribers.delete(instrumentId);
350
+
351
+ if (!this.recordings.has(instrumentId)) {
352
+ this.symbols.delete(instrumentId);
353
+ instrumentIds.add(instrumentId);
354
+ }
355
+ }
356
+ });
357
+
358
+ if (instrumentIds.size > 0) {
359
+ this._withRetry(() =>
360
+ this.marketApi?.unsubscribeMarketData(Array.from(instrumentIds)),
361
+ );
362
+ }
363
+ }
364
+ }
365
+
366
+ export const createMarket = (
367
+ flowMdPath: string,
368
+ frontMdAddrs: string | string[],
369
+ userInfo: CTPUserInfo,
370
+ ) => new Market(flowMdPath, frontMdAddrs, userInfo);
@@ -0,0 +1,88 @@
1
+ /*
2
+ * provider.ts
3
+ *
4
+ * Copyright (c) 2025 Xiongfei Shi
5
+ *
6
+ * Author: Xiongfei Shi <xiongfei.shi(a)icloud.com>
7
+ * License: Apache-2.0
8
+ *
9
+ * https://github.com/shixiongfei/hft.js
10
+ */
11
+
12
+ import ctp from "napi-ctp";
13
+ import { ErrorType, ILifecycleListener } from "./interfaces.js";
14
+
15
+ export type CTPUserInfo = {
16
+ BrokerID: string;
17
+ UserID: string;
18
+ Password: string;
19
+ InvestorID: string;
20
+ UserProductInfo: string;
21
+ AuthCode: string;
22
+ AppID: string;
23
+ };
24
+
25
+ export class CTPProvider {
26
+ protected readonly flowPath: string;
27
+ protected readonly frontAddrs: string | string[];
28
+ protected readonly userInfo: CTPUserInfo;
29
+
30
+ constructor(
31
+ flowPath: string,
32
+ frontAddrs: string | string[],
33
+ userInfo: CTPUserInfo,
34
+ ) {
35
+ this.flowPath = flowPath;
36
+ this.frontAddrs = frontAddrs;
37
+ this.userInfo = userInfo;
38
+ }
39
+
40
+ private _sleep(ms: number) {
41
+ return new Promise<void>((resolve) => {
42
+ setTimeout(resolve, ms);
43
+ });
44
+ }
45
+
46
+ protected async _withRetry(request: () => number | undefined, ms = 100) {
47
+ for (;;) {
48
+ const retval = request();
49
+
50
+ if (retval === 0) {
51
+ return ctp.getLastRequestId();
52
+ }
53
+
54
+ if (-2 !== retval && -3 !== retval) {
55
+ return retval;
56
+ }
57
+
58
+ await this._sleep(ms);
59
+ }
60
+ }
61
+
62
+ protected _parseSymbol(symbol: string): [string, string] {
63
+ const [instrumentId, exchangeId] = symbol.split(".");
64
+ return [instrumentId, exchangeId];
65
+ }
66
+
67
+ protected _isErrorResp(
68
+ lifecycle: ILifecycleListener,
69
+ options: ctp.CallbackOptions,
70
+ error: ErrorType,
71
+ ) {
72
+ if (!options.rspInfo) {
73
+ return false;
74
+ }
75
+
76
+ lifecycle.onError(
77
+ error,
78
+ `${options.rspInfo.ErrorID}:${options.rspInfo.ErrorMsg}`,
79
+ );
80
+
81
+ return true;
82
+ }
83
+
84
+ protected _parseTime(time: string) {
85
+ const [hour, minute, second] = time.split(":").map((x) => parseInt(x));
86
+ return hour * 10000 + minute * 100 + second;
87
+ }
88
+ }
package/src/tape.ts ADDED
@@ -0,0 +1,169 @@
1
+ /*
2
+ * tape.ts
3
+ *
4
+ * Copyright (c) 2025 Xiongfei Shi
5
+ *
6
+ * Author: Xiongfei Shi <xiongfei.shi(a)icloud.com>
7
+ * License: Apache-2.0
8
+ *
9
+ * https://github.com/shixiongfei/hft.js
10
+ */
11
+
12
+ import {
13
+ TapeData,
14
+ TapeDirection,
15
+ TapeStatus,
16
+ TapeType,
17
+ TickData,
18
+ } from "./typedef.js";
19
+
20
+ const calcTapeType = (volumeDelta: number, interestDelta: number): TapeType => {
21
+ if (interestDelta > 0) {
22
+ return volumeDelta === interestDelta ? "dual-open" : "open";
23
+ }
24
+
25
+ if (interestDelta < 0) {
26
+ return volumeDelta + interestDelta === 0 ? "dual-close" : "close";
27
+ }
28
+
29
+ if (volumeDelta > 0) {
30
+ return "turnover";
31
+ }
32
+
33
+ return "no-deal";
34
+ };
35
+
36
+ const calcTapeDirection = (
37
+ tick: TickData,
38
+ preTick?: TickData,
39
+ ): TapeDirection => {
40
+ const lastPrice = tick.lastPrice;
41
+
42
+ if (preTick) {
43
+ const preAskPrice1 = preTick.orderBook.asks.price[0] ?? Number.MAX_VALUE;
44
+
45
+ if (lastPrice >= preAskPrice1) {
46
+ return "up";
47
+ }
48
+
49
+ const preBidPrice1 = preTick.orderBook.bids.price[0] ?? Number.MIN_VALUE;
50
+
51
+ if (lastPrice <= preBidPrice1) {
52
+ return "down";
53
+ }
54
+
55
+ const askPrice1 = tick.orderBook.asks.price[0] ?? Number.MAX_VALUE;
56
+
57
+ if (lastPrice >= askPrice1) {
58
+ return "up";
59
+ }
60
+
61
+ const bidPrice1 = tick.orderBook.bids.price[0] ?? Number.MIN_VALUE;
62
+
63
+ if (lastPrice <= bidPrice1) {
64
+ return "down";
65
+ }
66
+
67
+ const prePrice = preTick.lastPrice;
68
+
69
+ if (lastPrice > prePrice) {
70
+ return "up";
71
+ }
72
+
73
+ if (lastPrice < prePrice) {
74
+ return "down";
75
+ }
76
+
77
+ if (bidPrice1 >= preAskPrice1) {
78
+ return "up";
79
+ }
80
+
81
+ if (askPrice1 <= preBidPrice1) {
82
+ return "down";
83
+ }
84
+
85
+ return "none";
86
+ } else {
87
+ const askPrice1 = tick.orderBook.asks.price[0] ?? Number.MAX_VALUE;
88
+
89
+ if (lastPrice >= askPrice1) {
90
+ return "up";
91
+ }
92
+
93
+ const bidPrice1 = tick.orderBook.bids.price[0] ?? Number.MIN_VALUE;
94
+
95
+ if (lastPrice <= bidPrice1) {
96
+ return "down";
97
+ }
98
+
99
+ if (lastPrice > tick.preClose) {
100
+ return "up";
101
+ }
102
+
103
+ if (lastPrice < tick.preClose) {
104
+ return "down";
105
+ }
106
+
107
+ return "none";
108
+ }
109
+ };
110
+
111
+ const calcTapeStatus = (
112
+ tapeType: TapeType,
113
+ tapeDirection: TapeDirection,
114
+ ): TapeStatus => {
115
+ if (tapeType === "open" && tapeDirection === "up") {
116
+ return "open-long";
117
+ }
118
+
119
+ if (tapeType === "open" && tapeDirection === "down") {
120
+ return "open-short";
121
+ }
122
+
123
+ if (tapeType === "close" && tapeDirection === "up") {
124
+ return "close-short";
125
+ }
126
+
127
+ if (tapeType === "close" && tapeDirection === "down") {
128
+ return "close-long";
129
+ }
130
+
131
+ if (tapeType === "turnover" && tapeDirection === "up") {
132
+ return "turnover-long";
133
+ }
134
+
135
+ if (tapeType === "turnover" && tapeDirection === "down") {
136
+ return "turnover-short";
137
+ }
138
+
139
+ if (tapeType === "dual-open") {
140
+ return "dual-open";
141
+ }
142
+
143
+ if (tapeType === "dual-close") {
144
+ return "dual-close";
145
+ }
146
+
147
+ return "invalid";
148
+ };
149
+
150
+ export const calcTapeData = (tick: TickData, preTick?: TickData): TapeData => {
151
+ const [volumeDelta, interestDelta] = preTick
152
+ ? [tick.volume - preTick.volume, tick.openInterest - preTick.openInterest]
153
+ : [tick.volume, tick.openInterest - tick.preOpenInterest];
154
+
155
+ const tapeType = calcTapeType(volumeDelta, interestDelta);
156
+ const tapeDirection = calcTapeDirection(tick, preTick);
157
+ const tapeStatus = calcTapeStatus(tapeType, tapeDirection);
158
+
159
+ return {
160
+ symbol: tick.symbol,
161
+ date: tick.date,
162
+ time: tick.time,
163
+ volumeDelta: volumeDelta,
164
+ interestDelta: interestDelta,
165
+ type: tapeType,
166
+ direction: tapeDirection,
167
+ status: tapeStatus,
168
+ };
169
+ };