@watsonserve/stock-loader 0.0.1 → 0.0.2-alpha.0

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/build.mjs ADDED
@@ -0,0 +1,28 @@
1
+ import { spawn } from 'child_process';
2
+ import { readFile, writeFile } from 'fs/promises';
3
+
4
+ (async function run() {
5
+ const txt = await readFile('./package.json', 'utf-8');
6
+ const pkg = JSON.parse(txt);
7
+ pkg.main = 'index.js';
8
+ pkg.types = './types/index.d.ts';
9
+ delete pkg.devDependencies;
10
+ delete pkg.packageManager;
11
+ pkg.publishConfig = { access: 'public' };
12
+ await writeFile('dist/package.json', JSON.stringify(pkg, null, 2));
13
+ })();
14
+
15
+ // const tsc = spawn('tsc', ['-p', 'tsconfig.json']);
16
+
17
+ // tsc.stdout.on('data', (data) => {
18
+ // console.log(`stdout: ${data}`);
19
+ // });
20
+
21
+ // tsc.stderr.on('data', (data) => {
22
+ // console.error(`stderr: ${data}`);
23
+ // });
24
+
25
+ // tsc.on('close', (code) => {
26
+ // console.log(`child process exited with code ${code}`);
27
+ // !code && run();
28
+ // });
package/package.json CHANGED
@@ -1,21 +1,23 @@
1
1
  {
2
2
  "name": "@watsonserve/stock-loader",
3
- "version": "0.0.1",
3
+ "version": "0.0.2-alpha.0",
4
4
  "description": "",
5
5
  "type": "module",
6
- "main": "index.js",
7
- "types": "./types",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/types/index.d.ts",
8
8
  "scripts": {
9
9
  "build": "tsc"
10
10
  },
11
11
  "keywords": [],
12
- "author": "hamishwatson@live.com",
12
+ "author": "",
13
13
  "license": "ISC",
14
+ "packageManager": "pnpm@10.33.0",
14
15
  "peerDependencies": {
15
16
  "@watsonserve/stock-base": "^0.0.20",
16
17
  "playwright": "^1.58.2"
17
18
  },
18
- "publishConfig": {
19
- "access": "public"
19
+ "devDependencies": {
20
+ "@types/node": "^25.3.5",
21
+ "typescript": "^6.0.2"
20
22
  }
21
- }
23
+ }
@@ -0,0 +1,123 @@
1
+ import { EnMarket } from '@watsonserve/stock-base';
2
+
3
+ export const A_MIN_MS = 60000;
4
+ export const A_HOUR_MS = 3600000;
5
+ export const A_DAY_MS = 86400000;
6
+
7
+ // 夏令时:3月最后一个周日凌晨1AM开始夏令时,10月最后一个周日凌晨2AM结束夏令时
8
+ function isDST_ByUK(timestamp: number): boolean {
9
+ const date = new Date(timestamp);
10
+ const year = date.getUTCFullYear();
11
+
12
+ // 英国夏令时:3月最后一个周日开始,10月最后一个周日结束
13
+ const march = new Date(Date.UTC(year, 2, 31));
14
+ const october = new Date(Date.UTC(year, 9, 31));
15
+
16
+ // 夏令时开始和结束的日期
17
+ const dstStart = new Date(Date.UTC(year, 2, 31 - march.getUTCDay(), 1)); // 3月最后一个周日凌晨1点
18
+ const dstEnd = new Date(Date.UTC(year, 9, 31 - october.getUTCDay(), 1)); // 10月最后一个周日凌晨2点 = UTC时间凌晨1点
19
+
20
+ return timestamp >= dstStart.getTime() && timestamp < dstEnd.getTime();
21
+ }
22
+
23
+ /**
24
+ * 判断给定时间戳(毫秒)是否处于美国东部夏令时(EDT)
25
+ * 夏令时:3月第二个周日 02:00 开始(时钟拨快至 03:00),11月第一个周日 02:00 结束(时钟拨回至 01:00)
26
+ */
27
+ function isDST_ByEST(timestamp: number): boolean {
28
+ const date = new Date(timestamp);
29
+ const year = date.getUTCFullYear();
30
+
31
+ // 计算3月1日是星期几(UTC)
32
+ const march = new Date(Date.UTC(year, 2, 1)); // UTC 时间:3月1日 00:00
33
+ const november = new Date(Date.UTC(year, 10, 1));
34
+
35
+ const marchDay = (7 - march.getUTCDay()) % 7 + 8; // 第二个周日(1~7是第一个周日,8~14是第二个)
36
+ const novemberDay = (7 - november.getUTCDay()) % 7 + 1; // 第一个周日
37
+
38
+ const dstStart = Date.UTC(year, 2, marchDay, 7); // UTC 时间 7点
39
+ const dstEnd = Date.UTC(year, 10, novemberDay, 6); // UTC 时间 6点
40
+
41
+ // 处于夏令时:在开始之后,结束之前
42
+ return timestamp >= dstStart && timestamp < dstEnd;
43
+ }
44
+
45
+ /**
46
+ * 获取市场 正式开盘时区偏移,单位分钟
47
+ * @param dst 是否夏令时
48
+ * @returns 分钟数
49
+ */
50
+ function getOpenTimeByUTC(market: EnMarket, time: number): number {
51
+ switch (market) {
52
+ case EnMarket.JPX:
53
+ return 0; // 9:00
54
+ case EnMarket.CHN:
55
+ return 90; // 9:30
56
+ case EnMarket.HKEX:
57
+ return 90; // 9:30
58
+ case EnMarket.SGX:
59
+ return 60; // 9:00
60
+ case EnMarket.LSE:
61
+ return isDST_ByUK(time) ? 420 : 480; // 7:00 or 8:00
62
+ case EnMarket.USA:
63
+ return isDST_ByEST(time) ? 810 : 870; // 13:30 or 14:30
64
+ default:
65
+ }
66
+ throw new Error(`Unsupported market: ${market}`);
67
+ }
68
+
69
+ /**
70
+ * 获取市场时区偏移,单位分钟
71
+ * @param dst 是否夏令时
72
+ * @returns 分钟数
73
+ */
74
+ function getCloseTimeByUTC(market: EnMarket, time: number): number {
75
+ switch (market) {
76
+ case EnMarket.JPX:
77
+ return 390; // 6:30
78
+ case EnMarket.CHN:
79
+ return 420; // 7:00
80
+ case EnMarket.HKEX:
81
+ return 490; // 8:10
82
+ case EnMarket.SGX:
83
+ return 556; // 9:16
84
+ case EnMarket.LSE:
85
+ return isDST_ByUK(time) ? 930 : 990; // 15:30 or 16:30
86
+ case EnMarket.USA:
87
+ return isDST_ByEST(time) ? 1200 : 1260; // 20:00 or 21:00
88
+ case EnMarket.FX:
89
+ return isDST_ByEST(time) ? 1260 : 1320; // 21:00 or 22:00
90
+ default:
91
+ }
92
+ throw new Error(`Unsupported market: ${market}`);
93
+ }
94
+
95
+ /**
96
+ * 获取市场收盘时间戳,单位秒
97
+ * @param market 市场
98
+ * @param time 可选时间戳(毫秒),默认为当前时间,用于确定日期和夏令时状态
99
+ * @returns 市场收盘秒级时间戳
100
+ */
101
+ export function getMarketCloseTime(market: EnMarket, time = Date.now()): number {
102
+ const d = new Date(time); // 确保 time 是有效的时间戳
103
+ switch (d.getUTCDay()) {
104
+ case 6:
105
+ time -= 86400000;
106
+ break;
107
+ case 0:
108
+ time -= 172800000;
109
+ break;
110
+ default:
111
+ }
112
+ const dayStart = (~~(time / A_DAY_MS)) * A_DAY_MS;
113
+ const closeTimestamp = dayStart + getCloseTimeByUTC(market, time) * A_MIN_MS;
114
+ let openTimestamp = 0;
115
+ if (EnMarket.FX !== market) {
116
+ openTimestamp = dayStart + getOpenTimeByUTC(market, time) * A_MIN_MS;
117
+ }
118
+
119
+ // after market open time, return close time of the day
120
+ if (openTimestamp < time) return ~~(closeTimestamp / 1000);
121
+ // return close time of the previous day
122
+ return getMarketCloseTime(market, closeTimestamp - A_DAY_MS);
123
+ }
@@ -0,0 +1,196 @@
1
+ import { log } from '@watsonserve/stock-base';
2
+ import PriceLoader from './price-loader.js';
3
+ import { type IDiv, ttm } from './helper.js';
4
+
5
+ // interface IDividend {
6
+ // detail: string;
7
+ // payment_date: '2026年4月30日',
8
+ // announcement_date: '2026年2月25日',
9
+ // ex_date: '2026年3月12日',
10
+ // fyear_end: '2025年12月31日',
11
+ // book_closed_from: '',
12
+ // book_closed_to: '',
13
+ // update_time: '2026年3月4日01:15',
14
+ // div_type_desc: null,
15
+ // announcement_URL: 'https://www1.hkexnews.hk/listedco/listconews/sehk/2026/0225/2026022500113.pdf'
16
+ // };
17
+
18
+ interface IDividend {
19
+ currency: string;
20
+ amount?: number;
21
+ ratio?: number;
22
+ annc: Date;
23
+ ex: Date;
24
+ paid: Date;
25
+ announcement_URL?: string;
26
+ }
27
+
28
+ function toDivPoint(fullNc: string, div: IDividend) {
29
+ const { annc, ex, paid, ...another } = div;
30
+ return {
31
+ ...another,
32
+ nc: fullNc,
33
+ annc: ~~(annc.getTime() / 1000),
34
+ ex: ~~(ex.getTime() / 1000),
35
+ paid: ~~(paid.getTime() / 1000)
36
+ } as IDiv;
37
+ }
38
+
39
+ const rxDate = /\d+/g;
40
+
41
+ export class DividendLoader extends PriceLoader {
42
+ private async __laodIbmCode(nc: string) {
43
+ const resp = await super.get(`https://api.sgx.com/marketmetadata/v2?stock-code=${nc}`);
44
+ return resp.data?.[0]?.ibmCode || '';
45
+ }
46
+
47
+ private async __loadHKDividend(nc: string) {
48
+ const token = await super._loadHkexToken();
49
+ const qid = Date.now();
50
+ const callback = `jsonp_${qid}`;
51
+ const params = { sym: +nc, token, lang: 'chi', recperpage: 5, page: 1, qid, callback };
52
+
53
+ const resp: string = await super.get('https://www1.hkex.com.hk/hkexwidget/data/getequityentitlement', params);
54
+ const strJson = resp.substring(callback.length + 1, resp.length - 1);
55
+ const { entitlementlist } = JSON.parse(strJson).data;
56
+
57
+ const list: IDividend[] = [];
58
+ for (const item of entitlementlist) {
59
+ const { detail, announcement_date, ex_date, payment_date, announcement_URL } = item;
60
+
61
+ const announcement_ymd = announcement_date.match(rxDate).slice(0, 3);
62
+ const ex_ymd: string[] | undefined = (ex_date as string).match(rxDate)?.slice(0, 3);
63
+ const payment_ymd: string[] | undefined = (payment_date as string).match(rxDate)?.slice(0, 3);
64
+
65
+ const divid = Array.from<string>(detail.match(/(USD|HKD|GBP) (\d+\.\d+)/g) || [])
66
+ .reduce<Record<string, any>>((pre, item) => {
67
+ const [currency, amount] = item.split(' ');
68
+ pre[currency] = { currency, amount: +amount };
69
+ return pre;
70
+ }, {});
71
+
72
+ list.push({
73
+ ...(divid['HKD'] || divid['USD'] || divid['GBP']),
74
+ annc: new Date(Date.UTC.apply(null, announcement_ymd)),
75
+ ex: ex_ymd && new Date(Date.UTC.apply(null, ex_ymd as any)) || null,
76
+ paid: payment_ymd && new Date(Date.UTC.apply(null, payment_ymd as any)) || null,
77
+ announcement_URL
78
+ });
79
+ }
80
+
81
+ return [ttm<IDividend>('ex', list), []];
82
+ }
83
+
84
+ private async __loadSGDividend(nc: string) {
85
+ const ibmcode = await this.__laodIbmCode(nc);
86
+ const params = {
87
+ pagesize: 15,
88
+ pagestart: 0,
89
+ ibmcode,
90
+ params: 'id,anncType,dateAnnc,exDate,name,particulars,recDate,datePaid',
91
+ order: 'desc',
92
+ orderBy: 'dateAnnc'
93
+ };
94
+
95
+ const { data } = await super.get('https://api.sgx.com/corporateactions/v1.0', params);
96
+
97
+ const bonus: IDividend[] = [];
98
+ const dividend: IDividend[] = [];
99
+
100
+ for (const item of data as any[]) {
101
+ const { anncType, particulars, dateAnnc, exDate, recDate, datePaid } = item;
102
+ const annc = new Date(dateAnnc);
103
+ const ex = new Date(exDate);
104
+ const rec = new Date(recDate); // 登记日
105
+ const paid = new Date(datePaid);
106
+
107
+ if ('DIVIDEND' === anncType) {
108
+ const amount = +particulars.match(/SGD (\d+\.\d+)/)?.[1] || 0;
109
+ const lstIdx = dividend.length - 1;
110
+ if (dividend[lstIdx]?.ex.getTime() === ex.getTime() && amount) {
111
+ dividend[lstIdx].amount! += amount;
112
+ } else {
113
+ dividend.push({ currency: 'SGD', amount, annc, ex, rec, paid } as IDividend);
114
+ }
115
+ continue;
116
+ }
117
+ if ('BONUS' === anncType) { // particulars: 'Ratio: 10:1'
118
+ const ratio = particulars.match(/Ratio: (\d+:\d+)/);
119
+ const [foo, bar] = ratio[1].split(':');
120
+
121
+ bonus.push({ currency: 'SGD', ratio: 1+(+bar)/(+foo), annc, ex, rec, paid } as IDividend);
122
+ continue;
123
+ }
124
+ }
125
+
126
+ return [ttm<IDividend>('ex', dividend), bonus];
127
+ }
128
+
129
+ private async __loadUSDividend(nc: string) {
130
+ const params = {
131
+ reportName: 'RPT_USF10_INFO_DIVIDEND',
132
+ columns: 'SECUCODE,SECURITY_CODE,SECURITY_NAME_ABBR,SECURITY_INNER_CODE,NOTICE_DATE,ASSIGN_TYPE,PLAN_EXPLAIN,EQUITY_RECORD_DATE,BONUS_PAY_DATE,EX_DIVIDEND_DATE,ASSIGN_PERIOD',
133
+ // filter: `(SECUCODE="${nc}.N")`,
134
+ filter: `(SECURITY_CODE="${nc.replace('.', '_')}")`,
135
+ pageNumber: 1,
136
+ pageSize: 200,
137
+ sortTypes: -1,
138
+ sortColumns: 'EX_DIVIDEND_DATE',
139
+ source: 'SECURITIES',
140
+ client: 'PC',
141
+ v: Math.random().toString().substring(2).padStart(17, '0')
142
+ };
143
+
144
+ const strResp = await super.get('https://datacenter.eastmoney.com/securities/api/data/v1/get', params);
145
+ const data = (JSON.parse(strResp).result?.data || [])as any[];
146
+
147
+ const dividend: IDividend[] = [];
148
+
149
+ for (const item of data) {
150
+ const { ASSIGN_TYPE, PLAN_EXPLAIN, NOTICE_DATE, EX_DIVIDEND_DATE, BONUS_PAY_DATE } = item;
151
+
152
+ if ('Cash' === ASSIGN_TYPE || 'Special cash' === ASSIGN_TYPE) {
153
+ const amount = +PLAN_EXPLAIN.match(/(\d*\.?\d+)美元/)[1]; // 每1股派0.82美元股息
154
+ if (!amount) console.warn(item);
155
+ dividend.push({
156
+ currency: 'USD',
157
+ amount,
158
+ annc: new Date(NOTICE_DATE),
159
+ ex: new Date(EX_DIVIDEND_DATE),
160
+ paid: new Date(BONUS_PAY_DATE),
161
+ });
162
+ continue;
163
+ }
164
+ }
165
+
166
+ return [ttm<IDividend>('ex', dividend), []];
167
+ }
168
+
169
+ async loadDividend(fullNc: string) {
170
+ const nc = fullNc.substring(0, fullNc.length - 3);
171
+ const market = fullNc.substring(fullNc.length - 2);
172
+ let data: IDividend[][];
173
+ log.info(`start loading ${fullNc} dividend...`);
174
+
175
+ switch (market) {
176
+ case 'SG':
177
+ data = await this.__loadSGDividend(nc);
178
+ break;
179
+ case 'HK':
180
+ data = await this.__loadHKDividend(nc);
181
+ break;
182
+ case 'US':
183
+ data = await this.__loadUSDividend(nc);
184
+ break;
185
+ default:
186
+ throw new Error(`Unsupported market: ${market}`);
187
+ }
188
+ const [div, bonus] = data;
189
+ const dps = div.map(item => toDivPoint(fullNc, item));
190
+ const bps = bonus.map(item => toDivPoint(fullNc, item));
191
+
192
+ log.info(`finished loading ${fullNc} dividend`);
193
+
194
+ return { fullNc, nc, dps, bps };
195
+ }
196
+ }
package/src/helper.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { EnMarket } from '@watsonserve/stock-base';
2
+
3
+ export function sleep(t = 0) {
4
+ return new Promise(resolve => setTimeout(resolve, t));
5
+ }
6
+
7
+ export function splitFullNc(fullNc: string) {
8
+ const nc = fullNc.substring(0, fullNc.length - 3);
9
+ const market = {
10
+ US: EnMarket.USA,
11
+ SG: EnMarket.SGX,
12
+ HK: EnMarket.HKEX
13
+ }[fullNc.substring(fullNc.length - 2)];
14
+ return { nc, market };
15
+ }
16
+
17
+ export interface IDiv {
18
+ nc: string;
19
+ annc: number;
20
+ ex: number;
21
+ paid: number;
22
+ currency: string;
23
+ amount: number;
24
+ }
25
+
26
+
27
+ export function ttm<T>(by: string, list: any[]): T[] {
28
+ if (!Array.isArray(list) || !list.length) return [];
29
+
30
+ const latest = new Date(list[0][by]);
31
+ const sinceTime = Date.UTC(latest.getUTCFullYear()-1, latest.getUTCMonth());
32
+ return list.filter(item => {
33
+ const val = new Date(item[by]);
34
+ return sinceTime < Date.UTC(val.getUTCFullYear(), val.getUTCMonth());
35
+ });
36
+ }
@@ -0,0 +1,71 @@
1
+ import { EnMarket } from '@watsonserve/stock-base';
2
+ import { getMarketCloseTime } from './close-time.js';
3
+ import WebRequest from './web-request.js';
4
+ import { sleep, splitFullNc } from './helper.js';
5
+
6
+ // 历史数据
7
+ export default class HistoryLoader extends WebRequest {
8
+ private async __getHKOrUSAHistory(market: EnMarket, nc: string) {
9
+ let cntry = EnMarket.USA === market ? 'us' : 'hk';
10
+ await this.goto(`https://quote.eastmoney.com/${cntry}/${nc}.html`);
11
+
12
+ let secid = `116.${nc}`;
13
+ if (EnMarket.USA === market) {
14
+ await sleep(500);
15
+ // secid = await this.get_em_code(nc);
16
+ secid = await this.read('quotecode');
17
+ }
18
+
19
+ const params = {
20
+ fields1: 'f1,f2,f3,f4,f5,f6',
21
+ fields2: 'f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61',
22
+ klt: 101, // 60 hour, 101 day, 102 week, 103 month
23
+ fqt: 0,
24
+ secid,
25
+ beg: 0,
26
+ end: 20500000,
27
+ // ut: 'bd1d9ddb04089700cf9c27f6f7426281',
28
+ // lmt: 1000000,
29
+ };
30
+ if (this.isDebug) {
31
+ await sleep(5000);
32
+ }
33
+ const resp = await super.get('https://push2his.eastmoney.com/api/qt/stock/kline/get', params);
34
+ // const resp = JSON.parse(await fs.readFile(path.join(import.meta.dirname, 'hsbc.json'), 'utf-8'));
35
+ const { klines } = resp.data;
36
+ return (klines as string[]).map(line => {
37
+ const [date, open, close, hight, low, vol] = line.split(',');
38
+ return {
39
+ market, nc, n: '', o:+open, c:+close, h:+hight, l:+low, v:+vol,
40
+ stamp: getMarketCloseTime(market, new Date(`${date}T15:00:00Z`).getTime())
41
+ };
42
+ });
43
+ }
44
+
45
+ private async __getSGXHistory(nc: string) {
46
+ const rsp = await super.get(`https://api.sgx.com/securities/v1.1//charts/historic/stocks/code/${nc}/5y`, { params: 'trading_time,vl,lt' });
47
+ const { historic } = rsp.data;
48
+ return (historic as any[]).map(st => {
49
+ const { trading_time, vl: v, lt: c } = st;
50
+ const fullYear = trading_time.substring(0, 4);
51
+ const month = trading_time.substring(4, 6);
52
+ const date = trading_time.substring(6, 8);
53
+ const stamp = ~~(new Date(`${fullYear}-${month}-${date}T09:16:00Z`).getTime() / 1000);
54
+ return { market: EnMarket.SGX, nc, n: '', o: 0, c, h: 0, l: 0, v, stamp };
55
+ });
56
+ }
57
+
58
+ loadHistory(fullNc: string) {
59
+ const { nc, market } = splitFullNc(fullNc);
60
+
61
+ switch (market) {
62
+ case EnMarket.SGX:
63
+ return this.__getSGXHistory(nc);
64
+ case EnMarket.HKEX:
65
+ case EnMarket.USA:
66
+ return this.__getHKOrUSAHistory(market, nc);
67
+ default:
68
+ }
69
+ throw new Error(`Unsupported market: ${market}`);
70
+ }
71
+ }
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { DividendLoader } from './dividend-loader.js';
2
+
3
+ export { type IDiv, ttm, sleep, splitFullNc } from './helper.js';
4
+ const A_DAY_S = 86400;
5
+
6
+ export interface IHoliday {
7
+ market: string;
8
+ title: string;
9
+ start: number;
10
+ end: number;
11
+ }
12
+
13
+ function parseUTCDate(str: string) {
14
+ return ~~(Date.UTC(+str.slice(0, 4), +str.slice(4, 6) - 1, +str.slice(6, 8)) / 1000);
15
+ }
16
+
17
+ export class StockLoader extends DividendLoader {
18
+ async loadHolidaysSG(year: number) {
19
+ const resp = await fetch(`https://www.mom.gov.sg/-/media/mom/documents/employment-practices/public-holidays/public-holidays-sg-${year}.ics`);
20
+ const text = (await resp.text()).replace(/\r\n/g, '\n').replace(/\n\n/g, '\n'); // handle line folding
21
+
22
+ return text.split('\nEND:VEVENT\nBEGIN:VEVENT\n').reduce<IHoliday[]>((pre, blk) => {
23
+ const ev = new Map(blk.split('\n').map(line => line.split(':', 2) as [string, string]));
24
+ let start = parseUTCDate(ev.get('DTSTART;VALUE=DATE') || '');
25
+ let end = parseUTCDate(ev.get('DTEND;VALUE=DATE') || '');
26
+ if (start === end) {
27
+ end += A_DAY_S;
28
+ }
29
+ // recording to SG calendar, we will move it to next day(Monday, 1) if holiday is Sunday(0)
30
+ if (!new Date(start).getUTCDay()) {
31
+ end += A_DAY_S;
32
+ }
33
+ const firstOne = pre[0];
34
+ const title = ev.get('SUMMARY') || '';
35
+
36
+ (firstOne && end === firstOne.start) ? Object.assign(firstOne, { title, start }) : pre.unshift({ market: 'SG', title, start, end });
37
+ return pre;
38
+ }, []);
39
+ }
40
+
41
+ async loadHolidaysHK() {
42
+ const resp = await fetch('https://www.hkex.com.hk/News/HKEX-Calendar/Subscribe-Calendar?sc_lang=zh-HK');
43
+ const text = (await resp.text()).replace(/\r\n/g, '\n').replace(/\n\n/g, '\n'); // handle line folding
44
+
45
+ return text.split('\nEND:VEVENT\nBEGIN:VEVENT\n').reduce<IHoliday[]>((pre, blk) => {
46
+ const ev = new Map(blk.split('\n').map(line => line.split(':', 2) as [string, string]));
47
+
48
+ if ('香港市場休市' !== ev.get('DESCRIPTION')) return pre;
49
+
50
+ const start = parseUTCDate(ev.get('DTSTART;VALUE=DATE') || '');
51
+ let end = parseUTCDate(ev.get('DTEND;VALUE=DATE') || '');
52
+ if (start === end) {
53
+ end += A_DAY_S;
54
+ }
55
+ const lastOne = pre[pre.length - 1];
56
+ if (lastOne && start === lastOne.end) {
57
+ lastOne.end = end;
58
+ } else {
59
+ pre.push({ market: 'HK', title: ev.get('SUMMARY') || '', start, end });
60
+ }
61
+
62
+ return pre;
63
+ }, []);
64
+ }
65
+
66
+ async loadHolidays() {
67
+ const [holidaysSG, holidaysHK] = await Promise.all([
68
+ this.loadHolidaysSG(new Date().getUTCFullYear()),
69
+ this.loadHolidaysHK()
70
+ ]);
71
+ return holidaysSG.concat(holidaysHK);
72
+ }
73
+ }
@@ -0,0 +1,125 @@
1
+ import { EnCurrency, EnMarket, IStock } from '@watsonserve/stock-base';
2
+ import { getMarketCloseTime } from './close-time.js';
3
+ import HistoryLoader from './history-loader.js';
4
+
5
+ export default class PriceLoader extends HistoryLoader {
6
+ // 匯率
7
+ private async __loadFx(ncs: EnCurrency[]) {
8
+ try {
9
+ const data = new Map<EnCurrency, number>();
10
+ for (const nc of ncs) {
11
+ const body = await this.get('https://finance.pae.baidu.com/selfselect/sug', {wd: `USD${nc}`, skip_login: 1, finClientType: 'pc'});
12
+ const fx = body.Result.stock[0];
13
+ data.set(nc, +fx.price);
14
+ }
15
+ return Object.fromEntries(data.entries());
16
+ } catch (err) {
17
+ throw new Error(`failed to load fx ${(err as Error).message}`);
18
+ }
19
+ }
20
+
21
+ async loadFx() {
22
+ const fxs = await this.__loadFx([EnCurrency.SGD, EnCurrency.HKD, EnCurrency.CNY]);
23
+ const stamp = getMarketCloseTime(EnMarket.FX);
24
+ return { ...fxs, stamp };
25
+ }
26
+
27
+ // 新加坡交易所全量股票
28
+ protected async sgx() {
29
+ try {
30
+ const body = await this.get('https://api.sgx.com/securities/v1.1/stocks', { params: 'nc,lt,ptd,n,o,h,l,vl,trading_time' });
31
+ const stockList = body.data.prices as any[];
32
+ return stockList.map(({ trading_time:t,nc,lt:c,n,o,h,l,vl:v }) => ({ t, nc,n,o,c,h,l,v } as IStock));
33
+ } catch (err) {
34
+ throw new Error(`failed to load sgx ${(err as Error).message}`);
35
+ }
36
+ }
37
+
38
+ // 香港交易所主板股票
39
+ protected async hkex() {
40
+ try {
41
+ const token = await this._loadHkexToken();
42
+ const qid = Date.now();
43
+ const callback = `jsonp_${qid}`;
44
+ const params = {
45
+ lang: 'chi',
46
+ token,
47
+ sort: 5,
48
+ order: 0,
49
+ all: 1,
50
+ subcat: 1,
51
+ market: 'MAIN',
52
+ qid,
53
+ callback,
54
+ };
55
+ const resp = await this.get('https://www1.hkex.com.hk/hkexwidget/data/getequityfilter', params);
56
+ const strJson = resp.substring(callback.length + 1, resp.length - 1);
57
+ const body = JSON.parse(strJson);
58
+ const { stocklist } = body.data;
59
+ return (stocklist as any[]).map(item => {
60
+ const { sym, nm: n, ls } = item;
61
+ return { nc: sym.padStart(5, '0'), n, c: +ls } as IStock;
62
+ });
63
+ } catch (err) {
64
+ throw new Error(`failed to load hkex ${(err as Error).message}`);
65
+ }
66
+ }
67
+
68
+ // 标普500成份股
69
+ private async __sp500(pn = 0, rn = 100) {
70
+ const params = {
71
+ financeType: 'index',
72
+ market: 'us',
73
+ code: 'SPX',
74
+ sortKey: 'marketValue',
75
+ sortType: 'desc',
76
+ style: 'tablelist',
77
+ finClientType: 'pc',
78
+ pn, rn,
79
+ };
80
+
81
+ try {
82
+ const resp = await this.get('https://finance.pae.baidu.com/sapi/v1/constituents', params);
83
+ const stocklist = resp.Result.list.body as any[];
84
+ return stocklist.map(item => {
85
+ const { code: nc, name: n, rawData } = item;
86
+ const { lastPx: c, volume: v, marketValue: mv } = rawData;
87
+ return { nc, n, c, v, mv } as IStock;
88
+ });
89
+ } catch (err) {
90
+ throw new Error(`failed to load s&p ${(err as Error).message}`);
91
+ }
92
+ }
93
+
94
+ protected async sp500() {
95
+ let pn = 0, rn = 100;
96
+ let sts: IStock[] = [];
97
+ while (true) {
98
+ const _sts = await this.__sp500(pn, rn);
99
+ const _len = _sts.length;
100
+ sts = sts.concat(_sts);
101
+ if (_len < rn) break;
102
+ pn += _len;
103
+ }
104
+ return sts;
105
+ }
106
+
107
+ async loadPrice(market: EnMarket) {
108
+ const stamp = getMarketCloseTime(EnMarket.USA);
109
+ let sts = Promise.resolve<IStock[]>([]);
110
+ switch (market) {
111
+ case EnMarket.HKEX:
112
+ sts = this.hkex();
113
+ break;
114
+ case EnMarket.SGX:
115
+ sts = this.sgx();
116
+ break;
117
+ case EnMarket.USA:
118
+ sts = this.sp500();
119
+ break;
120
+ default:
121
+ throw new Error(`unsupported market ${market}`);
122
+ }
123
+ return { sts: await sts, stamp };
124
+ }
125
+ }