@watsonserve/stock-base 0.1.6 → 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/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
+ }
@@ -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;
@@ -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 { DividendLoader } from './dividend-loader.js';
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 extends DividendLoader {
8
- loadHolidaysSG(year: number): Promise<IHoliday[]>;
9
- loadHolidaysHK(): Promise<IHoliday[]>;
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
@@ -52,6 +52,12 @@ export interface IDividend {
52
52
  paid: Date;
53
53
  announcement_URL?: string;
54
54
  }
55
+ export interface IDivBonInfo {
56
+ fullNc: string;
57
+ nc: string;
58
+ dps: IDiv[];
59
+ bps: IDiv[];
60
+ }
55
61
  export interface IDiv {
56
62
  nc: string;
57
63
  annc: number;
@@ -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: number): any[];
7
+ export declare function generateUSHolidays(year?: number): IHoliday[];
@@ -1,23 +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 pageCnt;
5
+ private static __idles;
7
6
  private static __promise?;
8
- protected static isDebug: boolean;
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
- mount(): Promise<void>;
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
- protected parisFetch<T>(url: string, method?: string, params?: Record<string, any>): Promise<T>;
21
20
  protected read<T>(key: string): Promise<T>;
22
- protected _loadHkexToken(): Promise<string>;
23
21
  }
@@ -43,8 +43,11 @@ function toUTCs(d) {
43
43
  * @param year 年份 如 2025
44
44
  * @returns ics 文本,可直接保存为 .ics 文件
45
45
  */
46
- export function generateUSHolidays(year) {
46
+ export function generateUSHolidays(year = 0) {
47
47
  const events = [];
48
+ if (!year) {
49
+ year = new Date().getUTCFullYear();
50
+ }
48
51
  // 1. 元旦(周六不补,周日补周一)
49
52
  const newYear = new Date(year, 0, 1);
50
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 pageCnt = 0;
5
+ static __idles = [];
8
6
  static __promise;
9
- static isDebug = false;
7
+ static __isDebug = false;
10
8
  page;
11
- static async __getInstance(debug = false) {
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: !debug,
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.isDebug;
30
+ static get isDebug() {
31
+ return WebRequest.__isDebug;
30
32
  }
31
33
  static async init(isDebug = false) {
32
- WebRequest.isDebug = isDebug;
34
+ WebRequest.__isDebug = isDebug;
33
35
  if (!WebRequest.__promise)
34
- WebRequest.__promise = WebRequest.__getInstance(isDebug);
36
+ WebRequest.__promise = WebRequest.__getInstance();
35
37
  }
36
38
  constructor() { }
37
- async mount() {
38
- await WebRequest.__promise;
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
- WebRequest.pageCnt++;
41
- await this.page.goto('about:blank');
54
+ await this.page.goto(addr);
42
55
  }
43
56
  static async destroy() {
44
- WebRequest.pageCnt--;
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, method = 'GET', params, headers) {
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,31 +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
- return this.page.goto(pageUrl);
104
- }
105
- async parisFetch(url, method = 'GET', params) {
106
- if (new URL(this.page.url()).origin !== 'https://finance.baidu.com')
107
- throw new Error('Not on finance.baidu.com');
108
- const token = await this.page.evaluate(() => new Promise(r => window['paris_2108'].getAcsToken(r)));
109
- return this.fetch(url, method, params, { 'Acs-Token': token });
116
+ // 'load' 事件在行情页因持续的网络请求常常无法触发,导致超时
117
+ return this.page.goto(pageUrl, { waitUntil: 'domcontentloaded' });
110
118
  }
111
119
  read(key) {
112
120
  return this.page.evaluate(async (conf) => {
113
121
  return window[conf.key];
114
122
  }, { key });
115
123
  }
116
- async _loadHkexToken() {
117
- const expiry = Date.now() - A_DAY_MS;
118
- if (WebRequest.HKEX_TOKEN?.t > expiry)
119
- return WebRequest.HKEX_TOKEN.token;
120
- const doc = await this.get('https://www.hkex.com.hk/Market-Data/Securities-Prices/Equities?sc_lang=zh-HK');
121
- const token = doc.split('//return "Base64-AES-Encrypted-Token";')[1].split('";')[0].split('"')[1];
122
- const decodedToken = decodeURIComponent(token);
123
- WebRequest.HKEX_TOKEN = { token: decodedToken, t: Date.now() };
124
- return decodedToken;
125
- }
126
124
  }
@@ -1,17 +1,6 @@
1
1
  import { getMarketCloseTime } from './close-time.js';
2
- import { splitFullNc, ttm } from './helper.js';
2
+ import { splitFullNc, toDivPoint, ttm } from './helper.js';
3
3
  import { EnCurrency, EnMarket } from './stock.js';
4
- function toDivPoint(fullNc, div) {
5
- const { annc, ex, paid, ...another } = div;
6
- return {
7
- ...another,
8
- nc: fullNc,
9
- annc: ~~(annc.getTime() / 1000),
10
- ex: ~~(ex.getTime() / 1000),
11
- paid: ~~(paid.getTime() / 1000),
12
- amount: div.amount || 0,
13
- };
14
- }
15
4
  function toYahooSymbol(nc, market) {
16
5
  switch (market) {
17
6
  case EnMarket.USA:
@@ -35,23 +24,22 @@ export default class YahooFinanceLoader {
35
24
  async getJson(url, params) {
36
25
  const uri = new URL(url);
37
26
  if (params) {
38
- uri.search = new URLSearchParams(Object.entries(params).reduce((pre, [k, v]) => {
39
- pre[k] = `${v}`;
40
- return pre;
41
- }, {})).toString();
27
+ const sp = uri.searchParams;
28
+ Object.entries(params).forEach(([k, v]) => sp.append(k, v.toString()));
29
+ uri.search = sp.toString();
42
30
  }
43
31
  const resp = await fetch(uri.toString());
44
32
  if (!resp.ok)
45
33
  throw new Error(`HTTP ${resp.status}`);
46
- return await resp.json();
34
+ const { chart, quoteResponse } = await resp.json();
35
+ return { chart, quote: quoteResponse?.result };
47
36
  }
48
37
  async getChart(symbol, params) {
49
38
  const resp = await this.getJson(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}`, params);
50
- const error = resp.chart?.error;
51
- if (error?.description) {
39
+ const { result, error } = resp.chart || {};
40
+ if (error?.description)
52
41
  throw new Error(error.description);
53
- }
54
- const data = resp.chart?.result?.[0];
42
+ const data = result?.[0];
55
43
  if (!data)
56
44
  throw new Error(`empty chart data for ${symbol}`);
57
45
  return data;
@@ -124,13 +112,11 @@ export default class YahooFinanceLoader {
124
112
  return { result, remain };
125
113
  }
126
114
  async loadFx() {
127
- const resp = await this.getJson('https://query1.finance.yahoo.com/v7/finance/quote', { symbols: 'USDHKD=X,USDSGD=X,USDCNY=X' });
128
- const rows = resp.quoteResponse?.result || [];
115
+ const { quote: rows = [] } = await this.getJson('https://query1.finance.yahoo.com/v7/finance/quote', { symbols: 'USDHKD=X,USDSGD=X,USDCNY=X' });
129
116
  const values = rows.reduce((pre, item) => {
130
- const sym = item.symbol || '';
131
- const nc = sym.slice(3, 6);
132
- if (item.regularMarketPrice)
133
- pre[nc] = item.regularMarketPrice;
117
+ const { symbol: sym = '', regularMarketPrice } = item;
118
+ if (regularMarketPrice)
119
+ pre[sym.slice(3, 6)] = regularMarketPrice;
134
120
  return pre;
135
121
  }, {});
136
122
  return {