From 9cc482485a171a175b938cf9c72b14fdeeedf615 Mon Sep 17 00:00:00 2001 From: bakabaka0613 Date: Mon, 8 Jun 2026 00:57:23 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(opentypebb):=20TWSE/TPEx=20data=20prov?= =?UTF-8?q?ider=20=E2=80=94=20search,=20quotes,=20key=20metrics,=20company?= =?UTF-8?q?=20profiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native twse provider backed by the free official open-data APIs (no key): - EquitySearch: enumerate TWSE listed + TPEx OTC securities with Yahoo-suffixed symbols (2330.TW / 6488.TWO); English short names merged from t187ap03_L so English queries match - EquityQuote: latest-day OHLCV from STOCK_DAY_ALL + tpex_mainboard_quotes (TPEx side includes bid/ask) - KeyMetrics: official P/E, dividend yield, P/B from BWIBBU_ALL + tpex_mainboard_peratio_analysis - EquityInfo: company profiles from t187ap03_L + mopsfin_t187ap03_O - shared helpers (models/common.ts): ROC-date→ISO, numeric-string parsing (empty→null), .TW/.TWO symbol parsing, lazy per-board fetch SymbolIndex now sources ['sec', 'twse'] and invalidates its cache when SOURCES changes (previously a stale cache could mask a new source for up to 24h). EquityHistorical deliberately omitted — the open APIs expose no per-symbol history endpoint; yfinance keeps serving .TW/.TWO history. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opentypebb/src/core/api/app-loader.ts | 2 + .../providers/twse/__tests__/common.spec.ts | 73 ++++++ .../twse/__tests__/equity-info.spec.ts | 123 ++++++++++ .../twse/__tests__/equity-quote.spec.ts | 108 ++++++++ .../twse/__tests__/equity-search.spec.ts | 95 +++++++ .../twse/__tests__/key-metrics.spec.ts | 80 ++++++ .../opentypebb/src/providers/twse/index.ts | 25 ++ .../src/providers/twse/models/common.ts | 68 ++++++ .../src/providers/twse/models/equity-info.ts | 231 ++++++++++++++++++ .../src/providers/twse/models/equity-quote.ts | 194 +++++++++++++++ .../providers/twse/models/equity-search.ts | 165 +++++++++++++ .../src/providers/twse/models/key-metrics.ts | 153 ++++++++++++ .../bbProviders/twse.bbProvider.spec.ts | 100 ++++++++ .../bbProviders/yfinance.bbProvider.spec.ts | 11 + .../market-data/equity/symbol-index.spec.ts | 92 +++++++ src/domain/market-data/equity/symbol-index.ts | 15 +- 16 files changed, 1531 insertions(+), 4 deletions(-) create mode 100644 packages/opentypebb/src/providers/twse/__tests__/common.spec.ts create mode 100644 packages/opentypebb/src/providers/twse/__tests__/equity-info.spec.ts create mode 100644 packages/opentypebb/src/providers/twse/__tests__/equity-quote.spec.ts create mode 100644 packages/opentypebb/src/providers/twse/__tests__/equity-search.spec.ts create mode 100644 packages/opentypebb/src/providers/twse/__tests__/key-metrics.spec.ts create mode 100644 packages/opentypebb/src/providers/twse/index.ts create mode 100644 packages/opentypebb/src/providers/twse/models/common.ts create mode 100644 packages/opentypebb/src/providers/twse/models/equity-info.ts create mode 100644 packages/opentypebb/src/providers/twse/models/equity-quote.ts create mode 100644 packages/opentypebb/src/providers/twse/models/equity-search.ts create mode 100644 packages/opentypebb/src/providers/twse/models/key-metrics.ts create mode 100644 src/domain/market-data/__tests__/bbProviders/twse.bbProvider.spec.ts create mode 100644 src/domain/market-data/equity/symbol-index.spec.ts diff --git a/packages/opentypebb/src/core/api/app-loader.ts b/packages/opentypebb/src/core/api/app-loader.ts index fdb33c52b..a34f3e996 100644 --- a/packages/opentypebb/src/core/api/app-loader.ts +++ b/packages/opentypebb/src/core/api/app-loader.ts @@ -26,6 +26,7 @@ import { intrinioProvider } from '../../providers/intrinio/index.js' import { blsProvider } from '../../providers/bls/index.js' import { eiaProvider } from '../../providers/eia/index.js' import { secProvider } from '../../providers/sec/index.js' +import { twseProvider } from '../../providers/twse/index.js' import { stubProvider } from '../../providers/stub/index.js' // --- Extension routers --- @@ -59,6 +60,7 @@ export function createRegistry(): Registry { registry.includeProvider(blsProvider) registry.includeProvider(eiaProvider) registry.includeProvider(secProvider) + registry.includeProvider(twseProvider) registry.includeProvider(stubProvider) return registry } diff --git a/packages/opentypebb/src/providers/twse/__tests__/common.spec.ts b/packages/opentypebb/src/providers/twse/__tests__/common.spec.ts new file mode 100644 index 000000000..8554745c6 --- /dev/null +++ b/packages/opentypebb/src/providers/twse/__tests__/common.spec.ts @@ -0,0 +1,73 @@ +/** + * Unit tests for shared TWSE provider helpers. + * + * Raw value fixtures mirror live API shapes (verified 2026-06-08): + * - ROC dates: "1150605" (= 2026-06-05) + * - Numeric strings: "14.55", "-0.3100", "+0.06", "" (empty = no data) + */ + +import { describe, it, expect } from 'vitest' +import { rocToIso, toNum, parseTwSymbol, boardsNeeded } from '../models/common.js' + +describe('rocToIso', () => { + it('converts ROC calendar dates to ISO', () => { + expect(rocToIso('1150605')).toBe('2026-06-05') + expect(rocToIso('0991231')).toBe('2010-12-31') + }) + + it('returns null for empty or malformed input', () => { + expect(rocToIso('')).toBeNull() + expect(rocToIso('115')).toBeNull() + expect(rocToIso(undefined)).toBeNull() + }) +}) + +describe('toNum', () => { + it('parses plain and signed numeric strings', () => { + expect(toNum('14.55')).toBe(14.55) + expect(toNum('-0.3100')).toBe(-0.31) + expect(toNum('+0.06')).toBe(0.06) + expect(toNum('60780296')).toBe(60780296) + }) + + it('strips thousands separators', () => { + expect(toNum('1,234,567')).toBe(1234567) + }) + + it('returns null for empty / non-numeric values', () => { + expect(toNum('')).toBeNull() + expect(toNum('--')).toBeNull() + expect(toNum(undefined)).toBeNull() + }) +}) + +describe('parseTwSymbol', () => { + it('parses Yahoo-suffixed symbols into code + board', () => { + expect(parseTwSymbol('2330.TW')).toEqual({ code: '2330', board: 'TWSE' }) + expect(parseTwSymbol('6488.TWO')).toEqual({ code: '6488', board: 'TPEX' }) + }) + + it('bare codes have no board (search both)', () => { + expect(parseTwSymbol('2330')).toEqual({ code: '2330', board: undefined }) + }) + + it('is case-insensitive on the suffix', () => { + expect(parseTwSymbol('2330.tw')).toEqual({ code: '2330', board: 'TWSE' }) + }) +}) + +describe('boardsNeeded', () => { + it('suffix-only queries touch only the needed board', () => { + expect(boardsNeeded([parseTwSymbol('2330.TW')])).toEqual({ twse: true, tpex: false }) + expect(boardsNeeded([parseTwSymbol('6488.TWO')])).toEqual({ twse: false, tpex: true }) + }) + + it('bare codes need both boards', () => { + expect(boardsNeeded([parseTwSymbol('2330')])).toEqual({ twse: true, tpex: true }) + }) + + it('mixed queries union the boards', () => { + expect(boardsNeeded([parseTwSymbol('2330.TW'), parseTwSymbol('6488.TWO')])) + .toEqual({ twse: true, tpex: true }) + }) +}) diff --git a/packages/opentypebb/src/providers/twse/__tests__/equity-info.spec.ts b/packages/opentypebb/src/providers/twse/__tests__/equity-info.spec.ts new file mode 100644 index 000000000..fe91dabec --- /dev/null +++ b/packages/opentypebb/src/providers/twse/__tests__/equity-info.spec.ts @@ -0,0 +1,123 @@ +/** + * Unit tests for the TWSE EquityInfo fetcher's pure transform logic. + * + * Raw API fixtures mirror the live shapes (verified 2026-06-08): + * - TWSE t187ap03_L: Chinese keys (公司代號, 公司名稱, …), ROC 出表日期, + * Gregorian 成立日期/上市日期 ("19501229"), "- " as the null marker. + * - TPEx mopsfin_t187ap03_O: English keys (SecuritiesCompanyCode, …), + * trailing full-width spaces in Symbol / WebAddress. + */ + +import { describe, it, expect } from 'vitest' +import { TwseEquityInfoFetcher, type TwseInfoRaw } from '../models/equity-info.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' + +const RAW: TwseInfoRaw = { + twse: [ + { + 出表日期: '1150606', + 公司代號: '1101', + 公司名稱: '臺灣水泥股份有限公司', + 公司簡稱: '台泥', + 外國企業註冊地國: '- ', + 產業別: '01', + 住址: '台北市中山北路2段113號', + 營利事業統一編號: '11913502', + 董事長: '張安平', + 總經理: '程耀輝', + 發言人: '葉毓君', + 總機電話: '(02)2531-7099', + 成立日期: '19501229', + 上市日期: '19620209', + 實收資本額: '77231817420', + 英文簡稱: 'TCC', + 電子郵件信箱: 'finance@taiwancement.com', + 網址: 'https://www.tccgroupholdings.com/tw/', + 已發行普通股數或TDR原股發行股數: '7523181742', + }, + ], + tpex: [ + { + Date: '1150607', + SecuritiesCompanyCode: '1240', + CompanyName: '茂生農經股份有限公司', + CompanyAbbreviation: '茂生農經', + Registration: '- ', + SecuritiesIndustryCode: '33', + Address: '2F.,No.30,Sec. 1,Heping W.Rd.,Taipei City', + 'UnifiedBusinessNo.': '18795706', + Chairman: '吳清德', + GeneralManager: '吳清德', + Spokesman: '林信鴻', + Telephone: '02-23671162', + DateOfIncorporation: '19670218', + DateOfListing: '20180808', + 'Paidin.Capital.NTDollars': '442323730', + Symbol: 'MORNSUN ', + EmailAddress: 'bedford@morn-sun.com.tw', + WebAddress: 'https://www.morn-sun.com.tw/ ', + IssueShares: '44232373', + }, + ], +} + +const fetchInfo = (symbol: string) => + TwseEquityInfoFetcher.transformData( + TwseEquityInfoFetcher.transformQuery({ symbol }), + RAW, + ) + +describe('TwseEquityInfoFetcher.transformData', () => { + it('maps a TWSE-listed company profile', () => { + const [info] = fetchInfo('1101.TW') + expect(info).toMatchObject({ + symbol: '1101.TW', + name: '台泥 (TCC)', + legal_name: '臺灣水泥股份有限公司', + stock_exchange: 'TWSE', + ceo: '程耀輝', + chairman: '張安平', + company_url: 'https://www.tccgroupholdings.com/tw/', + business_address: '台北市中山北路2段113號', + business_phone_no: '(02)2531-7099', + hq_country: 'TW', + industry_category: '01', + founded_date: '1950-12-29', + listed_date: '1962-02-09', + paid_in_capital: 77231817420, + issued_shares: 7523181742, + tax_id: '11913502', + email: 'finance@taiwancement.com', + }) + }) + + it('maps a TPEx company profile, stripping full-width spaces', () => { + const [info] = fetchInfo('1240.TWO') + expect(info).toMatchObject({ + symbol: '1240.TWO', + name: '茂生農經 (MORNSUN)', + legal_name: '茂生農經股份有限公司', + stock_exchange: 'TPEX', + ceo: '吳清德', + company_url: 'https://www.morn-sun.com.tw/', + founded_date: '1967-02-18', + listed_date: '2018-08-08', + paid_in_capital: 442323730, + issued_shares: 44232373, + }) + }) + + it('treats "-" registration as domestic (TW)', () => { + const [info] = fetchInfo('1101.TW') + expect(info.inc_country).toBe('TW') + }) + + it('resolves bare codes across both boards', () => { + expect(fetchInfo('1101')[0]?.symbol).toBe('1101.TW') + expect(fetchInfo('1240')[0]?.symbol).toBe('1240.TWO') + }) + + it('throws EmptyDataError when no symbol matches', () => { + expect(() => fetchInfo('0000.TW')).toThrow(EmptyDataError) + }) +}) diff --git a/packages/opentypebb/src/providers/twse/__tests__/equity-quote.spec.ts b/packages/opentypebb/src/providers/twse/__tests__/equity-quote.spec.ts new file mode 100644 index 000000000..e64474818 --- /dev/null +++ b/packages/opentypebb/src/providers/twse/__tests__/equity-quote.spec.ts @@ -0,0 +1,108 @@ +/** + * Unit tests for the TWSE EquityQuote fetcher's pure transform logic. + * + * Raw API fixtures mirror the live shapes (verified 2026-06-08): + * - TWSE STOCK_DAY_ALL: { Date, Code, Name, TradeVolume, TradeValue, + * OpeningPrice, HighestPrice, LowestPrice, ClosingPrice, Change, Transaction } + * - TPEx tpex_mainboard_quotes: { Date, SecuritiesCompanyCode, CompanyName, + * Close, Change, Open, High, Low, TradingShares, TransactionAmount, + * TransactionNumber, LatestBidPrice, LatesAskPrice, ... } + */ + +import { describe, it, expect } from 'vitest' +import { TwseEquityQuoteFetcher, type TwseQuoteRaw } from '../models/equity-quote.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' + +const RAW: TwseQuoteRaw = { + twse: [ + { + Date: '1150605', Code: '2330', Name: '台積電', + TradeVolume: '21882762', TradeValue: '21002472302', + OpeningPrice: '960.00', HighestPrice: '965.00', LowestPrice: '955.00', + ClosingPrice: '960.00', Change: '-5.0000', Transaction: '28491', + }, + { + // Halted / no-trade row — prices arrive as empty strings. + Date: '1150605', Code: '9999', Name: '停牌股', + TradeVolume: '0', TradeValue: '0', + OpeningPrice: '', HighestPrice: '', LowestPrice: '', + ClosingPrice: '', Change: '', Transaction: '0', + }, + ], + tpex: [ + { + Date: '1150605', SecuritiesCompanyCode: '6488', CompanyName: '環球晶', + Close: '380.00', Change: '+2.50', Open: '378.00', High: '382.00', Low: '376.50', + TradingShares: '1234567', TransactionAmount: '469135460', TransactionNumber: '2345', + LatestBidPrice: '379.50', LatesAskPrice: '380.00', + }, + ], +} + +const fetchQuotes = (symbol: string) => + TwseEquityQuoteFetcher.transformData( + TwseEquityQuoteFetcher.transformQuery({ symbol }), + RAW, + ) + +describe('TwseEquityQuoteFetcher.transformData', () => { + it('maps a TWSE-listed row to the standard quote shape', () => { + const [q] = fetchQuotes('2330.TW') + expect(q).toMatchObject({ + symbol: '2330.TW', + name: '台積電', + exchange: 'TWSE', + open: 960, high: 965, low: 955, close: 960, + last_price: 960, + volume: 21882762, + change: -5, + prev_close: 965, + last_timestamp: '2026-06-05', + currency: 'TWD', + transactions: 28491, + trade_value: 21002472302, + }) + expect(q.change_percent).toBeCloseTo(-5 / 965, 6) + expect(q.bid).toBeNull() + expect(q.ask).toBeNull() + }) + + it('maps a TPEx row including bid/ask and signed change', () => { + const [q] = fetchQuotes('6488.TWO') + expect(q).toMatchObject({ + symbol: '6488.TWO', + name: '環球晶', + exchange: 'TPEX', + open: 378, high: 382, low: 376.5, close: 380, + change: 2.5, + prev_close: 377.5, + volume: 1234567, + bid: 379.5, + ask: 380, + last_timestamp: '2026-06-05', + }) + }) + + it('resolves a bare code on TWSE first, falling back to TPEx', () => { + expect(fetchQuotes('2330')[0]?.symbol).toBe('2330.TW') + expect(fetchQuotes('6488')[0]?.symbol).toBe('6488.TWO') + }) + + it('supports comma-separated multi-symbol queries', () => { + const quotes = fetchQuotes('2330.TW,6488.TWO') + expect(quotes.map((q) => q.symbol)).toEqual(['2330.TW', '6488.TWO']) + }) + + it('nulls out empty-string prices (no-trade rows)', () => { + const [q] = fetchQuotes('9999.TW') + expect(q.open).toBeNull() + expect(q.close).toBeNull() + expect(q.change).toBeNull() + expect(q.prev_close).toBeNull() + expect(q.change_percent).toBeNull() + }) + + it('throws EmptyDataError when no symbol matches', () => { + expect(() => fetchQuotes('0000.TW')).toThrow(EmptyDataError) + }) +}) diff --git a/packages/opentypebb/src/providers/twse/__tests__/equity-search.spec.ts b/packages/opentypebb/src/providers/twse/__tests__/equity-search.spec.ts new file mode 100644 index 000000000..f955e30d3 --- /dev/null +++ b/packages/opentypebb/src/providers/twse/__tests__/equity-search.spec.ts @@ -0,0 +1,95 @@ +/** + * Unit tests for the TWSE EquitySearch fetcher's pure transform logic. + * + * Raw API fixtures mirror the live shapes (verified 2026-06-07): + * - TWSE STOCK_DAY_ALL: { Code, Name, ... } + * - TPEx tpex_mainboard_quotes: { SecuritiesCompanyCode, CompanyName, ... } + * - TWSE t187ap03_L: { 公司代號, 公司簡稱, 英文簡稱, ... } + */ + +import { describe, it, expect } from 'vitest' +import { + mergeTwSources, + TwseEquitySearchFetcher, + type TwSecurityEntry, +} from '../models/equity-search.js' + +const TWSE_DAILY = [ + { Code: '2330', Name: '台積電' }, + { Code: '0050', Name: '元大台灣50' }, +] + +const TPEX_QUOTES = [ + { SecuritiesCompanyCode: '6488', CompanyName: '環球晶' }, + { SecuritiesCompanyCode: '00679B', CompanyName: '元大美債20年' }, +] + +const TWSE_PROFILES = [ + { 公司代號: '2330', 公司簡稱: '台積電', 英文簡稱: 'TSMC' }, +] + +describe('mergeTwSources', () => { + it('merges listed + OTC with board tags and English names', () => { + const merged = mergeTwSources(TWSE_DAILY, TPEX_QUOTES, TWSE_PROFILES) + expect(merged).toHaveLength(4) + + const tsmc = merged.find((e) => e.code === '2330') + expect(tsmc).toMatchObject({ code: '2330', name: '台積電', nameEn: 'TSMC', board: 'TWSE' }) + + const gw = merged.find((e) => e.code === '6488') + expect(gw).toMatchObject({ code: '6488', name: '環球晶', board: 'TPEX' }) + expect(gw?.nameEn).toBeUndefined() + }) + + it('tolerates an empty profiles list (English names optional)', () => { + const merged = mergeTwSources(TWSE_DAILY, TPEX_QUOTES, []) + expect(merged).toHaveLength(4) + expect(merged.every((e) => e.nameEn === undefined)).toBe(true) + }) +}) + +describe('TwseEquitySearchFetcher.transformData', () => { + const ENTRIES: TwSecurityEntry[] = [ + { code: '2330', name: '台積電', nameEn: 'TSMC', board: 'TWSE' }, + { code: '0050', name: '元大台灣50', board: 'TWSE' }, + { code: '6488', name: '環球晶', board: 'TPEX' }, + ] + + const transform = (query: string) => + TwseEquitySearchFetcher.transformData( + TwseEquitySearchFetcher.transformQuery({ query }), + ENTRIES, + ) + + it('suffixes listed securities with .TW and OTC with .TWO', () => { + const all = transform('') + expect(all.map((d) => d.symbol)).toEqual(['2330.TW', '0050.TW', '6488.TWO']) + expect(all.map((d) => d.exchange)).toEqual(['TWSE', 'TWSE', 'TPEX']) + }) + + it('appends the English short name when available', () => { + const all = transform('') + expect(all[0].name).toBe('台積電 (TSMC)') + expect(all[1].name).toBe('元大台灣50') + }) + + it('empty query returns all entries (bulk load for SymbolIndex)', () => { + expect(transform('')).toHaveLength(3) + }) + + it('filters by code', () => { + const hits = transform('2330') + expect(hits).toHaveLength(1) + expect(hits[0].symbol).toBe('2330.TW') + }) + + it('filters by Chinese name', () => { + const hits = transform('台積電') + expect(hits.map((d) => d.symbol)).toEqual(['2330.TW']) + }) + + it('filters by English name, case-insensitive', () => { + const hits = transform('tsmc') + expect(hits.map((d) => d.symbol)).toEqual(['2330.TW']) + }) +}) diff --git a/packages/opentypebb/src/providers/twse/__tests__/key-metrics.spec.ts b/packages/opentypebb/src/providers/twse/__tests__/key-metrics.spec.ts new file mode 100644 index 000000000..1a2f112a5 --- /dev/null +++ b/packages/opentypebb/src/providers/twse/__tests__/key-metrics.spec.ts @@ -0,0 +1,80 @@ +/** + * Unit tests for the TWSE KeyMetrics fetcher's pure transform logic. + * + * Raw API fixtures mirror the live shapes (verified 2026-06-08): + * - TWSE BWIBBU_ALL: { Date, Code, Name, PEratio, DividendYield, PBratio } + * (PEratio is "" for loss-making companies — e.g. 台泥 on 2026-06-05) + * - TPEx tpex_mainboard_peratio_analysis: { Date, SecuritiesCompanyCode, + * CompanyName, PriceEarningRatio, DividendPerShare, YieldRatio, PriceBookRatio } + */ + +import { describe, it, expect } from 'vitest' +import { TwseKeyMetricsFetcher, type TwseKeyMetricsRaw } from '../models/key-metrics.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' + +const RAW: TwseKeyMetricsRaw = { + twse: [ + { Date: '1150605', Code: '1101', Name: '台泥', PEratio: '', DividendYield: '3.28', PBratio: '0.78' }, + { Date: '1150605', Code: '2330', Name: '台積電', PEratio: '23.50', DividendYield: '1.85', PBratio: '6.10' }, + ], + tpex: [ + { + Date: '1150605', SecuritiesCompanyCode: '6488', CompanyName: '環球晶', + PriceEarningRatio: '18.75', DividendPerShare: '16.00000000', + YieldRatio: '4.21', PriceBookRatio: '2.35', + }, + ], +} + +const fetchMetrics = (symbol: string) => + TwseKeyMetricsFetcher.transformData( + TwseKeyMetricsFetcher.transformQuery({ symbol }), + RAW, + ) + +describe('TwseKeyMetricsFetcher.transformData', () => { + it('maps TWSE valuation ratios with ISO snapshot date', () => { + const [m] = fetchMetrics('2330.TW') + expect(m).toMatchObject({ + symbol: '2330.TW', + name: '台積電', + price_to_earnings: 23.5, + dividend_yield: 1.85, + price_to_book: 6.1, + period_ending: '2026-06-05', + currency: 'TWD', + }) + }) + + it('nulls an empty PEratio (loss-making company)', () => { + const [m] = fetchMetrics('1101.TW') + expect(m.price_to_earnings).toBeNull() + expect(m.dividend_yield).toBe(3.28) + expect(m.price_to_book).toBe(0.78) + }) + + it('maps TPEx ratios including dividend per share', () => { + const [m] = fetchMetrics('6488.TWO') + expect(m).toMatchObject({ + symbol: '6488.TWO', + price_to_earnings: 18.75, + dividend_yield: 4.21, + price_to_book: 2.35, + dividend_per_share: 16, + }) + }) + + it('resolves bare codes across both boards', () => { + expect(fetchMetrics('2330')[0]?.symbol).toBe('2330.TW') + expect(fetchMetrics('6488')[0]?.symbol).toBe('6488.TWO') + }) + + it('supports comma-separated multi-symbol queries', () => { + const all = fetchMetrics('2330.TW,6488.TWO') + expect(all.map((m) => m.symbol)).toEqual(['2330.TW', '6488.TWO']) + }) + + it('throws EmptyDataError when no symbol matches', () => { + expect(() => fetchMetrics('0000.TW')).toThrow(EmptyDataError) + }) +}) diff --git a/packages/opentypebb/src/providers/twse/index.ts b/packages/opentypebb/src/providers/twse/index.ts new file mode 100644 index 000000000..0a443d2a9 --- /dev/null +++ b/packages/opentypebb/src/providers/twse/index.ts @@ -0,0 +1,25 @@ +/** + * TWSE Provider. + * + * Taiwan Stock Exchange + Taipei Exchange (TPEx) open data. + * Sources: https://openapi.twse.com.tw/ and https://www.tpex.org.tw/openapi/ + * Free, no API key required. + */ + +import { Provider } from '../../core/provider/abstract/provider.js' +import { TwseEquitySearchFetcher } from './models/equity-search.js' +import { TwseEquityQuoteFetcher } from './models/equity-quote.js' +import { TwseKeyMetricsFetcher } from './models/key-metrics.js' +import { TwseEquityInfoFetcher } from './models/equity-info.js' + +export const twseProvider = new Provider({ + name: 'twse', + description: 'TWSE / TPEx open data — Taiwan securities enumeration, quotes, valuation ratios, and company profiles.', + website: 'https://openapi.twse.com.tw/', + fetcherDict: { + EquitySearch: TwseEquitySearchFetcher, + EquityQuote: TwseEquityQuoteFetcher, + KeyMetrics: TwseKeyMetricsFetcher, + EquityInfo: TwseEquityInfoFetcher, + }, +}) diff --git a/packages/opentypebb/src/providers/twse/models/common.ts b/packages/opentypebb/src/providers/twse/models/common.ts new file mode 100644 index 000000000..a522d1d53 --- /dev/null +++ b/packages/opentypebb/src/providers/twse/models/common.ts @@ -0,0 +1,68 @@ +/** + * Shared helpers for the TWSE provider's data fetchers. + * + * Conventions across TWSE / TPEx open-data endpoints (verified live 2026-06-08): + * - Dates use the ROC calendar packed as "YYYMMDD" (e.g. "1150605" = 2026-06-05). + * - All numbers arrive as strings; empty string means "no data". TPEx signs + * changes ("+0.06"), TWSE uses plain negatives ("-0.3100"). + * - Symbols follow the Yahoo suffix convention established by EquitySearch: + * `2330.TW` (TWSE listed) / `6488.TWO` (TPEx OTC); bare codes match either. + */ + +export type TwBoard = 'TWSE' | 'TPEX' + +export interface ParsedTwSymbol { + code: string + /** undefined = no suffix — search both boards. */ + board: TwBoard | undefined +} + +export const TW_HEADERS = { Accept: 'application/json' } + +/** ROC packed date ("1150605") → ISO ("2026-06-05"). Null on empty/malformed. */ +export function rocToIso(value: string | undefined): string | null { + if (!value || !/^\d{6,7}$/.test(value)) return null + const rocYear = Number(value.slice(0, value.length - 4)) + const month = value.slice(-4, -2) + const day = value.slice(-2) + return `${rocYear + 1911}-${month}-${day}` +} + +/** Numeric string → number. Tolerates "+" signs and thousands separators; null on empty/non-numeric. */ +export function toNum(value: string | undefined): number | null { + if (value === undefined) return null + const cleaned = value.replace(/,/g, '').replace(/^\+/, '').trim() + if (cleaned === '') return null + const n = Number(cleaned) + return Number.isFinite(n) ? n : null +} + +/** Split a Yahoo-suffixed Taiwan symbol into code + board. */ +export function parseTwSymbol(symbol: string): ParsedTwSymbol { + const upper = symbol.trim().toUpperCase() + if (upper.endsWith('.TWO')) return { code: upper.slice(0, -4), board: 'TPEX' } + if (upper.endsWith('.TW')) return { code: upper.slice(0, -3), board: 'TWSE' } + return { code: upper, board: undefined } +} + +/** Which board-wide snapshot lists must be fetched to resolve these symbols. */ +export function boardsNeeded(symbols: ParsedTwSymbol[]): { twse: boolean; tpex: boolean } { + let twse = false + let tpex = false + for (const s of symbols) { + if (s.board === 'TWSE') twse = true + else if (s.board === 'TPEX') tpex = true + else { twse = true; tpex = true } + } + return { twse, tpex } +} + +/** Yahoo-suffix a code for its board. */ +export function toYahooSymbol(code: string, board: TwBoard): string { + return `${code}.${board === 'TWSE' ? 'TW' : 'TWO'}` +} + +/** Parse a comma-separated symbol query into distinct parsed symbols. */ +export function parseSymbolList(symbol: string): ParsedTwSymbol[] { + return symbol.split(',').map((s) => s.trim()).filter(Boolean).map(parseTwSymbol) +} diff --git a/packages/opentypebb/src/providers/twse/models/equity-info.ts b/packages/opentypebb/src/providers/twse/models/equity-info.ts new file mode 100644 index 000000000..0160cb2e7 --- /dev/null +++ b/packages/opentypebb/src/providers/twse/models/equity-info.ts @@ -0,0 +1,231 @@ +/** + * TWSE Equity Info Fetcher. + * + * Company profiles for Taiwan securities from the free official open-data + * APIs (no API key) — chairman / CEO, addresses, incorporation & listing + * dates, capital structure. Board-wide snapshots — extractData fetches + * only the board(s) the queried symbols need. + * + * Sources (shapes verified live 2026-06-08): + * - https://openapi.twse.com.tw/v1/opendata/t187ap03_L + * TWSE listed (.TW) — Chinese keys (公司代號, 公司名稱, 董事長, …). + * 成立日期/上市日期 are Gregorian "YYYYMMDD"; 出表日期 is ROC. + * - https://www.tpex.org.tw/openapi/v1/mopsfin_t187ap03_O + * TPEx OTC (.TWO) — English keys (SecuritiesCompanyCode, Chairman, …). + * String values may carry trailing full-width spaces; "-" marks null. + * + * 產業別 / SecuritiesIndustryCode is emitted verbatim into + * `industry_category` — the exchanges publish no open code→name table, and + * the two boards use different numbering, so no mapping is attempted here. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { EquityInfoQueryParamsSchema, EquityInfoDataSchema } from '../../../standard-models/equity-info.js' +import { + TW_HEADERS, toNum, toYahooSymbol, parseSymbolList, boardsNeeded, + type ParsedTwSymbol, +} from './common.js' + +// ==================== Provider-specific schemas ==================== + +export const TwseEquityInfoQueryParamsSchema = EquityInfoQueryParamsSchema +export type TwseEquityInfoQueryParams = z.infer + +export const TwseEquityInfoDataSchema = EquityInfoDataSchema.extend({ + chairman: z.string().nullable().default(null).describe('Chairman of the board.'), + spokesman: z.string().nullable().default(null).describe('Company spokesperson.'), + founded_date: z.string().nullable().default(null).describe('Date of incorporation (ISO).'), + listed_date: z.string().nullable().default(null).describe('Date of exchange listing (ISO).'), + paid_in_capital: z.number().nullable().default(null).describe('Paid-in capital, in TWD.'), + issued_shares: z.number().nullable().default(null).describe('Issued common shares.'), + tax_id: z.string().nullable().default(null).describe('Unified business number (統一編號).'), + email: z.string().nullable().default(null).describe('Investor-relations email address.'), +}).strip() +export type TwseEquityInfoData = z.infer + +// ==================== Raw API shapes ==================== + +export interface TwseProfileRow { + 公司代號: string + 公司名稱: string + 公司簡稱: string + 外國企業註冊地國: string + 產業別: string + 住址: string + 營利事業統一編號: string + 董事長: string + 總經理: string + 發言人: string + 總機電話: string + 成立日期: string + 上市日期: string + 實收資本額: string + 英文簡稱: string + 電子郵件信箱: string + 網址: string + 已發行普通股數或TDR原股發行股數: string + [key: string]: unknown +} + +export interface TpexProfileRow { + SecuritiesCompanyCode: string + CompanyName: string + CompanyAbbreviation: string + Registration: string + SecuritiesIndustryCode: string + Address: string + 'UnifiedBusinessNo.': string + Chairman: string + GeneralManager: string + Spokesman: string + Telephone: string + DateOfIncorporation: string + DateOfListing: string + 'Paidin.Capital.NTDollars': string + /** English short name — the API's own key for it. */ + Symbol: string + EmailAddress: string + WebAddress: string + IssueShares: string + [key: string]: unknown +} + +/** Board-wide snapshots — boards not needed by the query stay empty. */ +export interface TwseInfoRaw { + twse: TwseProfileRow[] + tpex: TpexProfileRow[] +} + +// ==================== Endpoints ==================== + +const TWSE_PROFILE_URL = 'https://openapi.twse.com.tw/v1/opendata/t187ap03_L' +const TPEX_PROFILE_URL = 'https://www.tpex.org.tw/openapi/v1/mopsfin_t187ap03_O' + +// ==================== Value cleaning ==================== + +/** Trim ASCII + full-width spaces; "" and "-" become null. */ +function cleanStr(value: string | undefined): string | null { + const trimmed = value?.replace(/[\s ]+$/g, '').replace(/^[\s ]+/g, '') + if (!trimmed || trimmed === '-') return null + return trimmed +} + +/** Gregorian packed date ("19501229") → ISO ("1950-12-29"). */ +function ymdToIso(value: string | undefined): string | null { + if (!value || !/^\d{8}$/.test(value)) return null + return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6)}` +} + +/** "台泥" + "TCC" → "台泥 (TCC)" — same convention as EquitySearch. */ +function displayName(short: string | null, english: string | null): string | null { + if (!short) return english + return english ? `${short} (${english})` : short +} + +// ==================== Row mapping ==================== + +function mapTwseRow(row: TwseProfileRow): TwseEquityInfoData { + // 外國企業註冊地國 is "-" for domestic companies. + const registration = cleanStr(row.外國企業註冊地國) + return TwseEquityInfoDataSchema.parse({ + symbol: toYahooSymbol(row.公司代號, 'TWSE'), + name: displayName(cleanStr(row.公司簡稱), cleanStr(row.英文簡稱)), + legal_name: cleanStr(row.公司名稱), + stock_exchange: 'TWSE', + ceo: cleanStr(row.總經理), + chairman: cleanStr(row.董事長), + spokesman: cleanStr(row.發言人), + company_url: cleanStr(row.網址), + business_address: cleanStr(row.住址), + business_phone_no: cleanStr(row.總機電話), + hq_country: 'TW', + inc_country: registration ?? 'TW', + industry_category: cleanStr(row.產業別), + founded_date: ymdToIso(row.成立日期), + listed_date: ymdToIso(row.上市日期), + paid_in_capital: toNum(row.實收資本額), + issued_shares: toNum(row.已發行普通股數或TDR原股發行股數), + tax_id: cleanStr(row.營利事業統一編號), + email: cleanStr(row.電子郵件信箱), + }) +} + +function mapTpexRow(row: TpexProfileRow): TwseEquityInfoData { + const registration = cleanStr(row.Registration) + return TwseEquityInfoDataSchema.parse({ + symbol: toYahooSymbol(row.SecuritiesCompanyCode, 'TPEX'), + name: displayName(cleanStr(row.CompanyAbbreviation), cleanStr(row.Symbol)), + legal_name: cleanStr(row.CompanyName), + stock_exchange: 'TPEX', + ceo: cleanStr(row.GeneralManager), + chairman: cleanStr(row.Chairman), + spokesman: cleanStr(row.Spokesman), + company_url: cleanStr(row.WebAddress), + business_address: cleanStr(row.Address), + business_phone_no: cleanStr(row.Telephone), + hq_country: 'TW', + inc_country: registration ?? 'TW', + industry_category: cleanStr(row.SecuritiesIndustryCode), + founded_date: ymdToIso(row.DateOfIncorporation), + listed_date: ymdToIso(row.DateOfListing), + paid_in_capital: toNum(row['Paidin.Capital.NTDollars']), + issued_shares: toNum(row.IssueShares), + tax_id: cleanStr(row['UnifiedBusinessNo.']), + email: cleanStr(row.EmailAddress), + }) +} + +/** Resolve one queried symbol against the fetched boards — TWSE wins for bare codes. */ +function resolveSymbol(parsed: ParsedTwSymbol, raw: TwseInfoRaw): TwseEquityInfoData | null { + if (parsed.board !== 'TPEX') { + const hit = raw.twse.find((r) => r.公司代號 === parsed.code) + if (hit) return mapTwseRow(hit) + } + if (parsed.board !== 'TWSE') { + const hit = raw.tpex.find((r) => r.SecuritiesCompanyCode === parsed.code) + if (hit) return mapTpexRow(hit) + } + return null +} + +// ==================== Fetcher ==================== + +export class TwseEquityInfoFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): TwseEquityInfoQueryParams { + return TwseEquityInfoQueryParamsSchema.parse(params) + } + + static override async extractData( + query: TwseEquityInfoQueryParams, + _credentials: Record | null, + ): Promise { + const needed = boardsNeeded(parseSymbolList(query.symbol)) + const [twse, tpex] = await Promise.all([ + needed.twse + ? amakeRequest(TWSE_PROFILE_URL, { headers: TW_HEADERS }) + : Promise.resolve([] as TwseProfileRow[]), + needed.tpex + ? amakeRequest(TPEX_PROFILE_URL, { headers: TW_HEADERS }) + : Promise.resolve([] as TpexProfileRow[]), + ]) + return { twse, tpex } + } + + static override transformData( + query: TwseEquityInfoQueryParams, + data: TwseInfoRaw, + ): TwseEquityInfoData[] { + const results = parseSymbolList(query.symbol) + .map((parsed) => resolveSymbol(parsed, data)) + .filter((i): i is TwseEquityInfoData => i !== null) + if (results.length === 0) { + throw new EmptyDataError(`No Taiwan company profiles found for: ${query.symbol}`) + } + return results + } +} diff --git a/packages/opentypebb/src/providers/twse/models/equity-quote.ts b/packages/opentypebb/src/providers/twse/models/equity-quote.ts new file mode 100644 index 000000000..f32e6ec42 --- /dev/null +++ b/packages/opentypebb/src/providers/twse/models/equity-quote.ts @@ -0,0 +1,194 @@ +/** + * TWSE Equity Quote Fetcher. + * + * Latest-trading-day quote for Taiwan securities from the free official + * open-data APIs (no API key). Both endpoints are board-wide snapshots — + * extractData fetches only the board(s) the queried symbols need, then + * transformData filters down to the requested codes. + * + * Sources (shapes verified live 2026-06-08): + * - https://openapi.twse.com.tw/v1/exchangeReport/STOCK_DAY_ALL + * TWSE listed (.TW) — { Date, Code, Name, TradeVolume, TradeValue, + * OpeningPrice, HighestPrice, LowestPrice, ClosingPrice, Change, Transaction } + * - https://www.tpex.org.tw/openapi/v1/tpex_mainboard_quotes + * TPEx OTC (.TWO) — { Date, SecuritiesCompanyCode, CompanyName, Close, + * Change, Open, High, Low, TradingShares, TransactionAmount, + * TransactionNumber, LatestBidPrice, LatesAskPrice, ... } + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { EquityQuoteQueryParamsSchema, EquityQuoteDataSchema } from '../../../standard-models/equity-quote.js' +import { + TW_HEADERS, rocToIso, toNum, toYahooSymbol, parseSymbolList, boardsNeeded, + type ParsedTwSymbol, +} from './common.js' + +// ==================== Provider-specific schemas ==================== + +export const TwseEquityQuoteQueryParamsSchema = EquityQuoteQueryParamsSchema +export type TwseEquityQuoteQueryParams = z.infer + +export const TwseEquityQuoteDataSchema = EquityQuoteDataSchema.extend({ + currency: z.string().nullable().default(null).describe('Currency of the price (always TWD).'), + trade_value: z.number().nullable().default(null).describe('Total traded value for the day, in TWD.'), + transactions: z.number().int().nullable().default(null).describe('Number of transactions for the day.'), +}).strip() +export type TwseEquityQuoteData = z.infer + +// ==================== Raw API shapes ==================== + +export interface TwseStockDayAllRow { + Date: string + Code: string + Name: string + TradeVolume: string + TradeValue: string + OpeningPrice: string + HighestPrice: string + LowestPrice: string + ClosingPrice: string + Change: string + Transaction: string + [key: string]: unknown +} + +export interface TpexMainboardQuoteRow { + Date: string + SecuritiesCompanyCode: string + CompanyName: string + Close: string + Change: string + Open: string + High: string + Low: string + TradingShares: string + TransactionAmount: string + TransactionNumber: string + LatestBidPrice: string + /** Field name typo is the API's own — kept verbatim. */ + LatesAskPrice: string + [key: string]: unknown +} + +/** Board-wide snapshots — boards not needed by the query stay empty. */ +export interface TwseQuoteRaw { + twse: TwseStockDayAllRow[] + tpex: TpexMainboardQuoteRow[] +} + +// ==================== Endpoints ==================== + +const TWSE_STOCK_DAY_ALL_URL = 'https://openapi.twse.com.tw/v1/exchangeReport/STOCK_DAY_ALL' +const TPEX_MAINBOARD_URL = 'https://www.tpex.org.tw/openapi/v1/tpex_mainboard_quotes' + +// ==================== Row mapping ==================== + +/** Round away float noise from `close - change` (TW prices have ≤ 2 decimals). */ +function round4(n: number): number { + return Math.round(n * 10_000) / 10_000 +} + +function mapTwseRow(row: TwseStockDayAllRow): TwseEquityQuoteData { + const close = toNum(row.ClosingPrice) + const change = toNum(row.Change) + const prevClose = close !== null && change !== null ? round4(close - change) : null + return TwseEquityQuoteDataSchema.parse({ + symbol: toYahooSymbol(row.Code, 'TWSE'), + name: row.Name, + exchange: 'TWSE', + open: toNum(row.OpeningPrice), + high: toNum(row.HighestPrice), + low: toNum(row.LowestPrice), + close, + last_price: close, + volume: toNum(row.TradeVolume), + change, + prev_close: prevClose, + change_percent: prevClose ? round4(change! / prevClose * 1e4) / 1e4 : null, + last_timestamp: rocToIso(row.Date), + currency: 'TWD', + trade_value: toNum(row.TradeValue), + transactions: toNum(row.Transaction), + }) +} + +function mapTpexRow(row: TpexMainboardQuoteRow): TwseEquityQuoteData { + const close = toNum(row.Close) + const change = toNum(row.Change) + const prevClose = close !== null && change !== null ? round4(close - change) : null + return TwseEquityQuoteDataSchema.parse({ + symbol: toYahooSymbol(row.SecuritiesCompanyCode, 'TPEX'), + name: row.CompanyName, + exchange: 'TPEX', + open: toNum(row.Open), + high: toNum(row.High), + low: toNum(row.Low), + close, + last_price: close, + volume: toNum(row.TradingShares), + change, + prev_close: prevClose, + change_percent: prevClose ? round4(change! / prevClose * 1e4) / 1e4 : null, + bid: toNum(row.LatestBidPrice), + ask: toNum(row.LatesAskPrice), + last_timestamp: rocToIso(row.Date), + currency: 'TWD', + trade_value: toNum(row.TransactionAmount), + transactions: toNum(row.TransactionNumber), + }) +} + +/** Resolve one queried symbol against the fetched boards — TWSE wins for bare codes. */ +function resolveSymbol(parsed: ParsedTwSymbol, raw: TwseQuoteRaw): TwseEquityQuoteData | null { + if (parsed.board !== 'TPEX') { + const hit = raw.twse.find((r) => r.Code === parsed.code) + if (hit) return mapTwseRow(hit) + } + if (parsed.board !== 'TWSE') { + const hit = raw.tpex.find((r) => r.SecuritiesCompanyCode === parsed.code) + if (hit) return mapTpexRow(hit) + } + return null +} + +// ==================== Fetcher ==================== + +export class TwseEquityQuoteFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): TwseEquityQuoteQueryParams { + return TwseEquityQuoteQueryParamsSchema.parse(params) + } + + static override async extractData( + query: TwseEquityQuoteQueryParams, + _credentials: Record | null, + ): Promise { + const needed = boardsNeeded(parseSymbolList(query.symbol)) + const [twse, tpex] = await Promise.all([ + needed.twse + ? amakeRequest(TWSE_STOCK_DAY_ALL_URL, { headers: TW_HEADERS }) + : Promise.resolve([] as TwseStockDayAllRow[]), + needed.tpex + ? amakeRequest(TPEX_MAINBOARD_URL, { headers: TW_HEADERS }) + : Promise.resolve([] as TpexMainboardQuoteRow[]), + ]) + return { twse, tpex } + } + + static override transformData( + query: TwseEquityQuoteQueryParams, + data: TwseQuoteRaw, + ): TwseEquityQuoteData[] { + const results = parseSymbolList(query.symbol) + .map((parsed) => resolveSymbol(parsed, data)) + .filter((q): q is TwseEquityQuoteData => q !== null) + if (results.length === 0) { + throw new EmptyDataError(`No Taiwan quotes found for: ${query.symbol}`) + } + return results + } +} diff --git a/packages/opentypebb/src/providers/twse/models/equity-search.ts b/packages/opentypebb/src/providers/twse/models/equity-search.ts new file mode 100644 index 000000000..a5edfb726 --- /dev/null +++ b/packages/opentypebb/src/providers/twse/models/equity-search.ts @@ -0,0 +1,165 @@ +/** + * TWSE Equity Search Fetcher. + * + * Enumerates all Taiwan-listed securities (TWSE listed + TPEx OTC) from the + * free official open-data APIs (no API key). Symbols are emitted with the + * Yahoo Finance suffix convention (`2330.TW`, `6488.TWO`) so they are + * directly usable by the yfinance provider for quotes / historical / + * fundamentals. + * + * Sources (shapes verified live 2026-06-07): + * - https://openapi.twse.com.tw/v1/exchangeReport/STOCK_DAY_ALL + * All TWSE-listed securities incl. ETFs — { Code, Name, ... } + * - https://www.tpex.org.tw/openapi/v1/tpex_mainboard_quotes + * All TPEx mainboard (OTC) securities — { SecuritiesCompanyCode, CompanyName, ... } + * - https://openapi.twse.com.tw/v1/opendata/t187ap03_L + * TWSE-listed company profiles — { 公司代號, 公司簡稱, 英文簡稱, ... } + * Used only to enrich names with the English abbreviation ("台積電 (TSMC)") + * so English queries match. TPEx has no English-name field; optional. + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' +import { EquitySearchQueryParamsSchema, EquitySearchDataSchema } from '../../../standard-models/equity-search.js' + +// ==================== Provider-specific schemas ==================== + +export const TwseEquitySearchQueryParamsSchema = EquitySearchQueryParamsSchema + +export type TwseEquitySearchQueryParams = z.infer + +export const TwseEquitySearchDataSchema = EquitySearchDataSchema.extend({ + exchange: z.enum(['TWSE', 'TPEX']).describe('Listing board — TWSE (listed) or TPEX (OTC mainboard).'), +}) + +export type TwseEquitySearchData = z.infer + +// ==================== Raw API shapes ==================== + +interface TwseStockDayAllRow { + Code: string + Name: string + [key: string]: unknown +} + +interface TpexMainboardRow { + SecuritiesCompanyCode: string + CompanyName: string + [key: string]: unknown +} + +interface TwseCompanyProfileRow { + 公司代號: string + 公司簡稱: string + 英文簡稱: string + [key: string]: unknown +} + +/** Merged intermediate entry — one Taiwan security. */ +export interface TwSecurityEntry { + code: string + name: string + nameEn?: string + board: 'TWSE' | 'TPEX' +} + +// ==================== Endpoints ==================== + +const TWSE_STOCK_DAY_ALL_URL = 'https://openapi.twse.com.tw/v1/exchangeReport/STOCK_DAY_ALL' +const TPEX_MAINBOARD_URL = 'https://www.tpex.org.tw/openapi/v1/tpex_mainboard_quotes' +const TWSE_PROFILE_URL = 'https://openapi.twse.com.tw/v1/opendata/t187ap03_L' + +const TW_HEADERS = { Accept: 'application/json' } + +// ==================== Pure merge logic ==================== + +/** Merge the three raw lists into board-tagged entries with English names. */ +export function mergeTwSources( + twseDaily: TwseStockDayAllRow[], + tpexQuotes: TpexMainboardRow[], + twseProfiles: TwseCompanyProfileRow[], +): TwSecurityEntry[] { + const englishByCode = new Map() + for (const p of twseProfiles) { + const en = p.英文簡稱?.trim() + if (p.公司代號 && en) englishByCode.set(p.公司代號, en) + } + + const entries: TwSecurityEntry[] = [] + const seen = new Set() + + for (const row of twseDaily) { + if (!row.Code || seen.has(`${row.Code}.TWSE`)) continue + seen.add(`${row.Code}.TWSE`) + entries.push({ + code: row.Code, + name: row.Name ?? '', + nameEn: englishByCode.get(row.Code), + board: 'TWSE', + }) + } + + for (const row of tpexQuotes) { + const code = row.SecuritiesCompanyCode + if (!code || seen.has(`${code}.TPEX`)) continue + seen.add(`${code}.TPEX`) + entries.push({ + code, + name: row.CompanyName ?? '', + board: 'TPEX', + }) + } + + return entries +} + +// ==================== Fetcher ==================== + +export class TwseEquitySearchFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): TwseEquitySearchQueryParams { + return TwseEquitySearchQueryParamsSchema.parse(params) + } + + static override async extractData( + _query: TwseEquitySearchQueryParams, + _credentials: Record | null, + ): Promise { + const [twseDaily, tpexQuotes, twseProfiles] = await Promise.all([ + amakeRequest(TWSE_STOCK_DAY_ALL_URL, { headers: TW_HEADERS }), + amakeRequest(TPEX_MAINBOARD_URL, { headers: TW_HEADERS }), + // English names are an enrichment — failure must not break the search. + amakeRequest(TWSE_PROFILE_URL, { headers: TW_HEADERS }).catch( + () => [] as TwseCompanyProfileRow[], + ), + ]) + + return mergeTwSources(twseDaily, tpexQuotes, twseProfiles) + } + + static override transformData( + query: TwseEquitySearchQueryParams, + data: TwSecurityEntry[], + ): TwseEquitySearchData[] { + const q = query.query.toLowerCase() + + // If empty query, return all (for bulk loading by SymbolIndex) + const filtered = q + ? data.filter((d) => + d.code.toLowerCase().includes(q) || + d.name.toLowerCase().includes(q) || + d.nameEn?.toLowerCase().includes(q), + ) + : data + + return filtered.map((d) => + TwseEquitySearchDataSchema.parse({ + symbol: `${d.code}.${d.board === 'TWSE' ? 'TW' : 'TWO'}`, + name: d.nameEn ? `${d.name} (${d.nameEn})` : d.name, + exchange: d.board, + }), + ) + } +} diff --git a/packages/opentypebb/src/providers/twse/models/key-metrics.ts b/packages/opentypebb/src/providers/twse/models/key-metrics.ts new file mode 100644 index 000000000..2f14af811 --- /dev/null +++ b/packages/opentypebb/src/providers/twse/models/key-metrics.ts @@ -0,0 +1,153 @@ +/** + * TWSE Key Metrics Fetcher. + * + * Daily valuation-ratio snapshot (P/E, dividend yield, P/B) for Taiwan + * securities from the free official open-data APIs (no API key). The + * official exchange figures are more reliable for TW tickers than what + * yfinance derives. Board-wide snapshots — extractData fetches only the + * board(s) the queried symbols need. + * + * Sources (shapes verified live 2026-06-08): + * - https://openapi.twse.com.tw/v1/exchangeReport/BWIBBU_ALL + * TWSE listed (.TW) — { Date, Code, Name, PEratio, DividendYield, PBratio } + * - https://www.tpex.org.tw/openapi/v1/tpex_mainboard_peratio_analysis + * TPEx OTC (.TWO) — { Date, SecuritiesCompanyCode, CompanyName, + * PriceEarningRatio, DividendPerShare, YieldRatio, PriceBookRatio } + */ + +import { z } from 'zod' +import { Fetcher } from '../../../core/provider/abstract/fetcher.js' +import { amakeRequest } from '../../../core/provider/utils/helpers.js' +import { EmptyDataError } from '../../../core/provider/utils/errors.js' +import { KeyMetricsQueryParamsSchema, KeyMetricsDataSchema } from '../../../standard-models/key-metrics.js' +import { + TW_HEADERS, rocToIso, toNum, toYahooSymbol, parseSymbolList, boardsNeeded, + type ParsedTwSymbol, +} from './common.js' + +// ==================== Provider-specific schemas ==================== + +export const TwseKeyMetricsQueryParamsSchema = KeyMetricsQueryParamsSchema +export type TwseKeyMetricsQueryParams = z.infer + +export const TwseKeyMetricsDataSchema = KeyMetricsDataSchema.extend({ + name: z.string().nullable().default(null).describe('Name of the security.'), + price_to_earnings: z.number().nullable().default(null).describe('Price-to-earnings ratio. Null for loss-making companies.'), + dividend_yield: z.number().nullable().default(null).describe('Trailing dividend yield, in percent (3.28 = 3.28%).'), + price_to_book: z.number().nullable().default(null).describe('Price-to-book ratio.'), + dividend_per_share: z.number().nullable().default(null).describe('Dividend per share, in TWD (TPEx only).'), +}).strip() +export type TwseKeyMetricsData = z.infer + +// ==================== Raw API shapes ==================== + +export interface TwseBwibbuRow { + Date: string + Code: string + Name: string + PEratio: string + DividendYield: string + PBratio: string + [key: string]: unknown +} + +export interface TpexPeratioRow { + Date: string + SecuritiesCompanyCode: string + CompanyName: string + PriceEarningRatio: string + DividendPerShare: string + YieldRatio: string + PriceBookRatio: string + [key: string]: unknown +} + +/** Board-wide snapshots — boards not needed by the query stay empty. */ +export interface TwseKeyMetricsRaw { + twse: TwseBwibbuRow[] + tpex: TpexPeratioRow[] +} + +// ==================== Endpoints ==================== + +const TWSE_BWIBBU_ALL_URL = 'https://openapi.twse.com.tw/v1/exchangeReport/BWIBBU_ALL' +const TPEX_PERATIO_URL = 'https://www.tpex.org.tw/openapi/v1/tpex_mainboard_peratio_analysis' + +// ==================== Row mapping ==================== + +function mapTwseRow(row: TwseBwibbuRow): TwseKeyMetricsData { + return TwseKeyMetricsDataSchema.parse({ + symbol: toYahooSymbol(row.Code, 'TWSE'), + name: row.Name, + period_ending: rocToIso(row.Date), + currency: 'TWD', + price_to_earnings: toNum(row.PEratio), + dividend_yield: toNum(row.DividendYield), + price_to_book: toNum(row.PBratio), + }) +} + +function mapTpexRow(row: TpexPeratioRow): TwseKeyMetricsData { + return TwseKeyMetricsDataSchema.parse({ + symbol: toYahooSymbol(row.SecuritiesCompanyCode, 'TPEX'), + name: row.CompanyName, + period_ending: rocToIso(row.Date), + currency: 'TWD', + price_to_earnings: toNum(row.PriceEarningRatio), + dividend_yield: toNum(row.YieldRatio), + price_to_book: toNum(row.PriceBookRatio), + dividend_per_share: toNum(row.DividendPerShare), + }) +} + +/** Resolve one queried symbol against the fetched boards — TWSE wins for bare codes. */ +function resolveSymbol(parsed: ParsedTwSymbol, raw: TwseKeyMetricsRaw): TwseKeyMetricsData | null { + if (parsed.board !== 'TPEX') { + const hit = raw.twse.find((r) => r.Code === parsed.code) + if (hit) return mapTwseRow(hit) + } + if (parsed.board !== 'TWSE') { + const hit = raw.tpex.find((r) => r.SecuritiesCompanyCode === parsed.code) + if (hit) return mapTpexRow(hit) + } + return null +} + +// ==================== Fetcher ==================== + +export class TwseKeyMetricsFetcher extends Fetcher { + static override requireCredentials = false + + static override transformQuery(params: Record): TwseKeyMetricsQueryParams { + return TwseKeyMetricsQueryParamsSchema.parse(params) + } + + static override async extractData( + query: TwseKeyMetricsQueryParams, + _credentials: Record | null, + ): Promise { + const needed = boardsNeeded(parseSymbolList(query.symbol)) + const [twse, tpex] = await Promise.all([ + needed.twse + ? amakeRequest(TWSE_BWIBBU_ALL_URL, { headers: TW_HEADERS }) + : Promise.resolve([] as TwseBwibbuRow[]), + needed.tpex + ? amakeRequest(TPEX_PERATIO_URL, { headers: TW_HEADERS }) + : Promise.resolve([] as TpexPeratioRow[]), + ]) + return { twse, tpex } + } + + static override transformData( + query: TwseKeyMetricsQueryParams, + data: TwseKeyMetricsRaw, + ): TwseKeyMetricsData[] { + const results = parseSymbolList(query.symbol) + .map((parsed) => resolveSymbol(parsed, data)) + .filter((m): m is TwseKeyMetricsData => m !== null) + if (results.length === 0) { + throw new EmptyDataError(`No Taiwan valuation metrics found for: ${query.symbol}`) + } + return results + } +} diff --git a/src/domain/market-data/__tests__/bbProviders/twse.bbProvider.spec.ts b/src/domain/market-data/__tests__/bbProviders/twse.bbProvider.spec.ts new file mode 100644 index 000000000..2b963bc60 --- /dev/null +++ b/src/domain/market-data/__tests__/bbProviders/twse.bbProvider.spec.ts @@ -0,0 +1,100 @@ +/** + * twse bbProvider integration test. + * + * Verifies the TWSE/TPEx open-data fetchers (EquitySearch, EquityQuote, + * KeyMetrics, EquityInfo) can reach the official APIs and return + * Yahoo-suffixed Taiwan symbols. Free provider — no API key required. + */ + +import { describe, it, expect, beforeAll } from 'vitest' +import { getTestContext, type TestContext } from './setup.js' + +let ctx: TestContext + +beforeAll(async () => { ctx = await getTestContext() }) + +const exec = (model: string, params: Record = {}) => + ctx.executor.execute('twse', model, params, ctx.credentials) as Promise[]> + +describe('twse — equity search', () => { + it('bulk load (empty query) returns both boards', async () => { + const all = await exec('EquitySearch', { query: '' }) + expect(all.length).toBeGreaterThan(1500) + const symbols = all.map((d) => d.symbol as string) + expect(symbols).toContain('2330.TW') + expect(symbols.some((s) => s.endsWith('.TWO'))).toBe(true) + }) + + it('query by code finds TSMC with English-enriched name', async () => { + const hits = await exec('EquitySearch', { query: '2330' }) + const tsmc = hits.find((d) => d.symbol === '2330.TW') + expect(tsmc).toBeDefined() + expect(tsmc?.name).toContain('TSMC') + expect(tsmc?.exchange).toBe('TWSE') + }) + + it('query by English abbreviation works', async () => { + const hits = await exec('EquitySearch', { query: 'TSMC' }) + expect(hits.map((d) => d.symbol)).toContain('2330.TW') + }) +}) + +describe('twse — equity quote', () => { + it('returns the latest-day quote for a TWSE-listed symbol', async () => { + const [q] = await exec('EquityQuote', { symbol: '2330.TW' }) + expect(q.symbol).toBe('2330.TW') + expect(q.exchange).toBe('TWSE') + expect(q.currency).toBe('TWD') + expect(typeof q.close).toBe('number') + expect(q.close as number).toBeGreaterThan(0) + expect(typeof q.volume).toBe('number') + expect(q.last_timestamp).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + + it('returns a TPEx quote with bid/ask for an OTC symbol', async () => { + const [q] = await exec('EquityQuote', { symbol: '6488.TWO' }) + expect(q.symbol).toBe('6488.TWO') + expect(q.exchange).toBe('TPEX') + expect(q.close as number).toBeGreaterThan(0) + }) + + it('resolves a multi-symbol query across both boards', async () => { + const quotes = await exec('EquityQuote', { symbol: '2330.TW,6488.TWO' }) + expect(quotes.map((q) => q.symbol)).toEqual(['2330.TW', '6488.TWO']) + }) +}) + +describe('twse — key metrics', () => { + it('returns official valuation ratios for TSMC', async () => { + const [m] = await exec('KeyMetrics', { symbol: '2330.TW' }) + expect(m.symbol).toBe('2330.TW') + expect(m.price_to_earnings as number).toBeGreaterThan(0) + expect(m.price_to_book as number).toBeGreaterThan(0) + expect(m.period_ending).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) + + it('returns TPEx ratios including dividend per share', async () => { + const [m] = await exec('KeyMetrics', { symbol: '6488.TWO' }) + expect(m.symbol).toBe('6488.TWO') + expect(typeof m.price_to_book).toBe('number') + }) +}) + +describe('twse — equity info', () => { + it('returns the TWSE company profile for TSMC', async () => { + const [info] = await exec('EquityInfo', { symbol: '2330.TW' }) + expect(info.symbol).toBe('2330.TW') + expect(info.name).toContain('台積電') + expect(info.legal_name).toContain('台灣積體電路') + expect(info.stock_exchange).toBe('TWSE') + expect(info.listed_date).toMatch(/^\d{4}-\d{2}-\d{2}$/) + expect(info.issued_shares as number).toBeGreaterThan(1e9) + }) + + it('returns a TPEx company profile for an OTC symbol', async () => { + const [info] = await exec('EquityInfo', { symbol: '6488.TWO' }) + expect(info.symbol).toBe('6488.TWO') + expect(info.stock_exchange).toBe('TPEX') + expect(typeof info.chairman).toBe('string') + }) +}) diff --git a/src/domain/market-data/__tests__/bbProviders/yfinance.bbProvider.spec.ts b/src/domain/market-data/__tests__/bbProviders/yfinance.bbProvider.spec.ts index 0d425904b..571bb54a8 100644 --- a/src/domain/market-data/__tests__/bbProviders/yfinance.bbProvider.spec.ts +++ b/src/domain/market-data/__tests__/bbProviders/yfinance.bbProvider.spec.ts @@ -96,3 +96,14 @@ describe('yfinance — commodity (canonical names)', () => { it('live_cattle', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'live_cattle' })).length).toBeGreaterThan(0) }) it('lean_hogs', async () => { expect((await exec('CommoditySpotPrice', { symbol: 'lean_hogs' })).length).toBeGreaterThan(0) }) }) + +describe('yfinance — Taiwan equities (.TW / .TWO suffix)', () => { + it('EquityQuote 2330.TW (TSMC, TWSE-listed)', async () => { + const rows = await exec('EquityQuote', { symbol: '2330.TW' }) as Record[] + expect(rows.length).toBeGreaterThan(0) + expect(rows[0].currency).toBe('TWD') + }) + it('EquityHistorical 2330.TW', async () => { expect((await exec('EquityHistorical', { symbol: '2330.TW' })).length).toBeGreaterThan(0) }) + it('EquityQuote 6488.TWO (GlobalWafers, TPEx OTC)', async () => { expect((await exec('EquityQuote', { symbol: '6488.TWO' })).length).toBeGreaterThan(0) }) + it('IndexHistorical ^TWII (TAIEX)', async () => { expect((await exec('IndexHistorical', { symbol: '^TWII' })).length).toBeGreaterThan(0) }) +}) diff --git a/src/domain/market-data/equity/symbol-index.spec.ts b/src/domain/market-data/equity/symbol-index.spec.ts new file mode 100644 index 000000000..75448dac0 --- /dev/null +++ b/src/domain/market-data/equity/symbol-index.spec.ts @@ -0,0 +1,92 @@ +/** + * SymbolIndex — cache freshness contract. + * + * The cache envelope records which SOURCES produced it. When the SOURCES + * list changes between releases (e.g. adding `twse`), a time-fresh cache + * is still stale by content — load() must refetch instead of serving the + * old source set for up to 24h. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { readFile, writeFile, mkdir } from 'fs/promises' +import { SymbolIndex } from './symbol-index.js' +import type { EquityClientLike } from '../client/types.js' + +vi.mock('fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), +})) + +const mockedReadFile = vi.mocked(readFile) + +/** Client that returns one symbol per provider it is asked for. */ +function fakeClient(): EquityClientLike & { calls: string[] } { + const calls: string[] = [] + return { + calls, + search: vi.fn(async (params: Record) => { + const provider = params.provider as string + calls.push(provider) + if (provider === 'sec') return [{ symbol: 'AAPL', name: 'Apple Inc.' }] + if (provider === 'twse') return [{ symbol: '2330.TW', name: '台積電 (TSMC)' }] + return [] + }), + } as unknown as EquityClientLike & { calls: string[] } +} + +function envelope(sources: string[], cachedAt = new Date().toISOString()) { + const entries = sources.map((s) => ({ symbol: `${s}-SYM`, name: s, source: s })) + return JSON.stringify({ cachedAt, sources, count: entries.length, entries }) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('SymbolIndex.load — cache source matching', () => { + it('serves a fresh cache whose sources match the current SOURCES', async () => { + mockedReadFile.mockResolvedValue(envelope(['sec', 'twse'])) + const client = fakeClient() + const index = new SymbolIndex() + await index.load(client) + + expect(client.calls).toEqual([]) + expect(index.size).toBe(2) + }) + + it('refetches when the cached source list no longer matches SOURCES', async () => { + // Time-fresh cache, but built before `twse` was added. + mockedReadFile.mockResolvedValue(envelope(['sec'])) + const client = fakeClient() + const index = new SymbolIndex() + await index.load(client) + + expect(client.calls).toContain('sec') + expect(client.calls).toContain('twse') + expect(index.resolve('2330.TW')).toMatchObject({ source: 'twse' }) + expect(writeFile).toHaveBeenCalled() + expect(mkdir).toHaveBeenCalled() + }) + + it('refetches when the cache is expired even if sources match', async () => { + const old = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString() + mockedReadFile.mockResolvedValue(envelope(['sec', 'twse'], old)) + const client = fakeClient() + const index = new SymbolIndex() + await index.load(client) + + expect(client.calls.length).toBeGreaterThan(0) + expect(index.resolve('AAPL')).toBeDefined() + }) + + it('includes twse entries in search results after a fetch', async () => { + mockedReadFile.mockRejectedValue(new Error('no cache')) + const client = fakeClient() + const index = new SymbolIndex() + await index.load(client) + + const hits = index.search('TSMC') + expect(hits.map((h) => h.symbol)).toContain('2330.TW') + }) +}) diff --git a/src/domain/market-data/equity/symbol-index.ts b/src/domain/market-data/equity/symbol-index.ts index 8495af0b0..457c01968 100644 --- a/src/domain/market-data/equity/symbol-index.ts +++ b/src/domain/market-data/equity/symbol-index.ts @@ -7,7 +7,7 @@ * * 当前缓存的数据源(免费,不需要 API key): * - SEC (sec): ~10,000 美股上市公司,来自 SEC EDGAR - * - TMX (tmx): ~3,600 加拿大上市公司,来自多伦多交易所 + * - TWSE (twse): ~2,400 台湾上市/上柜证券(含 ETF),来自台湾证交所 + 柜买中心开放数据 * * 扩展方法:在 SOURCES 数组中添加新的 provider 即可。 * 需要 API key 的 provider(intrinio, nasdaq, tradier)暂未接入。 @@ -37,7 +37,7 @@ interface CacheEnvelope { // ==================== Config ==================== /** 免费 provider 列表 — 扩展时在这里加 */ -const SOURCES = ['sec'] as const +const SOURCES = ['sec', 'twse'] as const const CACHE_FILE = dataPath('cache', 'equity', 'symbols.json') const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours @@ -59,9 +59,9 @@ export class SymbolIndex { * API 失败时降级到过期缓存。全部失败则以空索引启动(不中断)。 */ async load(client: EquityClientLike): Promise { - // 1. 尝试读缓存 + // 1. 尝试读缓存(SOURCES 变更时缓存按内容过期,避免新源最多延迟 24h 才生效) const cached = await this.readCache() - if (cached && !this.isExpired(cached.cachedAt)) { + if (cached && !this.isExpired(cached.cachedAt) && this.sourcesMatch(cached.sources)) { this.entries = cached.entries console.log(`equity: loaded ${this.entries.length} symbols from cache (${cached.sources.join(', ')})`) return @@ -184,4 +184,11 @@ export class SymbolIndex { private isExpired(cachedAt: string): boolean { return Date.now() - new Date(cachedAt).getTime() > CACHE_TTL_MS } + + /** 缓存的来源列表与当前 SOURCES 一致(忽略顺序)才算新鲜 */ + private sourcesMatch(cachedSources: string[]): boolean { + if (cachedSources.length !== SOURCES.length) return false + const set = new Set(cachedSources) + return SOURCES.every((s) => set.has(s)) + } } From e09484a2cf00defbe8059098bb37f9f57392e335 Mon Sep 17 00:00:00 2001 From: bakabaka0613 Date: Mon, 8 Jun 2026 18:02:08 +0800 Subject: [PATCH 2/4] feat(twse): rate-limit-aware fetch + route Taiwan equity profile to twse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TWSE/TPEx open-data hosts ban callers exceeding ~3 req/5s. Add a twseFetch() wrapper (provider-local, not touching the shared amakeRequest) with three layers: a URL-keyed snapshot cache (10-min TTL) plus in-flight coalescing — the endpoints are whole-market EOD snapshots, so N symbol queries collapse to one request per board and the cache dedupes across fetchers; a per-host serialized throttle spacing request starts >=1.7s (the two hosts are rate-limited independently); and bounded backoff retry, evicting failures so they are never served from cache. All four TWSE fetchers switch from amakeRequest to twseFetch. Route Taiwan-listed symbols in equityGetProfile to provider 'twse' (was hardcoded yfinance) so company profile + valuation metrics come straight from the exchanges. isTaiwanSymbol() matches .TW/.TWO suffixes and bare numeric listing codes. Only equityGetProfile is routed — it is the only AI-tool- reachable surface twse can serve (getProfile=EquityInfo, getKeyMetrics= KeyMetrics); broker quotes and financials/ratios/earnings/insider/discover have no twse fetcher and stay on yfinance/fmp. Verified: full pnpm test (1897), root + opentypebb tsc clean, live twse.bbProvider integration (10). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../providers/twse/__tests__/common.spec.ts | 96 +++++++++++++- .../src/providers/twse/models/common.ts | 117 ++++++++++++++++++ .../src/providers/twse/models/equity-info.ts | 7 +- .../src/providers/twse/models/equity-quote.ts | 7 +- .../providers/twse/models/equity-search.ts | 8 +- .../src/providers/twse/models/key-metrics.ts | 7 +- src/tool/equity.spec.ts | 83 +++++++++++++ src/tool/equity.ts | 24 +++- 8 files changed, 328 insertions(+), 21 deletions(-) create mode 100644 src/tool/equity.spec.ts diff --git a/packages/opentypebb/src/providers/twse/__tests__/common.spec.ts b/packages/opentypebb/src/providers/twse/__tests__/common.spec.ts index 8554745c6..371acbf4e 100644 --- a/packages/opentypebb/src/providers/twse/__tests__/common.spec.ts +++ b/packages/opentypebb/src/providers/twse/__tests__/common.spec.ts @@ -6,8 +6,16 @@ * - Numeric strings: "14.55", "-0.3100", "+0.06", "" (empty = no data) */ -import { describe, it, expect } from 'vitest' -import { rocToIso, toNum, parseTwSymbol, boardsNeeded } from '../models/common.js' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { + rocToIso, + toNum, + parseTwSymbol, + boardsNeeded, + twseFetch, + __resetTwseFetch, + __twseFetchInternals, +} from '../models/common.js' describe('rocToIso', () => { it('converts ROC calendar dates to ISO', () => { @@ -71,3 +79,87 @@ describe('boardsNeeded', () => { .toEqual({ twse: true, tpex: true }) }) }) + +describe('twseFetch', () => { + const TWSE_URL = 'https://openapi.twse.com.tw/v1/exchangeReport/STOCK_DAY_ALL' + const TPEX_URL = 'https://www.tpex.org.tw/openapi/v1/tpex_mainboard_quotes' + + // Fake clock + recording sleep so spacing/backoff are observable without real timers. + let clock = 0 + let sleeps: number[] + let request: ReturnType + + const origNow = __twseFetchInternals.now + const origSleep = __twseFetchInternals.sleep + const origRequest = __twseFetchInternals.request + + beforeEach(() => { + __resetTwseFetch() + clock = 0 + sleeps = [] + request = vi.fn(async (_url: string) => ({ ok: true })) + __twseFetchInternals.now = () => clock + __twseFetchInternals.sleep = async (ms: number) => { sleeps.push(ms) } + __twseFetchInternals.request = request as typeof __twseFetchInternals.request + }) + + afterEach(() => { + __twseFetchInternals.now = origNow + __twseFetchInternals.sleep = origSleep + __twseFetchInternals.request = origRequest + __resetTwseFetch() + }) + + it('caches by URL — a second call within TTL hits no network', async () => { + const a = await twseFetch(TWSE_URL) + const b = await twseFetch(TWSE_URL) + expect(request).toHaveBeenCalledTimes(1) + expect(a).toBe(b) // same cached promise resolution + }) + + it('re-fetches once the TTL has elapsed', async () => { + await twseFetch(TWSE_URL) + clock += 10 * 60 * 1000 + 1 // just past the 10-min TTL + await twseFetch(TWSE_URL) + expect(request).toHaveBeenCalledTimes(2) + }) + + it('coalesces concurrent callers into one in-flight request', async () => { + const [a, b] = await Promise.all([twseFetch(TWSE_URL), twseFetch(TWSE_URL)]) + expect(request).toHaveBeenCalledTimes(1) + expect(a).toBe(b) + }) + + it('spaces consecutive same-host requests by the min interval', async () => { + await twseFetch(TWSE_URL) + // Different URL, same host → cache miss, must wait its turn. + await twseFetch('https://openapi.twse.com.tw/v1/opendata/t187ap03_L') + expect(sleeps).toContain(1700) + }) + + it('does not throttle the first request to a host', async () => { + await twseFetch(TWSE_URL) // openapi.twse.com.tw + await twseFetch(TPEX_URL) // www.tpex.org.tw — independent host, no spacing + expect(sleeps).toEqual([]) + }) + + it('retries with backoff then succeeds', async () => { + request + .mockRejectedValueOnce(new Error('429')) + .mockResolvedValueOnce({ ok: true }) + const out = await twseFetch(TWSE_URL) + expect(out).toEqual({ ok: true }) + expect(request).toHaveBeenCalledTimes(2) + expect(sleeps).toContain(600) // first backoff + }) + + it('throws after exhausting retries and does not cache the failure', async () => { + request.mockRejectedValue(new Error('boom')) + await expect(twseFetch(TWSE_URL)).rejects.toThrow('boom') + expect(request).toHaveBeenCalledTimes(3) // initial + 2 backoff retries + + // Failure was evicted — the next call retries from scratch. + request.mockResolvedValue({ ok: true }) + await expect(twseFetch(TWSE_URL)).resolves.toEqual({ ok: true }) + }) +}) diff --git a/packages/opentypebb/src/providers/twse/models/common.ts b/packages/opentypebb/src/providers/twse/models/common.ts index a522d1d53..7edc8e3c2 100644 --- a/packages/opentypebb/src/providers/twse/models/common.ts +++ b/packages/opentypebb/src/providers/twse/models/common.ts @@ -9,6 +9,8 @@ * `2330.TW` (TWSE listed) / `6488.TWO` (TPEx OTC); bare codes match either. */ +import { amakeRequest } from '../../../core/provider/utils/helpers.js' + export type TwBoard = 'TWSE' | 'TPEX' export interface ParsedTwSymbol { @@ -66,3 +68,118 @@ export function toYahooSymbol(code: string, board: TwBoard): string { export function parseSymbolList(symbol: string): ParsedTwSymbol[] { return symbol.split(',').map((s) => s.trim()).filter(Boolean).map(parseTwSymbol) } + +// ==================== Rate-limited fetch (twseFetch) ==================== +// +// The TWSE / TPEx open-data hosts ban callers that exceed ~3 requests per +// 5 seconds. Every TWSE fetcher hits board-wide *snapshot* endpoints +// (STOCK_DAY_ALL, tpex_mainboard_quotes, t187ap03_L, …) that return the +// whole market in one response and only change once per trading day, so the +// right defence is mostly to *not* re-fetch. `twseFetch` wraps `amakeRequest` +// with three layers: +// +// 1. Snapshot cache (URL-keyed, 10-min TTL) + in-flight coalescing — N +// symbol queries collapse to one network request per board per window. +// 2. Per-host serialized throttle spacing request *starts* by ≥1.7s, so +// even an all-miss burst stays under 3/5s. TWSE and TPEx are distinct +// hosts and rate-limited independently, hence per-host. +// 3. Bounded backoff retry, to ride out a transient 429 / network blip. +// +// State is module-level on purpose: it must be shared across every fetcher +// and every concurrent query in the process. Tests reset it via +// `__resetTwseFetch` and substitute clock / sleep / request via +// `__twseFetchInternals`. + +const CACHE_TTL_MS = 10 * 60 * 1000 // 10 minutes — EOD snapshots, generous is fine. +const MIN_INTERVAL_MS = 1700 // per-host start-to-start spacing → ≤3 starts / 5s. +const RETRY_BACKOFF_MS = [600, 1800] as const // attempt count = length + 1. + +interface CacheEntry { + at: number + promise: Promise +} + +interface TwseFetchOptions { + headers?: Record + timeoutMs?: number +} + +/** Swappable seams so tests avoid real timers / network. */ +export const __twseFetchInternals = { + now: () => Date.now(), + sleep: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), + request: (url: string, opts: TwseFetchOptions) => amakeRequest(url, opts), +} + +const cache = new Map() +/** Per-host queue tail — chains requests so their starts are spaced. */ +const chainTail = new Map>() +/** Per-host timestamp of the last request start (for spacing). */ +const lastStart = new Map() + +/** Clear cache + throttle state. Test-only. */ +export function __resetTwseFetch(): void { + cache.clear() + chainTail.clear() + lastStart.clear() +} + +function hostOf(url: string): string { + try { + return new URL(url).host + } catch { + return url + } +} + +/** Serialize per host and space request starts by ≥ MIN_INTERVAL_MS. */ +function withThrottle(host: string, fn: () => Promise): Promise { + const prev = chainTail.get(host) ?? Promise.resolve() + const run = prev + .catch(() => {}) // a prior failure must not break the queue + .then(async () => { + const last = lastStart.get(host) + const wait = last === undefined ? 0 : MIN_INTERVAL_MS - (__twseFetchInternals.now() - last) + if (wait > 0) await __twseFetchInternals.sleep(wait) + lastStart.set(host, __twseFetchInternals.now()) + return fn() + }) + // Tail tracks completion (success or failure) so the next request waits its turn. + chainTail.set(host, run.then(() => {}, () => {})) + return run +} + +async function requestWithRetry(url: string, opts: TwseFetchOptions): Promise { + let lastErr: unknown + for (let attempt = 0; attempt <= RETRY_BACKOFF_MS.length; attempt++) { + try { + return await __twseFetchInternals.request(url, opts) + } catch (err) { + lastErr = err + const backoff = RETRY_BACKOFF_MS[attempt] + if (backoff !== undefined) await __twseFetchInternals.sleep(backoff) + } + } + throw lastErr +} + +/** + * Rate-limit-aware GET for TWSE / TPEx snapshot endpoints. Drop-in for + * `amakeRequest` inside this provider. Concurrent callers for the same URL + * share one in-flight request; results are cached for {@link CACHE_TTL_MS}. + */ +export function twseFetch(url: string, opts: TwseFetchOptions = {}): Promise { + const cached = cache.get(url) + if (cached && __twseFetchInternals.now() - cached.at < CACHE_TTL_MS) { + return cached.promise as Promise + } + + const promise = withThrottle(hostOf(url), () => requestWithRetry(url, opts)) + cache.set(url, { at: __twseFetchInternals.now(), promise }) + // Never serve a rejected promise from cache — evict on failure so the next + // caller retries instead of inheriting the error. + promise.catch(() => { + if (cache.get(url)?.promise === promise) cache.delete(url) + }) + return promise +} diff --git a/packages/opentypebb/src/providers/twse/models/equity-info.ts b/packages/opentypebb/src/providers/twse/models/equity-info.ts index 0160cb2e7..37a2b9693 100644 --- a/packages/opentypebb/src/providers/twse/models/equity-info.ts +++ b/packages/opentypebb/src/providers/twse/models/equity-info.ts @@ -21,11 +21,10 @@ import { z } from 'zod' import { Fetcher } from '../../../core/provider/abstract/fetcher.js' -import { amakeRequest } from '../../../core/provider/utils/helpers.js' import { EmptyDataError } from '../../../core/provider/utils/errors.js' import { EquityInfoQueryParamsSchema, EquityInfoDataSchema } from '../../../standard-models/equity-info.js' import { - TW_HEADERS, toNum, toYahooSymbol, parseSymbolList, boardsNeeded, + TW_HEADERS, twseFetch, toNum, toYahooSymbol, parseSymbolList, boardsNeeded, type ParsedTwSymbol, } from './common.js' @@ -207,10 +206,10 @@ export class TwseEquityInfoFetcher extends Fetcher { const needed = boardsNeeded(parseSymbolList(query.symbol)) const [twse, tpex] = await Promise.all([ needed.twse - ? amakeRequest(TWSE_PROFILE_URL, { headers: TW_HEADERS }) + ? twseFetch(TWSE_PROFILE_URL, { headers: TW_HEADERS }) : Promise.resolve([] as TwseProfileRow[]), needed.tpex - ? amakeRequest(TPEX_PROFILE_URL, { headers: TW_HEADERS }) + ? twseFetch(TPEX_PROFILE_URL, { headers: TW_HEADERS }) : Promise.resolve([] as TpexProfileRow[]), ]) return { twse, tpex } diff --git a/packages/opentypebb/src/providers/twse/models/equity-quote.ts b/packages/opentypebb/src/providers/twse/models/equity-quote.ts index f32e6ec42..1e99912f5 100644 --- a/packages/opentypebb/src/providers/twse/models/equity-quote.ts +++ b/packages/opentypebb/src/providers/twse/models/equity-quote.ts @@ -18,11 +18,10 @@ import { z } from 'zod' import { Fetcher } from '../../../core/provider/abstract/fetcher.js' -import { amakeRequest } from '../../../core/provider/utils/helpers.js' import { EmptyDataError } from '../../../core/provider/utils/errors.js' import { EquityQuoteQueryParamsSchema, EquityQuoteDataSchema } from '../../../standard-models/equity-quote.js' import { - TW_HEADERS, rocToIso, toNum, toYahooSymbol, parseSymbolList, boardsNeeded, + TW_HEADERS, twseFetch, rocToIso, toNum, toYahooSymbol, parseSymbolList, boardsNeeded, type ParsedTwSymbol, } from './common.js' @@ -170,10 +169,10 @@ export class TwseEquityQuoteFetcher extends Fetcher { const needed = boardsNeeded(parseSymbolList(query.symbol)) const [twse, tpex] = await Promise.all([ needed.twse - ? amakeRequest(TWSE_STOCK_DAY_ALL_URL, { headers: TW_HEADERS }) + ? twseFetch(TWSE_STOCK_DAY_ALL_URL, { headers: TW_HEADERS }) : Promise.resolve([] as TwseStockDayAllRow[]), needed.tpex - ? amakeRequest(TPEX_MAINBOARD_URL, { headers: TW_HEADERS }) + ? twseFetch(TPEX_MAINBOARD_URL, { headers: TW_HEADERS }) : Promise.resolve([] as TpexMainboardQuoteRow[]), ]) return { twse, tpex } diff --git a/packages/opentypebb/src/providers/twse/models/equity-search.ts b/packages/opentypebb/src/providers/twse/models/equity-search.ts index a5edfb726..a380894ed 100644 --- a/packages/opentypebb/src/providers/twse/models/equity-search.ts +++ b/packages/opentypebb/src/providers/twse/models/equity-search.ts @@ -20,7 +20,7 @@ import { z } from 'zod' import { Fetcher } from '../../../core/provider/abstract/fetcher.js' -import { amakeRequest } from '../../../core/provider/utils/helpers.js' +import { twseFetch } from './common.js' import { EquitySearchQueryParamsSchema, EquitySearchDataSchema } from '../../../standard-models/equity-search.js' // ==================== Provider-specific schemas ==================== @@ -128,10 +128,10 @@ export class TwseEquitySearchFetcher extends Fetcher { _credentials: Record | null, ): Promise { const [twseDaily, tpexQuotes, twseProfiles] = await Promise.all([ - amakeRequest(TWSE_STOCK_DAY_ALL_URL, { headers: TW_HEADERS }), - amakeRequest(TPEX_MAINBOARD_URL, { headers: TW_HEADERS }), + twseFetch(TWSE_STOCK_DAY_ALL_URL, { headers: TW_HEADERS }), + twseFetch(TPEX_MAINBOARD_URL, { headers: TW_HEADERS }), // English names are an enrichment — failure must not break the search. - amakeRequest(TWSE_PROFILE_URL, { headers: TW_HEADERS }).catch( + twseFetch(TWSE_PROFILE_URL, { headers: TW_HEADERS }).catch( () => [] as TwseCompanyProfileRow[], ), ]) diff --git a/packages/opentypebb/src/providers/twse/models/key-metrics.ts b/packages/opentypebb/src/providers/twse/models/key-metrics.ts index 2f14af811..05fb0540e 100644 --- a/packages/opentypebb/src/providers/twse/models/key-metrics.ts +++ b/packages/opentypebb/src/providers/twse/models/key-metrics.ts @@ -17,11 +17,10 @@ import { z } from 'zod' import { Fetcher } from '../../../core/provider/abstract/fetcher.js' -import { amakeRequest } from '../../../core/provider/utils/helpers.js' import { EmptyDataError } from '../../../core/provider/utils/errors.js' import { KeyMetricsQueryParamsSchema, KeyMetricsDataSchema } from '../../../standard-models/key-metrics.js' import { - TW_HEADERS, rocToIso, toNum, toYahooSymbol, parseSymbolList, boardsNeeded, + TW_HEADERS, twseFetch, rocToIso, toNum, toYahooSymbol, parseSymbolList, boardsNeeded, type ParsedTwSymbol, } from './common.js' @@ -129,10 +128,10 @@ export class TwseKeyMetricsFetcher extends Fetcher { const needed = boardsNeeded(parseSymbolList(query.symbol)) const [twse, tpex] = await Promise.all([ needed.twse - ? amakeRequest(TWSE_BWIBBU_ALL_URL, { headers: TW_HEADERS }) + ? twseFetch(TWSE_BWIBBU_ALL_URL, { headers: TW_HEADERS }) : Promise.resolve([] as TwseBwibbuRow[]), needed.tpex - ? amakeRequest(TPEX_PERATIO_URL, { headers: TW_HEADERS }) + ? twseFetch(TPEX_PERATIO_URL, { headers: TW_HEADERS }) : Promise.resolve([] as TpexPeratioRow[]), ]) return { twse, tpex } diff --git a/src/tool/equity.spec.ts b/src/tool/equity.spec.ts new file mode 100644 index 000000000..56ee303d8 --- /dev/null +++ b/src/tool/equity.spec.ts @@ -0,0 +1,83 @@ +/** + * Equity tool unit tests — Taiwan-symbol detection + provider routing. + * + * Mirrors the economy.spec.ts pattern: don't hit the network, mock the + * EquityClientLike surface, and verify the provider-selection logic that + * routes Taiwan-listed symbols to the `twse` provider (TWSE/TPEx open data) + * while everything else stays on yfinance. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { EquityClientLike } from '@/domain/market-data/client/types' +import { createEquityTools, isTaiwanSymbol } from './equity.js' + +function makeMockEquityClient(): EquityClientLike { + return { + search: vi.fn(async () => []), + getHistorical: vi.fn(async () => []), + getProfile: vi.fn(async () => []), + getKeyMetrics: vi.fn(async () => []), + getIncomeStatement: vi.fn(async () => []), + getBalanceSheet: vi.fn(async () => []), + getCashFlow: vi.fn(async () => []), + getFinancialRatios: vi.fn(async () => []), + getEstimateConsensus: vi.fn(async () => []), + getCalendarEarnings: vi.fn(async () => []), + getInsiderTrading: vi.fn(async () => []), + getGainers: vi.fn(async () => []), + getLosers: vi.fn(async () => []), + getActive: vi.fn(async () => []), + } +} + +// Bypass Vercel AI's tool execute typing — same pattern as economy.spec.ts +const exec = (t: any, args: unknown) => (t.execute as Function)(args) + +describe('isTaiwanSymbol', () => { + it('matches Yahoo-suffixed Taiwan symbols (case-insensitive)', () => { + expect(isTaiwanSymbol('2330.TW')).toBe(true) + expect(isTaiwanSymbol('6488.TWO')).toBe(true) + expect(isTaiwanSymbol('2330.tw')).toBe(true) + }) + + it('matches bare numeric listing codes (stocks + ETFs)', () => { + expect(isTaiwanSymbol('2330')).toBe(true) + expect(isTaiwanSymbol('0050')).toBe(true) + expect(isTaiwanSymbol('00878')).toBe(true) + expect(isTaiwanSymbol('911616')).toBe(true) + }) + + it('does not match US tickers or other suffixes', () => { + expect(isTaiwanSymbol('AAPL')).toBe(false) + expect(isTaiwanSymbol('MSFT')).toBe(false) + expect(isTaiwanSymbol('BRK.B')).toBe(false) + expect(isTaiwanSymbol('7203.T')).toBe(false) // Tokyo, not Taiwan + }) +}) + +describe('createEquityTools — equityGetProfile provider routing', () => { + let client: EquityClientLike + let tools: ReturnType + + beforeEach(() => { + client = makeMockEquityClient() + tools = createEquityTools(client) + }) + + it('routes Taiwan symbols to the twse provider', async () => { + await exec(tools.equityGetProfile, { symbol: '2330.TW' }) + expect(client.getProfile).toHaveBeenCalledWith({ symbol: '2330.TW', provider: 'twse' }) + expect(client.getKeyMetrics).toHaveBeenCalledWith({ symbol: '2330.TW', limit: 1, provider: 'twse' }) + }) + + it('routes bare Taiwan codes to the twse provider', async () => { + await exec(tools.equityGetProfile, { symbol: '2330' }) + expect(client.getProfile).toHaveBeenCalledWith({ symbol: '2330', provider: 'twse' }) + }) + + it('keeps US symbols on yfinance', async () => { + await exec(tools.equityGetProfile, { symbol: 'AAPL' }) + expect(client.getProfile).toHaveBeenCalledWith({ symbol: 'AAPL', provider: 'yfinance' }) + expect(client.getKeyMetrics).toHaveBeenCalledWith({ symbol: 'AAPL', limit: 1, provider: 'yfinance' }) + }) +}) diff --git a/src/tool/equity.ts b/src/tool/equity.ts index 69640fa53..7f28697e0 100644 --- a/src/tool/equity.ts +++ b/src/tool/equity.ts @@ -11,6 +11,20 @@ import { z } from 'zod' import type { EquityClientLike } from '@/domain/market-data/client/types' import type { EquityDiscoveryData } from '@traderalice/opentypebb' +/** + * Taiwan-listed symbols are served by the `twse` provider (TWSE / TPEx + * official open data — rate-limit-aware, sourced straight from the + * exchanges) instead of the default yfinance/fmp. Matches both the + * Yahoo-suffix convention emitted by marketSearchForResearch (`2330.TW`, + * `6488.TWO`) and the bare numeric listing codes a user might type directly + * (`2330`, `00878`). US tickers are alphabetic, so a purely-numeric symbol + * is unambiguously a Taiwan listing. + */ +export function isTaiwanSymbol(symbol: string): boolean { + const s = symbol.trim().toUpperCase() + return /\.(TW|TWO)$/.test(s) || /^\d{4,6}[A-Z]?$/.test(s) +} + export function createEquityTools(equityClient: EquityClientLike) { return { equityGetProfile: tool({ @@ -19,14 +33,18 @@ export function createEquityTools(equityClient: EquityClientLike) { Returns company overview (name, sector, industry, description, website, CEO, employees) combined with key metrics (market cap, PE ratio, PB ratio, EV/EBITDA, dividend yield, etc.). +Taiwan-listed symbols (e.g. "2330.TW", "6488.TWO") are sourced from official +TWSE/TPEx open data; other markets come from Yahoo Finance. + If unsure about the symbol, use marketSearchForResearch to find it.`, inputSchema: z.object({ - symbol: z.string().describe('Ticker symbol, e.g. "AAPL", "MSFT"'), + symbol: z.string().describe('Ticker symbol, e.g. "AAPL", "MSFT", "2330.TW"'), }).meta({ examples: [{ symbol: 'AAPL' }] }), execute: async ({ symbol }) => { + const provider = isTaiwanSymbol(symbol) ? 'twse' : 'yfinance' const [profile, metrics] = await Promise.all([ - equityClient.getProfile({ symbol, provider: 'yfinance' }).catch(() => []), - equityClient.getKeyMetrics({ symbol, limit: 1, provider: 'yfinance' }).catch(() => []), + equityClient.getProfile({ symbol, provider }).catch(() => []), + equityClient.getKeyMetrics({ symbol, limit: 1, provider }).catch(() => []), ]) return { profile: profile[0] ?? null, metrics: metrics[0] ?? null } }, From 55922ce51996d4b8a661fada1f830dacde84a10a Mon Sep 17 00:00:00 2001 From: bakabaka0613 Date: Mon, 8 Jun 2026 18:11:04 +0800 Subject: [PATCH 3/4] feat(equity): point Taiwan symbols at equityGetProfile in equityGetRatios equityGetRatios uses FMP, which covers US fundamentals only and needs an fmp_api_key; twse has no financial-ratios fetcher. For Taiwan symbols this surfaced a raw "missing fmp_api_key" error. Short-circuit isTaiwanSymbol() with a clear message pointing to equityGetProfile (which already returns P/E, P/B, and dividend yield from official TWSE/TPEx data, no key needed). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tool/equity.spec.ts | 24 ++++++++++++++++++++++++ src/tool/equity.ts | 10 ++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/tool/equity.spec.ts b/src/tool/equity.spec.ts index 56ee303d8..b62ed2ecf 100644 --- a/src/tool/equity.spec.ts +++ b/src/tool/equity.spec.ts @@ -81,3 +81,27 @@ describe('createEquityTools — equityGetProfile provider routing', () => { expect(client.getKeyMetrics).toHaveBeenCalledWith({ symbol: 'AAPL', limit: 1, provider: 'yfinance' }) }) }) + +describe('createEquityTools — equityGetRatios Taiwan short-circuit', () => { + let client: EquityClientLike + let tools: ReturnType + + beforeEach(() => { + client = makeMockEquityClient() + tools = createEquityTools(client) + }) + + it('short-circuits Taiwan symbols with a pointer to equityGetProfile', async () => { + const out = await exec(tools.equityGetRatios, { symbol: '2330.TW' }) + expect(client.getFinancialRatios).not.toHaveBeenCalled() + expect(out).toMatchObject({ ratios: [] }) + expect(out.message).toContain('equityGetProfile') + }) + + it('still routes US symbols to the fmp ratios provider', async () => { + await exec(tools.equityGetRatios, { symbol: 'AAPL' }) + expect(client.getFinancialRatios).toHaveBeenCalledWith( + expect.objectContaining({ symbol: 'AAPL', provider: 'fmp' }), + ) + }) +}) diff --git a/src/tool/equity.ts b/src/tool/equity.ts index 7f28697e0..7ba394352 100644 --- a/src/tool/equity.ts +++ b/src/tool/equity.ts @@ -101,6 +101,16 @@ If unsure about the symbol, use marketSearchForResearch to find it.`, ttm: z.enum(['include', 'exclude', 'only']).optional().describe('TTM handling: "include" (default — TTM + history), "exclude" (history only), "only" (TTM snapshot only)'), }).meta({ examples: [{ symbol: 'AAPL', period: 'annual', limit: 5 }] }), execute: async ({ symbol, period, limit, ttm }) => { + // FMP (the ratios provider) covers US fundamentals only, and twse has + // no financial-ratios fetcher. Short-circuit Taiwan symbols with a + // pointer to the tool that does work, instead of surfacing the raw + // "missing fmp_api_key" error. + if (isTaiwanSymbol(symbol)) { + return { + ratios: [], + message: `Financial ratios are not available for Taiwan-listed symbols (the FMP ratios provider does not cover Taiwan). For ${symbol}, use equityGetProfile — it returns P/E, P/B, and dividend yield from official TWSE/TPEx data.`, + } + } // The FMP fetcher defaults ttm to "only" (a single TTM row, with // period/limit dead). Default to "include" here so the historical // series — and therefore period/limit — actually come through. From 8b1424b299418210832596ce4b72c6740323eaa8 Mon Sep 17 00:00:00 2001 From: bakabaka0613 Date: Mon, 8 Jun 2026 21:16:01 +0800 Subject: [PATCH 4/4] feat(equity): add equityGetDividends tool for dividend history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The historical-dividends provider model and the SDK getDividends() method already existed, but nothing exposed them as an AI tool, so agents could not fetch dividend/distribution history at all. Add equityGetDividends: returns { ex_dividend_date, amount } per payout (split-adjusted, oldest first), routed to yfinance — which covers Taiwan ETFs/stocks well (verified 0056.TW/00878.TW/00918.TW return data) while twse has no dividend feed. A bare Taiwan code (e.g. 0056) is normalized to the .TW listing; an explicit .TWO selects a TPEx listing. Optional start_date/end_date and a limit (most-recent N) round it out. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../client/openbb-api/equity-client.ts | 3 +- src/domain/market-data/client/types.ts | 2 + src/tool/equity.spec.ts | 51 +++++++++++++++++++ src/tool/equity.ts | 42 +++++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/domain/market-data/client/openbb-api/equity-client.ts b/src/domain/market-data/client/openbb-api/equity-client.ts index 80236321f..51e19ec40 100644 --- a/src/domain/market-data/client/openbb-api/equity-client.ts +++ b/src/domain/market-data/client/openbb-api/equity-client.ts @@ -11,6 +11,7 @@ import type { EquitySearchData, EquityHistoricalData, EquityInfoData, KeyMetricsData, IncomeStatementData, BalanceSheetData, CashFlowStatementData, FinancialRatiosData, PriceTargetConsensusData, CalendarEarningsData, InsiderTradingData, EquityDiscoveryData, + HistoricalDividendsData, } from '@traderalice/opentypebb' export class OpenBBEquityClient { @@ -103,7 +104,7 @@ export class OpenBBEquityClient { } async getDividends(params: Record) { - return this.request('/fundamental/dividends', params) + return this.request('/fundamental/dividends', params) } async getEarningsHistory(params: Record) { diff --git a/src/domain/market-data/client/types.ts b/src/domain/market-data/client/types.ts index 74ee47a27..497838b76 100644 --- a/src/domain/market-data/client/types.ts +++ b/src/domain/market-data/client/types.ts @@ -12,6 +12,7 @@ import type { EquitySearchData, EquityHistoricalData, EquityInfoData, KeyMetricsData, IncomeStatementData, BalanceSheetData, CashFlowStatementData, FinancialRatiosData, PriceTargetConsensusData, CalendarEarningsData, InsiderTradingData, EquityDiscoveryData, + HistoricalDividendsData, // Crypto CryptoSearchData, CryptoHistoricalData, // Currency @@ -44,6 +45,7 @@ export interface EquityClientLike { getEstimateConsensus(params: Record): Promise getCalendarEarnings(params?: Record): Promise getInsiderTrading(params: Record): Promise + getDividends(params: Record): Promise getGainers(params?: Record): Promise getLosers(params?: Record): Promise getActive(params?: Record): Promise diff --git a/src/tool/equity.spec.ts b/src/tool/equity.spec.ts index b62ed2ecf..e4ef36f19 100644 --- a/src/tool/equity.spec.ts +++ b/src/tool/equity.spec.ts @@ -24,6 +24,7 @@ function makeMockEquityClient(): EquityClientLike { getEstimateConsensus: vi.fn(async () => []), getCalendarEarnings: vi.fn(async () => []), getInsiderTrading: vi.fn(async () => []), + getDividends: vi.fn(async () => []), getGainers: vi.fn(async () => []), getLosers: vi.fn(async () => []), getActive: vi.fn(async () => []), @@ -105,3 +106,53 @@ describe('createEquityTools — equityGetRatios Taiwan short-circuit', () => { ) }) }) + +describe('createEquityTools — equityGetDividends', () => { + let client: EquityClientLike + let tools: ReturnType + + beforeEach(() => { + client = makeMockEquityClient() + tools = createEquityTools(client) + }) + + it('fetches US dividends via yfinance, symbol unchanged', async () => { + await exec(tools.equityGetDividends, { symbol: 'AAPL' }) + expect(client.getDividends).toHaveBeenCalledWith( + expect.objectContaining({ symbol: 'AAPL', provider: 'yfinance' }), + ) + }) + + it('keeps an explicit Taiwan suffix and routes to yfinance (twse has no dividend feed)', async () => { + await exec(tools.equityGetDividends, { symbol: '0056.TW' }) + expect(client.getDividends).toHaveBeenCalledWith( + expect.objectContaining({ symbol: '0056.TW', provider: 'yfinance' }), + ) + }) + + it('appends .TW to a bare Taiwan code so Yahoo resolves it', async () => { + await exec(tools.equityGetDividends, { symbol: '00878' }) + expect(client.getDividends).toHaveBeenCalledWith( + expect.objectContaining({ symbol: '00878.TW', provider: 'yfinance' }), + ) + }) + + it('passes through a date range when provided', async () => { + await exec(tools.equityGetDividends, { symbol: 'AAPL', start_date: '2024-01-01', end_date: '2024-12-31' }) + expect(client.getDividends).toHaveBeenCalledWith( + expect.objectContaining({ symbol: 'AAPL', provider: 'yfinance', start_date: '2024-01-01', end_date: '2024-12-31' }), + ) + }) + + it('returns only the most recent `limit` distributions, newest last', async () => { + const rows = [ + { symbol: '0056.TW', ex_dividend_date: '2025-07-21', amount: 0.866 }, + { symbol: '0056.TW', ex_dividend_date: '2025-10-23', amount: 0.866 }, + { symbol: '0056.TW', ex_dividend_date: '2026-01-22', amount: 0.866 }, + { symbol: '0056.TW', ex_dividend_date: '2026-04-23', amount: 1.0 }, + ] + ;(client.getDividends as any).mockResolvedValueOnce(rows) + const out = await exec(tools.equityGetDividends, { symbol: '0056.TW', limit: 2 }) + expect(out).toEqual(rows.slice(-2)) + }) +}) diff --git a/src/tool/equity.ts b/src/tool/equity.ts index 7ba394352..bad147bd0 100644 --- a/src/tool/equity.ts +++ b/src/tool/equity.ts @@ -25,6 +25,20 @@ export function isTaiwanSymbol(symbol: string): boolean { return /\.(TW|TWO)$/.test(s) || /^\d{4,6}[A-Z]?$/.test(s) } +/** + * Normalize a symbol for Yahoo Finance. Yahoo needs the exchange suffix for + * Taiwan listings (`0056.TW`), but a user may type the bare listing code + * (`0056`). Bare Taiwan codes are assumed TWSE-listed (`.TW`) — the vast + * majority of traded ETFs/stocks; TPEx-listed ones need an explicit `.TWO`. + * Symbols that already carry a suffix, and US tickers, pass through unchanged. + */ +export function toYahooSymbol(symbol: string): string { + const s = symbol.trim().toUpperCase() + if (/\.(TW|TWO)$/.test(s)) return s + if (/^\d{4,6}[A-Z]?$/.test(s)) return `${s}.TW` + return s +} + export function createEquityTools(equityClient: EquityClientLike) { return { equityGetProfile: tool({ @@ -160,6 +174,34 @@ If unsure about the symbol, use marketSearchForResearch to find it.`, }, }), + equityGetDividends: tool({ + description: `Get the dividend / distribution history for a stock or ETF. + +Returns each distribution as { ex_dividend_date, amount } (per share, +split-adjusted), oldest first. Use this for income/yield analysis — e.g. an +ETF's recent payout cadence and per-unit amounts. + +Sourced from Yahoo Finance, which covers Taiwan ETFs/stocks well (e.g. +"0056.TW", "00878.TW") as well as US names. Taiwan symbols are routed to +Yahoo here (not TWSE) — pass a bare code like "0056" and it resolves to the +TWSE listing (".TW"); for a TPEx-listed symbol use the explicit ".TWO" suffix. + +If unsure about the symbol, use marketSearchForResearch to find it.`, + inputSchema: z.object({ + symbol: z.string().describe('Ticker symbol, e.g. "AAPL", "SCHD", "0056.TW"'), + start_date: z.string().optional().describe('Start date in YYYY-MM-DD format (omit for full history)'), + end_date: z.string().optional().describe('End date in YYYY-MM-DD format'), + limit: z.number().int().positive().optional().describe('Return only the most recent N distributions'), + }).meta({ examples: [{ symbol: '0056.TW', limit: 8 }] }), + execute: async ({ symbol, start_date, end_date, limit }) => { + const params: Record = { symbol: toYahooSymbol(symbol), provider: 'yfinance' } + if (start_date) params.start_date = start_date + if (end_date) params.end_date = end_date + const rows = await equityClient.getDividends(params) + return limit ? rows.slice(-limit) : rows + }, + }), + equityDiscover: tool({ description: `Discover trending stocks in the market right now.