@watsonserve/stock-base 0.0.29 → 0.1.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 +27 -0
- package/{index.js → dist/index.js} +14 -3
- package/dist/package.json +20 -0
- package/package.json +8 -6
- package/src/close-time.ts +124 -0
- package/src/dividend-loader.ts +195 -0
- package/src/helper.ts +37 -0
- package/src/history-loader.ts +114 -0
- package/src/index.ts +83 -0
- package/src/log.ts +49 -0
- package/src/price-loader.ts +125 -0
- package/src/stock.ts +104 -0
- package/src/web-request.ts +131 -0
- package/tsconfig.json +22 -0
- /package/{close-time.js → dist/close-time.js} +0 -0
- /package/{dividend-loader.js → dist/dividend-loader.js} +0 -0
- /package/{helper.js → dist/helper.js} +0 -0
- /package/{history-loader.js → dist/history-loader.js} +0 -0
- /package/{log.js → dist/log.js} +0 -0
- /package/{price-loader.js → dist/price-loader.js} +0 -0
- /package/{stock.js → dist/stock.js} +0 -0
- /package/{types → dist/types}/close-time.d.ts +0 -0
- /package/{types → dist/types}/dividend-loader.d.ts +0 -0
- /package/{types → dist/types}/helper.d.ts +0 -0
- /package/{types → dist/types}/history-loader.d.ts +0 -0
- /package/{types → dist/types}/index.d.ts +0 -0
- /package/{types → dist/types}/log.d.ts +0 -0
- /package/{types → dist/types}/price-loader.d.ts +0 -0
- /package/{types → dist/types}/stock.d.ts +0 -0
- /package/{types → dist/types}/web-request.d.ts +0 -0
- /package/{web-request.js → dist/web-request.js} +0 -0
package/build.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
2
|
+
|
|
3
|
+
(async function run() {
|
|
4
|
+
const txt = await readFile('./package.json', 'utf-8');
|
|
5
|
+
const pkg = JSON.parse(txt);
|
|
6
|
+
pkg.main = 'index.js';
|
|
7
|
+
pkg.types = './types/index.d.ts';
|
|
8
|
+
delete pkg.devDependencies;
|
|
9
|
+
delete pkg.packageManager;
|
|
10
|
+
pkg.publishConfig = { access: 'public' };
|
|
11
|
+
await writeFile('dist/package.json', JSON.stringify(pkg, null, 2));
|
|
12
|
+
})();
|
|
13
|
+
|
|
14
|
+
// const tsc = spawn('tsc', ['-p', 'tsconfig.json']);
|
|
15
|
+
|
|
16
|
+
// tsc.stdout.on('data', (data) => {
|
|
17
|
+
// console.log(`stdout: ${data}`);
|
|
18
|
+
// });
|
|
19
|
+
|
|
20
|
+
// tsc.stderr.on('data', (data) => {
|
|
21
|
+
// console.error(`stderr: ${data}`);
|
|
22
|
+
// });
|
|
23
|
+
|
|
24
|
+
// tsc.on('close', (code) => {
|
|
25
|
+
// console.log(`child process exited with code ${code}`);
|
|
26
|
+
// !code && run();
|
|
27
|
+
// });
|
|
@@ -7,6 +7,17 @@ export * from './helper.js';
|
|
|
7
7
|
function parseUTCDate(str) {
|
|
8
8
|
return ~~(Date.UTC(+str.slice(0, 4), +str.slice(4, 6) - 1, +str.slice(6, 8)) / 1000);
|
|
9
9
|
}
|
|
10
|
+
const HKDict = Object.entries({
|
|
11
|
+
'The first day of January': 'New Year\'s Day',
|
|
12
|
+
'Lunar New Year’s Day': 'Chinese New Year',
|
|
13
|
+
'Hong Kong Special Administrative Region': 'HK SAR',
|
|
14
|
+
'香港公眾假期 -': '',
|
|
15
|
+
'Hong Kong Public Holidays -': '',
|
|
16
|
+
'The day following': ''
|
|
17
|
+
});
|
|
18
|
+
function simpleTitle(title = '') {
|
|
19
|
+
return HKDict.reduce((str, [k, v]) => str.replace(k, v), title).trim();
|
|
20
|
+
}
|
|
10
21
|
export class StockLoader extends DividendLoader {
|
|
11
22
|
async loadHolidaysSG(year) {
|
|
12
23
|
const resp = await fetch(`https://www.mom.gov.sg/-/media/mom/documents/employment-practices/public-holidays/public-holidays-sg-${year}.ics`);
|
|
@@ -29,11 +40,11 @@ export class StockLoader extends DividendLoader {
|
|
|
29
40
|
}, []);
|
|
30
41
|
}
|
|
31
42
|
async loadHolidaysHK() {
|
|
32
|
-
const resp = await fetch('https://www.hkex.com.hk/News/HKEX-Calendar/Subscribe-Calendar?sc_lang=
|
|
43
|
+
const resp = await fetch('https://www.hkex.com.hk/News/HKEX-Calendar/Subscribe-Calendar?sc_lang=en');
|
|
33
44
|
const text = (await resp.text()).replace(/\r\n/g, '\n').replace(/\n\n/g, '\n'); // handle line folding
|
|
34
45
|
return text.split('\nEND:VEVENT\nBEGIN:VEVENT\n').reduce((pre, blk) => {
|
|
35
46
|
const ev = new Map(blk.split('\n').map(line => line.split(':', 2)));
|
|
36
|
-
if ('香港市場休市'
|
|
47
|
+
if (!['香港市場休市', 'Hong Kong Market is closed'].includes(ev.get('DESCRIPTION') || ''))
|
|
37
48
|
return pre;
|
|
38
49
|
const start = parseUTCDate(ev.get('DTSTART;VALUE=DATE') || '');
|
|
39
50
|
let end = parseUTCDate(ev.get('DTEND;VALUE=DATE') || '');
|
|
@@ -45,7 +56,7 @@ export class StockLoader extends DividendLoader {
|
|
|
45
56
|
lastOne.end = end;
|
|
46
57
|
}
|
|
47
58
|
else {
|
|
48
|
-
pre.push({ market: 'HK', title: ev.get('SUMMARY')
|
|
59
|
+
pre.push({ market: 'HK', title: simpleTitle(ev.get('SUMMARY')), start, end });
|
|
49
60
|
}
|
|
50
61
|
return pre;
|
|
51
62
|
}, []);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@watsonserve/stock-base",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"types": "./types/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "rm -rf ./dist && tsc && node ./build.mjs"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [],
|
|
12
|
+
"author": "",
|
|
13
|
+
"license": "ISC",
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"playwright": "^1.58.2"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@watsonserve/stock-base",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "index.js",
|
|
7
|
-
"types": "./types/index.d.ts",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/types/index.d.ts",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "rm -rf ./dist && tsc && node ./build.mjs"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [],
|
|
12
12
|
"author": "",
|
|
13
13
|
"license": "ISC",
|
|
14
|
+
"packageManager": "pnpm@10.33.0",
|
|
14
15
|
"peerDependencies": {
|
|
15
16
|
"playwright": "^1.58.2"
|
|
16
17
|
},
|
|
17
|
-
"
|
|
18
|
-
"
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^25.6.0",
|
|
20
|
+
"typescript": "^6.0.2"
|
|
19
21
|
}
|
|
20
|
-
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { EnMarket } from './stock.js';
|
|
2
|
+
|
|
3
|
+
export const A_MIN_MS = 60000;
|
|
4
|
+
export const A_HOUR_MS = 3600000;
|
|
5
|
+
export const A_DAY_MS = 86400000;
|
|
6
|
+
export const A_DAY_S = 86400;
|
|
7
|
+
|
|
8
|
+
// 夏令时:3月最后一个周日凌晨1AM开始夏令时,10月最后一个周日凌晨2AM结束夏令时
|
|
9
|
+
function isDST_ByUK(timestamp: number): boolean {
|
|
10
|
+
const date = new Date(timestamp);
|
|
11
|
+
const year = date.getUTCFullYear();
|
|
12
|
+
|
|
13
|
+
// 英国夏令时:3月最后一个周日开始,10月最后一个周日结束
|
|
14
|
+
const march = new Date(Date.UTC(year, 2, 31));
|
|
15
|
+
const october = new Date(Date.UTC(year, 9, 31));
|
|
16
|
+
|
|
17
|
+
// 夏令时开始和结束的日期
|
|
18
|
+
const dstStart = new Date(Date.UTC(year, 2, 31 - march.getUTCDay(), 1)); // 3月最后一个周日凌晨1点
|
|
19
|
+
const dstEnd = new Date(Date.UTC(year, 9, 31 - october.getUTCDay(), 1)); // 10月最后一个周日凌晨2点 = UTC时间凌晨1点
|
|
20
|
+
|
|
21
|
+
return timestamp >= dstStart.getTime() && timestamp < dstEnd.getTime();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 判断给定时间戳(毫秒)是否处于美国东部夏令时(EDT)
|
|
26
|
+
* 夏令时:3月第二个周日 02:00 开始(时钟拨快至 03:00),11月第一个周日 02:00 结束(时钟拨回至 01:00)
|
|
27
|
+
*/
|
|
28
|
+
function isDST_ByEST(timestamp: number): boolean {
|
|
29
|
+
const date = new Date(timestamp);
|
|
30
|
+
const year = date.getUTCFullYear();
|
|
31
|
+
|
|
32
|
+
// 计算3月1日是星期几(UTC)
|
|
33
|
+
const march = new Date(Date.UTC(year, 2, 1)); // UTC 时间:3月1日 00:00
|
|
34
|
+
const november = new Date(Date.UTC(year, 10, 1));
|
|
35
|
+
|
|
36
|
+
const marchDay = (7 - march.getUTCDay()) % 7 + 8; // 第二个周日(1~7是第一个周日,8~14是第二个)
|
|
37
|
+
const novemberDay = (7 - november.getUTCDay()) % 7 + 1; // 第一个周日
|
|
38
|
+
|
|
39
|
+
const dstStart = Date.UTC(year, 2, marchDay, 7); // UTC 时间 7点
|
|
40
|
+
const dstEnd = Date.UTC(year, 10, novemberDay, 6); // UTC 时间 6点
|
|
41
|
+
|
|
42
|
+
// 处于夏令时:在开始之后,结束之前
|
|
43
|
+
return timestamp >= dstStart && timestamp < dstEnd;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 获取市场 正式开盘时区偏移,单位分钟
|
|
48
|
+
* @param dst 是否夏令时
|
|
49
|
+
* @returns 分钟数
|
|
50
|
+
*/
|
|
51
|
+
function getOpenTimeByUTC(market: EnMarket, time: number): number {
|
|
52
|
+
switch (market) {
|
|
53
|
+
case EnMarket.JPX:
|
|
54
|
+
return 0; // 9:00
|
|
55
|
+
case EnMarket.CHN:
|
|
56
|
+
return 90; // 9:30
|
|
57
|
+
case EnMarket.HKEX:
|
|
58
|
+
return 90; // 9:30
|
|
59
|
+
case EnMarket.SGX:
|
|
60
|
+
return 60; // 9:00
|
|
61
|
+
case EnMarket.LSE:
|
|
62
|
+
return isDST_ByUK(time) ? 420 : 480; // 7:00 or 8:00
|
|
63
|
+
case EnMarket.USA:
|
|
64
|
+
return isDST_ByEST(time) ? 810 : 870; // 13:30 or 14:30
|
|
65
|
+
default:
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`Unsupported market: ${market}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 获取市场时区偏移,单位分钟
|
|
72
|
+
* @param dst 是否夏令时
|
|
73
|
+
* @returns 分钟数
|
|
74
|
+
*/
|
|
75
|
+
function getCloseTimeByUTC(market: EnMarket, time: number): number {
|
|
76
|
+
switch (market) {
|
|
77
|
+
case EnMarket.JPX:
|
|
78
|
+
return 390; // 6:30
|
|
79
|
+
case EnMarket.CHN:
|
|
80
|
+
return 420; // 7:00
|
|
81
|
+
case EnMarket.HKEX:
|
|
82
|
+
return 490; // 8:10
|
|
83
|
+
case EnMarket.SGX:
|
|
84
|
+
return 556; // 9:16
|
|
85
|
+
case EnMarket.LSE:
|
|
86
|
+
return isDST_ByUK(time) ? 930 : 990; // 15:30 or 16:30
|
|
87
|
+
case EnMarket.USA:
|
|
88
|
+
return isDST_ByEST(time) ? 1200 : 1260; // 20:00 or 21:00
|
|
89
|
+
case EnMarket.FX:
|
|
90
|
+
return isDST_ByEST(time) ? 1260 : 1320; // 21:00 or 22:00
|
|
91
|
+
default:
|
|
92
|
+
}
|
|
93
|
+
throw new Error(`Unsupported market: ${market}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 获取市场收盘时间戳,单位秒
|
|
98
|
+
* @param market 市场
|
|
99
|
+
* @param time 可选时间戳(毫秒),默认为当前时间,用于确定日期和夏令时状态
|
|
100
|
+
* @returns 市场收盘秒级时间戳
|
|
101
|
+
*/
|
|
102
|
+
export function getMarketCloseTime(market: EnMarket, time = Date.now()): number {
|
|
103
|
+
const d = new Date(time); // 确保 time 是有效的时间戳
|
|
104
|
+
switch (d.getUTCDay()) {
|
|
105
|
+
case 6:
|
|
106
|
+
time -= 86400000;
|
|
107
|
+
break;
|
|
108
|
+
case 0:
|
|
109
|
+
time -= 172800000;
|
|
110
|
+
break;
|
|
111
|
+
default:
|
|
112
|
+
}
|
|
113
|
+
const dayStart = (~~(time / A_DAY_MS)) * A_DAY_MS;
|
|
114
|
+
const closeTimestamp = dayStart + getCloseTimeByUTC(market, time) * A_MIN_MS;
|
|
115
|
+
let openTimestamp = 0;
|
|
116
|
+
if (EnMarket.FX !== market) {
|
|
117
|
+
openTimestamp = dayStart + getOpenTimeByUTC(market, time) * A_MIN_MS;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// after market open time, return close time of the day
|
|
121
|
+
if (openTimestamp < time) return ~~(closeTimestamp / 1000);
|
|
122
|
+
// return close time of the previous day
|
|
123
|
+
return getMarketCloseTime(market, closeTimestamp - A_DAY_MS);
|
|
124
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { log } from './log.js';
|
|
2
|
+
import PriceLoader from './price-loader.js';
|
|
3
|
+
import { ttm } from './helper.js';
|
|
4
|
+
import { type IDiv, type IDividend } from './stock.js';
|
|
5
|
+
|
|
6
|
+
function toDivPoint(fullNc: string, div: IDividend) {
|
|
7
|
+
const { annc, ex, paid, ...another } = div;
|
|
8
|
+
return {
|
|
9
|
+
...another,
|
|
10
|
+
nc: fullNc,
|
|
11
|
+
annc: ~~(annc.getTime() / 1000),
|
|
12
|
+
ex: ~~(ex.getTime() / 1000),
|
|
13
|
+
paid: ~~(paid.getTime() / 1000)
|
|
14
|
+
} as IDiv;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const rxDate = /\d+/g;
|
|
18
|
+
|
|
19
|
+
function dateCNtoDate(str: string) {
|
|
20
|
+
const ymd = (str as string).match(rxDate)?.slice(0, 3).map(i => +i);
|
|
21
|
+
if (!ymd) return null;
|
|
22
|
+
if ('number' === typeof ymd?.[1]) {
|
|
23
|
+
ymd[1] -= 1;
|
|
24
|
+
}
|
|
25
|
+
return new Date(Date.UTC.apply(null, ymd as any));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class DividendLoader extends PriceLoader {
|
|
29
|
+
private async __laodIbmCode(nc: string) {
|
|
30
|
+
const resp = await super.get(`https://api.sgx.com/marketmetadata/v2?stock-code=${nc}`);
|
|
31
|
+
return resp.data?.[0]?.ibmCode || '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private async __loadHKDividend(nc: string) {
|
|
35
|
+
const token = await super._loadHkexToken();
|
|
36
|
+
const qid = Date.now();
|
|
37
|
+
const callback = `jsonp_${qid}`;
|
|
38
|
+
const params = { sym: +nc, token, lang: 'chi', recperpage: 5, page: 1, qid, callback };
|
|
39
|
+
|
|
40
|
+
const resp: string = await super.get('https://www1.hkex.com.hk/hkexwidget/data/getequityentitlement', params);
|
|
41
|
+
const strJson = resp.substring(callback.length + 1, resp.length - 1);
|
|
42
|
+
const { entitlementlist } = JSON.parse(strJson).data;
|
|
43
|
+
|
|
44
|
+
const list: IDividend[] = [];
|
|
45
|
+
for (const item of entitlementlist) {
|
|
46
|
+
const { detail, announcement_date, ex_date, payment_date, announcement_URL } = item;
|
|
47
|
+
|
|
48
|
+
const annc = dateCNtoDate(announcement_date);
|
|
49
|
+
const ex = dateCNtoDate(ex_date);
|
|
50
|
+
const paid = dateCNtoDate(payment_date);
|
|
51
|
+
|
|
52
|
+
const divid = Array.from<string>(detail.match(/(USD|HKD|GBP) (\d+\.\d+)/g) || [])
|
|
53
|
+
.reduce<Record<string, any>>((pre, item) => {
|
|
54
|
+
const [currency, amount] = item.split(' ');
|
|
55
|
+
pre[currency] = { currency, amount: +amount };
|
|
56
|
+
return pre;
|
|
57
|
+
}, {});
|
|
58
|
+
|
|
59
|
+
list.push({ ...(divid['HKD'] || divid['USD'] || divid['GBP']), annc, ex, paid, announcement_URL });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return [ttm<IDividend>('ex', list), []];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async __loadSGDividend(nc: string) {
|
|
66
|
+
const ibmcode = await this.__laodIbmCode(nc);
|
|
67
|
+
const params = {
|
|
68
|
+
pagesize: 15,
|
|
69
|
+
pagestart: 0,
|
|
70
|
+
ibmcode,
|
|
71
|
+
params: 'id,anncType,dateAnnc,exDate,name,particulars,recDate,datePaid',
|
|
72
|
+
order: 'desc',
|
|
73
|
+
orderBy: 'dateAnnc'
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const { data } = await super.get('https://api.sgx.com/corporateactions/v1.0', params);
|
|
77
|
+
|
|
78
|
+
const bonus: IDividend[] = [];
|
|
79
|
+
const dividend: IDividend[] = [];
|
|
80
|
+
|
|
81
|
+
for (const item of data as any[]) {
|
|
82
|
+
const { anncType, particulars, dateAnnc, exDate, recDate, datePaid } = item;
|
|
83
|
+
const annc = new Date(dateAnnc);
|
|
84
|
+
const ex = new Date(exDate);
|
|
85
|
+
const rec = new Date(recDate); // 登记日
|
|
86
|
+
const paid = new Date(datePaid);
|
|
87
|
+
|
|
88
|
+
if ('DIVIDEND' === anncType) {
|
|
89
|
+
const amount = +particulars.match(/SGD (\d+\.\d+)/)?.[1] || 0;
|
|
90
|
+
const lstIdx = dividend.length - 1;
|
|
91
|
+
if (dividend[lstIdx]?.ex.getTime() === ex.getTime() && amount) {
|
|
92
|
+
dividend[lstIdx].amount! += amount;
|
|
93
|
+
} else {
|
|
94
|
+
dividend.push({ currency: 'SGD', amount, annc, ex, rec, paid } as IDividend);
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if ('BONUS' === anncType) { // particulars: 'Ratio: 10:1'
|
|
99
|
+
const ratio = particulars.match(/Ratio: (\d+:\d+)/);
|
|
100
|
+
const [foo, bar] = ratio[1].split(':');
|
|
101
|
+
|
|
102
|
+
bonus.push({ currency: 'SGD', ratio: 1+(+bar)/(+foo), annc, ex, rec, paid } as IDividend);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return [ttm<IDividend>('ex', dividend), bonus];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async __loadUSDividend(nc: string) {
|
|
111
|
+
const params = {
|
|
112
|
+
reportName: 'RPT_USF10_INFO_DIVIDEND',
|
|
113
|
+
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',
|
|
114
|
+
// filter: `(SECUCODE="${nc}.N")`,
|
|
115
|
+
filter: `(SECURITY_CODE="${nc.replace('.', '_')}")`,
|
|
116
|
+
pageNumber: 1,
|
|
117
|
+
pageSize: 200,
|
|
118
|
+
sortTypes: -1,
|
|
119
|
+
sortColumns: 'EX_DIVIDEND_DATE',
|
|
120
|
+
source: 'SECURITIES',
|
|
121
|
+
client: 'PC',
|
|
122
|
+
v: Math.random().toString().substring(2).padStart(17, '0')
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const strResp = await super.get('https://datacenter.eastmoney.com/securities/api/data/v1/get', params);
|
|
126
|
+
const data = (JSON.parse(strResp).result?.data || [])as any[];
|
|
127
|
+
|
|
128
|
+
const dividend: IDividend[] = [];
|
|
129
|
+
|
|
130
|
+
for (const item of data) {
|
|
131
|
+
const { ASSIGN_TYPE, PLAN_EXPLAIN, NOTICE_DATE, EX_DIVIDEND_DATE, BONUS_PAY_DATE } = item;
|
|
132
|
+
|
|
133
|
+
if ('Cash' === ASSIGN_TYPE || 'Special cash' === ASSIGN_TYPE) {
|
|
134
|
+
const amount = +PLAN_EXPLAIN.match(/(\d*\.?\d+)美元/)[1]; // 每1股派0.82美元股息
|
|
135
|
+
if (!amount) console.warn(item);
|
|
136
|
+
dividend.push({
|
|
137
|
+
currency: 'USD',
|
|
138
|
+
amount,
|
|
139
|
+
annc: new Date(NOTICE_DATE),
|
|
140
|
+
ex: new Date(EX_DIVIDEND_DATE),
|
|
141
|
+
paid: new Date(BONUS_PAY_DATE),
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return [ttm<IDividend>('ex', dividend), []];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async loadDividend(fullNc: string) {
|
|
151
|
+
const nc = fullNc.substring(0, fullNc.length - 3);
|
|
152
|
+
const market = fullNc.substring(fullNc.length - 2);
|
|
153
|
+
let data: IDividend[][];
|
|
154
|
+
log.info(`start loading ${fullNc} dividend...`);
|
|
155
|
+
|
|
156
|
+
switch (market) {
|
|
157
|
+
case 'SG':
|
|
158
|
+
data = await this.__loadSGDividend(nc);
|
|
159
|
+
break;
|
|
160
|
+
case 'HK':
|
|
161
|
+
data = await this.__loadHKDividend(nc);
|
|
162
|
+
break;
|
|
163
|
+
case 'US':
|
|
164
|
+
data = await this.__loadUSDividend(nc);
|
|
165
|
+
break;
|
|
166
|
+
default:
|
|
167
|
+
throw new Error(`Unsupported market: ${market}`);
|
|
168
|
+
}
|
|
169
|
+
const [div, bonus] = data;
|
|
170
|
+
const dps = div.map(item => toDivPoint(fullNc, item));
|
|
171
|
+
const bps = bonus.map(item => toDivPoint(fullNc, item));
|
|
172
|
+
|
|
173
|
+
log.info(`finished loading ${fullNc} dividend`);
|
|
174
|
+
|
|
175
|
+
return { fullNc, nc, dps, bps };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async loadDividends(ncs: string[]) {
|
|
179
|
+
const ign: string[] = [];
|
|
180
|
+
let points: IDiv[] = [];
|
|
181
|
+
const divPool: Record<string, IDiv[]> = {};
|
|
182
|
+
|
|
183
|
+
const divs = await Promise.all(ncs.map(fullNc => this.loadDividend(fullNc)));
|
|
184
|
+
for (const { fullNc, nc, dps, bps } of divs) {
|
|
185
|
+
if (!dps.length && !bps.length) {
|
|
186
|
+
ign.push(fullNc);
|
|
187
|
+
} else {
|
|
188
|
+
points = points.concat(dps, bps);
|
|
189
|
+
}
|
|
190
|
+
divPool[nc] = ttm('ex', dps);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { ign, points, divPool };
|
|
194
|
+
}
|
|
195
|
+
}
|
package/src/helper.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { EnMarket } from './stock.js';
|
|
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 function groupBy<T extends Record<string, any>>(key: string, list: T[], delKey = false) {
|
|
18
|
+
return list.reduce<Record<string, T[]>>((pre, item) => {
|
|
19
|
+
const foo = item[key];
|
|
20
|
+
const fooList = pre[foo] || [];
|
|
21
|
+
pre[foo] = fooList;
|
|
22
|
+
if (delKey) delete item[key];
|
|
23
|
+
fooList.push(item);
|
|
24
|
+
return pre;
|
|
25
|
+
}, {});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ttm<T>(by: string, list: any[]): T[] {
|
|
29
|
+
if (!Array.isArray(list) || !list.length) return [];
|
|
30
|
+
|
|
31
|
+
const latest = new Date(list[0][by]);
|
|
32
|
+
const sinceTime = Date.UTC(latest.getUTCFullYear()-1, latest.getUTCMonth());
|
|
33
|
+
return list.filter(item => {
|
|
34
|
+
const val = new Date(item[by]);
|
|
35
|
+
return sinceTime < Date.UTC(val.getUTCFullYear(), val.getUTCMonth());
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { EnMarket, IFx, type IStockPoint } from './stock.js';
|
|
3
|
+
import { getMarketCloseTime } from './close-time.js';
|
|
4
|
+
import WebRequest from './web-request.js';
|
|
5
|
+
import { sleep, splitFullNc } from './helper.js';
|
|
6
|
+
|
|
7
|
+
// 历史数据
|
|
8
|
+
export default class HistoryLoader extends WebRequest {
|
|
9
|
+
private async __getHKOrUSAHistory(market: EnMarket, nc: string): Promise<IStockPoint[]> {
|
|
10
|
+
let cntry = EnMarket.USA === market ? 'us' : 'hk';
|
|
11
|
+
await this.goto(`https://quote.eastmoney.com/${cntry}/${nc}.html`);
|
|
12
|
+
|
|
13
|
+
let secid = `116.${nc}`;
|
|
14
|
+
if (EnMarket.USA === market) {
|
|
15
|
+
await sleep(500);
|
|
16
|
+
// secid = await this.get_em_code(nc);
|
|
17
|
+
secid = await this.read('quotecode');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const params = {
|
|
21
|
+
fields1: 'f1,f2,f3,f4,f5,f6',
|
|
22
|
+
fields2: 'f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61',
|
|
23
|
+
klt: 101, // 60 hour, 101 day, 102 week, 103 month
|
|
24
|
+
fqt: 0,
|
|
25
|
+
secid,
|
|
26
|
+
beg: 0,
|
|
27
|
+
end: 20500000,
|
|
28
|
+
// ut: 'bd1d9ddb04089700cf9c27f6f7426281',
|
|
29
|
+
// lmt: 1000000,
|
|
30
|
+
};
|
|
31
|
+
if (this.isDebug) {
|
|
32
|
+
await sleep(5000);
|
|
33
|
+
}
|
|
34
|
+
const resp = await super.get('https://push2his.eastmoney.com/api/qt/stock/kline/get', params);
|
|
35
|
+
// const resp = JSON.parse(await fs.readFile(path.join(import.meta.dirname, 'hsbc.json'), 'utf-8'));
|
|
36
|
+
const { klines } = resp.data;
|
|
37
|
+
return (klines as string[]).map(line => {
|
|
38
|
+
const [date, open, close, hight, low, vol] = line.split(',');
|
|
39
|
+
return {
|
|
40
|
+
market,
|
|
41
|
+
timestamp: getMarketCloseTime(market, new Date(`${date}T15:00:00Z`).getTime()),
|
|
42
|
+
st: { nc, n: '', o:+open, c:+close, h:+hight, l:+low, v:+vol }
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async __getSGXHistory(nc: string): Promise<IStockPoint[]> {
|
|
48
|
+
const rsp = await super.get(`https://api.sgx.com/securities/v1.1/charts/historic/stocks/code/${nc}/5y`, { params: 'trading_time,vl,lt' });
|
|
49
|
+
const { historic } = rsp.data;
|
|
50
|
+
return (historic as any[]).map(st => {
|
|
51
|
+
const { trading_time, vl: v, lt: c } = st;
|
|
52
|
+
const fullYear = trading_time.substring(0, 4);
|
|
53
|
+
const month = trading_time.substring(4, 6);
|
|
54
|
+
const date = trading_time.substring(6, 8);
|
|
55
|
+
const timestamp = ~~(new Date(`${fullYear}-${month}-${date}T09:16:00Z`).getTime() / 1000);
|
|
56
|
+
return { market: EnMarket.SGX, timestamp, st: { nc, n: '', o: 0, c, h: 0, l: 0, v } };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async loadFxHistory() {
|
|
61
|
+
const fxs = await Promise.all(['USDHKD', 'USDSGD', 'USDCNY'].map(
|
|
62
|
+
code => this.get(
|
|
63
|
+
'https://finance.pae.baidu.com/vapi/v1/getquotation',
|
|
64
|
+
{ group: 'huilv_kline', ktype: 'day', code, finClientType: 'pc' }
|
|
65
|
+
)
|
|
66
|
+
));
|
|
67
|
+
const [hk, sg, cn] = fxs.map(body => (body.Result.newMarketData.marketData as string).split(';'));
|
|
68
|
+
const base = new Map(hk.map(line => {
|
|
69
|
+
const [_0, date, _1, c] = line.split(',');
|
|
70
|
+
const stamp = getMarketCloseTime(EnMarket.FX, new Date(`${date}T15:00:00Z`).getTime());
|
|
71
|
+
return [stamp, { date, HKD: +c } as Partial<IFx>];
|
|
72
|
+
}));
|
|
73
|
+
sg.forEach(line => {
|
|
74
|
+
const [_0, date, _1, c] = line.split(',');
|
|
75
|
+
const stamp = getMarketCloseTime(EnMarket.FX, new Date(`${date}T15:00:00Z`).getTime());
|
|
76
|
+
const fx = base.get(stamp) || { date } as Partial<IFx>;
|
|
77
|
+
fx.SGD = +c;
|
|
78
|
+
base.set(stamp, fx);
|
|
79
|
+
});
|
|
80
|
+
cn.map(line => {
|
|
81
|
+
const [_0, date, _1, c] = line.split(',');
|
|
82
|
+
const stamp = getMarketCloseTime(EnMarket.FX, new Date(`${date}T15:00:00Z`).getTime());
|
|
83
|
+
const fx = base.get(stamp) || { date } as Partial<IFx>;
|
|
84
|
+
fx.CNY = +c;
|
|
85
|
+
base.set(stamp, fx);
|
|
86
|
+
});
|
|
87
|
+
const result: IFx[] = [];
|
|
88
|
+
const remain: IFx[] = [];
|
|
89
|
+
[...base.entries()].forEach(([stamp, fx]) => {
|
|
90
|
+
const _fx = { stamp, ...fx } as IFx;
|
|
91
|
+
if (!fx.HKD || !fx.SGD || !fx.CNY) {
|
|
92
|
+
remain.push(_fx);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
result.push(_fx);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return { result, remain };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
loadHistory(fullNc: string) {
|
|
102
|
+
const { nc, market } = splitFullNc(fullNc);
|
|
103
|
+
|
|
104
|
+
switch (market) {
|
|
105
|
+
case EnMarket.SGX:
|
|
106
|
+
return this.__getSGXHistory(nc);
|
|
107
|
+
case EnMarket.HKEX:
|
|
108
|
+
case EnMarket.USA:
|
|
109
|
+
return this.__getHKOrUSAHistory(market, nc);
|
|
110
|
+
default:
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`Unsupported market: ${market}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { A_DAY_S } from './close-time.js';
|
|
2
|
+
import { DividendLoader } from './dividend-loader.js';
|
|
3
|
+
import { IHoliday } from './stock.js';
|
|
4
|
+
|
|
5
|
+
export * from './close-time.js';
|
|
6
|
+
export * from './stock.js';
|
|
7
|
+
export * from './log.js';
|
|
8
|
+
export * from './helper.js';
|
|
9
|
+
|
|
10
|
+
function parseUTCDate(str: string) {
|
|
11
|
+
return ~~(Date.UTC(+str.slice(0, 4), +str.slice(4, 6) - 1, +str.slice(6, 8)) / 1000);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const HKDict = Object.entries({
|
|
15
|
+
'The first day of January': 'New Year\'s Day',
|
|
16
|
+
'Lunar New Year’s Day': 'Chinese New Year',
|
|
17
|
+
'Hong Kong Special Administrative Region': 'HK SAR',
|
|
18
|
+
'香港公眾假期 -': '',
|
|
19
|
+
'Hong Kong Public Holidays -': '',
|
|
20
|
+
'The day following': ''
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function simpleTitle(title = '') {
|
|
24
|
+
return HKDict.reduce((str, [k, v]) => str.replace(k, v), title).trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class StockLoader extends DividendLoader {
|
|
28
|
+
async loadHolidaysSG(year: number) {
|
|
29
|
+
const resp = await fetch(`https://www.mom.gov.sg/-/media/mom/documents/employment-practices/public-holidays/public-holidays-sg-${year}.ics`);
|
|
30
|
+
const text = (await resp.text()).replace(/\r\n/g, '\n').replace(/\n\n/g, '\n'); // handle line folding
|
|
31
|
+
|
|
32
|
+
return text.split('\nEND:VEVENT\nBEGIN:VEVENT\n').reduce<IHoliday[]>((pre, blk) => {
|
|
33
|
+
const ev = new Map(blk.split('\n').map(line => line.split(':', 2) as [string, string]));
|
|
34
|
+
let start = parseUTCDate(ev.get('DTSTART;VALUE=DATE') || '');
|
|
35
|
+
let end = parseUTCDate(ev.get('DTEND;VALUE=DATE') || '');
|
|
36
|
+
if (start === end) {
|
|
37
|
+
end += A_DAY_S;
|
|
38
|
+
}
|
|
39
|
+
// recording to SG calendar, we will move it to next day(Monday, 1) if holiday is Sunday(0)
|
|
40
|
+
if (!new Date(start * 1000).getUTCDay()) {
|
|
41
|
+
end += A_DAY_S;
|
|
42
|
+
}
|
|
43
|
+
const firstOne = pre[0];
|
|
44
|
+
const title = ev.get('SUMMARY') || '';
|
|
45
|
+
|
|
46
|
+
(firstOne && end === firstOne.start) ? Object.assign(firstOne, { title, start }) : pre.unshift({ market: 'SG', title, start, end });
|
|
47
|
+
return pre;
|
|
48
|
+
}, []);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async loadHolidaysHK() {
|
|
52
|
+
const resp = await fetch('https://www.hkex.com.hk/News/HKEX-Calendar/Subscribe-Calendar?sc_lang=en');
|
|
53
|
+
const text = (await resp.text()).replace(/\r\n/g, '\n').replace(/\n\n/g, '\n'); // handle line folding
|
|
54
|
+
|
|
55
|
+
return text.split('\nEND:VEVENT\nBEGIN:VEVENT\n').reduce<IHoliday[]>((pre, blk) => {
|
|
56
|
+
const ev = new Map(blk.split('\n').map(line => line.split(':', 2) as [string, string]));
|
|
57
|
+
|
|
58
|
+
if (!['香港市場休市', 'Hong Kong Market is closed'].includes(ev.get('DESCRIPTION') || '')) return pre;
|
|
59
|
+
|
|
60
|
+
const start = parseUTCDate(ev.get('DTSTART;VALUE=DATE') || '');
|
|
61
|
+
let end = parseUTCDate(ev.get('DTEND;VALUE=DATE') || '');
|
|
62
|
+
if (start === end) {
|
|
63
|
+
end += A_DAY_S;
|
|
64
|
+
}
|
|
65
|
+
const lastOne = pre[pre.length - 1];
|
|
66
|
+
if (lastOne && start === lastOne.end) {
|
|
67
|
+
lastOne.end = end;
|
|
68
|
+
} else {
|
|
69
|
+
pre.push({ market: 'HK', title: simpleTitle(ev.get('SUMMARY')), start, end });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return pre;
|
|
73
|
+
}, []);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async loadHolidays() {
|
|
77
|
+
const [holidaysSG, holidaysHK] = await Promise.all([
|
|
78
|
+
this.loadHolidaysSG(new Date().getUTCFullYear()),
|
|
79
|
+
this.loadHolidaysHK()
|
|
80
|
+
]);
|
|
81
|
+
return holidaysSG.concat(holidaysHK);
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { appendFile } from 'fs/promises';
|
|
2
|
+
|
|
3
|
+
export enum EnLogLevel {
|
|
4
|
+
DEBUG = 7,
|
|
5
|
+
INFO = 6,
|
|
6
|
+
WARN = 4,
|
|
7
|
+
ERROR = 3,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function findAppName() {
|
|
11
|
+
const __dirPath = process.argv.find(arg => arg.endsWith('.js'))?.split('/').reverse().slice(1) || [];
|
|
12
|
+
if (['release', 'src', 'dist'].includes(__dirPath[0])) {
|
|
13
|
+
__dirPath.shift();
|
|
14
|
+
}
|
|
15
|
+
return __dirPath[0];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class Log {
|
|
19
|
+
private _logDir = `/var/log/${process.env.APP_NAME || findAppName()}`;
|
|
20
|
+
public level = EnLogLevel.INFO;
|
|
21
|
+
|
|
22
|
+
constructor(logDir = '') {
|
|
23
|
+
if (!logDir) return;
|
|
24
|
+
this._logDir = logDir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
log(lev: EnLogLevel, msg: string) {
|
|
28
|
+
if (lev > this.level) return Promise.resolve();
|
|
29
|
+
return appendFile(`${this._logDir}/${EnLogLevel[lev].toLowerCase()}.log`, `[${new Date().toISOString()}] ${msg}\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
debug(msg: string) {
|
|
33
|
+
return this.log(EnLogLevel.DEBUG, msg);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
info(msg: string) {
|
|
37
|
+
return this.log(EnLogLevel.INFO, msg);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
warn(msg: string) {
|
|
41
|
+
return this.log(EnLogLevel.WARN, msg);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
error(msg: string | Error) {
|
|
45
|
+
return this.log(EnLogLevel.ERROR, msg.toString());
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const log = new Log();
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { EnCurrency, EnMarket, type IFx, IStock } from './stock.js';
|
|
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 } as IFx;
|
|
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(market);
|
|
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
|
+
}
|
package/src/stock.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export enum EnMarket {
|
|
2
|
+
FX = 'FX',
|
|
3
|
+
JPX = 'JPX',
|
|
4
|
+
CHN = 'CHN',
|
|
5
|
+
HKEX = 'HKEX',
|
|
6
|
+
SGX = 'SGX',
|
|
7
|
+
LSE = 'LSE',
|
|
8
|
+
USA = 'USA',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export enum EnCurrency {
|
|
12
|
+
SGD = 'SGD',
|
|
13
|
+
USD = 'USD',
|
|
14
|
+
HKD = 'HKD',
|
|
15
|
+
CNY = 'CNY',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface IFx {
|
|
19
|
+
stamp: number;
|
|
20
|
+
[EnCurrency.SGD]: number;
|
|
21
|
+
[EnCurrency.HKD]: number;
|
|
22
|
+
[EnCurrency.CNY]: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface IStockBase {
|
|
26
|
+
c: number;
|
|
27
|
+
o?: number;
|
|
28
|
+
h?: number;
|
|
29
|
+
l?: number;
|
|
30
|
+
v?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface IStockNum {
|
|
34
|
+
boll_u: number;
|
|
35
|
+
boll_l: number;
|
|
36
|
+
ma_5: number;
|
|
37
|
+
ma_20: number;
|
|
38
|
+
ma_60: number;
|
|
39
|
+
ma_250: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface IStock extends IStockBase, Partial<IStockNum> {
|
|
43
|
+
nc: string;
|
|
44
|
+
n: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface IStockPoint {
|
|
48
|
+
market: string;
|
|
49
|
+
timestamp: number;
|
|
50
|
+
st: IStock;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface IDividend {
|
|
54
|
+
currency: string;
|
|
55
|
+
amount?: number;
|
|
56
|
+
ratio?: number;
|
|
57
|
+
annc: Date;
|
|
58
|
+
ex: Date;
|
|
59
|
+
paid: Date;
|
|
60
|
+
announcement_URL?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
export interface IDiv {
|
|
65
|
+
nc: string;
|
|
66
|
+
annc: number;
|
|
67
|
+
ex: number;
|
|
68
|
+
paid: number;
|
|
69
|
+
currency: string;
|
|
70
|
+
amount: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface IHoliday {
|
|
74
|
+
market: string;
|
|
75
|
+
title: string;
|
|
76
|
+
start: number;
|
|
77
|
+
end: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class Stock {
|
|
81
|
+
public nc: string;
|
|
82
|
+
public name: string;
|
|
83
|
+
public currency: EnCurrency;
|
|
84
|
+
public o: number = 0;
|
|
85
|
+
public c: number = 0;
|
|
86
|
+
public h: number = 0;
|
|
87
|
+
public l: number = 0;
|
|
88
|
+
public v: number = 0;
|
|
89
|
+
|
|
90
|
+
constructor(code: string, name = '', currency = EnCurrency.USD) {
|
|
91
|
+
this.nc = code;
|
|
92
|
+
this.name = name;
|
|
93
|
+
this.currency = currency;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
setInfo(name: string, close: number, open = 0, height = 0, low = 0, vol = 0) {
|
|
97
|
+
this.name = name;
|
|
98
|
+
this.o = open;
|
|
99
|
+
this.c = close;
|
|
100
|
+
this.h = height;
|
|
101
|
+
this.l = low;
|
|
102
|
+
this.v = vol;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
|
2
|
+
import { A_DAY_MS } from './close-time.js';
|
|
3
|
+
|
|
4
|
+
export default class WebRequest {
|
|
5
|
+
private static HKEX_TOKEN = { token: '', t: 0 };
|
|
6
|
+
private static browser?: Browser;
|
|
7
|
+
private static context?: BrowserContext;
|
|
8
|
+
private static pageCnt = 0;
|
|
9
|
+
private static __promise?: Promise<void>;
|
|
10
|
+
protected static isDebug: boolean = false;
|
|
11
|
+
protected page?: Page;
|
|
12
|
+
|
|
13
|
+
private static async __getInstance(debug = false) {
|
|
14
|
+
if (WebRequest.context) return;
|
|
15
|
+
// 1. 启动 Chromium,禁用 web security(解除 CORS)
|
|
16
|
+
WebRequest.browser = await chromium.launch({
|
|
17
|
+
headless: !debug,
|
|
18
|
+
channel: 'msedge',
|
|
19
|
+
args: [
|
|
20
|
+
'--disable-web-security',
|
|
21
|
+
'--disable-features=IsolateOrigins,site-per-process',
|
|
22
|
+
'--no-sandbox',
|
|
23
|
+
'--disable-setuid-sandbox'
|
|
24
|
+
]
|
|
25
|
+
});
|
|
26
|
+
WebRequest.context = await WebRequest.browser.newContext({});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get isDebug() {
|
|
30
|
+
return WebRequest.isDebug;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static async init(isDebug = false) {
|
|
34
|
+
WebRequest.isDebug = isDebug;
|
|
35
|
+
if (!WebRequest.__promise)
|
|
36
|
+
WebRequest.__promise = WebRequest.__getInstance(isDebug);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
constructor() {}
|
|
40
|
+
|
|
41
|
+
async mount() {
|
|
42
|
+
await WebRequest.__promise;
|
|
43
|
+
|
|
44
|
+
this.page = await WebRequest.context!.newPage();
|
|
45
|
+
WebRequest.pageCnt++;
|
|
46
|
+
await this.page.goto('about:blank');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private static async destroy() {
|
|
50
|
+
WebRequest.pageCnt--;
|
|
51
|
+
if (0 < WebRequest.pageCnt) return;
|
|
52
|
+
|
|
53
|
+
await WebRequest.context?.close();
|
|
54
|
+
await WebRequest.browser?.close();
|
|
55
|
+
WebRequest.context = undefined;
|
|
56
|
+
WebRequest.browser = undefined;
|
|
57
|
+
WebRequest.pageCnt = 0;
|
|
58
|
+
WebRequest.__promise = undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async destroy() {
|
|
62
|
+
if (!this.page) return;
|
|
63
|
+
|
|
64
|
+
await this.page.close();
|
|
65
|
+
this.page = undefined;
|
|
66
|
+
return WebRequest.destroy();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
protected fetch(url: string, method: string = 'GET', params?: Record<string, any>, headers?: Record<string, any>) {
|
|
70
|
+
method = method.toUpperCase();
|
|
71
|
+
let data = '';
|
|
72
|
+
if (params) {
|
|
73
|
+
if (['HEAD', 'GET'].includes(method)) {
|
|
74
|
+
const uri = new URL(url);
|
|
75
|
+
const urlParams = Object.fromEntries(uri.searchParams);
|
|
76
|
+
|
|
77
|
+
uri.search = new URLSearchParams(Object.assign(urlParams, params)).toString();
|
|
78
|
+
url = uri.toString();
|
|
79
|
+
} else {
|
|
80
|
+
const ct = (headers?.['Content-Type'] || '').split(';')[0];
|
|
81
|
+
switch (ct) {
|
|
82
|
+
case 'application/json':
|
|
83
|
+
data = JSON.stringify(params);
|
|
84
|
+
break;
|
|
85
|
+
default:
|
|
86
|
+
data = new URLSearchParams(params).toString();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return this.page!.evaluate(async (conf: any) => {
|
|
93
|
+
const resp = await fetch(conf.url, {
|
|
94
|
+
method: conf.method,
|
|
95
|
+
credentials: 'include', // 如果需要 Cookie
|
|
96
|
+
headers: conf.headers,
|
|
97
|
+
body: conf.data || undefined,
|
|
98
|
+
});
|
|
99
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
100
|
+
const ct = resp.headers.get('Content-Type')?.split(';')[0] || '';
|
|
101
|
+
if ('application/json' === ct) return resp.json();
|
|
102
|
+
if (ct.startsWith('text/')) return resp.text();
|
|
103
|
+
return resp.arrayBuffer();
|
|
104
|
+
}, { method, url, headers, data });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
protected get(url: string, params?: Record<string, any>, headers?: Record<string, any>) {
|
|
108
|
+
return this.fetch(url, 'GET', params, headers);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
protected goto(pageUrl: string) {
|
|
112
|
+
return this.page!.goto(pageUrl);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
protected read<T>(key: string) : Promise<T> {
|
|
116
|
+
return this.page!.evaluate(async (conf: any) => {
|
|
117
|
+
return (window as any)[conf.key];
|
|
118
|
+
}, { key });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
protected async _loadHkexToken() {
|
|
122
|
+
const expiry = Date.now() - A_DAY_MS;
|
|
123
|
+
if (WebRequest.HKEX_TOKEN?.t > expiry) return WebRequest.HKEX_TOKEN.token;
|
|
124
|
+
|
|
125
|
+
const doc = await this.get('https://www.hkex.com.hk/Market-Data/Securities-Prices/Equities?sc_lang=zh-HK');
|
|
126
|
+
const token = doc.split('//return "Base64-AES-Encrypted-Token";')[1].split('";')[0].split('"')[1];
|
|
127
|
+
const decodedToken = decodeURIComponent(token);
|
|
128
|
+
WebRequest.HKEX_TOKEN = { token: decodedToken, t: Date.now() };
|
|
129
|
+
return decodedToken;
|
|
130
|
+
}
|
|
131
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "esnext", // 👈 关键:编译成 CommonJS 模块
|
|
4
|
+
"module": "NodeNext", // 👈 关键:Node.js 默认用 CommonJS
|
|
5
|
+
"lib": ["es2022", "dom"], // 包含 includes + 浏览器 API
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"allowSyntheticDefaultImports": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"moduleResolution": "NodeNext", // 👈 关键:用 Node.js 模块解析
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"sourceMap": false,
|
|
14
|
+
"outDir": "./dist",
|
|
15
|
+
"rootDir": "./src",
|
|
16
|
+
"declaration": true,
|
|
17
|
+
"declarationDir": "./dist/types",
|
|
18
|
+
"types": ["node", "playwright"] // 显式包含类型
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/*.ts"], // 包含根目录 .ts 文件
|
|
21
|
+
"exclude": ["node_modules", "build.mjs"]
|
|
22
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/{log.js → dist/log.js}
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|