From fd51e3b90740c8b0d28fc2c979dbe639d028775b Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Thu, 15 Oct 2026 18:21:39 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20history:=20preserve=20comparison?= =?UTF-8?q?=20currencies=20and=20batch=20fiat=20requests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * keep selected Details pairs and display tiny reference values accurately * load daily fiat charts through bounded ECB and CBU range requests * cover pair combinations, provider failures, live data and cache reuse --- .../Composition/CurrencyE2EStartup.swift | 53 ++++- Apps/Currency/Tests/E2E/README.md | 8 +- Apps/Currency/Tests/E2E/e2e.config.ts | 2 +- .../Tests/E2E/tests/details-reference.e2e.ts | 115 +++++++++ .../Tests/E2E/tests/fiat-history-live.e2e.ts | 123 ++++++++++ .../Tests/E2E/tests/live-app-provider.e2e.ts | 46 ++-- .../App/Sources/CurrencyWatchApp.swift | 2 +- Documentation/DataSources.md | 2 +- Documentation/Development.md | 2 +- Documentation/FeatureMap.md | 6 +- Documentation/PrivacyPolicy.md | 2 +- .../ExchangeRates/FrankfurterHistory.swift | 66 ++++++ .../ExchangeRates/HistoryService.swift | 22 +- .../Sources/ExchangeRates/README.md | 2 +- .../ExchangeRates/Resources/DataSources.md | 2 +- .../ExchangeRatesUI/CurrencyDisplay.swift | 22 ++ .../FrankfurterHistoryTests.swift | 220 ++++++++++++++++++ .../ExchangeRatesTests/HistoryTests.swift | 1 + .../ReferenceRateFormattingTests.swift | 36 +++ .../CurrencyDetailsModel.swift | 25 +- .../CurrencyDetailsUI/RateDetailsScreen.swift | 15 +- .../CurrencyDetailsWatchScreen.swift | 3 +- .../CurrencyDetailsTests.swift | 157 ++++++++++--- 23 files changed, 826 insertions(+), 106 deletions(-) create mode 100644 Apps/Currency/Tests/E2E/tests/details-reference.e2e.ts create mode 100644 Apps/Currency/Tests/E2E/tests/fiat-history-live.e2e.ts create mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/FrankfurterHistory.swift create mode 100644 Domain/ExchangeRates/Tests/ExchangeRatesTests/FrankfurterHistoryTests.swift create mode 100644 Domain/ExchangeRates/Tests/ExchangeRatesUITests/ReferenceRateFormattingTests.swift diff --git a/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift b/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift index fc385ed..fa54d9b 100644 --- a/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift +++ b/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift @@ -77,6 +77,7 @@ let daily = FixedRateClient.quotes(now: now) var effective = daily effective["BTC"] = FixedRateClient.coinMetricsQuote(now: now) + effective["ETH"] = FixedRateClient.coinMetricsQuote(now: now, priceUSD: 100) try FileCoordination.write(at: directory.appendingPathComponent("rates.json")) { try RateCache(directory: directory) .save( @@ -91,21 +92,45 @@ try seedHistory( base: "EUR", quote: "XAU", values: [0.009, 0.01], directory: directory, now: now) - try seedHistory( - base: "BTC", quote: "USD", values: [90_000, 100_000], - directory: directory, now: now) let end = Date(timeIntervalSince1970: floor(now.timeIntervalSince1970 / 300) * 300) - let intraday = HistorySeries( + for reference in ["USD", "EUR", "CZK", "CHF", "VND"] { + guard let referenceRate = daily[reference], let usd = daily["USD"] else { + throw StartupError.seedVerificationFailed + } + let factor = NSDecimalNumber(decimal: referenceRate.value / usd.value).doubleValue + try seedHistory( + base: "BTC", quote: reference, values: [45_000 * factor, 50_000 * factor], + directory: directory, now: now) + let conversion: RateConversion? = + reference == "USD" + ? nil + : .init( + provider: .fawaz, published: FixedRateClient.day(now), + value: referenceRate.value / usd.value, base: "USD", quote: reference) + let intraday = HistorySeries( + points: (0...288) + .map { index in + HistoryPoint( + date: end.addingTimeInterval(Double(index - 288) * 300), + value: (45_000 + Double(index) * 5_000 / 288) * factor) + }, + source: .init( + provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt, + conversion: conversion), fetchedAt: end) + try writeHistory( + intraday, filename: "history-v3-BTC-\(reference)-1.json", directory: directory) + } + try seedHistory( + base: "ETH", quote: "BTC", values: [0.0018, 0.002], directory: directory, now: now) + let cryptoPair = HistorySeries( points: (0...288) .map { index in HistoryPoint( date: end.addingTimeInterval(Double(index - 288) * 300), - value: 45_000 + Double(index) * 5_000 / 288) + value: 0.0018 + Double(index) * 0.0002 / 288) }, source: .init(provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt), fetchedAt: end) - try writeHistory( - intraday, filename: "history-v3-BTC-USD-1.json", - directory: directory) + try writeHistory(cryptoPair, filename: "history-v3-ETH-BTC-1.json", directory: directory) let files = [ "input.json", "onboarding.json", "widget-location-refresh.json", "widget-location.json", "widget-location-status.json" @@ -221,7 +246,10 @@ let assets = items.first { $0.name == "assets" }?.value?.split(separator: ",") ?? [] let end = Date(timeIntervalSince1970: floor(Date.now.timeIntervalSince1970 / 60) * 60) let rows = assets.map { asset in - ["asset": String(asset), "time": end.ISO8601Format(), "ReferenceRateUSD": "50000"] + [ + "asset": String(asset), "time": end.ISO8601Format(), + "ReferenceRateUSD": asset == "eth" ? "100" : "50000" + ] } return try JSONSerialization.data(withJSONObject: ["data": rows]) } @@ -244,7 +272,8 @@ static func quotes(now: Date) -> [String: ExchangeRate] { let values: [String: Decimal] = [ - "EUR": 1, "USD": 2, "CHF": 0.5, "CZK": 25, "XAU": 0.01, "BTC": 0.00002 + "EUR": 1, "USD": 2, "CHF": 0.5, "CZK": 25, "XAU": 0.01, "BTC": 0.00002, + "ETH": 0.01, "VND": 50_000 ] return values.mapValues { ExchangeRate( @@ -253,9 +282,9 @@ } } - static func coinMetricsQuote(now: Date) -> ExchangeRate { + static func coinMetricsQuote(now: Date, priceUSD: Decimal = 50_000) -> ExchangeRate { ExchangeRate( - 0.00004, published: day(now), + 2 / priceUSD, published: day(now), source: .init( provider: .coinMetrics, observation: .minuteReference, conversion: RateConversion( diff --git a/Apps/Currency/Tests/E2E/README.md b/Apps/Currency/Tests/E2E/README.md index 232adcf..0415c19 100644 --- a/Apps/Currency/Tests/E2E/README.md +++ b/Apps/Currency/Tests/E2E/README.md @@ -59,10 +59,12 @@ These are representative outcomes, not exhaustive coverage of each feature ID. | CONV-02–03, CONV-05 | [currency-selection](tests/currency-selection.e2e.ts) | Change source, add/remove currencies, exact amounts and restart | | CONV-01, HIST-01–04, PREF-04 | [provider-policy](tests/provider-policy.e2e.ts) | Configured Fawaz/Coin Metrics conversion, 24-hour minute-reference provenance, All monthly sources, no spurious USD approximation and retained non-USD daily-FX notices, 1D capability, source credits and saved-cache compatibility after restart | | HIST-02 | [history-archive](tests/history-archive.e2e.ts) | With the seeded All cache removed, the real bundled BTC/USD history starts in 2010 and exposes monthly source/date information | +| HIST-02, HIST-04, RATE-01 | [fiat-history-live](tests/fiat-history-live.e2e.ts) | Refresh production rates, load complete Frankfurter USD/EUR and AED/EUR years and bundled monthly All archive from 2010, record cold/All/warm times and prove exact yearly cache reuse after relaunch; requires network | | HIST-02–03, WID-08 | [converted-crypto-history](tests/converted-crypto-history.e2e.ts) | An installed BTC/EUR History widget fetches a complete live day with the app closed and retains its historical USD/EUR rate, provider and publication date; requires network | -| RATE-01–02, HIST-01–04, CONV-05, PREF-04 | [live-app-provider](tests/live-app-provider.e2e.ts) | Disable the fixture network adapter, force-refresh all 22 crypto quotes, inspect the real phone 1D chart and bundled All history from 2010, then relaunch and verify saved input/chart reuse; requires network | +| RATE-01–02, HIST-01–04, CONV-05, PREF-04 | [live-app-provider](tests/live-app-provider.e2e.ts) | Disable the fixture network adapter, force-refresh all 22 crypto quotes, inspect the real phone USD and EUR 1D charts and bundled All history from 2010, then relaunch and verify saved input/chart reuse; requires network | | HIST-02, WID-08 | [widget-live-provider](tests/widget-live-provider.e2e.ts) | With the app terminated and the seeded 1D cache removed, an installed widget fetches and persists a complete live Coin Metrics day; requires network | | HIST-01–04 | [converter-details](tests/converter-details.e2e.ts) | Rate, period selection, source explanation and return | +| CONV-02–03, CONV-05, HIST-01–04 | [details-reference](tests/details-reference.e2e.ts) | Bitcoin Details retains EUR/USD/CZK/CHF references and non-USD approximation provenance; ETH/BTC and VND/BTC stay nonzero at exact current and 1M/1D values; selected sources and pairs survive restart | | PREF-05, ONB-01–02 | [metal-widget-preview](tests/metal-widget-preview.e2e.ts) | Replayed Calculator and Board showcase previews retain the saved grams preference and converted Gold amount | | PREF-05, WID-04–08 | [metal-widget-display](tests/metal-widget-display.e2e.ts) | Board, Cash, Pocket, Mental Math and History show Gold amounts and visible measurement units | | PREF-05, WID-02, WID-10 | [metal-widget](tests/metal-widget.e2e.ts) | Saved grams preference reaches Calculator; keypad edits return to app with matching metal conversion | @@ -79,9 +81,9 @@ For widget/shared-data changes, run the affected widget and both Calculator shar ## Fixtures, widgets and cache -Start each case once with a matching fixture. `fresh-onboarding` and `ready-converter` cover general flows; `ready-metals` adds Gold as a destination and `ready-metal-source` starts with one troy ounce of Gold for measurement checks. The first launch resets real stores; restart keeps edits. Fixed rates are EUR 1 / USD 2 / CHF 0.5 / CZK 25 / XAU 0.01 troy oz, with versioned reference-history caches for EUR/USD, EUR/Gold and BTC/USD. `ready-crypto` starts USD 1 → Bitcoin: 0.00002 from Coin Metrics, with a distinct 0.00001 Fawaz daily fallback stored alongside it. Restart omits fixture state and preserves the real stores. Keep `-CurrencyE2EState` out of default launch arguments. +Start each case once with a matching fixture. `fresh-onboarding` and `ready-converter` cover general flows; `ready-metals` adds Gold as a destination and `ready-metal-source` starts with one troy ounce of Gold for measurement checks. The first launch resets real stores; restart keeps edits. Fixed rates are EUR 1 / USD 2 / CHF 0.5 / CZK 25 / XAU 0.01 troy oz, with versioned reference-history caches for EUR/USD, EUR/Gold, BTC against USD/EUR/CZK/CHF/VND and ETH/BTC. Ethereum has distinct live/daily fixture quotes; VND exercises tiny crypto reference values. `ready-crypto` starts USD 1 → Bitcoin: 0.00002 from Coin Metrics, with a distinct 0.00001 Fawaz daily fallback stored alongside it. Restart omits fixture state and preserves the real stores. Keep `-CurrencyE2EState` out of default launch arguments. -Run the provider journey with the normal app build. It verifies the standard Coin Metrics current rate, 1D capability, source credits and restart behavior. The fixture cache identity is `currency-fixtures-v9-CM`; rebuild after app or fixture changes. +Run the provider journey with the normal app build. It verifies the standard Coin Metrics current rate, 1D capability, source credits and restart behavior. The fixture cache identity is `currency-fixtures-v10-pairs`; rebuild after app or fixture changes. ```sh npm run test:e2e -- tests/provider-policy.e2e.ts diff --git a/Apps/Currency/Tests/E2E/e2e.config.ts b/Apps/Currency/Tests/E2E/e2e.config.ts index 21fe566..0cf3cb3 100644 --- a/Apps/Currency/Tests/E2E/e2e.config.ts +++ b/Apps/Currency/Tests/E2E/e2e.config.ts @@ -15,7 +15,7 @@ export default { name: targetName, engine: mobile({ platform: 'ios', device: udid, session: `currency-e2e-${targetName}` }), app: { - identity: 'currency-fixtures-v9-CM', + identity: 'currency-fixtures-v10-pairs', bundleId: 'com.dimasike.currency', appPath: process.env.CURRENCY_E2E_APP_PATH ?? '../../../../.validation/e2e/DerivedData/Build/Products/Debug-iphonesimulator/Currency.app', launchArguments: ['-AppleLanguages', '(en)', '-AppleLocale', 'en_US', '-CurrencyE2E'], diff --git a/Apps/Currency/Tests/E2E/tests/details-reference.e2e.ts b/Apps/Currency/Tests/E2E/tests/details-reference.e2e.ts new file mode 100644 index 0000000..fb8d8e2 --- /dev/null +++ b/Apps/Currency/Tests/E2E/tests/details-reference.e2e.ts @@ -0,0 +1,115 @@ +import { expect } from 'e2e'; +import { test } from '../fixtures.js'; + +const references = [ + { code: 'EUR', name: 'Euro', value: '25,000 EUR' }, + { code: 'USD', name: 'US Dollar', value: '50,000 USD' }, + { code: 'CZK', name: 'Czech Koruna', value: '625,000 CZK' }, + { code: 'CHF', name: 'Swiss Franc', value: '12,500 CHF' }, +] as const; + +for (const reference of references) { + test(`Bitcoin Details preserves the ${reference.code} converter reference`, { tags: ['core', 'providers'] }, async ({ start, app, agent, screen }) => { + await start('ready-crypto'); + if (reference.code !== 'USD') { + await agent.act(`Open the source currency picker. Use its Search field to search ${reference.code}, then select the ${reference.name} result and return to the converter without editing the amount.`); + } + const sourceCurrency = screen.getByTestId('converter.source'); + await expect(sourceCurrency).toHaveAccessibleName(new RegExp(reference.name, 'i')); + await expect(screen.getByRole('button', `Edit amount in ${reference.code}`)).toHaveValue('1'); + + const assertPair = async () => { + await expect(screen.getByTestId('currency.details.rate')).toHaveAccessibleName(`1 BTC in ${reference.code}`); + await expect(screen.getByTestId('currency.details.rate')).toHaveValue(reference.value); + }; + await agent.act('Open Details & history for the Bitcoin destination currency.'); + await expect(screen.getByTestId('currency.details.BTC')).toBeVisible(); + await app.screenshot(`bitcoin-details-${reference.code.toLowerCase()}-opened`); + await assertPair(); + + const ranges = screen.getByTestId('currency.details.historyRange'); + const month = ranges.getByRole('tab', 'One month', { exact: true }); + const day = ranges.getByRole('tab', 'One day', { exact: true }); + await month.tap(); + await expect(month).toBeSelected(); + await assertPair(); + await expect(screen.getByText(reference.value, { exact: true })).toBeVisible(); + await day.tap(); + await expect(day).toBeSelected(); + await assertPair(); + await expect(screen.getByText(reference.value, { exact: true })).toBeVisible(); + + const source = screen.getByTestId('currency.details.sourceDisclosure') + .filter({ hasText: /Coin Metrics.*minute reference prices/i }); + await expect(source).toBeVisible(); + if (reference.code !== 'USD') { + await expect(source.filter({ hasText: /approximate conversion/i })).toBeVisible(); + } + await source.tap(); + await expect(source).toHaveValue('Expanded'); + await agent.act('Scroll in Details until the exact text "Crypto history combines daily and monthly reference data with intraday prices when available. Converted prices may be approximate. Historical and current sources can differ." is visible. Use that exact text when scrolling to text. Keep Details open.'); + if (reference.code === 'USD') { + await expect(screen.getByText(/Coin Metrics.*minute reference prices.*approximate conversion/i)).toBeHidden(); + await expect(screen.getByText(/^Approximate conversion:/i)).toBeHidden(); + } else { + await expect(screen.getByText(new RegExp(`^Approximate conversion: 1 USD = .* ${reference.code}`, 'i')).first()).toBeVisible(); + } + await app.screenshot(`bitcoin-details-${reference.code.toLowerCase()}-day-source`); + + await screen.getByTestId('currency.details.close').tap(); + await expect(screen.getByTestId('currency.details.close')).toBeHidden(); + await expect(sourceCurrency).toHaveAccessibleName(new RegExp(reference.name, 'i')); + await app.restart(); + await expect(sourceCurrency).toHaveAccessibleName(new RegExp(reference.name, 'i')); + await expect(screen.getByRole('button', `Edit amount in ${reference.code}`)).toHaveValue('1'); + await agent.act('Open Details & history for the Bitcoin destination currency.'); + await expect(screen.getByTestId('currency.details.BTC')).toBeVisible(); + await assertPair(); + await app.screenshot(`bitcoin-details-${reference.code.toLowerCase()}-after-restart`); + }); +} + +const bitcoinReferences = [ + { code: 'ETH', name: 'Ethereum', value: '0.002 BTC' }, + { code: 'VND', name: 'Vietnamese Dong', value: '0.0000000008 BTC' }, +] as const; + +for (const destination of bitcoinReferences) { + test(`${destination.code} Details preserves a nonzero BTC reference`, { tags: ['core', 'providers'] }, async ({ start, app, agent, screen }) => { + await start('ready-crypto'); + await agent.act('Open the source currency picker. Use its Search field to search BTC, then select the Bitcoin result and return to the converter without editing the amount.'); + await expect(screen.getByTestId('converter.source')).toHaveAccessibleName(/Bitcoin/i); + await expect(screen.getByRole('button', 'Edit amount in BTC')).toHaveValue('1'); + await agent.act(`Add ${destination.code} as a destination currency using Add currency and its Search field. Select the ${destination.name} result and finish on the converter. Keep Bitcoin as the source currency.`); + await expect(screen.getByRole('button', new RegExp(`^${destination.name},`, 'i'))).toBeVisible(); + + const assertPair = async () => { + await expect(screen.getByTestId('currency.details.rate')).toHaveAccessibleName(`1 ${destination.code} in BTC`); + await expect(screen.getByTestId('currency.details.rate')).toHaveValue(destination.value); + }; + await agent.act(`Open Details & history for the ${destination.name} destination currency.`); + await expect(screen.getByTestId(`currency.details.${destination.code}`)).toBeVisible(); + await app.screenshot(`${destination.code.toLowerCase()}-details-btc-opened`); + await assertPair(); + const ranges = screen.getByTestId('currency.details.historyRange'); + for (const title of ['One month', 'One day']) { + const range = ranges.getByRole('tab', title, { exact: true }); + await range.tap(); + await expect(range).toBeSelected(); + await assertPair(); + await expect(screen.getByText(destination.value, { exact: true })).toBeVisible(); + } + await app.screenshot(`${destination.code.toLowerCase()}-details-btc-day-nonzero`); + await screen.getByTestId('currency.details.close').tap(); + await expect(screen.getByTestId('currency.details.close')).toBeHidden(); + if (destination.code === 'ETH') { + await app.restart(); + await expect(screen.getByTestId('converter.source')).toHaveAccessibleName(/Bitcoin/i); + await expect(screen.getByRole('button', 'Edit amount in BTC')).toHaveValue('1'); + await agent.act('Open Details & history for the Ethereum destination currency.'); + await expect(screen.getByTestId('currency.details.ETH')).toBeVisible(); + await assertPair(); + await app.screenshot('eth-details-btc-after-restart'); + } + }); +} diff --git a/Apps/Currency/Tests/E2E/tests/fiat-history-live.e2e.ts b/Apps/Currency/Tests/E2E/tests/fiat-history-live.e2e.ts new file mode 100644 index 0000000..aebc38e --- /dev/null +++ b/Apps/Currency/Tests/E2E/tests/fiat-history-live.e2e.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect } from 'e2e'; +import { test } from '../fixtures.js'; + +for (const quote of [{ code: 'USD' }, { code: 'AED' }]) { +test(`live ${quote.code}/EUR yearly history loads, All uses the archive and relaunch reuses the year`, { tags: ['extended', 'live-providers'] }, async ({ start, app, device, screen, agent }) => { + await start('ready-converter'); + const udid = process.env.CURRENCY_E2E_UDID!; + const directory = execFileSync('xcrun', ['simctl', 'get_app_container', udid, 'com.dimasike.currency', 'group.com.dimasike.currency.shared'], { encoding: 'utf8' }).trim(); + const ratesPath = join(directory, 'rates.json'); + const yearPath = join(directory, `history-v3-${quote.code}-EUR-365.json`); + const allPath = join(directory, `history-v3-${quote.code}-EUR-0.json`); + await device.closeApp(); + for (const file of [ratesPath, yearPath, allPath]) { + if (existsSync(file)) unlinkSync(file); + assert.equal(existsSync(file), false); + } + const started = Date.now(); + const milliseconds = (appleDate: number) => (appleDate + 978_307_200) * 1000; + const openLive = () => device.openApp('com.dimasike.currency', { + relaunch: true, + launchArguments: ['-AppleLanguages', '(en)', '-AppleLocale', 'en_US'], + }); + await openLive(); + await expect(screen.getByRole('button', 'Edit amount in EUR')).toHaveValue('1'); + await screen.getByTestId('converter.options').tap(); + await screen.getByTestId('converter.settings').tap(); + await screen.getByTestId('settings.rates').tap(); + await screen.getByRole('button', 'Refresh now', { exact: true }).tap(); + type Quote = { value: number; retrievedAt?: number; source: { provider: { coinMetrics?: object; fawaz?: object } } }; + type Snapshot = { quotes: Record }; + let rates: Snapshot | undefined; + for (let attempt = 0; attempt < 180; attempt++) { + rates = existsSync(ratesPath) ? JSON.parse(readFileSync(ratesPath, 'utf8')) as Snapshot : undefined; + if (rates?.quotes.USD?.source.provider.fawaz + && Object.values(rates.quotes).filter(rate => rate.source.provider.coinMetrics && milliseconds(Number(rate.retrievedAt)) >= started).length === 22) break; + await new Promise(resolve => setTimeout(resolve, 500)); + } + assert.ok(rates?.quotes.USD?.source.provider.fawaz, 'Production refresh must persist the actual daily FX feed.'); + assert.notEqual(Number(rates.quotes.USD.value), 2, 'Live rates must replace the fixture FX table.'); + assert.equal(Object.values(rates.quotes).filter(rate => rate.source.provider.coinMetrics && milliseconds(Number(rate.retrievedAt)) >= started).length, 22); + await agent.act(`Close Settings and return to the converter. ${quote.code === 'AED' ? 'Add AED (UAE Dirham) as a destination currency using the currency picker Search field. ' : ''}Open Details & history for the ${quote.code} destination currency.`); + await expect(screen.getByTestId(`currency.details.${quote.code}`)).toBeVisible(); + const rate = screen.getByTestId('currency.details.rate'); + await expect(rate).toHaveAccessibleName(`1 ${quote.code} in EUR`); + const ranges = screen.getByTestId('currency.details.historyRange'); + const year = ranges.getByRole('tab', 'One year', { exact: true }); + const all = ranges.getByRole('tab', 'All available history', { exact: true }); + const source = screen.getByTestId('currency.details.sourceDisclosure'); + type History = { + points: { date: number; value: number }[]; + fetchedAt: number; + source: { provider: { frankfurter?: object }; observation: string }; + }; + const readHistory = (file: string): History => JSON.parse(readFileSync(file, 'utf8')); + const waitForHistory = async (file: string) => { + for (let attempt = 0; attempt < 180 && !existsSync(file); attempt++) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + assert.ok(existsSync(file), 'The requested history must finish and persist within 90 seconds.'); + return readHistory(file); + }; + const assertPositiveOrdered = (history: History) => { + assert.ok(history.points.every((point, index) => Number.isFinite(point.value) && point.value > 0 + && Number.isFinite(point.date) && (index === 0 || point.date > history.points[index - 1]!.date))); + }; + const coldStarted = Date.now(); + await year.tap(); + await expect(year).toBeSelected(); + const savedYear = await waitForHistory(yearPath); + const coldYearMs = Date.now() - coldStarted; + assert.ok(savedYear.source.provider.frankfurter); + assert.equal(savedYear.source.observation, 'dailyReference'); + assert.ok(savedYear.points.length > 200); + assertPositiveOrdered(savedYear); + assert.ok(milliseconds(savedYear.fetchedAt) >= started); + const dayMs = 86_400_000; + const first = milliseconds(savedYear.points[0]!.date); + const last = milliseconds(savedYear.points.at(-1)!.date); + assert.ok(first >= started - 366 * dayMs && first <= started - 357 * dayMs, 'The daily series must cover the beginning of the last year.'); + assert.ok(last >= started - 7 * dayMs && last <= Date.now(), 'The last daily observation must be recent.'); + await expect(source.filter({ hasText: /Frankfurter.*daily reference rates/i })).toBeVisible(); + await expect(rate).toHaveAccessibleName(`1 ${quote.code} in EUR`); + await agent.waitFor(`The ${quote.code} 1Y chart in EUR is loaded with a visible historical trend and readable values. It does not show Loading history, History unavailable or placeholder dashes.`, { vision: 'only', timeout: 30_000 }); + await app.screenshot(`live-fiat-${quote.code.toLowerCase()}-eur-year-cold-loaded`); + + const allStarted = Date.now(); + await all.tap(); + await expect(all).toBeSelected(); + const savedAll = await waitForHistory(allPath); + const allMs = Date.now() - allStarted; + assert.equal(savedAll.source.observation, 'monthlyReference'); + assert.ok(savedAll.points.length > 150); + assertPositiveOrdered(savedAll); + assert.equal(new Date(milliseconds(savedAll.points[0]!.date)).getUTCFullYear(), 2010); + assert.ok(milliseconds(savedAll.fetchedAt) >= started); + await expect(source.filter({ hasText: /Frankfurter.*monthly reference rates/i })).toBeVisible(); + await expect(rate).toHaveAccessibleName(`1 ${quote.code} in EUR`); + await agent.waitFor(`The ${quote.code} All history chart in EUR is loaded with a visible historical trend and readable values. It does not show Loading history, History unavailable or placeholder dashes.`, { vision: 'only', timeout: 30_000 }); + await app.screenshot(`live-fiat-${quote.code.toLowerCase()}-eur-all-bundled-archive`); + + await openLive(); + await expect(screen.getByTestId('converter.source')).toHaveAccessibleName(/Euro/i); + await expect(screen.getByRole('button', 'Edit amount in EUR')).toHaveValue('1'); + await agent.act(`Open Details & history for the ${quote.code} destination currency.`); + await expect(screen.getByTestId(`currency.details.${quote.code}`)).toBeVisible(); + await expect(rate).toHaveAccessibleName(`1 ${quote.code} in EUR`); + const warmStarted = Date.now(); + await year.tap(); + await expect(year).toBeSelected(); + await expect(source.filter({ hasText: /Frankfurter.*daily reference rates/i })).toBeVisible(); + const warmYearMs = Date.now() - warmStarted; + await agent.waitFor(`After relaunch, the ${quote.code} 1Y chart in EUR is loaded with a visible historical trend and readable values. It does not show Loading history, History unavailable or placeholder dashes.`, { vision: 'only', timeout: 30_000 }); + assert.equal(readHistory(yearPath).fetchedAt, savedYear.fetchedAt); + assert.deepEqual(readHistory(yearPath).points, savedYear.points); + await app.screenshot(`live-fiat-${quote.code.toLowerCase()}-eur-year-warm-reuse`); + console.info(`${quote.code}/EUR history wall times (ms): cold 1Y=${coldYearMs}, bundled All=${allMs}, warm 1Y=${warmYearMs}`); +}); + +} diff --git a/Apps/Currency/Tests/E2E/tests/live-app-provider.e2e.ts b/Apps/Currency/Tests/E2E/tests/live-app-provider.e2e.ts index 85b8c62..5f3f8f4 100644 --- a/Apps/Currency/Tests/E2E/tests/live-app-provider.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/live-app-provider.e2e.ts @@ -5,13 +5,17 @@ import { join } from 'node:path'; import { expect } from 'e2e'; import { test } from '../fixtures.js'; -test('real provider refresh and phone history survive a live-network relaunch', { tags: ['extended', 'live-providers'] }, async ({ start, app, device, screen, agent }) => { +for (const quote of ['USD', 'EUR'] as const) { +test(`real provider refresh and ${quote} phone history survive a live-network relaunch`, { tags: ['extended', 'live-providers'] }, async ({ start, app, device, screen, agent }) => { await start('ready-crypto'); + if (quote === 'EUR') { + await agent.act('Open the source currency picker. Search EUR, select Euro, and return to the converter without editing the amount.'); + } const udid = process.env.CURRENCY_E2E_UDID!; const directory = execFileSync('xcrun', ['simctl', 'get_app_container', udid, 'com.dimasike.currency', 'group.com.dimasike.currency.shared'], { encoding: 'utf8' }).trim(); const ratesPath = join(directory, 'rates.json'); - const dayPath = join(directory, 'history-v3-BTC-USD-1.json'); - const allPath = join(directory, 'history-v3-BTC-USD-0.json'); + const dayPath = join(directory, `history-v3-BTC-${quote}-1.json`); + const allPath = join(directory, `history-v3-BTC-${quote}-0.json`); await device.closeApp(); for (const file of [ratesPath, dayPath, allPath]) { if (existsSync(file)) unlinkSync(file); @@ -23,7 +27,7 @@ test('real provider refresh and phone history survive a live-network relaunch', launchArguments: ['-AppleLanguages', '(en)', '-AppleLocale', 'en_US'], }); await openLive(); - await expect(screen.getByRole('button', 'Edit amount in USD')).toHaveValue('1'); + await expect(screen.getByRole('button', `Edit amount in ${quote}`)).toHaveValue('1'); await screen.getByTestId('converter.options').tap(); await screen.getByTestId('converter.settings').tap(); await screen.getByTestId('settings.rates').tap(); @@ -45,17 +49,22 @@ test('real provider refresh and phone history survive a live-network relaunch', assert.ok(Object.values(rates.quotes).every(rate => Number.isFinite(Number(rate.value)) && Number(rate.value) > 0)); await agent.act('Scroll Rates until the BTC quote and its source information are visible. Keep Rates open.'); await expect(screen.getByText(/Coin Metrics.*minute reference prices/i).first()).toBeVisible(); - await expect(screen.getByText(/^Approximate conversion:/i)).toBeHidden(); - await app.screenshot('live-phone-refreshed-coin-metrics-rates'); + if (quote === 'USD') { + await expect(screen.getByText(/^Approximate conversion:/i)).toBeHidden(); + } else { + await expect(screen.getByText(/^Approximate conversion:/i).first()).toBeVisible(); + } + await app.screenshot(`live-phone-${quote.toLowerCase()}-refreshed-coin-metrics-rates`); await agent.act('Close Settings and return to the converter. Open Details & history for the Bitcoin destination currency.'); await expect(screen.getByTestId('currency.details.BTC')).toBeVisible(); + await expect(screen.getByTestId('currency.details.rate')).toHaveAccessibleName(`1 BTC in ${quote}`); const ranges = screen.getByTestId('currency.details.historyRange'); const day = ranges.getByRole('tab', 'One day', { exact: true }); await day.tap(); await expect(day).toBeSelected(); const source = screen.getByTestId('currency.details.sourceDisclosure'); await expect(source.filter({ hasText: /Coin Metrics.*minute reference prices/i })).toBeVisible(); - type History = { points: { date: number; value: number }[]; fetchedAt: number; source: { provider: { coinMetrics?: object }; observation: string } }; + type History = { points: { date: number; value: number }[]; fetchedAt: number; source: { provider: { coinMetrics?: object }; observation: string; conversion?: { base: string; quote: string; value: number; published: string } } }; const readHistory = (file: string): History => JSON.parse(readFileSync(file, 'utf8')); for (let attempt = 0; attempt < 180 && !existsSync(dayPath); attempt++) { await new Promise(resolve => setTimeout(resolve, 500)); @@ -64,12 +73,20 @@ test('real provider refresh and phone history survive a live-network relaunch', const savedDay = readHistory(dayPath); assert.ok(savedDay.source.provider.coinMetrics); assert.equal(savedDay.source.observation, 'minuteReference'); + if (quote === 'EUR') { + assert.equal(savedDay.source.conversion?.base, 'USD'); + assert.equal(savedDay.source.conversion?.quote, 'EUR'); + assert.ok(Number(savedDay.source.conversion?.value) > 0); + assert.match(savedDay.source.conversion!.published, /^\d{4}-\d{2}-\d{2}$/); + } else { + assert.equal(savedDay.source.conversion, undefined); + } assert.ok((savedDay.fetchedAt + 978_307_200) * 1000 >= started); assert.ok(savedDay.points.length >= 270); assert.ok(savedDay.points.at(-1)!.date - savedDay.points[0]!.date >= 85_500); assert.ok(savedDay.points.every(point => point.value > 0 && Number.isFinite(point.value))); - await agent.waitFor('The Bitcoin USD 1D chart is loaded with a visible price trend and values, with no History unavailable or placeholder dashes.', { vision: 'only', timeout: 30_000 }); - await app.screenshot('live-phone-bitcoin-usd-complete-day'); + await agent.waitFor(`The Bitcoin ${quote} 1D chart is loaded with a visible price trend and values, with no History unavailable or placeholder dashes.`, { vision: 'only', timeout: 30_000 }); + await app.screenshot(`live-phone-bitcoin-${quote.toLowerCase()}-complete-day`); const all = ranges.getByRole('tab', 'All available history', { exact: true }); await all.tap(); await expect(all).toBeSelected(); @@ -78,17 +95,20 @@ test('real provider refresh and phone history survive a live-network relaunch', assert.ok(savedAll.points.length >= 190); assert.equal(new Date((savedAll.points[0]!.date + 978_307_200) * 1000).getUTCFullYear(), 2010); assert.ok(savedAll.points.every(point => point.value > 0 && Number.isFinite(point.value))); - await app.screenshot('live-phone-bitcoin-all-archive-from-2010'); + await app.screenshot(`live-phone-bitcoin-${quote.toLowerCase()}-all-archive-from-2010`); await openLive(); - await expect(screen.getByRole('button', 'Edit amount in USD')).toHaveValue('1'); + await expect(screen.getByRole('button', `Edit amount in ${quote}`)).toHaveValue('1'); await expect(screen.getByRole('button', /^Bitcoin,/i)).toBeVisible(); await agent.act('Open Details & history for the Bitcoin destination currency.'); await expect(screen.getByTestId('currency.details.BTC')).toBeVisible(); + await expect(screen.getByTestId('currency.details.rate')).toHaveAccessibleName(`1 BTC in ${quote}`); await day.tap(); await expect(day).toBeSelected(); await expect(source.filter({ hasText: /Coin Metrics.*minute reference prices/i })).toBeVisible(); - await agent.waitFor('After relaunch, the Bitcoin USD 1D chart is loaded with a visible price trend and readable values. It does not show Loading history, History unavailable or placeholder dashes.', { vision: 'only', timeout: 30_000 }); + await agent.waitFor(`After relaunch, the Bitcoin ${quote} 1D chart is loaded with a visible price trend and readable values. It does not show Loading history, History unavailable or placeholder dashes.`, { vision: 'only', timeout: 30_000 }); assert.equal(readHistory(dayPath).fetchedAt, savedDay.fetchedAt); assert.deepEqual(readHistory(dayPath).points, savedDay.points); - await app.screenshot('live-phone-relaunch-preserves-day-chart'); + await app.screenshot(`live-phone-${quote.toLowerCase()}-relaunch-preserves-day-chart`); }); + +} diff --git a/Apps/CurrencyWatch/App/Sources/CurrencyWatchApp.swift b/Apps/CurrencyWatch/App/Sources/CurrencyWatchApp.swift index 1ede15f..54ba1e8 100644 --- a/Apps/CurrencyWatch/App/Sources/CurrencyWatchApp.swift +++ b/Apps/CurrencyWatch/App/Sources/CurrencyWatchApp.swift @@ -71,7 +71,7 @@ private struct WatchRoot: View { detail = WatchDetail( input: .init( code: source, reference: quote, snapshot: composition.rates.loadRates(), - referencePolicy: .requestedPair, metalUnit: composition.conversion.input().metalUnit)) + metalUnit: composition.conversion.input().metalUnit)) case .convert(let source, let quote, let amount, let metalUnit): do { _ = try composition.edit { diff --git a/Documentation/DataSources.md b/Documentation/DataSources.md index 43abae4..34e2f67 100644 --- a/Documentation/DataSources.md +++ b/Documentation/DataSources.md @@ -18,4 +18,4 @@ Daily rates, fallback history and bundled monthly references: [Fawaz Ahmed’s E ## Frankfurter -[Frankfurter](https://frankfurter.dev/) supplies selected institutional fiat reference rates. Bundled monthly history covers available observations from July 2010 through February 2024; coverage differs by currency. Source IDs and dates are retained in the bundled metadata. Selection, formatting, cross-rates and blends are Currency’s calculations, not official institutional quotes. GGP, IMP and JEP use a GBP-parity assumption. +[Frankfurter](https://frankfurter.dev/) supplies selected institutional fiat reference rates, including daily fiat charts from ECB or CBU and older bundled monthly history. Daily cross-rates use observations from the same date. If a daily fiat request is unavailable, Currency retains saved Frankfurter history or falls back to Fawaz. Bundled monthly history covers available observations from July 2010 through February 2024; coverage differs by currency. Source IDs and dates are retained in the bundled metadata. Selection, formatting, cross-rates and blends are Currency’s calculations, not official institutional quotes. GGP, IMP and JEP use a GBP-parity assumption. diff --git a/Documentation/Development.md b/Documentation/Development.md index bdabb0c..c90e439 100644 --- a/Documentation/Development.md +++ b/Documentation/Development.md @@ -22,7 +22,7 @@ For command-line Xcode builds, use `set -o pipefail` and pipe combined output th ## Rate providers -Coin Metrics Community supplies current crypto reference prices and supported 1D charts across the phone app, widgets, independent Watch app and Watch widgets. Fawaz supplies daily rates and automatically provides the crypto fallback when current Coin Metrics data is unavailable. Selected Frankfurter fiat sources and Coin Metrics archives extend bundled monthly history before March 2024 where observations exist. WidgetKit controls timeline delivery; no requested refresh cadence guarantees execution. +Coin Metrics Community supplies current crypto reference prices and supported 1D charts across the phone app, widgets, independent Watch app and Watch widgets. Fawaz supplies daily rates and automatically provides the crypto fallback when current Coin Metrics data is unavailable. ECB and CBU sources through Frankfurter supply daily fiat charts in at most two batched requests; Fawaz is the daily fallback. Saved Frankfurter charts remain explicit when a refresh fails. Selected Frankfurter fiat sources and Coin Metrics archives extend bundled monthly history before March 2024 where observations exist. WidgetKit controls timeline delivery; no requested refresh cadence guarantees execution. The standard provider set is Fawaz daily rates plus Coin Metrics crypto reference prices. Provider failures retain daily fallback; there is no provider-mode flag. diff --git a/Documentation/FeatureMap.md b/Documentation/FeatureMap.md index 04d9183..d0f73a0 100644 --- a/Documentation/FeatureMap.md +++ b/Documentation/FeatureMap.md @@ -37,9 +37,9 @@ Owners: ExchangeRates domain, [CurrencyDetails](../Features/CurrencyDetails/Sour | --- | --- | --- | | RATE-01 | Initial bootstrap, foreground refresh, explicit rate refresh | Fawaz supplies daily fiat, crypto and metal rates. Coin Metrics supplies current crypto reference prices with more frequent refresh attempts across the phone, widgets, Watch and system actions. Failed current crypto requests automatically fall back to available Fawaz daily rates. Observation dates, approximate daily FX conversion and failures remain explicit. | | RATE-02 | Current rate/source information in converter, details and Settings | Currency-specific provenance, timestamps and warning states correspond to the snapshot used for conversion. | -| HIST-01 | Currency row → Details & history | The requested currency and its applicable reference pair are shown; opening details does not change saved selections. | -| HIST-02 | History range controls and chart | Fawaz supplies recent daily history from March 2024. All combines bundled older Coin Metrics crypto and selected Frankfurter fiat monthly reference samples with Fawaz history; coverage depends on the pair. Coin Metrics supplies 1D crypto reference prices, sampled locally, with aligned crypto legs or explicitly approximate daily FX conversion. Incomplete 24-hour windows remain unavailable or explicitly cached. Invalid or incomplete cached charts are rejected; no synthetic history is substituted. | -| HIST-03 | Rate source disclosure | Expanded content explains current/historical sources, observation/retrieval times and reference-rate gaps. Current crypto quotes identify their approximate FX input only when the requested pair actually needs that input. Direct crypto/USD and matching crypto/crypto normalization omit the approximation notice; actual mixed daily-FX conversions retain the rate, provider and publication date. Ordinary phone crypto Details use USD; configured widget/Watch histories retain their requested pair and dated conversion metadata. Collapsing restores the compact disclosure. | +| HIST-01 | Currency row → Details & history | Details retain the converter’s selected base as the comparison currency for distinct fiat, crypto and metal pairs; a self reference uses a distinct fiat fallback. The current quote and every history range use that same pair; tiny positive reference values remain nonzero; opening details does not change saved selections. | +| HIST-02 | History range controls and chart | Frankfurter supplies daily fiat history through ECB or CBU in at most two batched range requests, with dated EUR cross-rates when needed. Fawaz supplies recent crypto/metal daily history and fiat fallback from March 2024. A failed Frankfurter refresh returns compatible saved Frankfurter history without a dated-request fanout. All combines bundled older Coin Metrics crypto and selected Frankfurter fiat monthly reference samples with Fawaz history; coverage depends on the pair. Coin Metrics supplies 1D crypto reference prices, sampled locally, with aligned crypto legs or explicitly approximate daily FX conversion. Incomplete 24-hour windows remain unavailable or explicitly cached. Invalid or incomplete cached charts are rejected; no synthetic history is substituted. | +| HIST-03 | Rate source disclosure | Expanded content explains current/historical sources, observation/retrieval times and reference-rate gaps. Current crypto quotes identify their approximate FX input only when the requested pair actually needs that input. Direct crypto/USD and matching crypto/crypto normalization omit the approximation notice; actual mixed daily-FX conversions retain the rate, provider and publication date. Phone and Watch Details retain the requested comparison currency, including EUR/other fiat and crypto references; widgets retain their configured pair. History requests invert fiat/crypto legs when needed without changing the displayed pair or dated conversion metadata. Collapsing restores the compact disclosure. | | HIST-04 | Close details or change range while loading | Returning preserves converter input; an earlier load does not replace the newly selected range or a dismissed flow. | | HIST-05 | Select a point on the history chart | The selected point’s date and value are shown for the current pair/range. | diff --git a/Documentation/PrivacyPolicy.md b/Documentation/PrivacyPolicy.md index 7eefc07..31a582f 100644 --- a/Documentation/PrivacyPolicy.md +++ b/Documentation/PrivacyPolicy.md @@ -10,7 +10,7 @@ Dmytro Mishchenko, an individual, operates Currency. The app has no accounts, ad **Optional location.** With your permission, Apple location, map and geocoding services help identify your country and currency. Coordinates are processed by Apple services; Currency saves only the country, currency and update time. Location is not sent to rate providers. Revoke permission in iOS Settings; the app clears its saved location when it observes revocation. -**Network requests.** The iPhone/iPad app, widgets and independent Apple Watch app and widgets request data directly from Coin Metrics Community (crypto reference prices and intraday history) and Fawaz feed hosts, including jsDelivr/Cloudflare (daily fallback rates and history). These providers and their network hosts receive your IP address, requested assets/currencies and dates, request times, and ordinary device/network metadata transmitted by the operating system. Currency does not send entered amounts, saved location or the Watch installation identifier to rate providers. There is no Currency-operated server, account or embedded provider API key. Bundled historical reference data includes Coin Metrics and selected Frankfurter institutional rates and can be read without a network request; later refreshes and cache misses may contact providers. Provider processing follows their own policies: [Coin Metrics / Talos](https://www.talos.com/legals/privacy), [Frankfurter](https://frankfurter.dev/), [jsDelivr](https://www.jsdelivr.com/terms/privacy-policy-jsdelivr-net) and [Cloudflare](https://www.cloudflare.com/privacypolicy/). Apple services follow [Apple’s privacy policy](https://www.apple.com/legal/privacy/). +**Network requests.** The iPhone/iPad app, widgets and independent Apple Watch app and widgets request data directly from Coin Metrics Community (crypto reference prices and intraday history), Frankfurter (daily fiat history) and Fawaz feed hosts, including jsDelivr/Cloudflare (daily fallback rates and history). These providers and their network hosts receive your IP address, requested assets/currencies and dates, request times, and ordinary device/network metadata transmitted by the operating system. Currency does not send entered amounts, saved location or the Watch installation identifier to rate providers. There is no Currency-operated server, account or embedded provider API key. Bundled historical reference data includes Coin Metrics and selected Frankfurter institutional rates and can be read without a network request; later refreshes and cache misses may contact providers. Provider processing follows their own policies: [Coin Metrics / Talos](https://www.talos.com/legals/privacy), [Frankfurter](https://frankfurter.dev/), [jsDelivr](https://www.jsdelivr.com/terms/privacy-policy-jsdelivr-net) and [Cloudflare](https://www.cloudflare.com/privacypolicy/). Apple services follow [Apple’s privacy policy](https://www.apple.com/legal/privacy/). **Support and links.** External websites apply their own policies. Support email, including your address, message and any attachments you choose to send, is stored in Gmail and used to answer your request. We retain it only as needed for that purpose or legal obligations. Google processes mail under its [privacy policy](https://policies.google.com/privacy), including outside your country, with [transfer protections](https://policies.google.com/privacy/frameworks); provider backups may outlast mailbox deletion. Support relies on our legitimate interest in answering requests; optional location relies on your permission, which you may withdraw. diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/FrankfurterHistory.swift b/Domain/ExchangeRates/Sources/ExchangeRates/FrankfurterHistory.swift new file mode 100644 index 0000000..997e123 --- /dev/null +++ b/Domain/ExchangeRates/Sources/ExchangeRates/FrankfurterHistory.swift @@ -0,0 +1,66 @@ +import Foundation + +extension HistoryService { + static func usesFiatHistory(base: String, quote: String) -> Bool { + !CurrencyCatalog.crypto.contains(base) && !CurrencyCatalog.crypto.contains(quote) + && !CurrencyCatalog.metals.contains(base) && !CurrencyCatalog.metals.contains(quote) + } + + static func fetchFiatHistory( + client: any HTTPClient, base: String, quote: String, start: Date, now: Date + ) async throws -> [HistoryPoint] { + func canonical(_ code: String) -> String { + ["GGP", "IMP", "JEP"].contains(code) ? "GBP" : code + } + let base = canonical(base) + let quote = canonical(quote) + let codes = Set([base, quote]).subtracting(["EUR"]).sorted() + let from = String(start.ISO8601Format().prefix(10)) + let through = String(now.ISO8601Format().prefix(10)) + for provider in ["ECB", "CBU"] { + try Task.checkCancellation() + do { + guard var components = URLComponents(string: "https://api.frankfurter.dev/v2/rates") + else { throw RateError.invalidData } + components.queryItems = [ + URLQueryItem(name: "base", value: "EUR"), + URLQueryItem(name: "quotes", value: codes.joined(separator: ",")), + URLQueryItem(name: "from", value: from), + URLQueryItem(name: "to", value: through), + URLQueryItem(name: "providers", value: provider) + ] + guard let url = components.url else { throw RateError.invalidData } + let rows = try JSONDecoder().decode([FrankfurterRow].self, from: await client.get(url)) + let formatter = ISO8601DateFormatter() + var observations: [Date: [String: Double]] = [:] + for row in rows { + guard row.base == "EUR", codes.contains(row.quote), + validDate(row.date), row.date >= from, row.date <= through, + row.rate > 0, !row.rate.isNaN, + let date = formatter.date(from: "\(row.date)T00:00:00Z") + else { throw RateError.invalidData } + let value = NSDecimalNumber(decimal: row.rate).doubleValue + guard value.isFinite, value > 0, observations[date]?[row.quote] == nil else { + throw RateError.invalidData + } + observations[date, default: [:]][row.quote] = value + } + let points = + try observations.compactMap { date, values -> HistoryPoint? in + let a = base == "EUR" ? 1 : values[base] + let b = quote == "EUR" ? 1 : values[quote] + guard let a, let b else { return nil } + let value = b / a + guard value.isFinite, value > 0 else { throw RateError.invalidData } + return HistoryPoint(date: date, value: value) + } + .sorted { $0.date < $1.date } + guard points.count >= 2 else { throw RateError.unavailable } + return points + } catch { + try Task.checkCancellation() + } + } + throw RateError.unavailable + } +} diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift index 8c7f289..3500d58 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift @@ -1,6 +1,6 @@ import Foundation -/// Loads dated daily publications, bundled monthly references, and intraday crypto references. +/// Loads daily fiat references, dated fallback publications, bundled months, and intraday crypto. public actor HistoryService { private let client: any HTTPClient private let directory: URL @@ -48,7 +48,10 @@ public actor HistoryService { ? .minuteReference : range == .all ? .monthlyReference : .dailyReference guard (series.source.provider == expected - || (range == .all && [.coinMetrics, .frankfurter].contains(series.source.provider))), + || (range == .all && [.coinMetrics, .frankfurter].contains(series.source.provider)) + || (range != .day && range != .all + && Self.usesFiatHistory(base: base, quote: quote) + && series.source.provider == .frankfurter)), series.source.observation == observation, series.points.count >= 2, series.points.allSatisfy({ $0.value.isFinite && $0.value > 0 && $0.date <= now }), @@ -102,6 +105,7 @@ public actor HistoryService { base: base, quote: quote, through: monthStart.addingTimeInterval(-1)) : [] let recent: [HistoryPoint] + var dailyProvider: RateProviderID = .fawaz if range == .all { let lastBundled = bundledRecent.last?.date ?? start.addingTimeInterval(-86400) guard let missingStart = calendar.date(byAdding: .month, value: 1, to: lastBundled) @@ -131,6 +135,18 @@ public actor HistoryService { fetchedAt: bundledRecent.last?.date ?? older.last?.date ?? now) return HistoryResult(series: fallback, issue: .usingCachedSeries) } + } else if Self.usesFiatHistory(base: base, quote: quote) { + do { + recent = try await Self.fetchFiatHistory( + client: client, base: base, quote: quote, start: start, now: now) + dailyProvider = .frankfurter + } catch { + try Task.checkCancellation() + if cached?.source.provider == .frankfurter { throw error } + recent = try await Self.fetchDailyHistory( + client: client, directory: directory, base: base, quote: quote, + start: start, now: now, monthly: false) + } } else { recent = try await Self.fetchDailyHistory( client: client, directory: directory, base: base, quote: quote, @@ -141,7 +157,7 @@ public actor HistoryService { range == .all ? Self.monthlySource( base: base, quote: quote, hasOlder: !older.isEmpty, hasRecent: !recent.isEmpty) - : RateSource(provider: .fawaz, observation: .dailyReference, timeZone: .gmt) + : RateSource(provider: dailyProvider, observation: .dailyReference, timeZone: .gmt) } try Task.checkCancellation() diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/README.md b/Domain/ExchangeRates/Sources/ExchangeRates/README.md index d669893..ed0be5e 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/README.md +++ b/Domain/ExchangeRates/Sources/ExchangeRates/README.md @@ -20,7 +20,7 @@ Quotes are currency units per EUR. Conversion returns an unrounded `Decimal?`; c Coin Metrics supplies USD crypto reference prices. Cross-crypto charts divide aligned observations; other quote currencies may use a separately dated daily Fawaz FX reference and are approximate converted reference prices. They are not intraday FX or exchange candles. Intraday availability depends on Community coverage; missing observations remain explicit. -Longer ranges use dated Fawaz observations from March 2024, with older bundled Coin Metrics crypto and selected Frankfurter fiat monthly references where available. All uses bundled completed-month reference observations where available, omits the current incomplete month and fetches missing completed months when necessary. Bundled Fawaz samples use March 2, 2024 and then each completed month’s first day. These are reference observations rather than exchange closing prices. Coverage differs by currency and provider. Missing pre-listing history is not invented. History caches use a versioned pair/range key and validate source provenance and observations before reuse. +Daily fiat ranges use at most two Frankfurter range requests, trying ECB and then CBU. Each request uses dated EUR references for the requested pair; unsupported or unavailable pairs fall back to dated Fawaz publications. Crypto and metal daily ranges use dated Fawaz observations from March 2024. Older bundled Coin Metrics crypto and selected Frankfurter fiat monthly references extend All where available. All uses bundled completed-month reference observations where available, omits the current incomplete month and fetches missing completed months when necessary. Bundled Fawaz samples use March 2, 2024 and then each completed month’s first day. These are reference observations rather than exchange closing prices. Coverage differs by currency and provider. Missing pre-listing history is not invented. History caches use a versioned pair/range key and validate source provenance and observations before reuse. Bundled history is generated by `Scripts/History/generate_archive.py`; the resource records its source selection and sampling. Preserve the bundled licence and source notices when redistributing data. [Data sources and licences](../../../../Documentation/DataSources.md) covers Coin Metrics CC BY-NC 4.0, Fawaz CC0 and original fiat-source terms. The code’s MIT licence does not cover third-party data. diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/Resources/DataSources.md b/Domain/ExchangeRates/Sources/ExchangeRates/Resources/DataSources.md index 40f3ea1..f9261c9 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/Resources/DataSources.md +++ b/Domain/ExchangeRates/Sources/ExchangeRates/Resources/DataSources.md @@ -18,4 +18,4 @@ Daily rates, fallback history and bundled monthly references: [Fawaz Ahmed’s E ## Frankfurter -[Frankfurter](https://frankfurter.dev/) supplies selected institutional fiat reference rates. Bundled monthly history covers available observations from July 2010 through February 2024; coverage differs by currency. Source IDs and dates are retained in the bundled metadata. Selection, formatting, cross-rates and blends are Currency’s calculations, not official institutional quotes. GGP, IMP and JEP use a GBP-parity assumption. +[Frankfurter](https://frankfurter.dev/) supplies selected institutional fiat reference rates, including daily fiat charts from ECB or CBU and older bundled monthly history. Daily cross-rates use observations from the same date. If a daily fiat request is unavailable, Currency retains saved Frankfurter history or falls back to Fawaz. Bundled monthly history covers available observations from July 2010 through February 2024; coverage differs by currency. Source IDs and dates are retained in the bundled metadata. Selection, formatting, cross-rates and blends are Currency’s calculations, not official institutional quotes. GGP, IMP and JEP use a GBP-parity assumption. diff --git a/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift b/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift index ee93dc9..c30f158 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift @@ -24,6 +24,28 @@ public enum CurrencyDisplay { } extension CurrencyDisplay { + /// Formats a reference rate while preserving meaningful values smaller than the usual precision. + public static func referenceRate( + _ value: Decimal?, from code: String, to quote: String, locale: Locale = .current, + metalUnit: MetalUnit = .troyOunce + ) -> String { + guard let value, !value.isNaN else { return "—" } + let formatter = NumberFormatter() + formatter.locale = locale + formatter.numberStyle = .decimal + let quotePrecision = CurrencyPrecision.fractionDigits(quote, metalUnit: metalUnit) + let digits = + CurrencyCatalog.crypto.contains(quote) || CurrencyCatalog.metals.contains(quote) + ? quotePrecision : CurrencyCatalog.crypto.contains(code) ? max(2, quotePrecision) : 6 + formatter.maximumFractionDigits = digits + if value != 0 && abs(value) < Decimal(1) / pow(Decimal(10), digits) { + formatter.usesSignificantDigits = true + formatter.minimumSignificantDigits = 1 + formatter.maximumSignificantDigits = 6 + } + return formatter.string(from: NSDecimalNumber(decimal: value)) ?? "—" + } + /// Returns the display precision for a currency code. public static func fractionDigits(_ code: String, metalUnit: MetalUnit = .troyOunce) -> Int { CurrencyPrecision.fractionDigits(code, metalUnit: metalUnit) diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/FrankfurterHistoryTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/FrankfurterHistoryTests.swift new file mode 100644 index 0000000..52b475f --- /dev/null +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/FrankfurterHistoryTests.swift @@ -0,0 +1,220 @@ +import Foundation +import Testing + +@testable import ExchangeRates + +private actor FiatHistoryHTTP: HTTPClient { + private(set) var urls: [URL] = [] + let response: String? + let ecbResponse: String? + let cbuResponse: String? + var failing = false + func fail() { failing = true } + let fallback = DailyHistoryHTTP() + + init(response: String? = nil, ecbResponse: String? = nil, cbuResponse: String? = nil) { + self.response = response + self.ecbResponse = ecbResponse + self.cbuResponse = cbuResponse + } + + func get(_ url: URL) async throws -> Data { + urls.append(url) + if failing { throw RateError.http(503) } + guard url.host == "api.frankfurter.dev" else { return try await fallback.get(url) } + if response == "unsupported" { throw RateError.http(404) } + if let response { return Data(response.utf8) } + let query = URLComponents(url: url, resolvingAgainstBaseURL: false)!.queryItems! + func value(_ name: String) -> String { query.first { $0.name == name }!.value! } + if value("providers") == "ECB", let ecbResponse { return Data(ecbResponse.utf8) } + if value("providers") == "CBU", let cbuResponse { return Data(cbuResponse.utf8) } + let first = try historyDate(value("from") + "T00:00:00Z") + let last = try historyDate(value("to") + "T00:00:00Z") + let count = Int(last.timeIntervalSince(first) / 86400) + let rates = ["USD": 2.0, "GBP": 1.0, "CZK": 50.0, "AED": 4.0] + let rows: [[String: Any]] = (0...count) + .flatMap { offset in + value("quotes").split(separator: ",") + .map { code in + [ + "date": String( + first.addingTimeInterval(Double(offset) * 86400).ISO8601Format().prefix(10)), + "base": "EUR", "quote": String(code), "rate": rates[String(code), default: 2] + ] + } + } + return try JSONSerialization.data(withJSONObject: rows) + } +} + +@Suite struct FrankfurterHistoryTests { + @Test(arguments: [("EUR", "USD"), ("CZK", "GBP"), ("USD", "EUR")]) + func yearUsesOneScopedRequestAndFreshCache(base: String, quote: String) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let now = try historyDate("2026-10-15T12:00:00Z") + let client = FiatHistoryHTTP() + let service = HistoryService(directory: directory, client: client) + let first = await service.load(base: base, quote: quote, range: .year, now: now) + #expect(first.issue == nil) + #expect(first.series?.points.count == 366) + let expected = base == "EUR" ? 2.0 : base == "USD" ? 0.5 : 0.02 + #expect(first.series?.points.allSatisfy { $0.value == expected } == true) + #expect( + first.series?.source + == .init(provider: .frankfurter, observation: .dailyReference, timeZone: .gmt)) + let urls = await client.urls + #expect(urls.count == 1) + let url = try #require(urls.first) + #expect(url.host == "api.frankfurter.dev") + let query = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + #expect(query.first { $0.name == "base" }?.value == "EUR") + #expect( + query.first { $0.name == "quotes" }?.value + == Set([base, quote]).subtracting(["EUR"]).sorted().joined(separator: ",")) + #expect(query.first { $0.name == "from" }?.value == "2025-10-15") + #expect(query.first { $0.name == "to" }?.value == "2026-10-15") + #expect(query.first { $0.name == "providers" }?.value == "ECB") + #expect(!query.contains { $0.name == "group" }) + let fresh = await HistoryService(directory: directory, client: client) + .load(base: base, quote: quote, range: .year, now: now.addingTimeInterval(60)) + #expect(fresh.series?.points == first.series?.points) + #expect(fresh.series?.source == first.series?.source) + #expect(fresh.series?.fetchedAt == first.series?.fetchedAt) + #expect(await client.urls.count == 1) + let cacheFile = directory.appendingPathComponent("history-v3-\(base)-\(quote)-365.json") + let saved = try Data(contentsOf: cacheFile) + await client.fail() + let stale = await service.load( + base: base, quote: quote, range: .year, now: now.addingTimeInterval(86400)) + #expect(stale.issue == .usingCachedSeries) + #expect(stale.series?.points == first.series?.points) + #expect(stale.series?.source.provider == .frankfurter) + #expect(stale.series?.fetchedAt == now) + #expect(await client.urls.count == 3) + #expect(await client.urls.allSatisfy { $0.host == "api.frankfurter.dev" }) + #expect(try Data(contentsOf: cacheFile) == saved) + } + + @Test(arguments: [HistoryRange.week, .month, .quarter, .yearToDate]) + func allDailyFiatRangesUseOneRequest(range: HistoryRange) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let client = FiatHistoryHTTP() + let result = await HistoryService(directory: directory, client: client) + .load(base: "EUR", quote: "USD", range: range, now: try historyDate("2026-10-15T12:00:00Z")) + #expect(result.issue == nil) + #expect(result.series?.source.provider == .frankfurter) + #expect(await client.urls.count == 1) + } + + @Test(arguments: [ + "unsupported", "[]", + "[{\"date\":\"2026-01-01\",\"base\":\"EUR\",\"quote\":\"USD\",\"rate\":2},{\"date\":\"2026-01-01\",\"base\":\"EUR\",\"quote\":\"USD\",\"rate\":3}]", + "[{\"date\":\"2026-01-01\",\"base\":\"EUR\",\"quote\":\"USD\",\"rate\":0},{\"date\":\"2026-01-02\",\"base\":\"EUR\",\"quote\":\"USD\",\"rate\":3}]", + "[{\"date\":\"2026-01-01\",\"base\":\"EUR\",\"quote\":\"USD\",\"rate\":2}]", + "[{\"date\":\"2026-01-01\",\"base\":\"EUR\",\"quote\":\"USD\",\"rate\":2},{\"date\":\"2026-01-02\",\"base\":\"USD\",\"quote\":\"EUR\",\"rate\":3}]", + "[{\"date\":\"2025-12-31\",\"base\":\"EUR\",\"quote\":\"USD\",\"rate\":2},{\"date\":\"2026-01-02\",\"base\":\"EUR\",\"quote\":\"USD\",\"rate\":3}]" + ]) + func unusableRangeFallsBackToDatedPublications(response: String) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let client = FiatHistoryHTTP(response: response) + let now = try historyDate("2026-01-03T12:00:00Z") + let service = HistoryService(directory: directory, client: client) + let first = await service.load(base: "EUR", quote: "USD", range: .yearToDate, now: now) + #expect(first.issue == nil) + #expect(first.series?.source.provider == .fawaz) + #expect(first.series?.points.count == 3) + #expect(await client.urls.count == 5) + let fresh = await service.load(base: "EUR", quote: "USD", range: .yearToDate, now: now) + #expect(fresh.series?.points == first.series?.points) + #expect(fresh.series?.source == first.series?.source) + #expect(fresh.series?.fetchedAt == first.series?.fetchedAt) + #expect(await client.urls.count == 5) + let expired = await service.load( + base: "EUR", quote: "USD", range: .yearToDate, now: now.addingTimeInterval(21601)) + #expect(expired.issue == nil) + #expect(expired.series?.source.provider == .fawaz) + #expect(expired.series?.fetchedAt == now.addingTimeInterval(21601)) + #expect(await client.urls.count == 7) + } + + @Test(arguments: [("GGP", "USD"), ("GBP", "IMP"), ("JEP", "GGP")]) + func sterlingParityUsesObservedPublicationDates(base: String, quote: String) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let client = FiatHistoryHTTP() + let result = await HistoryService(directory: directory, client: client) + .load( + base: base, quote: quote, range: .yearToDate, now: try historyDate("2026-01-03T12:00:00Z")) + #expect( + result.series?.points.map(\.value) == Array(repeating: quote == "USD" ? 2.0 : 1.0, count: 3)) + #expect(await client.urls.count == 1) + } + + @Test(arguments: ["[]", "invalid JSON"]) + func aedHistoryUsesDailyCBUAfterECBUnavailable(ecbResponse: String) async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let client = FiatHistoryHTTP(ecbResponse: ecbResponse) + let now = try historyDate("2026-10-15T12:00:00Z") + let service = HistoryService(directory: directory, client: client) + let inverse = await service.load(base: "AED", quote: "EUR", range: .year, now: now) + #expect(inverse.issue == nil) + #expect(inverse.series?.points.count == 366) + #expect(inverse.series?.points.allSatisfy { $0.value == 0.25 } == true) + let urls = await client.urls + #expect(urls.count == 2) + let queries = urls.map { URLComponents(url: $0, resolvingAgainstBaseURL: false)!.queryItems! } + #expect(queries.map { $0.first { $0.name == "providers" }!.value! } == ["ECB", "CBU"]) + #expect(queries.allSatisfy { $0.first { $0.name == "base" }?.value == "EUR" }) + #expect(queries.allSatisfy { $0.first { $0.name == "quotes" }?.value == "AED" }) + let direct = await service.load(base: "EUR", quote: "AED", range: .year, now: now) + #expect(direct.series?.points.allSatisfy { $0.value == 4 } == true) + #expect(await client.urls.count == 4) + } + + @Test func nonEuroCrossUsesOnlyMatchingObservedDates() async throws { + let client = FiatHistoryHTTP( + ecbResponse: "[]", + cbuResponse: """ + [{"date":"2026-01-01","base":"EUR","quote":"AED","rate":4}, + {"date":"2026-01-01","base":"EUR","quote":"USD","rate":2}, + {"date":"2026-01-02","base":"EUR","quote":"AED","rate":4.2}, + {"date":"2026-01-03","base":"EUR","quote":"AED","rate":4.4}, + {"date":"2026-01-03","base":"EUR","quote":"USD","rate":2.2}] + """) + let points = try await HistoryService.fetchFiatHistory( + client: client, base: "USD", quote: "AED", + start: try historyDate("2026-01-01T00:00:00Z"), now: try historyDate("2026-01-03T12:00:00Z")) + #expect(points.map(\.value) == [2, 2]) + #expect( + points.map { String($0.date.ISO8601Format().prefix(10)) } == ["2026-01-01", "2026-01-03"]) + #expect(await client.urls.count == 2) + } + + @Test func cryptoYearRetainsEveryDailyObservation() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let client = FiatHistoryHTTP() + let result = await HistoryService(directory: directory, client: client) + .load(base: "BTC", quote: "EUR", range: .year, now: try historyDate("2026-10-15T12:00:00Z")) + #expect(result.issue == nil) + #expect(result.series?.points.count == 366) + #expect(result.series?.source.provider == .fawaz) + #expect(await client.urls.count == 366) + #expect(await client.urls.allSatisfy { $0.host == "cdn.jsdelivr.net" }) + } + + @Test func allStillUsesCompletedBundledMonthsWithoutNetwork() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let client = FiatHistoryHTTP() + let result = await HistoryService(directory: directory, client: client) + .load(base: "EUR", quote: "USD", range: .all, now: try historyDate("2026-10-15T12:00:00Z")) + #expect(result.issue == nil) + #expect(result.series?.source.observation == .monthlyReference) + #expect(await client.urls.isEmpty) + } +} diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift index 1cf735c..125f2fb 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift @@ -15,6 +15,7 @@ actor DailyHistoryHTTP: HTTPClient { func failPrimaryRequests() { failPrimary = true } func get(_ url: URL) async throws -> Data { + if url.host == "api.frankfurter.dev" { throw RateError.http(404) } urls.append(url) if failing || (failPrimary && url.host == "cdn.jsdelivr.net") { throw RateError.http(503) } let day: String diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesUITests/ReferenceRateFormattingTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesUITests/ReferenceRateFormattingTests.swift new file mode 100644 index 0000000..87fda75 --- /dev/null +++ b/Domain/ExchangeRates/Tests/ExchangeRatesUITests/ReferenceRateFormattingTests.swift @@ -0,0 +1,36 @@ +import ExchangeRates +import ExchangeRatesUI +import Foundation +import Testing + +@Suite struct ReferenceRateFormattingTests { + @Test(arguments: [ + ("BTC", "USD", "50000", "50,000"), + ("ETH", "BTC", "0.002", "0.002"), + ("USDC", "BTC", "0.00002", "0.00002"), + ("VND", "BTC", "0.0000000008", "0.0000000008"), + ("DOGE", "USD", "0.000001", "0.000001"), + ("EUR", "USD", "1.1234567", "1.123457"), + ("BTC", "KWD", "1.2346", "1.235"), + ("USD", "EUR", "0", "0") + ]) + func retainsMeaningfulReferenceValues( + code: String, quote: String, value: String, expected: String + ) throws { + let decimal = try #require(Decimal(string: value)) + #expect( + CurrencyDisplay.referenceRate( + decimal, from: code, to: quote, locale: .init(identifier: "en_US")) + == expected) + } + + @Test func tinyRateRespectsLocaleAndMissingRateStaysExplicit() { + let tiny = Decimal(string: "0.0000000008") + #expect( + CurrencyDisplay.referenceRate( + tiny, from: "VND", to: "BTC", locale: .init(identifier: "de_DE")) + == "0,0000000008") + #expect(CurrencyDisplay.referenceRate(nil, from: "BTC", to: "EUR") == "—") + #expect(CurrencyDisplay.referenceRate(.nan, from: "BTC", to: "EUR") == "—") + } +} diff --git a/Features/CurrencyDetails/Sources/CurrencyDetails/CurrencyDetailsModel.swift b/Features/CurrencyDetails/Sources/CurrencyDetails/CurrencyDetailsModel.swift index 58000be..7b98807 100644 --- a/Features/CurrencyDetails/Sources/CurrencyDetails/CurrencyDetailsModel.swift +++ b/Features/CurrencyDetails/Sources/CurrencyDetails/CurrencyDetailsModel.swift @@ -8,15 +8,6 @@ public struct CurrencyDetailsInput: Sendable { public let code: String /// The originating workspace's base currency. public let reference: String - /// Whether a caller requests its exact pair or the standard details reference policy. - public enum ReferencePolicy: Sendable { - /// Uses the usual fiat reference for crypto and self references. - case automatic - /// Preserves the distinct supported pair validated by the caller. - case requestedPair - } - /// The reference policy selected for this flow. - public let referencePolicy: ReferencePolicy /// The current rate snapshot supplied by the caller. public let snapshot: RateSnapshot /// Measurement used to present metal rates and history for this flow. @@ -25,12 +16,11 @@ public struct CurrencyDetailsInput: Sendable { /// Creates the immutable context of a details flow. public init( code: String, reference: String, snapshot: RateSnapshot, - referencePolicy: ReferencePolicy = .automatic, metalUnit: MetalUnit = .troyOunce + metalUnit: MetalUnit = .troyOunce ) { self.code = code self.reference = reference self.snapshot = snapshot - self.referencePolicy = referencePolicy self.metalUnit = metalUnit } } @@ -96,16 +86,11 @@ public final class CurrencyDetailsModel { self.dependencies = dependencies } - /// Explicit pairs retain their quote; automatic references use the standard fiat policy. + /// Keeps the caller's comparison currency, with a distinct fallback for a self reference. public var quote: String { - if input.referencePolicy == .requestedPair { return input.reference } - let currency = CurrencyCode(rawValue: input.code) - let referenceCurrency = CurrencyCode(rawValue: input.reference) - if currency?.isCryptocurrency == true { return CurrencyCode.usd.rawValue } - if input.reference == input.code || referenceCurrency?.isCryptocurrency == true { - return currency == .eur ? CurrencyCode.usd.rawValue : CurrencyCode.eur.rawValue - } - return input.reference + guard input.code == input.reference else { return input.reference } + return input.code == CurrencyCode.eur.rawValue || CurrencyCatalog.crypto.contains(input.code) + ? CurrencyCode.usd.rawValue : CurrencyCode.eur.rawValue } private var historyRequest: (base: String, quote: String, inverted: Bool) { diff --git a/Features/CurrencyDetails/Sources/CurrencyDetailsUI/RateDetailsScreen.swift b/Features/CurrencyDetails/Sources/CurrencyDetailsUI/RateDetailsScreen.swift index 92074f9..ae693cb 100644 --- a/Features/CurrencyDetails/Sources/CurrencyDetailsUI/RateDetailsScreen.swift +++ b/Features/CurrencyDetails/Sources/CurrencyDetailsUI/RateDetailsScreen.swift @@ -49,7 +49,7 @@ struct RateDetailsScreen: View { let values = series?.points.map(\.value) ?? [0, 1] let low = values.min() ?? 0 let high = values.max() ?? 1 - let padding = max((high - low) * 0.15, max(high * 0.001, 0.00000001)) + let padding = max((high - low) * 0.15, max(high * 0.001, Double.leastNormalMagnitude)) return max(0, low - padding)...(high + padding) } @@ -438,7 +438,7 @@ struct RateDetailsScreen: View { VStack(alignment: .leading, spacing: AppStyle.Space.small) { if range == .all { Text(.Details.maxExplanation) } Text( - CurrencyCatalog.crypto.contains(code) + (CurrencyCatalog.crypto.contains(code) || CurrencyCatalog.crypto.contains(quote)) ? .Details.cryptoExplanation : .Details.fiatExplanation) } .font(AppStyle.font(.caption)) @@ -460,15 +460,8 @@ struct RateDetailsScreen: View { } private func rateLabel(_ value: Decimal?) -> String { - guard let value else { return "—" } - let formatter = NumberFormatter() - formatter.locale = locale - formatter.numberStyle = .decimal - formatter.maximumFractionDigits = - CurrencyCode(rawValue: quote)?.isMetal == true - ? CurrencyPrecision.fractionDigits(quote, metalUnit: model.input.metalUnit) - : CurrencyCatalog.crypto.contains(code) ? 2 : 6 - return formatter.string(from: NSDecimalNumber(decimal: value)) ?? "—" + CurrencyDisplay.referenceRate( + value, from: code, to: quote, locale: locale, metalUnit: model.input.metalUnit) } private func dayLabel(_ date: Date) -> String { diff --git a/Features/CurrencyDetails/Sources/CurrencyDetailsWatchUI/CurrencyDetailsWatchScreen.swift b/Features/CurrencyDetails/Sources/CurrencyDetailsWatchUI/CurrencyDetailsWatchScreen.swift index 18f60e2..ed321b8 100644 --- a/Features/CurrencyDetails/Sources/CurrencyDetailsWatchUI/CurrencyDetailsWatchScreen.swift +++ b/Features/CurrencyDetails/Sources/CurrencyDetailsWatchUI/CurrencyDetailsWatchScreen.swift @@ -140,7 +140,8 @@ public struct CurrencyDetailsWatchScreen: View { let values = series.points.map(\.value) let minimum = values.min() ?? 0 let maximum = values.max() ?? 1 - let padding = max((maximum - minimum) * 0.1, max(abs(maximum) * 0.001, 0.00000001)) + let padding = max( + (maximum - minimum) * 0.1, max(abs(maximum) * 0.001, Double.leastNormalMagnitude)) return max(0, minimum - padding)...(maximum + padding) } diff --git a/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift b/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift index bd43fa1..14b04e1 100644 --- a/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift +++ b/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift @@ -17,7 +17,8 @@ private final class PendingHistory { @Suite @MainActor struct CurrencyDetailsTests { @Test(arguments: [ - ("XAU", "USD", 3110.34768, 100.0), ("USD", "XAU", 0.01, 0.311034768) + ("XAU", "USD", 3110.34768, 100.0), ("USD", "XAU", 0.01, 0.311034768), + ("BTC", "XAU", 20.0, 622.069536), ("XAU", "BTC", 20.0, 0.05 / 31.1034768) ]) func historyDisplaysSavedMetalUnitsWithoutChangingProviderValues( code: String, reference: String, canonicalValue: Double, expected: Double @@ -33,34 +34,89 @@ struct CurrencyDetailsTests { #expect(canonical.points.first?.value == canonicalValue) } - @Test func quotePolicyPreservesCryptoHistoryAndDistinctFiatReferences() { - for (code, reference, quote) in [ - ("BTC", "CZK", "USD"), ("EUR", "EUR", "USD"), ("USD", "USD", "EUR"), - ("GBP", "BTC", "EUR"), ("GBP", "CZK", "CZK") - ] { + @Test(arguments: [ + ("EUR", "EUR", "USD"), ("USD", "USD", "EUR"), ("GBP", "GBP", "EUR"), + ("BTC", "BTC", "USD") + ]) + func sameCurrencyReferencesKeepTheirFallback(code: String, reference: String, quote: String) { + let model = CurrencyDetailsModel( + input: .init(code: code, reference: reference, snapshot: RateSnapshot()), + dependencies: .init { _, _, _ in HistoryResult(series: nil, issue: .unavailable) }) + #expect(model.quote == quote) + } + + @Test(arguments: CurrencyCatalog.codes) + func distinctReferencesPreserveTheOriginatingPair(code: String) { + for reference in ["USD", "EUR", "GBP", "CZK", "CHF", "BTC", "ETH", "XAU", "XAG"] + where code != reference { let model = CurrencyDetailsModel( input: .init(code: code, reference: reference, snapshot: RateSnapshot()), dependencies: .init { _, _, _ in HistoryResult(series: nil, issue: .unavailable) }) - #expect(model.quote == quote) + #expect(model.quote == reference) } } - @Test(arguments: [("BTC", "EUR"), ("EUR", "BTC"), ("BTC", "ETH")]) - func explicitlyRequestedPairPreservesChartAndRateQuote(code: String, reference: String) async { - var requested: [String] = [] + @Test(arguments: CurrencyCatalog.crypto.sorted(), ["USD", "EUR", "GBP", "CZK", "CHF", "ETH"]) + func cryptoPairRetainsQuoteConversionHistoryAndRanges( + code: String, reference: String + ) async throws { + guard code != reference else { return } + let date = Date(timeIntervalSince1970: 1_700_000_000) + let source = RateSource(provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt) + let values: [String: Decimal] = [ + "USD": 2, "EUR": 1, "GBP": 3, "CZK": 25, "CHF": 4, "ETH": 5 + ] + var quotes = values.mapValues { + ExchangeRate($0, published: "2023-11-14", source: source, observedAt: date) + } + quotes[code] = ExchangeRate( + 8, published: "2023-11-14", source: source, observedAt: date) + let snapshot = RateSnapshot(quotes: quotes, fetchedAt: date.addingTimeInterval(60)) + let expectedQuote = try #require(values[reference]) + let received = HistorySeries( + points: [ + HistoryPoint(date: date, value: 7), + HistoryPoint(date: date.addingTimeInterval(3600), value: 11) + ], source: source, fetchedAt: date.addingTimeInterval(7200)) + var requests: [(String, String, HistoryRange)] = [] let model = CurrencyDetailsModel( - input: .init( - code: code, reference: reference, snapshot: RateSnapshot(), referencePolicy: .requestedPair), - dependencies: .init { base, quote, _ in - requested = [base, quote] - return HistoryResult(series: nil, issue: .unavailable) + input: .init(code: code, reference: reference, snapshot: snapshot), + dependencies: .init { base, quote, range in + requests.append((base, quote, range)) + return HistoryResult(series: received, issue: .usingCachedSeries) }) #expect(model.quote == reference) - await model.load() - #expect(requested == (code == "EUR" ? [reference, code] : [code, reference])) + #expect(model.input.reference == reference) + #expect(model.input.snapshot.quotes[code]?.source == source) + #expect(model.input.snapshot.quotes[code]?.observedAt == date) + #expect(model.input.snapshot.fetchedAt == snapshot.fetchedAt) + #expect(model.input.snapshot.convert(1, from: code, to: model.quote) == expectedQuote / 8) + if reference != "USD" { + #expect( + model.input.snapshot.convert(1, from: code, to: model.quote) + != snapshot.convert(1, from: code, to: "USD")) + } + let ranges = model.availableRanges + #expect(ranges == CurrencyDetailsModel.ranges) + for range in ranges { + model.range = range + await model.load() + #expect(model.quote == reference) + #expect(requests.last?.0 == code) + #expect(requests.last?.1 == reference) + #expect(requests.last?.2 == range) + #expect(model.series?.points == received.points) + #expect(model.series?.source == source) + #expect(model.series?.fetchedAt == received.fetchedAt) + #expect(model.issue == .usingCachedSeries) + } + #expect(requests.count == ranges.count) } - @Test func requestedFiatCryptoPairInvertsProviderSeriesAndRetainsProvenance() async { + @Test(arguments: ["USD", "EUR", "GBP", "CZK", "CHF"], CurrencyCatalog.crypto.sorted()) + func fiatCryptoPairInvertsProviderSeriesAndRetainsProvenance( + code: String, reference: String + ) async { let date = Date(timeIntervalSince1970: 1_700_000_000) let source = RateSource(provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt) let received = HistorySeries( @@ -69,23 +125,58 @@ struct CurrencyDetailsTests { HistoryPoint(date: date.addingTimeInterval(3600), value: 50_000) ], source: source, fetchedAt: date.addingTimeInterval(7200)) + let snapshot = RateSnapshot( + quotes: [ + code: ExchangeRate(4, published: "2023-11-14", source: source), + reference: ExchangeRate(8, published: "2023-11-14", source: source, observedAt: date) + ], fetchedAt: date.addingTimeInterval(60)) + var requestedRanges: [HistoryRange] = [] let model = CurrencyDetailsModel( - input: .init( - code: "EUR", reference: "BTC", snapshot: RateSnapshot(), referencePolicy: .requestedPair), - dependencies: .init { base, quote, _ in - #expect(base == "BTC") - #expect(quote == "EUR") + input: .init(code: code, reference: reference, snapshot: snapshot), + dependencies: .init { base, quote, range in + #expect(base == reference) + #expect(quote == code) + requestedRanges.append(range) return HistoryResult(series: received, issue: .usingCachedSeries) }) - #expect(model.availableRanges.contains(.day)) - model.range = .day - await model.load() - #expect(model.quote == "BTC") - #expect(model.series?.points.map(\.value) == [1.0 / 40_000, 1.0 / 50_000]) - #expect(model.series?.points.map(\.date) == received.points.map(\.date)) - #expect(model.series?.source == source) - #expect(model.series?.fetchedAt == received.fetchedAt) - #expect(model.issue == .usingCachedSeries) + #expect(model.availableRanges == CurrencyDetailsModel.ranges) + #expect(model.input.snapshot.convert(1, from: code, to: model.quote) == 2) + for range in model.availableRanges { + model.range = range + await model.load() + #expect(model.quote == reference) + #expect(model.series?.points.map(\.value) == [1.0 / 40_000, 1.0 / 50_000]) + #expect(model.series?.points.map(\.date) == received.points.map(\.date)) + #expect(model.series?.source == source) + #expect(model.series?.fetchedAt == received.fetchedAt) + #expect(model.issue == .usingCachedSeries) + } + #expect(requestedRanges == model.availableRanges) + } + + @Test(arguments: CurrencyCatalog.crypto.sorted(), CurrencyCatalog.metals.sorted()) + func metalCryptoPairsRetainReferenceWithoutIntraday(crypto: String, metal: String) async { + for (code, reference) in [(crypto, metal), (metal, crypto)] { + var requests: [(String, String, HistoryRange)] = [] + let model = CurrencyDetailsModel( + input: .init(code: code, reference: reference, snapshot: RateSnapshot()), + dependencies: .init { base, quote, range in + requests.append((base, quote, range)) + return HistoryResult(series: nil, issue: .unavailable) + }) + #expect(model.quote == reference) + #expect(model.availableRanges == CurrencyDetailsModel.ranges.filter { $0 != .day }) + for range in model.availableRanges { + model.range = range + await model.load() + #expect(model.quote == reference) + #expect(requests.last?.0 == crypto) + #expect(requests.last?.1 == metal) + #expect(requests.last?.2 == range) + #expect(model.issue == .unavailable) + } + #expect(requests.count == model.availableRanges.count) + } } @Test(arguments: CurrencyDetailsModel.ranges) @@ -125,7 +216,7 @@ struct CurrencyDetailsTests { var requestedRange: HistoryRange? let model = CurrencyDetailsModel( input: .init( - code: "EUR", reference: "BTC", snapshot: RateSnapshot(), referencePolicy: .requestedPair), + code: "EUR", reference: "BTC", snapshot: RateSnapshot()), dependencies: .init( supportsIntraday: { base, quote in capabilityPair = [base, quote]