diff --git a/.changeset/rate-basis-round-trip-utils.md b/.changeset/rate-basis-round-trip-utils.md new file mode 100644 index 000000000..6c3b82bd6 --- /dev/null +++ b/.changeset/rate-basis-round-trip-utils.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +新增來回換匯成本與計價基準判定的內部工具模組,供後續在幣別頁揭露「換出去再換回來」的實際損失與交叉換算警示。成本計算限定台銀牌告的報價單位,換錢所的反向報價會被單位保護擋下而不產生錯誤數字。本次尚未有使用者可見變更。 diff --git a/apps/ratewise/src/i18n/locales/en.ts b/apps/ratewise/src/i18n/locales/en.ts index 03e0a0e68..60e864c14 100644 --- a/apps/ratewise/src/i18n/locales/en.ts +++ b/apps/ratewise/src/i18n/locales/en.ts @@ -85,6 +85,12 @@ const en = { cashSellBasis: 'Cash sell rate trend', }, + // Rate Basis(計價基準揭露;僅在 selector 表達不出來時顯示) + rateBasis: { + mid: 'Mid-market rate (not a dealable quote)', + cross: 'Cross conversion (via TWD, two spreads applied)', + }, + // Multi Currency Converter multiConverter: { title: 'Multi-Currency', diff --git a/apps/ratewise/src/i18n/locales/ja.ts b/apps/ratewise/src/i18n/locales/ja.ts index 3d9eb7613..de50aa996 100644 --- a/apps/ratewise/src/i18n/locales/ja.ts +++ b/apps/ratewise/src/i18n/locales/ja.ts @@ -85,6 +85,12 @@ const ja = { cashSellBasis: '現金売レートの推移', }, + // Rate Basis(計價基準揭露;僅在 selector 表達不出來時顯示) + rateBasis: { + mid: '仲値(取引レートではありません)', + cross: 'クロス換算(TWD 経由、スプレッド 2 回分)', + }, + // Multi Currency Converter multiConverter: { title: '複数通貨換算', diff --git a/apps/ratewise/src/i18n/locales/ko.ts b/apps/ratewise/src/i18n/locales/ko.ts index 3f698830f..9f0d24ecd 100644 --- a/apps/ratewise/src/i18n/locales/ko.ts +++ b/apps/ratewise/src/i18n/locales/ko.ts @@ -85,6 +85,12 @@ const ko = { cashSellBasis: '현금 매도율 추이', }, + // Rate Basis(計價基準揭露;僅在 selector 表達不出來時顯示) + rateBasis: { + mid: '중간 환율 (거래 가능 호가 아님)', + cross: '교차 환산 (TWD 경유, 스프레드 2회 적용)', + }, + // Multi Currency Converter multiConverter: { title: '복수 통화 환산', diff --git a/apps/ratewise/src/i18n/locales/zh-TW.ts b/apps/ratewise/src/i18n/locales/zh-TW.ts index 9c07b9392..571ce5788 100644 --- a/apps/ratewise/src/i18n/locales/zh-TW.ts +++ b/apps/ratewise/src/i18n/locales/zh-TW.ts @@ -85,6 +85,12 @@ const zhTW = { cashSellBasis: '現金賣出走勢', }, + // Rate Basis(計價基準揭露;僅在 selector 表達不出來時顯示) + rateBasis: { + mid: '參考中間價(非交易報價)', + cross: '交叉換算(經台幣中轉,計入兩次價差)', + }, + // Multi Currency Converter multiConverter: { title: '多幣別換算', diff --git a/apps/ratewise/src/utils/__tests__/rateBasisLabel.test.ts b/apps/ratewise/src/utils/__tests__/rateBasisLabel.test.ts new file mode 100644 index 000000000..f4046b020 --- /dev/null +++ b/apps/ratewise/src/utils/__tests__/rateBasisLabel.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { + getRateBasisKind, + getRateBasisLabelKey, + shouldDiscloseRateBasis, + type RateBasisKind, +} from '../rateBasisLabel'; +import zhTW from '../../i18n/locales/zh-TW'; +import en from '../../i18n/locales/en'; +import ja from '../../i18n/locales/ja'; +import ko from '../../i18n/locales/ko'; + +describe('getRateBasisKind', () => { + it('rateMode 為 mid 時一律回傳 mid(優先於 cross)', () => { + expect(getRateBasisKind('USD', 'JPY', 'mid')).toBe('mid'); + expect(getRateBasisKind('TWD', 'USD', 'mid')).toBe('mid'); + expect(getRateBasisKind('USD', 'USD', 'mid')).toBe('mid'); + }); + + it('兩端皆非台幣時為 cross(經台幣中轉吃兩次價差)', () => { + expect(getRateBasisKind('USD', 'JPY', 'auto')).toBe('cross'); + expect(getRateBasisKind('JPY', 'KRW', 'sell')).toBe('cross'); + }); + + it('任一端為台幣時為 direct(方向已由 selector 表達)', () => { + expect(getRateBasisKind('TWD', 'USD', 'auto')).toBe('direct'); + expect(getRateBasisKind('USD', 'TWD', 'auto')).toBe('direct'); + expect(getRateBasisKind('TWD', 'JPY', 'sell')).toBe('direct'); + }); + + it('同幣別不構成換匯,為 direct', () => { + expect(getRateBasisKind('USD', 'USD', 'auto')).toBe('direct'); + expect(getRateBasisKind('TWD', 'TWD', 'sell')).toBe('direct'); + }); +}); + +describe('getRateBasisLabelKey', () => { + it('mid 與 cross 回傳對應的 i18n key', () => { + expect(getRateBasisLabelKey('mid')).toBe('rateBasis.mid'); + expect(getRateBasisLabelKey('cross')).toBe('rateBasis.cross'); + }); + + it('direct 不需標籤故回傳 null', () => { + expect(getRateBasisLabelKey('direct')).toBeNull(); + }); +}); + +describe('shouldDiscloseRateBasis', () => { + it('僅 mid 與 cross 需要揭露', () => { + expect(shouldDiscloseRateBasis('mid')).toBe(true); + expect(shouldDiscloseRateBasis('cross')).toBe(true); + expect(shouldDiscloseRateBasis('direct')).toBe(false); + }); +}); + +describe('i18n key 完整性', () => { + const locales = { zhTW, en, ja, ko }; + const disclosedKinds: RateBasisKind[] = ['mid', 'cross']; + + it('四語系皆有 rateBasis 區塊', () => { + for (const [name, locale] of Object.entries(locales)) { + expect(locale.rateBasis, `${name} 缺少 rateBasis 區塊`).toBeDefined(); + } + }); + + it('四語系皆備齊需揭露的 kind 且非空字串', () => { + for (const [name, locale] of Object.entries(locales)) { + for (const kind of disclosedKinds) { + const value = (locale.rateBasis as Record)[kind]; + expect(value, `${name}.rateBasis.${kind} 缺失`).toBeTruthy(); + expect(value?.trim().length ?? 0, `${name}.rateBasis.${kind} 為空`).toBeGreaterThan(0); + } + } + }); + + it('不得為 direct 建立文案(該狀態不顯示標籤)', () => { + for (const [name, locale] of Object.entries(locales)) { + expect( + (locale.rateBasis as Record)['direct'], + `${name} 不應有 rateBasis.direct`, + ).toBeUndefined(); + } + }); + + it('zh-TW 文案明示中間價非可成交報價', () => { + expect(zhTW.rateBasis.mid).toContain('非交易報價'); + }); + + it('zh-TW 交叉換算文案明示計入兩次價差', () => { + expect(zhTW.rateBasis.cross).toContain('兩次價差'); + }); +}); diff --git a/apps/ratewise/src/utils/__tests__/roundTripCost.test.ts b/apps/ratewise/src/utils/__tests__/roundTripCost.test.ts new file mode 100644 index 000000000..7cba59c8c --- /dev/null +++ b/apps/ratewise/src/utils/__tests__/roundTripCost.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest'; +import { + computeRoundTripCost, + computeRoundTripLossTwd, + classifyRoundTripSeverity, + deriveCashBuy, + ROUND_TRIP_SEVERITY_THRESHOLDS, +} from '../roundTripCost'; +import { SEO_RATE_EXAMPLES } from '../../config/generated/seo-rate-examples'; + +describe('computeRoundTripCost', () => { + it('以 (sell - buy) / sell 計算損失率', () => { + // 取 USD 量級的合成值:賣出 32.56、買入 31.89,價差 0.67 + const cost = computeRoundTripCost(32.56, 31.89); + expect(cost).not.toBeNull(); + expect(cost!.lossPct).toBe(2.06); + expect(cost!.spread).toBeCloseTo(0.67, 6); + }); + + it('損失率與換匯金額無關(僅由價差決定)', () => { + const a = computeRoundTripCost(100, 90); + const b = computeRoundTripCost(0.001, 0.0009); + expect(a!.lossPct).toBe(b!.lossPct); + }); + + it('缺任一報價時回傳 null', () => { + expect(computeRoundTripCost(32.65, null)).toBeNull(); + expect(computeRoundTripCost(null, 31.98)).toBeNull(); + expect(computeRoundTripCost(undefined, undefined)).toBeNull(); + }); + + it('非有限數回傳 null', () => { + expect(computeRoundTripCost(NaN, 31.98)).toBeNull(); + expect(computeRoundTripCost(32.65, Infinity)).toBeNull(); + }); + + it('sell <= 0 或 buy <= 0 回傳 null(避免除以零)', () => { + expect(computeRoundTripCost(0, 0)).toBeNull(); + expect(computeRoundTripCost(-1, -2)).toBeNull(); + }); + + it('買入高於賣出代表傳入反向報價單位,拒絕計算', () => { + expect(computeRoundTripCost(30, 31)).toBeNull(); + }); + + it('換錢所 KRW_PER_TWD 報價(sell 46.0 / buy 46.7)回傳 null 而非錯誤數字', () => { + // 明洞換錢所報價單位為每 1 台幣換得 N 韓元,方向與台銀 TWD_PER_FOREIGN 相反; + // 此處刻意驗證單位保護生效,避免對換錢所資料算出反號的損失率。 + expect(computeRoundTripCost(46.0, 46.7)).toBeNull(); + }); + + it('買賣同價時損失率為 0', () => { + const cost = computeRoundTripCost(30, 30); + expect(cost!.lossPct).toBe(0); + expect(cost!.severity).toBe('low'); + }); +}); + +describe('classifyRoundTripSeverity', () => { + it('依門檻分級,邊界值歸入較嚴重的一級', () => { + expect(classifyRoundTripSeverity(0)).toBe('low'); + expect(classifyRoundTripSeverity(4.99)).toBe('low'); + expect(classifyRoundTripSeverity(ROUND_TRIP_SEVERITY_THRESHOLDS.medium)).toBe('medium'); + expect(classifyRoundTripSeverity(14.99)).toBe('medium'); + expect(classifyRoundTripSeverity(ROUND_TRIP_SEVERITY_THRESHOLDS.high)).toBe('high'); + expect(classifyRoundTripSeverity(32.11)).toBe('high'); + }); +}); + +describe('computeRoundTripLossTwd', () => { + it('換 30000 台幣的 USD 再換回,損失約 618 元', () => { + const cost = computeRoundTripCost(32.56, 31.89); + expect(computeRoundTripLossTwd(30000, cost)).toBe(618); + }); + + it('cost 為 null 時回傳 null', () => { + expect(computeRoundTripLossTwd(30000, null)).toBeNull(); + }); + + it('金額為負或非有限數時回傳 null', () => { + const cost = computeRoundTripCost(32.56, 31.89); + expect(computeRoundTripLossTwd(-1, cost)).toBeNull(); + expect(computeRoundTripLossTwd(NaN, cost)).toBeNull(); + }); + + it('金額為 0 時損失為 0', () => { + const cost = computeRoundTripCost(32.56, 31.89); + expect(computeRoundTripLossTwd(0, cost)).toBe(0); + }); +}); + +describe('deriveCashBuy', () => { + it('自 bankMid 代數還原買入價(非估計,無誤差)', () => { + // bankMid = (cashBuy + cashSell) / 2 → cashBuy = 2 * bankMid - cashSell + expect(deriveCashBuy(32.225, 32.56)).toBeCloseTo(31.89, 6); + }); + + it('bankMid 為 null 代表無現金買入報價,回傳 null', () => { + expect(deriveCashBuy(null, 32.56)).toBeNull(); + expect(deriveCashBuy(undefined, 32.56)).toBeNull(); + }); + + it('非有限數或推導出非正值時回傳 null', () => { + expect(deriveCashBuy(NaN, 32.56)).toBeNull(); + expect(deriveCashBuy(10, 30)).toBeNull(); + }); +}); + +describe('真實牌告資料迴歸', () => { + const entries = Object.entries(SEO_RATE_EXAMPLES); + + it('所有具 bankMid 的幣別皆可還原買入價並算出來回成本', () => { + const withMid = entries.filter(([, ex]) => ex.bankMid != null); + expect(withMid.length).toBeGreaterThan(0); + for (const [code, ex] of withMid) { + const buy = deriveCashBuy(ex.bankMid, ex.cashSell); + expect(buy, `${code} 應可還原買入價`).not.toBeNull(); + const cost = computeRoundTripCost(ex.cashSell, buy); + expect(cost, `${code} 應可算出來回成本`).not.toBeNull(); + expect(cost!.lossPct).toBeGreaterThanOrEqual(0); + } + }); + + it('還原的買入價恆低於賣出價(價差方向正確)', () => { + for (const [code, ex] of entries) { + const buy = deriveCashBuy(ex.bankMid, ex.cashSell); + if (buy != null) { + expect(buy, `${code} 買入不應高於賣出`).toBeLessThan(ex.cashSell); + } + } + }); + + it('東南亞現鈔幣別的來回損失顯著高於主要貨幣', () => { + const costOf = (code: 'USD' | 'IDR') => { + const ex = SEO_RATE_EXAMPLES[code]; + expect(ex, `${code} 應存在於 SEO_RATE_EXAMPLES`).toBeDefined(); + return computeRoundTripCost(ex!.cashSell, deriveCashBuy(ex!.bankMid, ex!.cashSell)); + }; + const usd = costOf('USD'); + const idr = costOf('IDR'); + expect(usd!.severity).toBe('low'); + expect(idr!.severity).toBe('high'); + expect(idr!.lossPct).toBeGreaterThan(usd!.lossPct); + }); +}); diff --git a/apps/ratewise/src/utils/rateBasisLabel.ts b/apps/ratewise/src/utils/rateBasisLabel.ts new file mode 100644 index 000000000..a14913bdc --- /dev/null +++ b/apps/ratewise/src/utils/rateBasisLabel.ts @@ -0,0 +1,45 @@ +import type { CurrencyCode, RateMode } from '../features/ratewise/types'; + +/** + * 計價基準語意 SSOT。 + * + * 只揭露 RateTypeSelector 講不出來的事: + * - `mid`:中間價非可成交報價,數字本身不代表任何一方向的成交價。 + * - `cross`:外幣兌外幣需經台幣中轉,買賣價差被吃兩次,畫面上無其他痕跡。 + * - `direct`:直接買或賣,方向已由 selector 的可見狀態表達,不重複標註。 + * + * 設計原則:恆亮的標籤會退化為視覺壁紙;只在有話要說時出現才具訊號價值。 + */ +export type RateBasisKind = 'mid' | 'cross' | 'direct'; + +/** i18n key 前綴;標籤文案集中於 locales 的 rateBasis 區塊。 */ +export const RATE_BASIS_I18N_PREFIX = 'rateBasis' as const; + +/** 取得對應的 i18n key;`direct` 不需標籤故回傳 null。 */ +export function getRateBasisLabelKey(kind: RateBasisKind): `rateBasis.${RateBasisKind}` | null { + return kind === 'direct' ? null : `${RATE_BASIS_I18N_PREFIX}.${kind}`; +} + +/** + * 判定當前換算採用的計價基準。 + * + * 優先序 `mid` > `cross` > `direct`:中間價根本不可成交, + * 此警示強於「跨了兩次價差」,兩者同時成立時以 mid 為準。 + */ +export function getRateBasisKind( + fromCurrency: CurrencyCode, + toCurrency: CurrencyCode, + rateMode: RateMode, +): RateBasisKind { + if (rateMode === 'mid') return 'mid'; + // 同幣別不構成換匯,無跨價差問題。 + if (fromCurrency === toCurrency) return 'direct'; + // 兩端皆非台幣時需經台幣中轉,買賣價差被吃兩次。 + if (fromCurrency !== 'TWD' && toCurrency !== 'TWD') return 'cross'; + return 'direct'; +} + +/** 該基準是否需要在 UI 顯示標籤。 */ +export function shouldDiscloseRateBasis(kind: RateBasisKind): boolean { + return kind !== 'direct'; +} diff --git a/apps/ratewise/src/utils/roundTripCost.ts b/apps/ratewise/src/utils/roundTripCost.ts new file mode 100644 index 000000000..9f538720c --- /dev/null +++ b/apps/ratewise/src/utils/roundTripCost.ts @@ -0,0 +1,99 @@ +/** + * 來回換匯成本計算。 + * + * 「換出去再換回來」會被銀行賺兩次價差:以賣出價買外幣、以買入價換回台幣。 + * 損失率僅由買賣價差決定,與換匯金額無關(手續費另計,不在此模組範圍)。 + * + * 推導:持 X 台幣 → 外幣 X/sell → 換回台幣 X*buy/sell + * 損失 = X * (sell - buy) / sell + */ + +/** 來回損失嚴重度分級門檻(%);依 17 幣別實測分布訂定。 */ +export const ROUND_TRIP_SEVERITY_THRESHOLDS = { + /** 低於此值為 low */ + medium: 5, + /** 達到此值為 high */ + high: 15, +} as const; + +export type RoundTripSeverity = 'low' | 'medium' | 'high'; + +export interface RoundTripCost { + /** 來回損失率(%),已四捨五入至小數兩位 */ + lossPct: number; + /** 買賣價差(以台幣計價,每 1 單位外幣) */ + spread: number; + /** 嚴重度分級 */ + severity: RoundTripSeverity; +} + +/** 依損失率分級;門檻採 [low, medium) / [medium, high) / [high, ∞)。 */ +export function classifyRoundTripSeverity(lossPct: number): RoundTripSeverity { + if (lossPct >= ROUND_TRIP_SEVERITY_THRESHOLDS.high) return 'high'; + if (lossPct >= ROUND_TRIP_SEVERITY_THRESHOLDS.medium) return 'medium'; + return 'low'; +} + +/** + * 計算單一牌告類型(現金或即期)的來回成本。 + * + * **報價單位限定 `TWD_PER_FOREIGN`**(每 1 單位外幣 = N 台幣,台銀牌告的形式)。 + * 換錢所報價為 `KRW_PER_TWD`(每 1 台幣 = N 韓元),該單位下 buy 反而高於 sell + * (例如明洞 sell 46.0 / buy 46.7),語意與此函式的參數相反, + * 故會落入下方守衛回傳 null——這是刻意的單位保護,不是資料異常。 + * 換錢所若要計算來回成本,需另行依 `api-semantics-v2` 的 quoteUnit 換算後再呼叫。 + * + * @param sell 賣出價(客戶買外幣所適用),單位 TWD_PER_FOREIGN + * @param buy 買入價(客戶換回台幣所適用),單位 TWD_PER_FOREIGN + * @returns 缺任一報價、非有限數、sell <= 0,或 buy > sell(單位不符)時回傳 null + */ +export function computeRoundTripCost( + sell: number | null | undefined, + buy: number | null | undefined, +): RoundTripCost | null { + if (sell == null || buy == null) return null; + if (!Number.isFinite(sell) || !Number.isFinite(buy)) return null; + // sell <= 0 無法作為分母;在 TWD_PER_FOREIGN 單位下 buy 不應高於 sell, + // 若成立代表傳入了反向報價單位(如換錢所的 KRW_PER_TWD),拒絕計算以免給出錯誤數字。 + if (sell <= 0 || buy <= 0 || buy > sell) return null; + + const spread = sell - buy; + return { + lossPct: Number(((spread / sell) * 100).toFixed(2)), + spread: Number(spread.toFixed(6)), + severity: classifyRoundTripSeverity((spread / sell) * 100), + }; +} + +/** + * 自台銀自身中間價反推現金買入價。 + * + * `seo-rate-examples` 產出 `bankMid = (cashBuy + cashSell) / 2`,未直接保留 `cashBuy`, + * 故買入價需由此還原:`cashBuy = 2 * bankMid - cashSell`。此為代數還原而非估計, + * 不引入誤差。`bankMid` 為 null 代表該幣別無現金買入報價。 + */ +export function deriveCashBuy( + bankMid: number | null | undefined, + cashSell: number | null | undefined, +): number | null { + if (bankMid == null || cashSell == null) return null; + if (!Number.isFinite(bankMid) || !Number.isFinite(cashSell)) return null; + const buy = 2 * bankMid - cashSell; + return buy > 0 ? Number(buy.toFixed(8)) : null; +} + +/** + * 換出再換回的台幣損失金額。 + * + * @param twdAmount 起始台幣金額 + * @param cost 來回成本 + * @returns 損失台幣金額(四捨五入至整數);金額非有限數或為負時回傳 null + */ +export function computeRoundTripLossTwd( + twdAmount: number, + cost: RoundTripCost | null, +): number | null { + if (cost == null) return null; + if (!Number.isFinite(twdAmount) || twdAmount < 0) return null; + return Math.round((twdAmount * cost.lossPct) / 100); +} diff --git a/docs/dev/002_development_reward_penalty_log.md b/docs/dev/002_development_reward_penalty_log.md index ca2b3dac6..fef31038b 100644 --- a/docs/dev/002_development_reward_penalty_log.md +++ b/docs/dev/002_development_reward_penalty_log.md @@ -2,7 +2,7 @@ > 版本:outline-v2-ultra > 原則:每筆只保留日期、ID、原因、解法。 -> 本次分數變化:-1(reward 0、penalty 1、neutral 0)|累計總分:+317 +> 本次分數變化:-1(reward 0、penalty 1、neutral 1)|累計總分:+316 ## 新增模板(4 行) @@ -13,6 +13,16 @@ ## 條目(新→舊) +- 日期:2026-08-08 +- ID:penalty-stale-branch-analysis-misled-prd-twice +- 原因:在 checkout 於 feat/single-fold-fluid-fit(落後 main 200+ commits)的主 worktree 上盤點「main 現況」,同一份 PRD 兩度建立在過期程式碼上——先誤判幣別頁 schema 方向有 bug(main 實為自洽),後誤判 RateExample 已有 cashBuy/spotBuy/spotSell(main 僅有 cashSell/bankMid) +- 解法:撤回 v1.0 主張並改於 origin/main worktree 重驗;新增 deriveCashBuy 以 bankMid 代數還原買入價,刪除 main 無法計算的即期來回損失欄位;往後現況盤點一律在對應 base 的 worktree 執行 + +- 日期:2026-08-08 +- ID:neutral-ratewise-round-trip-cost-util +- 原因:換出再換回被銀行賺兩次價差的成本從未量化,17 幣別損失率全距 2.06%~32.11% 的事實無任何模組可產出 +- 解法:新增 roundTripCost 與 rateBasisLabel 純函式模組(含真實牌告迴歸測試與四語系 key 完整性守門),計價基準自 #433 的 8 種精簡為 mid/cross/direct 三種 + - 日期:2026-08-08 - ID:penalty-types-node-major-misjudged-as-patch - 原因:盤點 dependabot PR 時只看 mergeStateStatus=CLEAN 與 checks 全綠,未讀版本號就把 @types/node 24→26 判為「patch 級可直接合」,忽略 runtime 鎖在 Node 24(engines/.nvmrc/CI 三處一致)