@watsonserve/stock-base 0.1.5 → 0.1.7
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/baidu.js +137 -0
- package/eastmoney.js +95 -0
- package/helper.js +19 -0
- package/hkex.js +191 -0
- package/index.js +103 -61
- package/log.js +5 -1
- package/package.json +2 -2
- package/sgx.js +111 -0
- package/types/baidu.d.ts +20 -0
- package/types/eastmoney.d.ts +14 -0
- package/types/helper.d.ts +4 -1
- package/types/hkex.d.ts +47 -0
- package/types/index.d.ts +15 -5
- package/types/sgx.d.ts +9 -0
- package/types/stock.d.ts +6 -0
- package/types/{us-holiday.d.ts → usa-holiday.d.ts} +2 -1
- package/types/web-request.d.ts +6 -7
- package/types/yahoo-finance-loader.d.ts +17 -0
- package/{us-holiday.js → usa-holiday.js} +5 -20
- package/web-request.js +36 -32
- package/yahoo-finance-loader.js +154 -0
- package/dividend-loader.js +0 -167
- package/history-loader.js +0 -100
- package/price-loader.js +0 -122
- package/types/dividend-loader.d.ts +0 -19
- package/types/history-loader.d.ts +0 -11
- package/types/price-loader.d.ts +0 -14
package/sgx.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { A_DAY_S } from './close-time.js';
|
|
2
|
+
import { formatFullNc, parseUTCDate, ttm } from './helper.js';
|
|
3
|
+
import { EnMarket } from './stock.js';
|
|
4
|
+
export class SGXLoader {
|
|
5
|
+
async __fetch(url, params) {
|
|
6
|
+
const uri = new URL(url);
|
|
7
|
+
const urlParams = Object.fromEntries(uri.searchParams);
|
|
8
|
+
uri.search = new URLSearchParams(Object.assign(urlParams, params)).toString();
|
|
9
|
+
const resp = await fetch(uri.toString(), {
|
|
10
|
+
method: 'GET',
|
|
11
|
+
credentials: 'include'
|
|
12
|
+
});
|
|
13
|
+
if (!resp.ok)
|
|
14
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
15
|
+
const ct = resp.headers.get('Content-Type')?.split(';')[0] || '';
|
|
16
|
+
if ('application/json' === ct)
|
|
17
|
+
return resp.json();
|
|
18
|
+
if (ct.startsWith('text/'))
|
|
19
|
+
return resp.text();
|
|
20
|
+
return resp.arrayBuffer();
|
|
21
|
+
}
|
|
22
|
+
async __laodIbmCode(nc) {
|
|
23
|
+
const resp = await this.__fetch(`https://api.sgx.com/marketmetadata/v2`, { 'stock-code': nc });
|
|
24
|
+
return resp.data?.[0]?.ibmCode || '';
|
|
25
|
+
}
|
|
26
|
+
async loadDividend(nc) {
|
|
27
|
+
const ibmcode = await this.__laodIbmCode(nc);
|
|
28
|
+
const params = {
|
|
29
|
+
pagesize: 15,
|
|
30
|
+
pagestart: 0,
|
|
31
|
+
ibmcode,
|
|
32
|
+
params: 'id,anncType,dateAnnc,exDate,name,particulars,recDate,datePaid',
|
|
33
|
+
order: 'desc',
|
|
34
|
+
orderBy: 'dateAnnc'
|
|
35
|
+
};
|
|
36
|
+
const { data } = await this.__fetch('https://api.sgx.com/corporateactions/v1.0', params);
|
|
37
|
+
const bonus = [];
|
|
38
|
+
const dividend = [];
|
|
39
|
+
for (const item of data) {
|
|
40
|
+
const { anncType, particulars, dateAnnc, exDate, recDate, datePaid } = item;
|
|
41
|
+
const annc = new Date(dateAnnc);
|
|
42
|
+
const ex = new Date(exDate);
|
|
43
|
+
const rec = new Date(recDate); // 登记日
|
|
44
|
+
const paid = new Date(datePaid);
|
|
45
|
+
if ('DIVIDEND' === anncType) {
|
|
46
|
+
const amount = +particulars.match(/SGD (\d+\.\d+)/)?.[1] || 0;
|
|
47
|
+
const lstIdx = dividend.length - 1;
|
|
48
|
+
if (dividend[lstIdx]?.ex.getTime() === ex.getTime() && amount) {
|
|
49
|
+
dividend[lstIdx].amount += amount;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
dividend.push({ currency: 'SGD', amount, annc, ex, rec, paid });
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if ('BONUS' === anncType) { // particulars: 'Ratio: 10:1'
|
|
57
|
+
const ratio = particulars.match(/Ratio: (\d+:\d+)/);
|
|
58
|
+
const [foo, bar] = ratio[1].split(':');
|
|
59
|
+
bonus.push({ currency: 'SGD', ratio: 1 + (+bar) / (+foo), annc, ex, rec, paid });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return formatFullNc(nc, 'SG', ttm('ex', dividend), bonus);
|
|
64
|
+
}
|
|
65
|
+
async loadHistory(nc, int = '5y') {
|
|
66
|
+
const rsp = await this.__fetch(`https://api.sgx.com/securities/v1.1/charts/historic/stocks/code/${nc}/5y`, { params: 'trading_time,vl,lt' });
|
|
67
|
+
const { historic } = rsp.data;
|
|
68
|
+
return historic.map(st => {
|
|
69
|
+
const { trading_time, vl: v, lt: c } = st;
|
|
70
|
+
const fullYear = trading_time.substring(0, 4);
|
|
71
|
+
const month = trading_time.substring(4, 6);
|
|
72
|
+
const date = trading_time.substring(6, 8);
|
|
73
|
+
const timestamp = ~~(new Date(`${fullYear}-${month}-${date}T09:16:00Z`).getTime() / 1000);
|
|
74
|
+
return { market: EnMarket.SGX, timestamp, st: { nc, n: '', o: 0, c, h: 0, l: 0, v } };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// 新加坡交易所全量股票
|
|
78
|
+
async loadPrices() {
|
|
79
|
+
try {
|
|
80
|
+
const body = await this.__fetch('https://api.sgx.com/securities/v1.1/stocks', { params: 'nc,lt,ptd,n,o,h,l,vl,trading_time' });
|
|
81
|
+
const stockList = body.data.prices;
|
|
82
|
+
return stockList.map(({ trading_time: t, nc, lt: c, n, o, h, l, vl: v }) => ({ t, nc, n, o, c, h, l, v }));
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
throw new Error(`failed to load sgx ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async loadHolidays(year = 0) {
|
|
89
|
+
if (!year) {
|
|
90
|
+
year = new Date().getUTCFullYear();
|
|
91
|
+
}
|
|
92
|
+
const resp = await fetch(`https://www.mom.gov.sg/-/media/mom/documents/employment-practices/public-holidays/public-holidays-sg-${year}.ics`);
|
|
93
|
+
const text = (await resp.text()).replace(/\r\n/g, '\n').replace(/\n\n/g, '\n'); // handle line folding
|
|
94
|
+
return text.split('\nEND:VEVENT\nBEGIN:VEVENT\n').reduce((pre, blk) => {
|
|
95
|
+
const ev = new Map(blk.split('\n').map(line => line.split(':', 2)));
|
|
96
|
+
let start = parseUTCDate(ev.get('DTSTART;VALUE=DATE') || '');
|
|
97
|
+
let end = parseUTCDate(ev.get('DTEND;VALUE=DATE') || '');
|
|
98
|
+
if (start === end) {
|
|
99
|
+
end += A_DAY_S;
|
|
100
|
+
}
|
|
101
|
+
// recording to SG calendar, we will move it to next day(Monday, 1) if holiday is Sunday(0)
|
|
102
|
+
if (!new Date(start * 1000).getUTCDay()) {
|
|
103
|
+
end += A_DAY_S;
|
|
104
|
+
}
|
|
105
|
+
const firstOne = pre[0];
|
|
106
|
+
const title = ev.get('SUMMARY') || '';
|
|
107
|
+
(firstOne && end === firstOne.start) ? Object.assign(firstOne, { title, start }) : pre.unshift({ market: 'SG', title, start, end });
|
|
108
|
+
return pre;
|
|
109
|
+
}, []);
|
|
110
|
+
}
|
|
111
|
+
}
|
package/types/baidu.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { IFx, IStock } from './stock.js';
|
|
2
|
+
import WebRequest from './web-request.js';
|
|
3
|
+
export interface IFXs {
|
|
4
|
+
result: IFx[];
|
|
5
|
+
remain: IFx[];
|
|
6
|
+
}
|
|
7
|
+
export declare class BaiduLoader extends WebRequest {
|
|
8
|
+
private static __instance;
|
|
9
|
+
private __fetch;
|
|
10
|
+
private __loadFx;
|
|
11
|
+
private __sp500;
|
|
12
|
+
private static factory;
|
|
13
|
+
static getInstance(): Promise<BaiduLoader>;
|
|
14
|
+
private constructor();
|
|
15
|
+
protected _mount(): Promise<void>;
|
|
16
|
+
destroy(): Promise<void>;
|
|
17
|
+
loadFxHistory(): Promise<IFXs>;
|
|
18
|
+
loadFx(): Promise<IFx>;
|
|
19
|
+
loadPrices(): Promise<IStock[]>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { IStockPoint, IDivBonInfo } from './stock.js';
|
|
2
|
+
import WebRequest from './web-request.js';
|
|
3
|
+
export declare class EastmoneyLoader extends WebRequest {
|
|
4
|
+
private static __instance;
|
|
5
|
+
private static factory;
|
|
6
|
+
static getInstance(): Promise<EastmoneyLoader>;
|
|
7
|
+
destroy(): Promise<void>;
|
|
8
|
+
private constructor();
|
|
9
|
+
loadDividend(nc: string): Promise<IDivBonInfo>;
|
|
10
|
+
/**
|
|
11
|
+
* @warning This method requires the Eastmoney page to be loaded and may not work if the page structure changes.
|
|
12
|
+
*/
|
|
13
|
+
loadHistory(nc: string): Promise<IStockPoint[]>;
|
|
14
|
+
}
|
package/types/helper.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EnMarket } from './stock.js';
|
|
1
|
+
import { EnMarket, IDiv, IDivBonInfo, IDividend } from './stock.js';
|
|
2
2
|
export declare function sleep(t?: number): Promise<unknown>;
|
|
3
3
|
export declare function splitFullNc(fullNc: string): {
|
|
4
4
|
nc: string;
|
|
@@ -6,3 +6,6 @@ export declare function splitFullNc(fullNc: string): {
|
|
|
6
6
|
};
|
|
7
7
|
export declare function groupBy<T extends Record<string, any>>(key: string, list: T[], delKey?: boolean): Record<string, T[]>;
|
|
8
8
|
export declare function ttm<T>(by: string, list: any[]): T[];
|
|
9
|
+
export declare function toDivPoint(fullNc: string, div: IDividend): IDiv;
|
|
10
|
+
export declare function parseUTCDate(str: string): number;
|
|
11
|
+
export declare function formatFullNc(nc: string, market: 'SG' | 'HK' | 'US', div: IDividend[], bonus: IDividend[]): IDivBonInfo;
|
package/types/hkex.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import WebRequest from './web-request.js';
|
|
2
|
+
import { EnMarket, IDivBonInfo, IHoliday, IStock } from './stock.js';
|
|
3
|
+
export declare enum EnInt {
|
|
4
|
+
day_1 = 0,
|
|
5
|
+
mo_1 = 2,
|
|
6
|
+
mo_3 = 3,
|
|
7
|
+
mo_6 = 4,
|
|
8
|
+
yr_1 = 5,
|
|
9
|
+
yrs_2 = 6,
|
|
10
|
+
yrs_5 = 7,
|
|
11
|
+
yrs_10 = 8,
|
|
12
|
+
ytd = 9
|
|
13
|
+
}
|
|
14
|
+
export declare enum EnSpan {
|
|
15
|
+
min = 0,
|
|
16
|
+
day = 6,
|
|
17
|
+
week = 7,
|
|
18
|
+
month = 8,
|
|
19
|
+
quarter = 9
|
|
20
|
+
}
|
|
21
|
+
export declare class HKEXLoader extends WebRequest {
|
|
22
|
+
private static HKEX_TOKEN;
|
|
23
|
+
private static tokenPromise;
|
|
24
|
+
private static __instance;
|
|
25
|
+
private __fetchHkexToken;
|
|
26
|
+
private __loadHkexToken;
|
|
27
|
+
private static factory;
|
|
28
|
+
static getInstance(): Promise<HKEXLoader>;
|
|
29
|
+
private constructor();
|
|
30
|
+
destroy(): Promise<void>;
|
|
31
|
+
loadDividend(nc: string): Promise<IDivBonInfo>;
|
|
32
|
+
loadPrices(): Promise<IStock[]>;
|
|
33
|
+
loadHolidays(): Promise<IHoliday[]>;
|
|
34
|
+
loadHistory(nc: string, int?: EnInt): Promise<{
|
|
35
|
+
market: EnMarket;
|
|
36
|
+
timestamp: number;
|
|
37
|
+
st: {
|
|
38
|
+
nc: string;
|
|
39
|
+
n: string;
|
|
40
|
+
o: number;
|
|
41
|
+
h: number;
|
|
42
|
+
l: number;
|
|
43
|
+
c: number;
|
|
44
|
+
v: number;
|
|
45
|
+
};
|
|
46
|
+
}[]>;
|
|
47
|
+
}
|
package/types/index.d.ts
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { IHoliday } from './stock.js';
|
|
1
|
+
import { IFXs } from './baidu.js';
|
|
2
|
+
import { EnMarket, IDiv, IFx, IHoliday, IStock, IStockPoint } from './stock.js';
|
|
3
3
|
export * from './close-time.js';
|
|
4
4
|
export * from './stock.js';
|
|
5
5
|
export * from './log.js';
|
|
6
6
|
export * from './helper.js';
|
|
7
|
-
export declare class StockLoader
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
export declare class StockLoader {
|
|
8
|
+
get isDebug(): boolean;
|
|
9
|
+
static init(isDebug?: boolean): Promise<void>;
|
|
10
|
+
loadFxHistory(): Promise<IFXs>;
|
|
11
|
+
loadFx(): Promise<IFx>;
|
|
12
|
+
loadPrices(market: EnMarket): Promise<IStock[]>;
|
|
10
13
|
loadHolidays(): Promise<IHoliday[]>;
|
|
14
|
+
loadHistory(fullNc: string): Promise<IStockPoint[]>;
|
|
15
|
+
private _splitByMarket;
|
|
16
|
+
loadDividends(ncs: string[]): Promise<{
|
|
17
|
+
ign: string[];
|
|
18
|
+
points: IDiv[];
|
|
19
|
+
divPool: Record<string, IDiv[]>;
|
|
20
|
+
}>;
|
|
11
21
|
}
|
package/types/sgx.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { IHoliday, IStock, IStockPoint } from './stock.js';
|
|
2
|
+
export declare class SGXLoader {
|
|
3
|
+
private __fetch;
|
|
4
|
+
private __laodIbmCode;
|
|
5
|
+
loadDividend(nc: string): Promise<import("./stock.js").IDivBonInfo>;
|
|
6
|
+
loadHistory(nc: string, int?: string): Promise<IStockPoint[]>;
|
|
7
|
+
loadPrices(): Promise<IStock[]>;
|
|
8
|
+
loadHolidays(year?: number): Promise<IHoliday[]>;
|
|
9
|
+
}
|
package/types/stock.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { IHoliday } from './stock.js';
|
|
1
2
|
/**
|
|
2
3
|
* 生成美股休市 & 提前收盘日历(ICS格式)
|
|
3
4
|
* @param year 年份 如 2025
|
|
4
5
|
* @returns ics 文本,可直接保存为 .ics 文件
|
|
5
6
|
*/
|
|
6
|
-
export declare function generateUSHolidays(year
|
|
7
|
+
export declare function generateUSHolidays(year?: number): IHoliday[];
|
package/types/web-request.d.ts
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
import { type Page } from 'playwright';
|
|
2
2
|
export default class WebRequest {
|
|
3
|
-
private static HKEX_TOKEN;
|
|
4
3
|
private static browser?;
|
|
5
4
|
private static context?;
|
|
6
|
-
private static
|
|
5
|
+
private static __idles;
|
|
7
6
|
private static __promise?;
|
|
8
|
-
|
|
7
|
+
private static __isDebug;
|
|
9
8
|
protected page?: Page;
|
|
10
9
|
private static __getInstance;
|
|
11
|
-
get isDebug(): boolean;
|
|
10
|
+
static get isDebug(): boolean;
|
|
12
11
|
static init(isDebug?: boolean): Promise<void>;
|
|
13
12
|
constructor();
|
|
14
|
-
|
|
13
|
+
protected _mount(addr?: string): Promise<void>;
|
|
15
14
|
private static destroy;
|
|
16
15
|
destroy(): Promise<void>;
|
|
16
|
+
protected fetch(url: string): Promise<any>;
|
|
17
|
+
protected fetch(url: string, params?: Record<string, any>, headers?: Record<string, any>): Promise<any>;
|
|
17
18
|
protected fetch(url: string, method?: string, params?: Record<string, any>, headers?: Record<string, any>): Promise<any>;
|
|
18
|
-
protected get(url: string, params?: Record<string, any>, headers?: Record<string, any>): Promise<any>;
|
|
19
19
|
protected goto(pageUrl: string): Promise<import("playwright").Response | null>;
|
|
20
20
|
protected read<T>(key: string): Promise<T>;
|
|
21
|
-
protected _loadHkexToken(): Promise<string>;
|
|
22
21
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type IDiv, type IFx, type IStockPoint } from './stock.js';
|
|
2
|
+
export default class YahooFinanceLoader {
|
|
3
|
+
private getJson;
|
|
4
|
+
private getChart;
|
|
5
|
+
loadHistory(fullNc: string): Promise<IStockPoint[]>;
|
|
6
|
+
loadFxHistory(): Promise<{
|
|
7
|
+
result: IFx[];
|
|
8
|
+
remain: IFx[];
|
|
9
|
+
}>;
|
|
10
|
+
loadFx(): Promise<IFx>;
|
|
11
|
+
loadUSDividend(fullNc: string): Promise<{
|
|
12
|
+
fullNc: string;
|
|
13
|
+
nc: string;
|
|
14
|
+
dps: IDiv[];
|
|
15
|
+
bps: IDiv[];
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import { A_DAY_S } from
|
|
2
|
-
// ==================== 工具函数 ====================
|
|
3
|
-
function formatDate(d) {
|
|
4
|
-
const y = d.getFullYear();
|
|
5
|
-
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
6
|
-
const day = String(d.getDate()).padStart(2, '0');
|
|
7
|
-
return `${y}${m}${day}`;
|
|
8
|
-
}
|
|
1
|
+
import { A_DAY_S } from './close-time.js';
|
|
9
2
|
// 获取第N个周X
|
|
10
3
|
function getNthWeekday(y, m, n, weekday) {
|
|
11
4
|
const first = new Date(y, m - 1, 1);
|
|
@@ -42,17 +35,6 @@ function getGoodFriday(y) {
|
|
|
42
35
|
goodFriday.setDate(easter.getDate() - 2);
|
|
43
36
|
return goodFriday;
|
|
44
37
|
}
|
|
45
|
-
function createEvent(uid, dateStr, summary, description) {
|
|
46
|
-
return `BEGIN:VEVENT
|
|
47
|
-
UID:${uid}-${dateStr}@usstockmarket
|
|
48
|
-
DTSTART;VALUE=DATE:${dateStr}
|
|
49
|
-
DTEND;VALUE=DATE:${dateStr}
|
|
50
|
-
SUMMARY:${summary}
|
|
51
|
-
DESCRIPTION:${description}
|
|
52
|
-
STATUS:CONFIRMED
|
|
53
|
-
END:VEVENT
|
|
54
|
-
`;
|
|
55
|
-
}
|
|
56
38
|
function toUTCs(d) {
|
|
57
39
|
return ~~(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 1000);
|
|
58
40
|
}
|
|
@@ -61,8 +43,11 @@ function toUTCs(d) {
|
|
|
61
43
|
* @param year 年份 如 2025
|
|
62
44
|
* @returns ics 文本,可直接保存为 .ics 文件
|
|
63
45
|
*/
|
|
64
|
-
export function generateUSHolidays(year) {
|
|
46
|
+
export function generateUSHolidays(year = 0) {
|
|
65
47
|
const events = [];
|
|
48
|
+
if (!year) {
|
|
49
|
+
year = new Date().getUTCFullYear();
|
|
50
|
+
}
|
|
66
51
|
// 1. 元旦(周六不补,周日补周一)
|
|
67
52
|
const newYear = new Date(year, 0, 1);
|
|
68
53
|
if (6 !== newYear.getDay()) {
|
package/web-request.js
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import { chromium } from 'playwright';
|
|
2
|
-
import { A_DAY_MS } from './close-time.js';
|
|
3
2
|
export default class WebRequest {
|
|
4
|
-
static HKEX_TOKEN = { token: '', t: 0 };
|
|
5
3
|
static browser;
|
|
6
4
|
static context;
|
|
7
|
-
static
|
|
5
|
+
static __idles = [];
|
|
8
6
|
static __promise;
|
|
9
|
-
static
|
|
7
|
+
static __isDebug = false;
|
|
10
8
|
page;
|
|
11
|
-
static async __getInstance(
|
|
9
|
+
static async __getInstance() {
|
|
12
10
|
if (WebRequest.context)
|
|
13
11
|
return;
|
|
14
12
|
// 1. 启动 Chromium,禁用 web security(解除 CORS)
|
|
15
13
|
WebRequest.browser = await chromium.launch({
|
|
16
14
|
executablePath: '/opt/microsoft/msedge/msedge',
|
|
17
|
-
headless: !
|
|
15
|
+
headless: !WebRequest.isDebug,
|
|
18
16
|
channel: 'msedge',
|
|
19
17
|
args: [
|
|
20
18
|
'--disable-web-security',
|
|
@@ -24,31 +22,44 @@ export default class WebRequest {
|
|
|
24
22
|
]
|
|
25
23
|
});
|
|
26
24
|
WebRequest.context = await WebRequest.browser.newContext({});
|
|
25
|
+
const [foo, ...list] = WebRequest.context.pages();
|
|
26
|
+
list.length && list.forEach(page => page.close());
|
|
27
|
+
await foo.goto('about:blank');
|
|
28
|
+
WebRequest.__idles = [foo];
|
|
27
29
|
}
|
|
28
|
-
get isDebug() {
|
|
29
|
-
return WebRequest.
|
|
30
|
+
static get isDebug() {
|
|
31
|
+
return WebRequest.__isDebug;
|
|
30
32
|
}
|
|
31
33
|
static async init(isDebug = false) {
|
|
32
|
-
WebRequest.
|
|
34
|
+
WebRequest.__isDebug = isDebug;
|
|
33
35
|
if (!WebRequest.__promise)
|
|
34
|
-
WebRequest.__promise = WebRequest.__getInstance(
|
|
36
|
+
WebRequest.__promise = WebRequest.__getInstance();
|
|
35
37
|
}
|
|
36
38
|
constructor() { }
|
|
37
|
-
async
|
|
38
|
-
|
|
39
|
+
async _mount(addr = 'about:blank') {
|
|
40
|
+
if (!WebRequest.__promise)
|
|
41
|
+
WebRequest.__promise = WebRequest.__getInstance();
|
|
42
|
+
try {
|
|
43
|
+
await WebRequest.__promise;
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
WebRequest.__promise = undefined;
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
if (WebRequest.__idles.length) {
|
|
50
|
+
this.page = WebRequest.__idles.pop();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
39
53
|
this.page = await WebRequest.context.newPage();
|
|
40
|
-
|
|
41
|
-
await this.page.goto('about:blank');
|
|
54
|
+
await this.page.goto(addr);
|
|
42
55
|
}
|
|
43
56
|
static async destroy() {
|
|
44
|
-
WebRequest.
|
|
45
|
-
if (0 < WebRequest.pageCnt)
|
|
57
|
+
if (WebRequest.context?.pages().length)
|
|
46
58
|
return;
|
|
47
59
|
await WebRequest.context?.close();
|
|
48
60
|
await WebRequest.browser?.close();
|
|
49
61
|
WebRequest.context = undefined;
|
|
50
62
|
WebRequest.browser = undefined;
|
|
51
|
-
WebRequest.pageCnt = 0;
|
|
52
63
|
WebRequest.__promise = undefined;
|
|
53
64
|
}
|
|
54
65
|
async destroy() {
|
|
@@ -58,7 +69,12 @@ export default class WebRequest {
|
|
|
58
69
|
this.page = undefined;
|
|
59
70
|
return WebRequest.destroy();
|
|
60
71
|
}
|
|
61
|
-
fetch(url,
|
|
72
|
+
fetch(url, ...args) {
|
|
73
|
+
let method, params, headers;
|
|
74
|
+
[method, params, headers] = args;
|
|
75
|
+
if ('string' !== typeof method) {
|
|
76
|
+
[method, params, headers] = ['GET', method, params];
|
|
77
|
+
}
|
|
62
78
|
method = method.toUpperCase();
|
|
63
79
|
let data = '';
|
|
64
80
|
if (params) {
|
|
@@ -96,25 +112,13 @@ export default class WebRequest {
|
|
|
96
112
|
return resp.arrayBuffer();
|
|
97
113
|
}, { method, url, headers, data });
|
|
98
114
|
}
|
|
99
|
-
get(url, params, headers) {
|
|
100
|
-
return this.fetch(url, 'GET', params, headers);
|
|
101
|
-
}
|
|
102
115
|
goto(pageUrl) {
|
|
103
|
-
|
|
116
|
+
// 'load' 事件在行情页因持续的网络请求常常无法触发,导致超时
|
|
117
|
+
return this.page.goto(pageUrl, { waitUntil: 'domcontentloaded' });
|
|
104
118
|
}
|
|
105
119
|
read(key) {
|
|
106
120
|
return this.page.evaluate(async (conf) => {
|
|
107
121
|
return window[conf.key];
|
|
108
122
|
}, { key });
|
|
109
123
|
}
|
|
110
|
-
async _loadHkexToken() {
|
|
111
|
-
const expiry = Date.now() - A_DAY_MS;
|
|
112
|
-
if (WebRequest.HKEX_TOKEN?.t > expiry)
|
|
113
|
-
return WebRequest.HKEX_TOKEN.token;
|
|
114
|
-
const doc = await this.get('https://www.hkex.com.hk/Market-Data/Securities-Prices/Equities?sc_lang=zh-HK');
|
|
115
|
-
const token = doc.split('//return "Base64-AES-Encrypted-Token";')[1].split('";')[0].split('"')[1];
|
|
116
|
-
const decodedToken = decodeURIComponent(token);
|
|
117
|
-
WebRequest.HKEX_TOKEN = { token: decodedToken, t: Date.now() };
|
|
118
|
-
return decodedToken;
|
|
119
|
-
}
|
|
120
124
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { getMarketCloseTime } from './close-time.js';
|
|
2
|
+
import { splitFullNc, toDivPoint, ttm } from './helper.js';
|
|
3
|
+
import { EnCurrency, EnMarket } from './stock.js';
|
|
4
|
+
function toYahooSymbol(nc, market) {
|
|
5
|
+
switch (market) {
|
|
6
|
+
case EnMarket.USA:
|
|
7
|
+
return nc.replace(/\./g, '-');
|
|
8
|
+
case EnMarket.HKEX:
|
|
9
|
+
return `${nc.padStart(4, '0')}.HK`;
|
|
10
|
+
case EnMarket.SGX:
|
|
11
|
+
return `${nc}.SI`;
|
|
12
|
+
default:
|
|
13
|
+
}
|
|
14
|
+
throw new Error(`unsupported market ${market}`);
|
|
15
|
+
}
|
|
16
|
+
function getMarketFromCode(code) {
|
|
17
|
+
return {
|
|
18
|
+
US: EnMarket.USA,
|
|
19
|
+
HK: EnMarket.HKEX,
|
|
20
|
+
SG: EnMarket.SGX,
|
|
21
|
+
}[code];
|
|
22
|
+
}
|
|
23
|
+
export default class YahooFinanceLoader {
|
|
24
|
+
async getJson(url, params) {
|
|
25
|
+
const uri = new URL(url);
|
|
26
|
+
if (params) {
|
|
27
|
+
const sp = uri.searchParams;
|
|
28
|
+
Object.entries(params).forEach(([k, v]) => sp.append(k, v.toString()));
|
|
29
|
+
uri.search = sp.toString();
|
|
30
|
+
}
|
|
31
|
+
const resp = await fetch(uri.toString());
|
|
32
|
+
if (!resp.ok)
|
|
33
|
+
throw new Error(`HTTP ${resp.status}`);
|
|
34
|
+
const { chart, quoteResponse } = await resp.json();
|
|
35
|
+
return { chart, quote: quoteResponse?.result };
|
|
36
|
+
}
|
|
37
|
+
async getChart(symbol, params) {
|
|
38
|
+
const resp = await this.getJson(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}`, params);
|
|
39
|
+
const { result, error } = resp.chart || {};
|
|
40
|
+
if (error?.description)
|
|
41
|
+
throw new Error(error.description);
|
|
42
|
+
const data = result?.[0];
|
|
43
|
+
if (!data)
|
|
44
|
+
throw new Error(`empty chart data for ${symbol}`);
|
|
45
|
+
return data;
|
|
46
|
+
}
|
|
47
|
+
async loadHistory(fullNc) {
|
|
48
|
+
const { nc, market } = splitFullNc(fullNc);
|
|
49
|
+
if (!market)
|
|
50
|
+
throw new Error(`unsupported market in ${fullNc}`);
|
|
51
|
+
const symbol = toYahooSymbol(nc, market);
|
|
52
|
+
const chart = await this.getChart(symbol, { range: 'max', interval: '1d', events: 'history' });
|
|
53
|
+
const timestamps = chart.timestamp || [];
|
|
54
|
+
const quote = chart.indicators?.quote?.[0];
|
|
55
|
+
if (!quote)
|
|
56
|
+
return [];
|
|
57
|
+
return timestamps.reduce((pre, ts, idx) => {
|
|
58
|
+
const c = quote.close?.[idx];
|
|
59
|
+
if ('number' !== typeof c)
|
|
60
|
+
return pre;
|
|
61
|
+
pre.push({
|
|
62
|
+
market,
|
|
63
|
+
timestamp: getMarketCloseTime(market, ts * 1000),
|
|
64
|
+
st: {
|
|
65
|
+
nc,
|
|
66
|
+
n: '',
|
|
67
|
+
o: quote.open?.[idx] || 0,
|
|
68
|
+
c,
|
|
69
|
+
h: quote.high?.[idx] || c,
|
|
70
|
+
l: quote.low?.[idx] || c,
|
|
71
|
+
v: quote.volume?.[idx] || 0,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
return pre;
|
|
75
|
+
}, []);
|
|
76
|
+
}
|
|
77
|
+
async loadFxHistory() {
|
|
78
|
+
const symbols = [
|
|
79
|
+
{ symbol: 'USDHKD=X', currency: EnCurrency.HKD },
|
|
80
|
+
{ symbol: 'USDSGD=X', currency: EnCurrency.SGD },
|
|
81
|
+
{ symbol: 'USDCNY=X', currency: EnCurrency.CNY },
|
|
82
|
+
];
|
|
83
|
+
const historyList = await Promise.all(symbols.map(async ({ symbol, currency }) => {
|
|
84
|
+
const chart = await this.getChart(symbol, { range: 'max', interval: '1d', events: 'history' });
|
|
85
|
+
const timestamps = chart.timestamp || [];
|
|
86
|
+
const quote = chart.indicators?.quote?.[0];
|
|
87
|
+
const close = quote?.close || [];
|
|
88
|
+
return { currency, lines: timestamps.map((ts, idx) => ({ ts, c: close[idx] })) };
|
|
89
|
+
}));
|
|
90
|
+
const base = new Map();
|
|
91
|
+
historyList.forEach(({ currency, lines }) => {
|
|
92
|
+
lines.forEach(({ ts, c }) => {
|
|
93
|
+
if ('number' !== typeof c)
|
|
94
|
+
return;
|
|
95
|
+
const stamp = getMarketCloseTime(EnMarket.FX, ts * 1000);
|
|
96
|
+
const fx = base.get(stamp) || {};
|
|
97
|
+
fx[currency] = c;
|
|
98
|
+
base.set(stamp, fx);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
const result = [];
|
|
102
|
+
const remain = [];
|
|
103
|
+
[...base.entries()].forEach(([stamp, fx]) => {
|
|
104
|
+
const data = { stamp, ...fx };
|
|
105
|
+
if (!fx.HKD || !fx.SGD || !fx.CNY) {
|
|
106
|
+
remain.push(data);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
result.push(data);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
return { result, remain };
|
|
113
|
+
}
|
|
114
|
+
async loadFx() {
|
|
115
|
+
const { quote: rows = [] } = await this.getJson('https://query1.finance.yahoo.com/v7/finance/quote', { symbols: 'USDHKD=X,USDSGD=X,USDCNY=X' });
|
|
116
|
+
const values = rows.reduce((pre, item) => {
|
|
117
|
+
const { symbol: sym = '', regularMarketPrice } = item;
|
|
118
|
+
if (regularMarketPrice)
|
|
119
|
+
pre[sym.slice(3, 6)] = regularMarketPrice;
|
|
120
|
+
return pre;
|
|
121
|
+
}, {});
|
|
122
|
+
return {
|
|
123
|
+
stamp: getMarketCloseTime(EnMarket.FX),
|
|
124
|
+
HKD: values.HKD || 0,
|
|
125
|
+
SGD: values.SGD || 0,
|
|
126
|
+
CNY: values.CNY || 0,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async loadUSDividend(fullNc) {
|
|
130
|
+
const marketCode = fullNc.substring(fullNc.length - 2);
|
|
131
|
+
if ('US' !== marketCode)
|
|
132
|
+
throw new Error(`unsupported market in ${fullNc}`);
|
|
133
|
+
const nc = fullNc.substring(0, fullNc.length - 3);
|
|
134
|
+
const market = getMarketFromCode(marketCode);
|
|
135
|
+
const symbol = toYahooSymbol(nc, market);
|
|
136
|
+
const chart = await this.getChart(symbol, { range: 'max', interval: '1d', events: 'div' });
|
|
137
|
+
const dividendMap = chart.events?.dividends || {};
|
|
138
|
+
const dividends = Object.values(dividendMap)
|
|
139
|
+
.sort((a, b) => (b.date || 0) - (a.date || 0))
|
|
140
|
+
.map(item => {
|
|
141
|
+
const ts = (item.date || 0) * 1000;
|
|
142
|
+
const date = new Date(ts);
|
|
143
|
+
return {
|
|
144
|
+
currency: EnCurrency.USD,
|
|
145
|
+
amount: item.amount || 0,
|
|
146
|
+
annc: date,
|
|
147
|
+
ex: date,
|
|
148
|
+
paid: date,
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
const dps = ttm('ex', dividends).map(item => toDivPoint(fullNc, item));
|
|
152
|
+
return { fullNc, nc, dps, bps: [] };
|
|
153
|
+
}
|
|
154
|
+
}
|