Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rate-basis-round-trip-utils.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@app/ratewise': patch
---

新增來回換匯成本與計價基準判定的內部工具模組,供後續在幣別頁揭露「換出去再換回來」的實際損失與交叉換算警示。成本計算限定台銀牌告的報價單位,換錢所的反向報價會被單位保護擋下而不產生錯誤數字。本次尚未有使用者可見變更。
6 changes: 6 additions & 0 deletions apps/ratewise/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions apps/ratewise/src/i18n/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ const ja = {
cashSellBasis: '現金売レートの推移',
},

// Rate Basis(計價基準揭露;僅在 selector 表達不出來時顯示)
rateBasis: {
mid: '仲値(取引レートではありません)',
cross: 'クロス換算(TWD 経由、スプレッド 2 回分)',
},

// Multi Currency Converter
multiConverter: {
title: '複数通貨換算',
Expand Down
6 changes: 6 additions & 0 deletions apps/ratewise/src/i18n/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ const ko = {
cashSellBasis: '현금 매도율 추이',
},

// Rate Basis(計價基準揭露;僅在 selector 表達不出來時顯示)
rateBasis: {
mid: '중간 환율 (거래 가능 호가 아님)',
cross: '교차 환산 (TWD 경유, 스프레드 2회 적용)',
},

// Multi Currency Converter
multiConverter: {
title: '복수 통화 환산',
Expand Down
6 changes: 6 additions & 0 deletions apps/ratewise/src/i18n/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ const zhTW = {
cashSellBasis: '現金賣出走勢',
},

// Rate Basis(計價基準揭露;僅在 selector 表達不出來時顯示)
rateBasis: {
mid: '參考中間價(非交易報價)',
cross: '交叉換算(經台幣中轉,計入兩次價差)',
},

// Multi Currency Converter
multiConverter: {
title: '多幣別換算',
Expand Down
92 changes: 92 additions & 0 deletions apps/ratewise/src/utils/__tests__/rateBasisLabel.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>)[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<string, string | undefined>)['direct'],
`${name} 不應有 rateBasis.direct`,
).toBeUndefined();
}
});

it('zh-TW 文案明示中間價非可成交報價', () => {
expect(zhTW.rateBasis.mid).toContain('非交易報價');
});

it('zh-TW 交叉換算文案明示計入兩次價差', () => {
expect(zhTW.rateBasis.cross).toContain('兩次價差');
});
});
145 changes: 145 additions & 0 deletions apps/ratewise/src/utils/__tests__/roundTripCost.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
45 changes: 45 additions & 0 deletions apps/ratewise/src/utils/rateBasisLabel.ts
Original file line number Diff line number Diff line change
@@ -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';
}
Loading
Loading