@watsonserve/stock-loader 0.0.2-alpha.1 → 0.0.2-alpha.2
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/close-time.js +110 -0
- package/dividend-loader.js +148 -0
- package/helper.js +23 -0
- package/history-loader.js +66 -0
- package/index.js +57 -0
- package/package.json +7 -9
- package/price-loader.js +122 -0
- package/types/close-time.d.ts +11 -0
- package/types/dividend-loader.d.ts +14 -0
- package/types/helper.d.ts +15 -0
- package/types/history-loader.d.ts +7 -0
- package/types/index.d.ts +13 -0
- package/types/price-loader.d.ts +16 -0
- package/types/web-request.d.ts +22 -0
- package/web-request.js +119 -0
- package/build.mjs +0 -28
- package/src/close-time.ts +0 -123
- package/src/dividend-loader.ts +0 -196
- package/src/helper.ts +0 -36
- package/src/history-loader.ts +0 -72
- package/src/index.ts +0 -73
- package/src/price-loader.ts +0 -125
- package/src/web-request.ts +0 -131
- package/tsconfig.json +0 -22
package/web-request.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { A_DAY_MS } from './close-time.js';
|
|
3
|
+
export default class WebRequest {
|
|
4
|
+
static HKEX_TOKEN = { token: '', t: 0 };
|
|
5
|
+
static browser;
|
|
6
|
+
static context;
|
|
7
|
+
static pageCnt = 0;
|
|
8
|
+
static __promise;
|
|
9
|
+
static isDebug = false;
|
|
10
|
+
page;
|
|
11
|
+
static async __getInstance(debug = false) {
|
|
12
|
+
if (WebRequest.context)
|
|
13
|
+
return;
|
|
14
|
+
// 1. 启动 Chromium,禁用 web security(解除 CORS)
|
|
15
|
+
WebRequest.browser = await chromium.launch({
|
|
16
|
+
headless: !debug,
|
|
17
|
+
channel: 'msedge',
|
|
18
|
+
args: [
|
|
19
|
+
'--disable-web-security',
|
|
20
|
+
'--disable-features=IsolateOrigins,site-per-process',
|
|
21
|
+
'--no-sandbox',
|
|
22
|
+
'--disable-setuid-sandbox'
|
|
23
|
+
]
|
|
24
|
+
});
|
|
25
|
+
WebRequest.context = await WebRequest.browser.newContext({});
|
|
26
|
+
}
|
|
27
|
+
get isDebug() {
|
|
28
|
+
return WebRequest.isDebug;
|
|
29
|
+
}
|
|
30
|
+
static async init(isDebug = false) {
|
|
31
|
+
WebRequest.isDebug = isDebug;
|
|
32
|
+
if (!WebRequest.__promise)
|
|
33
|
+
WebRequest.__promise = WebRequest.__getInstance(isDebug);
|
|
34
|
+
}
|
|
35
|
+
constructor() { }
|
|
36
|
+
async mount() {
|
|
37
|
+
await WebRequest.__promise;
|
|
38
|
+
this.page = await WebRequest.context.newPage();
|
|
39
|
+
WebRequest.pageCnt++;
|
|
40
|
+
await this.page.goto('about:blank');
|
|
41
|
+
}
|
|
42
|
+
static async destroy() {
|
|
43
|
+
WebRequest.pageCnt--;
|
|
44
|
+
if (0 < WebRequest.pageCnt)
|
|
45
|
+
return;
|
|
46
|
+
await WebRequest.context?.close();
|
|
47
|
+
await WebRequest.browser?.close();
|
|
48
|
+
WebRequest.context = undefined;
|
|
49
|
+
WebRequest.browser = undefined;
|
|
50
|
+
WebRequest.pageCnt = 0;
|
|
51
|
+
WebRequest.__promise = undefined;
|
|
52
|
+
}
|
|
53
|
+
async destroy() {
|
|
54
|
+
if (!this.page)
|
|
55
|
+
return;
|
|
56
|
+
await this.page.close();
|
|
57
|
+
this.page = undefined;
|
|
58
|
+
return WebRequest.destroy();
|
|
59
|
+
}
|
|
60
|
+
fetch(url, method = 'GET', params, headers) {
|
|
61
|
+
method = method.toUpperCase();
|
|
62
|
+
let data = '';
|
|
63
|
+
if (params) {
|
|
64
|
+
if (['HEAD', 'GET'].includes(method)) {
|
|
65
|
+
const uri = new URL(url);
|
|
66
|
+
const urlParams = Object.fromEntries(uri.searchParams);
|
|
67
|
+
uri.search = new URLSearchParams(Object.assign(urlParams, params)).toString();
|
|
68
|
+
url = uri.toString();
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const ct = (headers?.['Content-Type'] || '').split(';')[0];
|
|
72
|
+
switch (ct) {
|
|
73
|
+
case 'application/json':
|
|
74
|
+
data = JSON.stringify(params);
|
|
75
|
+
break;
|
|
76
|
+
default:
|
|
77
|
+
data = new URLSearchParams(params).toString();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return this.page.evaluate(async (conf) => {
|
|
82
|
+
const resp = await fetch(conf.url, {
|
|
83
|
+
method: conf.method,
|
|
84
|
+
credentials: 'include', // 如果需要 Cookie
|
|
85
|
+
headers: conf.headers,
|
|
86
|
+
body: conf.data || undefined,
|
|
87
|
+
});
|
|
88
|
+
if (!resp.ok)
|
|
89
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
90
|
+
const ct = resp.headers.get('Content-Type')?.split(';')[0] || '';
|
|
91
|
+
if ('application/json' === ct)
|
|
92
|
+
return resp.json();
|
|
93
|
+
if (ct.startsWith('text/'))
|
|
94
|
+
return resp.text();
|
|
95
|
+
return resp.arrayBuffer();
|
|
96
|
+
}, { method, url, headers, data });
|
|
97
|
+
}
|
|
98
|
+
get(url, params, headers) {
|
|
99
|
+
return this.fetch(url, 'GET', params, headers);
|
|
100
|
+
}
|
|
101
|
+
goto(pageUrl) {
|
|
102
|
+
return this.page.goto(pageUrl);
|
|
103
|
+
}
|
|
104
|
+
read(key) {
|
|
105
|
+
return this.page.evaluate(async (conf) => {
|
|
106
|
+
return window[conf.key];
|
|
107
|
+
}, { key });
|
|
108
|
+
}
|
|
109
|
+
async _loadHkexToken() {
|
|
110
|
+
const expiry = Date.now() - A_DAY_MS;
|
|
111
|
+
if (WebRequest.HKEX_TOKEN?.t > expiry)
|
|
112
|
+
return WebRequest.HKEX_TOKEN.token;
|
|
113
|
+
const doc = await this.get('https://www.hkex.com.hk/Market-Data/Securities-Prices/Equities?sc_lang=zh-HK');
|
|
114
|
+
const token = doc.split('//return "Base64-AES-Encrypted-Token";')[1].split('";')[0].split('"')[1];
|
|
115
|
+
const decodedToken = decodeURIComponent(token);
|
|
116
|
+
WebRequest.HKEX_TOKEN = { token: decodedToken, t: Date.now() };
|
|
117
|
+
return decodedToken;
|
|
118
|
+
}
|
|
119
|
+
}
|
package/build.mjs
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
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/src/close-time.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
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
|
-
}
|
package/src/dividend-loader.ts
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
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
|
-
}
|
package/src/history-loader.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { EnMarket, type StockPoint } 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): Promise<StockPoint[]> {
|
|
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,
|
|
40
|
-
timestamp: getMarketCloseTime(market, new Date(`${date}T15:00:00Z`).getTime()),
|
|
41
|
-
st: { nc, n: '', o:+open, c:+close, h:+hight, l:+low, v:+vol }
|
|
42
|
-
};
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
private async __getSGXHistory(nc: string): Promise<StockPoint[]> {
|
|
47
|
-
const rsp = await super.get(`https://api.sgx.com/securities/v1.1//charts/historic/stocks/code/${nc}/5y`, { params: 'trading_time,vl,lt' });
|
|
48
|
-
const { historic } = rsp.data;
|
|
49
|
-
return (historic as any[]).map(st => {
|
|
50
|
-
const { trading_time, vl: v, lt: c } = st;
|
|
51
|
-
const fullYear = trading_time.substring(0, 4);
|
|
52
|
-
const month = trading_time.substring(4, 6);
|
|
53
|
-
const date = trading_time.substring(6, 8);
|
|
54
|
-
const timestamp = ~~(new Date(`${fullYear}-${month}-${date}T09:16:00Z`).getTime() / 1000);
|
|
55
|
-
return { market: EnMarket.SGX, timestamp, st: { nc, n: '', o: 0, c, h: 0, l: 0, v } };
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
loadHistory(fullNc: string) {
|
|
60
|
-
const { nc, market } = splitFullNc(fullNc);
|
|
61
|
-
|
|
62
|
-
switch (market) {
|
|
63
|
-
case EnMarket.SGX:
|
|
64
|
-
return this.__getSGXHistory(nc);
|
|
65
|
-
case EnMarket.HKEX:
|
|
66
|
-
case EnMarket.USA:
|
|
67
|
-
return this.__getHKOrUSAHistory(market, nc);
|
|
68
|
-
default:
|
|
69
|
-
}
|
|
70
|
-
throw new Error(`Unsupported market: ${market}`);
|
|
71
|
-
}
|
|
72
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
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
|
-
}
|