Skip to content
2 changes: 2 additions & 0 deletions packages/opentypebb/src/core/api/app-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down Expand Up @@ -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
}
Expand Down
165 changes: 165 additions & 0 deletions packages/opentypebb/src/providers/twse/__tests__/common.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* 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, 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', () => {
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 })
})
})

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<typeof vi.fn>

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 })
})
})
123 changes: 123 additions & 0 deletions packages/opentypebb/src/providers/twse/__tests__/equity-info.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading