From b6f7dc199b9a065e0484cf7db95fba5d11ccbd7f Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Fri, 9 Oct 2026 22:44:20 +0200 Subject: [PATCH 01/17] =?UTF-8?q?=E2=9C=A8=20rates:=20make=20Coinbase=20an?= =?UTF-8?q?=20optional=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * share the internal provider flag across phone, widgets, Watch and system actions * use Fawaz daily rates and history with policy-aware cache migration and refresh cadence * maintain both-mode native journeys and CI coverage --- .github/workflows/tests.yml | 21 + .../Sources/WatchWidgetActions.swift | 4 +- .../Sources/WatchWidgetConfiguration.swift | 4 +- .../Sources/WatchWidgetTimeline.swift | 36 +- .../Composition/WidgetComposition.swift | 14 +- .../Composition/WidgetDependencies.swift | 3 +- .../Configuration/HistorySettings.swift | 5 +- .../Sources/Timelines/WidgetTimeline.swift | 3 +- .../HistoryWidgetAdapterTests.swift | 94 ++++- .../Sources/Composition/AppComposition.swift | 41 +- .../Composition/CurrencyE2EStartup.swift | 170 ++++++-- .../Composition/SystemActionComposition.swift | 10 +- .../Sources/ConversionAction.swift | 8 +- .../Sources/CurrencyRateConfiguration.swift | 29 ++ .../CurrencyRateConfigurationTests.swift | 50 +++ .../AppCompositionTests.swift | 4 +- .../ProviderPolicyIntegrationTests.swift | 49 +++ Apps/Currency/Tests/E2E/README.md | 12 +- Apps/Currency/Tests/E2E/e2e.config.ts | 4 +- Apps/Currency/Tests/E2E/fixtures.ts | 2 +- .../Tests/E2E/tests/converter-details.e2e.ts | 2 +- .../Tests/E2E/tests/provider-policy.e2e.ts | 60 +++ .../E2E/tests/settings-information.e2e.ts | 2 +- .../App/Sources/WatchComposition.swift | 16 +- Apps/CurrencyWatch/README.md | 11 + Documentation/Development.md | 8 + Documentation/FeatureMap.md | 14 +- Documentation/PrivacyPolicy.md | 4 +- .../ExchangeRates.docc/ExchangeRates.md | 2 + .../Sources/ExchangeRates/FawazHistory.swift | 93 +++++ .../ExchangeRates/HistoryDecoding.swift | 40 -- .../ExchangeRates/HistoryService.swift | 150 +++----- .../Sources/ExchangeRates/README.md | 8 +- .../ExchangeRates/RateProviderPolicy.swift | 52 +++ .../Sources/ExchangeRates/RateService.swift | 41 +- .../Sources/ExchangeRates/RateStore.swift | 23 +- .../HistoryCrossRateTests.swift | 193 ++-------- .../HistoryFailureTests.swift | 94 +++-- .../HistoryIntervalTests.swift | 362 +----------------- .../ExchangeRatesTests/HistoryTests.swift | 216 ++++++----- .../RateProviderPolicyTests.swift | 157 ++++++++ .../ExchangeRatesTests/RateStoreTests.swift | 18 + .../CryptoHistoryWidgetHarness.swift | 3 +- Domain/Widgets/README.md | 2 +- .../Widgets/HistoryWidgetSnapshot.swift | 18 +- .../WidgetsTests/HistoryWidgetTests.swift | 46 ++- .../CurrencyDetailsModel.swift | 13 +- .../Resources/Details.xcstrings | 28 +- .../CurrencyDetailsTests.swift | 22 ++ .../Sources/Settings/SettingsModel.swift | 8 +- .../SettingsUI/Resources/Settings.xcstrings | 28 +- .../Sources/SettingsUI/SettingsScreen.swift | 16 +- .../SettingsTests/SettingsModelTests.swift | 9 +- Project.swift | 3 + 54 files changed, 1319 insertions(+), 1006 deletions(-) create mode 100644 Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift create mode 100644 Apps/Currency/Modules/CurrencyApplication/Tests/CurrencyRateConfigurationTests.swift create mode 100644 Apps/Currency/Tests/ApplicationIntegrationTests/ProviderPolicyIntegrationTests.swift create mode 100644 Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts create mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/FawazHistory.swift create mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/RateProviderPolicy.swift create mode 100644 Domain/ExchangeRates/Tests/ExchangeRatesTests/RateProviderPolicyTests.swift diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 84fd221..9ed21fe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -144,6 +144,27 @@ jobs: -derivedDataPath "$RUNNER_TEMP/CurrencyTestsDerivedData" \ CODE_SIGN_IDENTITY=- \ 2>&1 | xcbeautify + - name: Test Fawaz-only build configuration + if: needs.changes.outputs.xcode == 'true' + run: | + set -o pipefail + xcodebuild test \ + -workspace Currency.xcworkspace \ + -scheme CurrencyTests \ + -configuration Debug \ + -destination "platform=iOS Simulator,id=$CURRENCY_TEST_SIMULATOR" \ + -derivedDataPath "$RUNNER_TEMP/CurrencyDailyTestsDerivedData" \ + -collect-test-diagnostics never \ + -only-testing:ExchangeRatesPackageTests \ + -only-testing:CurrencyDetailsPackageTests \ + -only-testing:WidgetsPackageTests \ + -only-testing:SettingsPackageTests \ + -only-testing:CurrencyApplicationTests \ + -only-testing:ApplicationIntegrationTests/ProviderPolicyIntegrationTests \ + -only-testing:WidgetIntegrationTests \ + CODE_SIGN_IDENTITY=- \ + OTHER_SWIFT_FLAGS='$(inherited) -D CURRENCY_DISABLE_COINBASE' \ + 2>&1 | xcbeautify - name: Check compiler-extracted localization coverage if: needs.changes.outputs.xcode == 'true' timeout-minutes: 1 diff --git a/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetActions.swift b/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetActions.swift index fd94251..ef74dbf 100644 --- a/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetActions.swift +++ b/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetActions.swift @@ -1,5 +1,6 @@ import AppIntents import Conversion +import CurrencyApplication import ExchangeRates import Foundation import WidgetKit @@ -68,7 +69,8 @@ struct WatchWidgetSwapIntent: AppIntent { func perform() async throws -> some IntentResult { guard codes.count >= 2 else { return .result() } let directory = WatchWidgetAppGroup.directory() - let rates = RateStore(directory: directory).loadRates() + let rates = RateStore(directory: directory, policy: CurrencyRateConfiguration.policy) + .loadRates() try WidgetStore(directory: directory) .updateWidgetInput( key: key, codes: codes, amount: initialAmount diff --git a/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetConfiguration.swift b/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetConfiguration.swift index aecc359..ec6a0b3 100644 --- a/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetConfiguration.swift +++ b/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetConfiguration.swift @@ -1,5 +1,6 @@ import AppIntents import Conversion +import CurrencyApplication import ExchangeRates import Foundation import Widgets @@ -202,7 +203,8 @@ struct WatchHistoryRangeOptions: DynamicOptionsProvider { func results() async throws -> [WatchHistoryRange] { let pair = HistoryWidgetPair( - app: WatchWidgetAppGroup.input(), base: settings?.source.id, quote: settings?.quote.id) + app: WatchWidgetAppGroup.input(), base: settings?.source.id, quote: settings?.quote.id, + policy: CurrencyRateConfiguration.policy) return pair.supportsIntradayHistory ? WatchHistoryRange.allCases : WatchHistoryRange.allCases.filter { $0 != .day } diff --git a/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetTimeline.swift b/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetTimeline.swift index f39f00d..1e4d3cd 100644 --- a/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetTimeline.swift +++ b/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetTimeline.swift @@ -1,5 +1,6 @@ import AppIntents import Conversion +import CurrencyApplication import ExchangeRates import Foundation import WidgetKit @@ -108,7 +109,9 @@ enum WatchWidgetComposition { style: style, key: key, codes: [base] + selected, amount: initial, app: app) return WatchWidgetEntry( date: now, style: style, source: base, targets: selected, input: input, key: key, - initialAmount: initial, snapshot: RateStore(directory: directory).loadRates(), + initialAmount: initial, + snapshot: RateStore(directory: directory, policy: CurrencyRateConfiguration.policy) + .loadRates(), configurationIsValid: WidgetMath.parseAmount(amount) != nil && CurrencyCatalog.codes.contains(base) && (style != .cash @@ -149,9 +152,12 @@ enum WatchWidgetComposition { guard Date().timeIntervalSince(entry.input.editedAt ?? .distantPast) >= 60 else { return entry } var refreshed = entry do { - let result = try await RateStore(directory: directory()) - .refreshRates( - using: RateService(), force: entry.snapshot.quotes.isEmpty, providerTimeout: .seconds(2)) + let result = try await RateStore( + directory: directory(), policy: CurrencyRateConfiguration.policy + ) + .refreshRates( + using: RateService(policy: CurrencyRateConfiguration.policy), + force: entry.snapshot.quotes.isEmpty, providerTimeout: .seconds(2)) refreshed = WatchWidgetEntry( date: .now, style: entry.style, source: entry.source, targets: entry.targets, input: widgetInput( @@ -178,7 +184,10 @@ struct WatchPairTimeline: AppIntentTimelineProvider { for configuration: WatchPairSettings, in context: Context ) async -> Timeline { let current = await WatchWidgetComposition.refresh(entry(configuration)) - return Timeline(entries: [current], policy: .after(current.date.addingTimeInterval(1800))) + return Timeline( + entries: [current], + policy: .after( + current.date.addingTimeInterval(CurrencyRateConfiguration.widgetRefreshInterval))) } private func entry(_ configuration: WatchPairSettings) -> WatchWidgetEntry { WatchWidgetComposition.entry( @@ -200,7 +209,10 @@ struct WatchCashTimeline: AppIntentTimelineProvider { for configuration: WatchCashSettings, in context: Context ) async -> Timeline { let current = await WatchWidgetComposition.refresh(entry(configuration)) - return Timeline(entries: [current], policy: .after(current.date.addingTimeInterval(1800))) + return Timeline( + entries: [current], + policy: .after( + current.date.addingTimeInterval(CurrencyRateConfiguration.widgetRefreshInterval))) } private func entry(_ configuration: WatchCashSettings) -> WatchWidgetEntry { WatchWidgetComposition.entry( @@ -224,7 +236,10 @@ struct WatchBoardTimeline: AppIntentTimelineProvider { for configuration: WatchBoardSettings, in context: Context ) async -> Timeline { let current = await WatchWidgetComposition.refresh(entry(configuration)) - return Timeline(entries: [current], policy: .after(current.date.addingTimeInterval(1800))) + return Timeline( + entries: [current], + policy: .after( + current.date.addingTimeInterval(CurrencyRateConfiguration.widgetRefreshInterval))) } private func entry(_ configuration: WatchBoardSettings) -> WatchWidgetEntry { WatchWidgetComposition.entry( @@ -259,12 +274,13 @@ struct WatchHistoryTimeline: AppIntentTimelineProvider { let now = Date() let pair = HistoryWidgetPair( app: WatchWidgetComposition.input(), base: configuration.source?.id, - quote: configuration.quote?.id) - let range = configuration.range.value + quote: configuration.quote?.id, policy: CurrencyRateConfiguration.policy) + let range = pair.normalizedRange(configuration.range.value) let result: HistoryResult if let request = pair.historyRequest { result = await HistoryService( - directory: WatchWidgetComposition.directory(), client: client + directory: WatchWidgetComposition.directory(), client: client, + policy: CurrencyRateConfiguration.policy ) .load( base: request.base, quote: request.quote, range: range, now: now, diff --git a/AppExtensions/CurrencyWidgets/Sources/Composition/WidgetComposition.swift b/AppExtensions/CurrencyWidgets/Sources/Composition/WidgetComposition.swift index 8f54e22..aba1901 100644 --- a/AppExtensions/CurrencyWidgets/Sources/Composition/WidgetComposition.swift +++ b/AppExtensions/CurrencyWidgets/Sources/Composition/WidgetComposition.swift @@ -1,4 +1,5 @@ import Conversion +import CurrencyApplication import ExchangeRates import Foundation import LocalCurrency @@ -34,7 +35,7 @@ enum WidgetComposition { static func timeline() -> WidgetTimelineDependencies { let directory = directory() let conversion = ConversionStore(directory: directory) - let rates = RateStore(directory: directory) + let rates = RateStore(directory: directory, policy: CurrencyRateConfiguration.policy) let local = LocalCurrencyStore(directory: directory) let widgets = WidgetStore(directory: directory) return WidgetTimelineDependencies( @@ -45,21 +46,24 @@ enum WidgetComposition { }, refreshRates: { force in try await rates.refreshRates( - using: RateService(), force: force, providerTimeout: .seconds(2)) + using: RateService(policy: CurrencyRateConfiguration.policy), force: force, + providerTimeout: .seconds(2)) }, refreshLocalCurrency: { let controller = await LocalCurrencyController( store: local, timeoutDuration: .seconds(5), reloadWidgets: {}) await controller.refreshForWidget() }, - now: { .now }) + now: { .now }, refreshInterval: CurrencyRateConfiguration.widgetRefreshInterval) } static func history() -> HistoryTimelineDependencies { let directory = directory() let conversion = ConversionStore(directory: directory) let local = LocalCurrencyStore(directory: directory) - let history = HistoryService(directory: directory, client: NetworkClient(timeout: 10)) + let history = HistoryService( + directory: directory, client: NetworkClient(timeout: 10), + policy: CurrencyRateConfiguration.policy) return HistoryTimelineDependencies( input: { conversion.input() }, load: { base, quote, range, now in @@ -72,7 +76,7 @@ enum WidgetComposition { static func action() -> WidgetActionDependencies { let directory = directory() - let rates = RateStore(directory: directory) + let rates = RateStore(directory: directory, policy: CurrencyRateConfiguration.policy) let widgets = WidgetStore(directory: directory) return WidgetActionDependencies( rates: { rates.loadRates() }, diff --git a/AppExtensions/CurrencyWidgets/Sources/Composition/WidgetDependencies.swift b/AppExtensions/CurrencyWidgets/Sources/Composition/WidgetDependencies.swift index 31018a6..398107d 100644 --- a/AppExtensions/CurrencyWidgets/Sources/Composition/WidgetDependencies.swift +++ b/AppExtensions/CurrencyWidgets/Sources/Composition/WidgetDependencies.swift @@ -4,7 +4,6 @@ import Foundation import LocalCurrency import Widgets -/// Capabilities supplied to WidgetKit timeline providers by extension composition. struct WidgetTimelineDependencies: Sendable { let input: @Sendable () -> ConverterState let rates: @Sendable () -> RateSnapshot @@ -13,9 +12,9 @@ struct WidgetTimelineDependencies: Sendable { let refreshRates: @Sendable (_ force: Bool) async throws -> RefreshResult let refreshLocalCurrency: @Sendable () async -> Void let now: @Sendable () -> Date + var refreshInterval: TimeInterval = 1_800 } -/// Focused capabilities for one installed AppIntent interaction. struct WidgetActionDependencies: Sendable { let rates: @Sendable () -> RateSnapshot let apply: @Sendable (WidgetCommand, String, RateSnapshot?) throws -> Bool diff --git a/AppExtensions/CurrencyWidgets/Sources/Configuration/HistorySettings.swift b/AppExtensions/CurrencyWidgets/Sources/Configuration/HistorySettings.swift index 925b336..ff4db43 100644 --- a/AppExtensions/CurrencyWidgets/Sources/Configuration/HistorySettings.swift +++ b/AppExtensions/CurrencyWidgets/Sources/Configuration/HistorySettings.swift @@ -1,5 +1,6 @@ import AppIntents import Conversion +import CurrencyApplication import ExchangeRates import Foundation import Widgets @@ -213,6 +214,8 @@ struct HistorySettings: WidgetConfigurationIntent { default: choice.id } } - return HistoryWidgetPair(app: input, base: resolve(base), quote: resolve(comparison)) + return HistoryWidgetPair( + app: input, base: resolve(base), quote: resolve(comparison), + policy: CurrencyRateConfiguration.policy) } } diff --git a/AppExtensions/CurrencyWidgets/Sources/Timelines/WidgetTimeline.swift b/AppExtensions/CurrencyWidgets/Sources/Timelines/WidgetTimeline.swift index 62b076a..fdfabea 100644 --- a/AppExtensions/CurrencyWidgets/Sources/Timelines/WidgetTimeline.swift +++ b/AppExtensions/CurrencyWidgets/Sources/Timelines/WidgetTimeline.swift @@ -46,7 +46,8 @@ struct SuiteTimeline: AppIntentTimelineProvid } return Timeline( entries: entries, - policy: .after(dependencies.now().addingTimeInterval(retry ? 300 : 1800))) + policy: .after( + dependencies.now().addingTimeInterval(retry ? 300 : dependencies.refreshInterval))) } func entry(_ configuration: Configuration) -> SuiteEntry { diff --git a/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift b/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift index 9b006a5..345ecf8 100644 --- a/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift +++ b/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift @@ -1,4 +1,5 @@ import Conversion +import CurrencyApplication import ExchangeRates import Foundation import LocalCurrency @@ -9,6 +10,37 @@ import Widgets @Suite struct HistoryWidgetAdapterTests { private let now = Date(timeIntervalSince1970: 1_790_035_200) + private var cryptoRanges: [HistoryWidgetRange] { + CurrencyRateConfiguration.coinbaseEnabled + ? HistoryWidgetRange.allCases + : HistoryWidgetRange.allCases.filter { $0 != .day } + } + private var effectiveDay: HistoryRange { + CurrencyRateConfiguration.coinbaseEnabled ? .day : .month + } + + @Test func liveBuildPolicyControlsSavedDayRangeAndTimeline() async { + let now = now + let requests = Mutex<[HistoryRange]>([]) + let provider = HistoryTimeline( + dependencies: .init( + input: { ConverterState() }, + load: { _, _, range, _ in + requests.withLock { $0.append(range) } + return HistoryResult(series: nil, issue: .unavailable) + }, now: { now })) + let settings = HistorySettings() + settings.base = HistoryCurrency("BTC") + settings.comparison = HistoryCurrency("USD") + settings.range = .day + let timeline = await provider.loadTimeline(settings) + let enhanced = CurrencyRateConfiguration.coinbaseEnabled + #expect(requests.withLock { $0 } == [enhanced ? .day : .month]) + #expect(settings.range == .day) + #expect(settings.availableRanges(input: ConverterState()).contains(.day) == enhanced) + #expect(timeline.entries.first?.snapshot.pair.policy == CurrencyRateConfiguration.policy) + #expect(timeline.policy == .after(now.addingTimeInterval(enhanced ? 3_600 : 86_400))) + } @Test func nativeDefaultsFollowAppAndOverridesAreIndependent() { var input = ConverterState() @@ -86,8 +118,10 @@ import Widgets settings.range = range #expect(settings.pair(input: input).base == "BTC") #expect(settings.pair(input: input).quote == "USD") - #expect(settings.effectiveRange(for: settings.pair(input: input)) == range.range) - #expect(settings.availableRanges(input: input) == HistoryWidgetRange.allCases) + #expect( + settings.effectiveRange(for: settings.pair(input: input)) + == (range == .day ? effectiveDay : range.range)) + #expect(settings.availableRanges(input: input) == cryptoRanges) } input.changeSource("CHF") input.setDestinations(["JPY"]) @@ -134,26 +168,26 @@ import Widgets let saved = input let settings = HistorySettings() let fiatRanges: [HistoryWidgetRange] = [.week, .month, .quarter, .year, .all] - #expect(settings.availableRanges(input: saved) == HistoryWidgetRange.allCases) + #expect(settings.availableRanges(input: saved) == cryptoRanges) #expect( try await HistoryRangeOptionsProvider(readInput: { saved }).results() - == HistoryWidgetRange.allCases) + == cryptoRanges) settings.base = HistoryCurrency("USD") settings.comparison = HistoryCurrency("BTC") - #expect(settings.availableRanges(input: saved) == HistoryWidgetRange.allCases) + #expect(settings.availableRanges(input: saved) == cryptoRanges) settings.comparison = HistoryCurrency("EUR") #expect(settings.availableRanges(input: saved) == fiatRanges) settings.base = HistoryCurrency("BTC") - #expect(settings.availableRanges(input: saved) == HistoryWidgetRange.allCases) + #expect(settings.availableRanges(input: saved) == cryptoRanges) settings.comparison = HistoryCurrency("USD") - #expect(settings.availableRanges(input: saved) == HistoryWidgetRange.allCases) + #expect(settings.availableRanges(input: saved) == cryptoRanges) settings.comparison = HistoryCurrency("ETH") - #expect(settings.availableRanges(input: saved) == HistoryWidgetRange.allCases) + #expect(settings.availableRanges(input: saved) == cryptoRanges) input.changeSource("EUR") input.setDestinations(["CZK"]) - #expect(settings.availableRanges(input: input) == HistoryWidgetRange.allCases) + #expect(settings.availableRanges(input: input) == cryptoRanges) settings.base = nil settings.comparison = nil #expect(settings.availableRanges(input: input) == fiatRanges) @@ -163,11 +197,22 @@ import Widgets settings.comparison = HistoryCurrency("@local") #expect( settings.availableRanges(input: input, localCurrency: "USD") - == HistoryWidgetRange.allCases) + == cryptoRanges) #expect(settings.availableRanges(input: input, localCurrency: "CZK") == fiatRanges) #expect(settings.availableRanges(input: input) == fiatRanges) } + @Test func dailyProviderHidesIntradayOptionsAndNormalizesSavedSelection() { + let pair = HistoryWidgetPair( + app: ConverterState(), base: "BTC", quote: "USD", policy: .daily) + let settings = HistorySettings() + settings.range = .day + #expect(!HistoryWidgetRange.available(for: pair).contains(.day)) + #expect(HistoryWidgetRange.available(for: pair).contains(.month)) + #expect(settings.effectiveRange(for: pair) == .month) + #expect(settings.range == .day) + } + @Test func rangeDefaultsAndFallbackOnlyReplaceUnsupportedDay() async throws { let settings = HistorySettings() #expect(settings.range == .month) @@ -339,7 +384,7 @@ import Widgets settings.range = .day let entry = await provider.entry(settings) #expect(requested.withLock { $0 } == ["BTC", "USD"]) - #expect(entry.snapshot.range == .day) + #expect(entry.snapshot.range == effectiveDay) #expect(entry.snapshot.pair.base == "USD") #expect(entry.snapshot.pair.quote == "BTC") #expect(entry.snapshot.latest?.value == 0.00001) @@ -397,24 +442,27 @@ import Widgets settings.comparison = HistoryCurrency(comparison) settings.range = .day let crypto = await provider.loadTimeline(settings) - #expect(requests.withLock { $0 }.map(\.2) == [.day]) - #expect(crypto.entries.first?.snapshot.range == .day) - #expect(crypto.policy == .after(now.addingTimeInterval(3600))) + #expect(requests.withLock { $0 }.map(\.2) == [effectiveDay]) + #expect(crypto.entries.first?.snapshot.range == effectiveDay) + #expect( + crypto.policy + == .after(now.addingTimeInterval(CurrencyRateConfiguration.coinbaseEnabled ? 3600 : 86_400)) + ) settings.base = HistoryCurrency("EUR") settings.comparison = HistoryCurrency("CZK") let fiat = await provider.loadTimeline(settings) - #expect(requests.withLock { $0 }.map(\.2) == [.day, .month]) + #expect(requests.withLock { $0 }.map(\.2) == [effectiveDay, .month]) #expect(fiat.entries.first?.snapshot.range == .month) #expect(fiat.policy == .after(now.addingTimeInterval(86_400))) settings.range = .week _ = await provider.entry(settings) - #expect(requests.withLock { $0 }.map(\.2) == [.day, .month, .week]) + #expect(requests.withLock { $0 }.map(\.2) == [effectiveDay, .month, .week]) } @Test(arguments: HistoryWidgetRange.allCases) - func supportedCryptoRangeSchedulesTheCanonicalHourlyRefresh(range: HistoryWidgetRange) async { + func cryptoRangeSchedulesOnlyIntradayHourlyRefresh(range: HistoryWidgetRange) async { let now = now.addingTimeInterval(2220) let requests = Mutex<[HistoryRange]>([]) let provider = HistoryTimeline( @@ -429,9 +477,13 @@ import Widgets settings.comparison = HistoryCurrency("EUR") settings.range = range let timeline = await provider.loadTimeline(settings) - #expect(requests.withLock { $0 } == [range.range]) - #expect(timeline.entries.first?.snapshot.range == range.range) - #expect(timeline.policy == .after(HistoryService.nextHour(after: now))) + #expect(requests.withLock { $0 } == [range == .day ? effectiveDay : range.range]) + #expect(timeline.entries.first?.snapshot.range == (range == .day ? effectiveDay : range.range)) + #expect( + timeline.policy + == .after( + range == .day && CurrencyRateConfiguration.coinbaseEnabled + ? HistoryService.nextHour(after: now) : now.addingTimeInterval(86_400))) } @Test func unsupportedPairSkipsNetworkAndFailedLoadHasNoSampleData() async throws { @@ -455,6 +507,6 @@ import Widgets #expect(calls.withLock { $0 } == 1) #expect(timeline.entries.first?.snapshot.series == nil) #expect(timeline.entries.first?.snapshot.latest == nil) - #expect(timeline.policy == .after(HistoryService.nextHour(after: now))) + #expect(timeline.policy == .after(now.addingTimeInterval(86_400))) } } diff --git a/Apps/Currency/App/Sources/Composition/AppComposition.swift b/Apps/Currency/App/Sources/Composition/AppComposition.swift index 604605f..623d6c2 100644 --- a/Apps/Currency/App/Sources/Composition/AppComposition.swift +++ b/Apps/Currency/App/Sources/Composition/AppComposition.swift @@ -29,6 +29,7 @@ final class AppComposition { let discovery: HomeDiscoveryStore let history: HistoryService let service: RateService + let policy: RateProviderPolicy let appearance: AppearancePreferences let systemActions: SystemActionComposition lazy var searchIndex = CurrencySearchIndex.live(composition: systemActions) @@ -37,11 +38,18 @@ final class AppComposition { private var rateIssue: HomeIssue? private var widgetReload: Task? lazy var foregroundLocation = makeLocationController() - lazy var foreground = ForegroundRefresh( - dependencies: .init( - refreshRates: { [weak self] in _ = try? await self?.refresh(force: false) }, - refreshLocalCurrency: { [weak self] in await self?.foregroundLocation.refreshIfNeeded() }, - changed: { [weak self] in self?.changed() })) + lazy var foreground = makeForegroundRefresh() + + private func makeForegroundRefresh() -> ForegroundRefresh { + ForegroundRefresh( + dependencies: .init( + refreshRates: { [weak self] in _ = try? await self?.refresh(force: false) }, + refreshLocalCurrency: { [weak self] in await self?.foregroundLocation.refreshIfNeeded() }, + changed: { [weak self] in self?.changed() }, + sleepRates: { [policy] in + try await Task.sleep(for: .seconds(policy == .coinbaseEnhanced ? 60 : 21_600)) + }, sleepLocalCurrency: { try await Task.sleep(for: .seconds(300)) })) + } static func launch() -> AppComposition { #if DEBUG && targetEnvironment(simulator) @@ -58,18 +66,21 @@ final class AppComposition { init( directory: URL = AppGroup.directory, appearance: AppearancePreferences? = nil, - discoveryDefaults: UserDefaults = .standard, service: RateService = RateService() + discoveryDefaults: UserDefaults = .standard, + policy: RateProviderPolicy = CurrencyRateConfiguration.policy, service: RateService? = nil ) { syncDirectory = directory - self.service = service + self.policy = policy + self.service = service ?? RateService(policy: policy) self.appearance = appearance ?? AppearancePreferences(defaults: .standard) - rates = RateStore(directory: directory) + rates = RateStore(directory: directory, policy: policy) conversion = ConversionStore(directory: directory) local = LocalCurrencyStore(directory: directory) progress = OnboardingProgressStore(directory: directory) discovery = HomeDiscoveryStore(defaults: discoveryDefaults) - history = HistoryService(directory: directory) - systemActions = SystemActionComposition(directory: directory, service: self.service) + history = HistoryService(directory: directory, policy: policy) + systemActions = SystemActionComposition( + directory: directory, policy: policy, service: self.service) let actions = systemActions AppDependencyManager.shared.add(dependency: actions) let index = searchIndex @@ -189,9 +200,11 @@ final class AppComposition { now: { .now }) } var details: CurrencyDetailsDependencies { - CurrencyDetailsDependencies(loadHistory: { [history] code, quote, range in - await history.load(base: code, quote: quote, range: range) - }) + CurrencyDetailsDependencies( + supportsIntraday: { [history] in history.supportsIntraday(base: $0, quote: $1) }, + loadHistory: { [history] code, quote, range in + await history.load(base: code, quote: quote, range: range) + }) } private var settingsState: SettingsRateState { let input = conversion.input() @@ -213,7 +226,7 @@ final class AppComposition { refresh: { [self] in _ = try await refresh(force: true); return settingsState }, - replay: { try scene.restartOnboarding() }, + replay: { try scene.restartOnboarding() }, usesCoinbase: policy == .coinbaseEnhanced, output: { [self] output in if case .manageLocation = output { routeLocationPermission( diff --git a/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift b/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift index c16846f..48a84ad 100644 --- a/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift +++ b/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift @@ -1,6 +1,7 @@ #if DEBUG && targetEnvironment(simulator) import Conversion import CoordinatedFiles + import CurrencyApplication import ExchangeRates import Foundation import Home @@ -15,6 +16,7 @@ case readyConverter = "ready-converter" case readyMetals = "ready-metals" case readyMetalSource = "ready-metal-source" + case readyCrypto = "ready-crypto" } enum StartupError: Error, Equatable { @@ -34,8 +36,7 @@ try seed(state, directory: directory(), defaults: defaults, now: now) WidgetCenter.shared.reloadAllTimelines() } - let provider = FixedRateProvider() - return RateService(fiat: provider, daily: provider, crypto: nil) + return RateService(policy: CurrencyRateConfiguration.policy, client: FixedRateClient()) } private static func parse( @@ -72,39 +73,38 @@ private static func seed( _ state: InitialState, directory: URL, defaults: UserDefaults, now: Date ) throws { - let rates = RateStore(directory: directory) + let policy = CurrencyRateConfiguration.policy + let rates = RateStore(directory: directory, policy: policy) + let daily = FixedRateClient.quotes(now: now) + var effective = daily + effective["BTC"] = FixedRateClient.coinbaseQuote(now: now) try FileCoordination.write(at: directory.appendingPathComponent("rates.json")) { try RateCache(directory: directory) .save( - RateSnapshot(quotes: FixedRateProvider.quotes(now: now), fetchedAt: now, checkedAt: now) - ) + RateSnapshot( + quotes: effective, fetchedAt: now, dailyQuotes: daily, dailyFetchedAt: now, + checkedAt: now, supplementalFetchedAt: now, supplementalQuotes: daily)) } - let historyFile = directory.appendingPathComponent("history-EUR-USD-30.json") - let history = HistorySeries( - points: [ - HistoryPoint(date: now.addingTimeInterval(-7 * 86400), value: 1.8), - HistoryPoint(date: now.addingTimeInterval(-86400), value: 2) - ], source: .init(provider: .ecb, observation: .dailyReference), fetchedAt: now) - try FileCoordination.write(at: historyFile) { - try JSONEncoder().encode(history).write(to: historyFile, options: .atomic) - } - let seededHistory = try JSONDecoder() - .decode( - HistorySeries.self, from: Data(contentsOf: historyFile)) - guard seededHistory.points == history.points, seededHistory.source == history.source, - seededHistory.fetchedAt == now - else { throw StartupError.seedVerificationFailed } - if state == .readyMetals || state == .readyMetalSource { - let metalHistory = HistorySeries( - points: [ - HistoryPoint(date: now.addingTimeInterval(-7 * 86400), value: 0.009), - HistoryPoint(date: now.addingTimeInterval(-86400), value: 0.01) - ], source: .init(provider: .ecb, observation: .dailyReference), fetchedAt: now) - let metalHistoryFile = directory.appendingPathComponent("history-EUR-XAU-30.json") - try FileCoordination.write(at: metalHistoryFile) { - try JSONEncoder().encode(metalHistory).write(to: metalHistoryFile, options: .atomic) - } + for mode in [RateProviderPolicy.daily, .coinbaseEnhanced] { + try seedHistory( + base: "EUR", quote: "USD", values: [1.8, 2], mode: mode, + directory: directory, now: now) + try seedHistory( + base: "EUR", quote: "XAU", values: [0.009, 0.01], mode: mode, + directory: directory, now: now) + try seedHistory( + base: "BTC", quote: "USD", values: [90_000, 100_000], mode: mode, + directory: directory, now: now) } + let hourly = HistorySeries( + points: [ + HistoryPoint(date: now.addingTimeInterval(-2 * 3600), value: 45_000), + HistoryPoint(date: now.addingTimeInterval(-3600), value: 50_000) + ], source: .init(provider: .coinbase, observation: .hourlyClose, timeZone: .gmt), + fetchedAt: now) + try writeHistory( + hourly, filename: "history-coinbaseEnhanced-coinbase-BTC-USD-1.json", + directory: directory) let files = [ "input.json", "onboarding.json", "widget-location-refresh.json", "widget-location.json", "widget-location-status.json" @@ -121,11 +121,15 @@ let conversion = ConversionStore(directory: directory) let progress = OnboardingProgressStore(directory: directory) switch state { - case .readyConverter, .readyMetals, .readyMetalSource: + case .readyConverter, .readyMetals, .readyMetalSource, .readyCrypto: let input = try conversion.updateInput { $0 = ConverterState() $0.setDestinations(state == .readyMetals ? ["USD", "XAU"] : ["USD"]) if state == .readyMetalSource { $0.changeSource("XAU") } + if state == .readyCrypto { + $0.changeSource("USD") + $0.setDestinations(["BTC"]) + } } try progress.save(OnboardingProgress(draft: input, step: .ready, completed: true)) guard conversion.input() == input, progress.load()?.completed == true, @@ -149,25 +153,107 @@ let seededRates = rates.loadRates() guard seededRates.quotes["EUR"]?.value == 1, seededRates.quotes["USD"]?.value == 2, seededRates.quotes["CHF"]?.value == 0.5, seededRates.quotes["CZK"]?.value == 25, - seededRates.checkedAt == now, discoveryStore.load() == discovery + seededRates.quotes["BTC"]?.value == (policy == .coinbaseEnhanced ? 0.00004 : 0.00002), + seededRates.quotes["BTC"]?.source.provider + == (policy == .coinbaseEnhanced ? .coinbase : .fawaz), + discoveryStore.load() == discovery + else { throw StartupError.seedVerificationFailed } + } + + private static func seedHistory( + base: String, quote: String, values: [Double], mode: RateProviderPolicy, + directory: URL, now: Date + ) throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + guard + let yearStart = calendar.date( + from: DateComponents(year: calendar.component(.year, from: now), month: 1, day: 1)) + else { throw StartupError.seedVerificationFailed } + for range in HistoryRange.allCases where range != .day { + let first = + range == .yearToDate + ? yearStart + : now.addingTimeInterval(-Double(range == .all ? 60 : max(2, range.rawValue - 1)) * 86400) + let series = HistorySeries( + points: [ + HistoryPoint(date: first, value: values[0]), + HistoryPoint(date: now.addingTimeInterval(-86400), value: values[1]) + ], + source: .init( + provider: .fawaz, + observation: range == .all ? .monthlyReference : .dailyReference, timeZone: .gmt), + fetchedAt: now) + let interval = + range == .yearToDate + ? "\(range.rawValue)-\(calendar.component(.year, from: now))" : "\(range.rawValue)" + try writeHistory( + series, + filename: "history-\(mode.rawValue)-fawaz-\(base)-\(quote)-\(interval).json", + directory: directory) + } + } + + private static func writeHistory( + _ history: HistorySeries, filename: String, directory: URL + ) throws { + let file = directory.appendingPathComponent(filename) + try FileCoordination.write(at: file) { + try JSONEncoder().encode(history).write(to: file, options: .atomic) + } + let saved = try JSONDecoder().decode(HistorySeries.self, from: Data(contentsOf: file)) + guard saved.points == history.points, saved.source == history.source, + saved.fetchedAt == history.fetchedAt else { throw StartupError.seedVerificationFailed } } + } - private struct FixedRateProvider: RateProvider { - func fetch() async throws -> [String: ExchangeRate] { + private struct FixedRateClient: HTTPClient { + func get(_ url: URL) async throws -> Data { try Task.checkCancellation() - return Self.quotes(now: .now) + if url.host == "api.coinbase.com", url.path == "/v2/exchange-rates", + CurrencyRateConfiguration.coinbaseEnabled + { + var values = Dictionary( + uniqueKeysWithValues: CurrencyCatalog.crypto.map { ($0, "0.00004") }) + values["EUR"] = "1" + return try JSONSerialization.data(withJSONObject: [ + "data": ["currency": "EUR", "rates": values] + ]) + } + guard ["cdn.jsdelivr.net", "latest.currency-api.pages.dev"].contains(url.host ?? ""), + url.path.hasSuffix("/currencies/eur.min.json") + else { throw RateError.unavailable } + struct Payload: Encodable { let date: String; let eur: [String: Decimal] } + return try JSONEncoder() + .encode( + Payload( + date: Self.day(.now), + eur: Dictionary( + uniqueKeysWithValues: Self.quotes(now: .now) + .map { + ($0.key.lowercased(), $0.value.value) + }))) } + static func day(_ now: Date) -> String { String(now.ISO8601Format().prefix(10)) } + static func quotes(now: Date) -> [String: ExchangeRate] { - let day = now.formatted(.iso8601.year().month().day().dateSeparator(.dash)) - let values: [String: Decimal] = ["EUR": 1, "USD": 2, "CHF": 0.5, "CZK": 25, "XAU": 0.01] - return - values - .mapValues { - ExchangeRate($0, published: day, source: .init(provider: .ecb), cachedAt: now) - } + let values: [String: Decimal] = [ + "EUR": 1, "USD": 2, "CHF": 0.5, "CZK": 25, "XAU": 0.01, "BTC": 0.00002 + ] + return values.mapValues { + ExchangeRate( + $0, published: day(now), + source: .init(provider: .fawaz, observation: .dailyRate), cachedAt: now) + } + } + + static func coinbaseQuote(now: Date) -> ExchangeRate { + ExchangeRate( + 0.00004, published: day(now), + source: .init(provider: .coinbase, observation: .exchangeRate), retrievedAt: now) } } #endif diff --git a/Apps/Currency/App/Sources/Composition/SystemActionComposition.swift b/Apps/Currency/App/Sources/Composition/SystemActionComposition.swift index 95048bb..9e98bb5 100644 --- a/Apps/Currency/App/Sources/Composition/SystemActionComposition.swift +++ b/Apps/Currency/App/Sources/Composition/SystemActionComposition.swift @@ -12,10 +12,14 @@ struct SystemActionComposition: Sendable { let readSelected: @Sendable () -> [String] let readLocal: @Sendable () -> (LocalCurrency.WidgetLocation?, WidgetLocationStatus) - init(directory: URL, service: RateService = RateService()) { + init( + directory: URL, policy: RateProviderPolicy = CurrencyRateConfiguration.policy, + service: RateService? = nil + ) { + let service = service ?? RateService(policy: policy) let conversion = ConversionStore(directory: directory) let local = LocalCurrencyStore(directory: directory) - let rates = RateStore(directory: directory) + let rates = RateStore(directory: directory, policy: policy) let readLocal: @Sendable () -> (LocalCurrency.WidgetLocation?, WidgetLocationStatus) = { let (observation, saved) = local.snapshot() switch CLLocationManager().authorizationStatus { @@ -36,6 +40,6 @@ struct SystemActionComposition: Sendable { try Task.checkCancellation() WidgetCenter.shared.reloadAllTimelines() return result - })) + }, refreshInterval: policy.refreshInterval)) } } diff --git a/Apps/Currency/Modules/CurrencyApplication/Sources/ConversionAction.swift b/Apps/Currency/Modules/CurrencyApplication/Sources/ConversionAction.swift index 014fc79..e66552a 100644 --- a/Apps/Currency/Modules/CurrencyApplication/Sources/ConversionAction.swift +++ b/Apps/Currency/Modules/CurrencyApplication/Sources/ConversionAction.swift @@ -17,16 +17,18 @@ public struct ConversionAction: Sendable { public let refresh: @Sendable (Date) async throws -> RefreshResult /// Supplies evaluation and refresh-attempt time. public let now: @Sendable () -> Date + /// Minimum time between automatic provider attempts. + public let refreshInterval: TimeInterval /// Assembles explicit operations for an executable or isolated test. public init( readInput: @escaping @Sendable () -> ConverterState, readLocal: @escaping @Sendable () -> (WidgetLocation?, WidgetLocationStatus), readRates: @escaping @Sendable () -> RateSnapshot, refresh: @escaping @Sendable (Date) async throws -> RefreshResult, - now: @escaping @Sendable () -> Date = { .now } + now: @escaping @Sendable () -> Date = { .now }, refreshInterval: TimeInterval = 1_800 ) { self.readInput = readInput; self.readLocal = readLocal; self.readRates = readRates - self.refresh = refresh; self.now = now + self.refresh = refresh; self.now = now; self.refreshInterval = refreshInterval } } /// The explicitly supplied operations used by this action. @@ -68,7 +70,7 @@ public struct ConversionAction: Sendable { let needsRates = request.destinations.contains { $0.code != nil && $0.code != request.source } if needsRates, now < (snapshot.checkedAt ?? .distantPast) - || now.timeIntervalSince(snapshot.checkedAt ?? .distantPast) >= 1800 + || now.timeIntervalSince(snapshot.checkedAt ?? .distantPast) >= dependencies.refreshInterval { do { let result = try await dependencies.refresh(now) diff --git a/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift b/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift new file mode 100644 index 0000000..cee2004 --- /dev/null +++ b/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift @@ -0,0 +1,29 @@ +import ExchangeRates +import Foundation + +/// Shared build configuration for the app, extensions and independent Watch app. +public enum CurrencyRateConfiguration { + /// Set CURRENCY_DISABLE_COINBASE when building to use only daily Fawaz data. + public static let coinbaseEnabled: Bool = { + #if CURRENCY_DISABLE_COINBASE + false + #else + true + #endif + }() + + /// Provider capabilities selected once at each executable composition root. + public static var policy: RateProviderPolicy { + coinbaseEnabled ? .coinbaseEnhanced : .daily + } + + /// Foreground polling respects the available provider update frequency. + public static var foregroundRefreshInterval: TimeInterval { + coinbaseEnabled ? 60 : 21_600 + } + + /// Requested widget refresh cadence; the system decides actual execution times. + public static var widgetRefreshInterval: TimeInterval { + policy.refreshInterval + } +} diff --git a/Apps/Currency/Modules/CurrencyApplication/Tests/CurrencyRateConfigurationTests.swift b/Apps/Currency/Modules/CurrencyApplication/Tests/CurrencyRateConfigurationTests.swift new file mode 100644 index 0000000..ff3e7c4 --- /dev/null +++ b/Apps/Currency/Modules/CurrencyApplication/Tests/CurrencyRateConfigurationTests.swift @@ -0,0 +1,50 @@ +import Conversion +import CurrencyApplication +import ExchangeRates +import Foundation +import LocalCurrency +import Testing + +struct CurrencyRateConfigurationTests { + @Test func allExecutablesShareTheBuildFlagAndCadence() { + #if CURRENCY_DISABLE_COINBASE + #expect(!CurrencyRateConfiguration.coinbaseEnabled) + #expect(CurrencyRateConfiguration.policy == .daily) + #expect(CurrencyRateConfiguration.foregroundRefreshInterval == 21_600) + #expect(CurrencyRateConfiguration.widgetRefreshInterval == 21_600) + #else + #expect(CurrencyRateConfiguration.coinbaseEnabled) + #expect(CurrencyRateConfiguration.policy == .coinbaseEnhanced) + #expect(CurrencyRateConfiguration.foregroundRefreshInterval == 60) + #expect(CurrencyRateConfiguration.widgetRefreshInterval == 1_800) + #endif + } + @Test(arguments: [RateProviderPolicy.daily, .coinbaseEnhanced]) + func headlessConversionsRespectProviderAttemptCadence(policy: RateProviderPolicy) async throws { + let now = Date.now + let attempts = ProviderAttemptCounter() + let snapshot = RateSnapshot( + quotes: [ + "EUR": .init(1, published: "2026-10-09", source: .init(provider: .fawaz)), + "USD": .init(2, published: "2026-10-09", source: .init(provider: .fawaz)) + ], fetchedAt: now, checkedAt: now.addingTimeInterval(-1_800)) + let action = ConversionAction( + dependencies: .init( + readInput: { ConverterState() }, readLocal: { (nil, .notDetermined) }, + readRates: { snapshot }, + refresh: { _ in + await attempts.record() + return RefreshResult(snapshot: snapshot, warning: nil) + }, now: { now }, refreshInterval: policy.refreshInterval)) + let request = try action.request(amount: "10", source: "EUR", destination: "USD") + let result = try await action.perform(request) + #expect(result.results.first?.amount == "20") + #expect(await attempts.count == (policy == .coinbaseEnhanced ? 1 : 0)) + } + +} + +private actor ProviderAttemptCounter { + private(set) var count = 0 + func record() { count += 1 } +} diff --git a/Apps/Currency/Tests/ApplicationIntegrationTests/AppCompositionTests.swift b/Apps/Currency/Tests/ApplicationIntegrationTests/AppCompositionTests.swift index fb15ab5..87c81dc 100644 --- a/Apps/Currency/Tests/ApplicationIntegrationTests/AppCompositionTests.swift +++ b/Apps/Currency/Tests/ApplicationIntegrationTests/AppCompositionTests.swift @@ -93,8 +93,8 @@ struct AppCompositionTests { #expect(composition.appearance.theme == .dark) let snapshot = RateSnapshot( quotes: [ - "EUR": ExchangeRate(1, published: "2026-09-26", source: .init(provider: .ecb)), - "USD": ExchangeRate(2, published: "2026-09-26", source: .init(provider: .ecb)) + "EUR": ExchangeRate(1, published: "2026-09-26", source: .init(provider: .fawaz)), + "USD": ExchangeRate(2, published: "2026-09-26", source: .init(provider: .fawaz)) ], fetchedAt: .now) _ = try composition.onboarding.saveRates(snapshot, .now) #expect(home.readRates().quotes["USD"]?.value == 2) diff --git a/Apps/Currency/Tests/ApplicationIntegrationTests/ProviderPolicyIntegrationTests.swift b/Apps/Currency/Tests/ApplicationIntegrationTests/ProviderPolicyIntegrationTests.swift new file mode 100644 index 0000000..5b18462 --- /dev/null +++ b/Apps/Currency/Tests/ApplicationIntegrationTests/ProviderPolicyIntegrationTests.swift @@ -0,0 +1,49 @@ +import AppearancePreferences +import CurrencyApplication +import ExchangeRates +import Foundation +import Testing + +@MainActor +struct ProviderPolicyIntegrationTests { + @Test(arguments: [RateProviderPolicy.daily, .coinbaseEnhanced]) + func everyPhoneEntryUsesTheSelectedPolicy(policy: RateProviderPolicy) throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let suite = UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { + try? FileManager.default.removeItem(at: directory) + defaults.removePersistentDomain(forName: suite) + } + let now = Date.now + let day = now.formatted(.iso8601.year().month().day().dateSeparator(.dash)) + let daily: [String: ExchangeRate] = [ + "EUR": .init(1, published: day, source: .init(provider: .fawaz), cachedAt: now), + "BTC": .init(0.01, published: day, source: .init(provider: .fawaz), cachedAt: now) + ] + var quotes = daily + quotes["BTC"] = .init( + 0.02, published: day, source: .init(provider: .coinbase, observation: .exchangeRate), + retrievedAt: now) + try RateCache(directory: directory) + .save( + .init( + quotes: quotes, fetchedAt: now, dailyQuotes: daily, dailyFetchedAt: now, + checkedAt: now, supplementalFetchedAt: now, supplementalQuotes: daily)) + let composition = AppComposition( + directory: directory, appearance: AppearancePreferences(defaults: defaults), + discoveryDefaults: defaults, policy: policy) + _ = try composition.edit { $0.setDestinations(["BTC"]) } + let expected: Decimal = policy == .daily ? 1 : 2 + #expect(composition.home.readRates().convert(100, from: "EUR", to: "BTC") == expected) + #expect( + composition.systemActions.action.dependencies.readRates() + .convert(100, from: "EUR", to: "BTC") == expected) + let settings = composition.settings(scene: composition.makeScene()) + #expect(settings.readState().snapshot.convert(100, from: "EUR", to: "BTC") == expected) + #expect(settings.usesCoinbase == (policy == .coinbaseEnhanced)) + #expect(composition.details.supportsIntraday("BTC", "USD") == (policy == .coinbaseEnhanced)) + #expect(!composition.details.supportsIntraday("EUR", "USD")) + #expect(composition.history.policy == policy) + } +} diff --git a/Apps/Currency/Tests/E2E/README.md b/Apps/Currency/Tests/E2E/README.md index bba196a..4531d9c 100644 --- a/Apps/Currency/Tests/E2E/README.md +++ b/Apps/Currency/Tests/E2E/README.md @@ -57,6 +57,7 @@ These are representative outcomes, not exhaustive coverage of each feature ID. | ONB-01–02 | [onboarding](tests/onboarding.e2e.ts) | Currency selection, Back, completion and restart | | CONV-01, CONV-05 | [converter](tests/converter.e2e.ts) | EUR 42 → USD 84 persists after restart | | 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/Coinbase conversion, history provenance, 1D capability, source credits and saved-cache filtering after restart (run both builds) | | HIST-01–04 | [converter-details](tests/converter-details.e2e.ts) | Rate, period selection, source explanation and return | | 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 | @@ -74,7 +75,16 @@ 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 seeded EUR/USD history and EUR/Gold history for metal fixtures. 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 Fawaz history caches for EUR/USD, EUR/Gold and BTC/USD. `ready-crypto` starts USD 1 → Bitcoin: 0.00001 from Fawaz, or 0.00002 with Coinbase enabled. Its stored snapshot deliberately contains a Coinbase overlay plus the daily fallback, so the disabled build exercises cache exclusion. Restart omits fixture state and preserves the real stores. Keep `-CurrencyE2EState` out of default launch arguments. + +Run provider coverage with both separately built apps. `CURRENCY_E2E_COINBASE` selects assertions and recording identity; it does not change app behavior. The off build must compile every target, including CurrencyApplication, with `CURRENCY_DISABLE_COINBASE` (append it to the existing Swift conditions). Use separate DerivedData directories, then select the app explicitly: + +```sh +CURRENCY_E2E_COINBASE=on CURRENCY_E2E_APP_PATH= npm run test:e2e -- tests/provider-policy.e2e.ts +CURRENCY_E2E_COINBASE=off CURRENCY_E2E_APP_PATH= npm run test:e2e -- tests/provider-policy.e2e.ts +``` + +The fixtures use the production provider decoders with a fixed HTTP client. Fawaz supplies every asset; Coinbase supplies crypto only in the enabled build. Fresh namespaced chart caches avoid live network timing. This journey proves same-build relaunch filtering of a deliberately incompatible cached overlay; changing installed builds on one retained app container is a separate upgrade check. Home Screen setup uses [widgetctl](https://github.com/DimaMishchenko/widgetctl), pinned as a GitHub dev dependency. It installs/configures the requested kind and size, preserves other apps’ widgets and unloads its helper after the module. Tests still validate rendered data and interactions. Set `CURRENCY_E2E_WIDGET_SETUP=gallery` to exercise system installation; private-configuration cases then skip. Lock Screen installation uses system UI. diff --git a/Apps/Currency/Tests/E2E/e2e.config.ts b/Apps/Currency/Tests/E2E/e2e.config.ts index 1c7007a..117a511 100644 --- a/Apps/Currency/Tests/E2E/e2e.config.ts +++ b/Apps/Currency/Tests/E2E/e2e.config.ts @@ -15,9 +15,9 @@ export default { name: targetName, engine: mobile({ platform: 'ios', device: udid, session: `currency-e2e-${targetName}` }), app: { - identity: 'currency-fixtures-v4', + identity: `currency-fixtures-v5-${process.env.CURRENCY_E2E_COINBASE ?? 'on'}`, bundleId: 'com.dimasike.currency', - appPath: '../../../../.validation/e2e/DerivedData/Build/Products/Debug-iphonesimulator/Currency.app', + 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/fixtures.ts b/Apps/Currency/Tests/E2E/fixtures.ts index 9b37ad2..add9b62 100644 --- a/Apps/Currency/Tests/E2E/fixtures.ts +++ b/Apps/Currency/Tests/E2E/fixtures.ts @@ -1,7 +1,7 @@ import { test as mobileTest } from '@e2e-dev/mobile'; import { deviceProfile } from './device-profile.js'; -type InitialState = 'fresh-onboarding' | 'ready-converter' | 'ready-metals' | 'ready-metal-source'; +type InitialState = 'fresh-onboarding' | 'ready-converter' | 'ready-metals' | 'ready-metal-source' | 'ready-crypto'; const worker = globalThis as typeof globalThis & { currencyE2EInstallation?: Promise }; export const test = mobileTest.extend<{ start: (state: InitialState) => Promise }>({ diff --git a/Apps/Currency/Tests/E2E/tests/converter-details.e2e.ts b/Apps/Currency/Tests/E2E/tests/converter-details.e2e.ts index 66c9f3a..3e8c6ec 100644 --- a/Apps/Currency/Tests/E2E/tests/converter-details.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/converter-details.e2e.ts @@ -16,7 +16,7 @@ test('converter state survives inspecting history and rate sources', { tags: ['c await agent.act('Change the history range to Three months (3M).'); await expect(screen.getByTestId('currency.details.historyRange').getByRole('tab', 'Three months')).toBeSelected(); - const source = screen.getByTestId('currency.details.sourceDisclosure').filter({ hasText: /daily reference rates/i }); + const source = screen.getByTestId('currency.details.sourceDisclosure').filter({ hasText: /Fawaz.*daily reference rates/i }); await source.tap(); await expect(source).toHaveValue('Expanded'); await agent.act('Scroll down in Details until the exact text "Reference-rate history may have gaps on weekends or when no data is published. Historical and current conversion sources may differ." is visible. Use that exact text when scrolling to text. Keep Details open.'); diff --git a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts new file mode 100644 index 0000000..79016b2 --- /dev/null +++ b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts @@ -0,0 +1,60 @@ +import { expect } from 'e2e'; +import { test } from '../fixtures.js'; + +const mode = process.env.CURRENCY_E2E_COINBASE ?? 'on'; +if (mode !== 'on' && mode !== 'off') { + throw new Error('CURRENCY_E2E_COINBASE must be on or off and match the installed build.'); +} +const enhanced = mode === 'on'; + +test(`provider policy ${mode} preserves crypto fallback, ranges and credits after restart`, { tags: ['core', 'providers'] }, async ({ start, app, agent, screen }) => { + await start('ready-crypto'); + const bitcoin = screen.getByRole('button', /^Bitcoin,/i); + const expectedAmount = enhanced ? 'Bitcoin, 0.00002' : 'Bitcoin, 0.00001'; + await expect(screen.getByRole('button', 'Edit amount in USD')).toHaveValue('1'); + await expect(bitcoin).toHaveAccessibleName(expectedAmount); + + const inspectDetails = async (capture: string) => { + 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 USD'); + await expect(screen.getByTestId('currency.details.rate')).toHaveValue(enhanced ? '50,000 USD' : '100,000 USD'); + const ranges = screen.getByTestId('currency.details.historyRange'); + const day = ranges.getByRole('tab', 'One day', { exact: true }); + const source = screen.getByTestId('currency.details.sourceDisclosure') + .filter({ hasText: /daily reference rates|hourly closes/i }); + await expect(source.filter({ hasText: /Fawaz.*daily reference rates/i })).toBeVisible(); + if (enhanced) { + await expect(day).toBeVisible(); + await day.tap(); + await expect(day).toBeSelected(); + await expect(source.filter({ hasText: /Coinbase.*hourly closes/i })).toBeVisible(); + } else { + await expect(day).toBeHidden(); + await expect(screen.getByText(/Coinbase/i)).toBeHidden(); + } + await source.tap(); + await expect(source).toHaveValue('Expanded'); + await agent.act('Scroll in Details until the current conversion provider information below the expanded source disclosure is visible. Keep Details open.'); + await expect(screen.getByText(enhanced ? /Coinbase.*retrieved/i : /Fawaz · daily ·/i)).toBeVisible(); + await app.screenshot(capture); + await screen.getByTestId('currency.details.close').tap(); + await expect(bitcoin).toHaveAccessibleName(expectedAmount); + }; + + await inspectDetails(`provider-${mode}-history`); + await app.restart(); + await expect(screen.getByRole('button', 'Edit amount in USD')).toHaveValue('1'); + await expect(bitcoin).toHaveAccessibleName(expectedAmount); + await inspectDetails(`provider-${mode}-history-after-restart`); + + await screen.getByTestId('converter.options').tap(); + await screen.getByTestId('converter.settings').tap(); + await screen.getByTestId('settings.sources').tap(); + await expect(screen.getByText('Fawaz Exchange API', { exact: true })).toBeVisible(); + const coinbase = screen.getByText('Coinbase', { exact: true }); + if (enhanced) await expect(coinbase).toBeVisible(); + else await expect(coinbase).toBeHidden(); + await expect(screen.getByText('Frankfurter', { exact: true })).toBeHidden(); + await app.screenshot(`provider-${mode}-credits`); +}); diff --git a/Apps/Currency/Tests/E2E/tests/settings-information.e2e.ts b/Apps/Currency/Tests/E2E/tests/settings-information.e2e.ts index d474c2c..91af2a4 100644 --- a/Apps/Currency/Tests/E2E/tests/settings-information.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/settings-information.e2e.ts @@ -46,7 +46,7 @@ test('settings groups expose rates, credits and legal links', { tags: ['core'] } await expect(screen.getByText(/Estimates only, not financial advice/)).toBeVisible(); await screen.getByRole('button', 'Settings', { exact: true }).tap(); await screen.getByTestId('settings.sources').tap(); - await expect(screen.getByText('Frankfurter', { exact: true })).toBeVisible(); + await expect(screen.getByText('Fawaz Exchange API', { exact: true })).toBeVisible(); await screen.getByRole('button', 'Settings', { exact: true }).tap(); await agent.act('Scroll Settings until the About section with Acknowledgements, Privacy policy, Terms of use and Open source is visible. Stay in Settings.'); await expect(screen.getByText('About', { exact: true })).toBeVisible(); diff --git a/Apps/CurrencyWatch/App/Sources/WatchComposition.swift b/Apps/CurrencyWatch/App/Sources/WatchComposition.swift index 2721d17..aa04f60 100644 --- a/Apps/CurrencyWatch/App/Sources/WatchComposition.swift +++ b/Apps/CurrencyWatch/App/Sources/WatchComposition.swift @@ -17,7 +17,7 @@ final class WatchComposition { let conversion: ConversionStore let rates: RateStore let history: HistoryService - private let service = RateService() + private let service = RateService(policy: CurrencyRateConfiguration.policy) private let discovery = HomeDiscoveryStore(defaults: .standard) private var issue: HomeIssue? private var observers: [UUID: AsyncStream.Continuation] = [:] @@ -30,8 +30,8 @@ final class WatchComposition { else { preconditionFailure("Currency Watch requires its App Group entitlement") } self.directory = directory conversion = ConversionStore(directory: directory) - rates = RateStore(directory: directory) - history = HistoryService(directory: directory) + rates = RateStore(directory: directory, policy: CurrencyRateConfiguration.policy) + history = HistoryService(directory: directory, policy: CurrencyRateConfiguration.policy) systemActions = SystemActionComposition(directory: directory) let actions = systemActions AppDependencyManager.shared.add(dependency: actions) @@ -102,7 +102,9 @@ final class WatchComposition { } var details: CurrencyDetailsDependencies { - .init(loadHistory: { [history] in await history.load(base: $0, quote: $1, range: $2) }) + .init( + supportsIntraday: { [history] in history.supportsIntraday(base: $0, quote: $1) }, + loadHistory: { [history] in await history.load(base: $0, quote: $1, range: $2) }) } } @@ -112,8 +114,8 @@ struct SystemActionComposition: Sendable { init(directory: URL) { let conversion = ConversionStore(directory: directory) - let rates = RateStore(directory: directory) - let service = RateService() + let rates = RateStore(directory: directory, policy: CurrencyRateConfiguration.policy) + let service = RateService(policy: CurrencyRateConfiguration.policy) readSelected = { [conversion] in let input = conversion.input() return [input.source] + input.manualDestinations @@ -128,6 +130,6 @@ struct SystemActionComposition: Sendable { try Task.checkCancellation() WidgetCenter.shared.reloadAllTimelines() return result - })) + }, refreshInterval: CurrencyRateConfiguration.policy.refreshInterval)) } } diff --git a/Apps/CurrencyWatch/README.md b/Apps/CurrencyWatch/README.md index 526b607..d9c7ed0 100644 --- a/Apps/CurrencyWatch/README.md +++ b/Apps/CurrencyWatch/README.md @@ -29,6 +29,17 @@ xcrun simctl install "$WATCH_SIMULATOR_UDID" "$WATCH_APP" Confirm `TeamIdentifier` matches the project team. Verify a custom pair such as GBP/CZK through the native widget editor, installed Smart Stack and corner complication, rather than accepting a default EUR/USD rendering as configuration proof. Board/Favorite Pairs must also retain configured amount/targets; Cash must use its configured currencies; History must retain its pair/range. Snapshots use configured cached data, while timeline refreshes fetch live data. +## Provider mode acceptance + +Use one explicitly owned Watch simulator, separate enabled/disabled builds and device-targeted controls. `CURRENCY_DISABLE_COINBASE` must reach CurrencyApplication and both Watch executables; changing an assertion environment variable does not change the binary. Keep the independent Watch networking path active. + +1. Install the enabled CurrencyWatch app. Launch from a fresh container, add BTC to My Currencies and open Bitcoin details. The History range picker must offer One day alongside the daily ranges. Select it and verify a rendered BTC/USD chart or an explicit network issue; availability alone does not prove downloaded candles. +2. Return, terminate and relaunch the app. Verify the selected pair and available One day range persist. Capture native accessibility state and screenshots with the owned UDID. +3. Terminate and replace the app with the disabled build while retaining its container. Open the same Bitcoin details. One day must be absent; daily ranges remain available. A saved Coinbase overlay must be excluded in favor of its Fawaz daily fallback, and an old hourly cache must not appear as a daily chart. +4. Repeat a warm launch and verify the disabled capability and saved pair. If checking deterministic amounts, seed a coherent real `rates.json` with distinct Coinbase/Fawaz values and both daily fallbacks before launch; record whether direct network refresh replaced it. Seed chart data only in the matching `history---BTC-USD-.json` namespace. + +Use `serve-sim` with the Watch UDID for native controls and screenshots; the iPhone E2E runner does not support this surface. If the Watch accessibility tree contains only an application root, stop automated picker navigation and record that gap; `simctl openurl` may also be unsupported. Save local evidence outside tracked documentation. A compiled Watch target, fixture inspection or failed driver connection does not count as a completed native journey. Spoken Siri, physical companion transport and installed complication configuration remain separate acceptance checks. + ## Metal measurement acceptance The iPhone E2E driver does not cover Watch surfaces. Keep this native Watch journey for WATCH-14 on an explicitly owned simulator; use the selected CompanionSync, Home editing, Watch route and shared metal/widget tests for numerical and synchronization regressions. diff --git a/Documentation/Development.md b/Documentation/Development.md index 4d9d8e7..49e4f4e 100644 --- a/Documentation/Development.md +++ b/Documentation/Development.md @@ -20,6 +20,14 @@ handshake. The native suite starts from a stopped app and runs serially on the o For command-line Xcode builds, use `set -o pipefail` and pipe combined output through `xcbeautify`. Use a separate simulator and derived-data directory for parallel work. +## Rate provider configuration + +`CurrencyRateConfiguration` supplies one internal build flag to the phone app, widgets, independent Watch app and Watch widgets. Coinbase is enabled by default. Build every executable with `OTHER_SWIFT_FLAGS='$(inherited) -D CURRENCY_DISABLE_COINBASE'` to disable it; do not mix configurations across embedded targets. + +Both modes use Fawaz for daily rates and daily history. Enabled Coinbase adds current crypto rates and supported 24-hour hourly charts. Disabled builds hide 1D, normalize saved 1D widgets to 1M, exclude incompatible cached data, and reduce automatic rate attempts from 30 minutes to six hours. Foreground polling is one minute when enabled and six hours when disabled; WidgetKit controls actual timeline delivery. Explicit refresh remains available. + +Fawaz history starts on 2 March 2024. All uses month-end/current-month reference samples; the other daily ranges use available dated publications. Neither mode fabricates missing history or labels daily reference samples as exchange closes. + ## Dependency cache Tuist defines repository modules as native targets in a single Currency project. Default generation keeps their source visible and substitutes only external dependencies with cached binaries. `Tuist/Package.swift` is reserved for external dependencies. Normal development does not require authentication or custom build scripts. diff --git a/Documentation/FeatureMap.md b/Documentation/FeatureMap.md index 613d1e8..fcc3955 100644 --- a/Documentation/FeatureMap.md +++ b/Documentation/FeatureMap.md @@ -35,10 +35,10 @@ Owners: ExchangeRates domain, [CurrencyDetails](../Features/CurrencyDetails/Sour | ID | Capability / entry | Observable outcome | | --- | --- | --- | -| RATE-01 | Initial bootstrap, foreground refresh, explicit rate refresh | Available rates update conversion; loading, provider failures, missing rates and cached fallback are represented without fabricating a successful fetch. | +| RATE-01 | Initial bootstrap, foreground refresh, explicit rate refresh | Fawaz supplies daily fiat, crypto and metal rates. The internal Coinbase build flag is enabled by default and adds current crypto rates with more frequent refresh attempts. Disabling it excludes Coinbase quotes from cached reads across the phone, widgets, Watch and system actions; loading, failures and daily fallback 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 | Available 1D, 1W, 1M, 3M, 1Y, YTD and All ranges load the matching series. Unsupported pairs, missing intraday data, unavailable history and cached series have explicit states. | +| HIST-02 | History range controls and chart | Fawaz daily history supports 1W, 1M, 3M, 1Y, YTD and All, limited to its archive from March 2024; All uses monthly reference samples. Enabled Coinbase adds 1D completed hourly observations for supported crypto pairs. Disabled mode hides 1D and rejects incompatible cached history. Missing observations and cached fallback remain explicit. | | HIST-03 | Rate source disclosure | Expanded content explains current/historical sources and reference-rate gaps; 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. | @@ -74,7 +74,7 @@ Owners: [widget guide](../Features/WidgetOnboarding/Sources/WidgetOnboardingUI/O | WID-05 | Cash: medium | Configured base/comparison and cash presets/keypad produce the corresponding amounts, including Local setup/unavailable states where applicable. | | WID-06 | Pocket Rate: small | Show a convenient reference amount and conversion for the configured pair. | | WID-07 | Mental Math: small | Show the configured pair’s approximate mental-conversion rule and its accuracy information. | -| WID-08 | History: small/medium | Configured base/quote/range show history and its current, cached or unavailable state. | +| WID-08 | History: small/medium | Configured base/quote/range show history and its current, cached or unavailable state. Disabling Coinbase turns a saved 1D configuration into 1M; 1D requests hourly refresh and other ranges daily refresh. | | WID-09 | Currency Icon: accessory circular Lock Screen widget | The configured currency symbol appears in the system accessory presentation. | | WID-10 | System gallery, Edit Widget, taps, removal and reload | Installation/configuration produce the selected widget; intents execute in the real extension; removal works and shared data remains consistent. | @@ -94,22 +94,22 @@ Owners: [SystemActions](../Apps/Currency/App/Sources/SystemActions), [CurrencyRo | SYS-06 | `currency://converter?v=1`, `currency://local-currency` | Open the converter or location setup respectively; malformed/unsupported versioned content URLs are rejected. | | SYS-07 | Published App Shortcut phrases and system discovery | Currency actions can be discovered/configured/run through the actual system surface. Siri/device behavior is distinct from native intent contract execution. | -## Apple Watch (pending PR #75) +## Apple Watch -These capabilities are implemented in [PR #75](https://github.com/DimaMishchenko/currency/pull/75), targeting watchOS 26+, and are not yet part of `main`. Ownership follows the Watch application composition, HomeWatchUI, CurrencyDetailsWatchUI and CurrencyWatchWidgets, reusing the Conversion, ExchangeRates and Widgets domains. Preserve these IDs when the feature lands and remove this pending status. +These capabilities target watchOS 26+. Ownership follows the Watch application composition, HomeWatchUI, CurrencyDetailsWatchUI and CurrencyWatchWidgets, reusing the Conversion, ExchangeRates and Widgets domains. | ID | Capability / entry | Observable outcome | | --- | --- | --- | | WATCH-01 | Open the independent Watch app | The source amount and first conversion appear together on the small Watch; the app fetches rates directly and remains useful without an iPhone connection. | | WATCH-02 | Tap source amount → decimal keypad | Digits, locale decimal separator and deletion edit a local draft; Done commits the amount and updates conversions, Cancel keeps the previous amount. The keypad fits without scrolling; there are no in-app 1/10/100 presets. | | WATCH-03 | Source picker, Add currency and favorite row actions | Flag/icon currency choices update the source or destinations without duplicates. Favorite rows can become the base or be removed; selections and custom amount survive restart. | -| WATCH-04 | Favorite row → currency details/history | Show currency identity, inverse rate, selected history range/chart and the reference-rate note. No provider/source sections, saved-rate label or standalone swap action are shown in the app. Metal conversion rates and history use the shared troy-ounce/gram/kilogram preference. | +| WATCH-04 | Favorite row → currency details/history | Show currency identity, inverse rate, selected history range/chart and the reference-rate note. The shared Coinbase flag controls 1D availability; daily ranges use Fawaz. No provider/source sections, saved-rate label or standalone swap action are shown in the app. Metal conversion rates and history use the shared troy-ounce/gram/kilogram preference. | | WATCH-05 | Refresh rates, offline and unavailable history | Explicit refresh preserves cached rates/history; loading, stale, missing and failed-refresh states remain distinguishable without claiming a successful fetch. | | WATCH-06 | iPhone preferred-currency synchronization | Preferred currencies and metal measurement synchronize opportunistically; Watch base and physical metal amount remain local, and connectivity is not required for direct fetch/cache use. | | WATCH-07 | Pocket Rate in Smart Stack | Configure source/comparison currencies, then use 1/10/100 and swap buttons to update the displayed pair/conversion. Setup has no amount field; taps on conversion content open the corresponding Watch route. | | WATCH-14 | Metal measurement from iPhone settings | Follow the iPhone app’s troy-ounce/gram/kilogram setting through companion preference sync, without a Watch picker; conversions, details, history, widgets and Siri show that unit, and source-metal quantity is preserved. Cash keeps the same physical gram presets as the iPhone widget and translates routes into the selected app unit. | | WATCH-08 | Currency Board and Favorite Pairs | Configure base, destinations and amount. Board aligns flag/code left and value right, with a bold source row; Favorite Pairs centers the flagged source above three flagged destination links. | -| WATCH-09 | History widget | Configured pair/range displays trend, period/change, current value and date, with explicit cached/unavailable behavior. The chart uses the space previously occupied by the daily-reference footer. | +| WATCH-09 | History widget | Configured pair/range displays trend, period/change, current value and date, with explicit cached/unavailable behavior. The shared Coinbase flag controls 1D availability; saved 1D falls back to 1M when disabled. The chart uses the space previously occupied by the daily-reference footer. | | WATCH-10 | Know Your Cash widget | Configure currencies only; banknote or metal-weight buttons choose the amount and update conversion with the correct units. Opening the app preserves the displayed conversion semantics. | | WATCH-11 | Mental Math widget | Display a simple rounded conversion rule for the configured pair without an error percentage. | | WATCH-12 | Watch-face complications | Pocket Rate supports rectangular, circular, inline and corner families. Cash, Board, History and Mental Math support the first three; Favorite Pairs is rectangular only. Corner selection offers only Pocket Rate, with both compact values on the outer curve, one arrow, names/units inside and full values in accessibility. Verify slot fitting, including bottom-right 100-unit conversions. | diff --git a/Documentation/PrivacyPolicy.md b/Documentation/PrivacyPolicy.md index 4d9c009..5709e7e 100644 --- a/Documentation/PrivacyPolicy.md +++ b/Documentation/PrivacyPolicy.md @@ -1,6 +1,6 @@ # Privacy Policy -Effective 7 October 2026. +Effective 9 October 2026. Dmytro Mishchenko, an individual, operates Currency. The app has no accounts, advertising or analytics. We do not sell personal data or track you across apps. @@ -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.** Currency contacts Frankfurter, the European Central Bank, Fawaz feed hosts (including jsDelivr/Cloudflare) and Coinbase for rates and charts. They receive your IP address and request metadata, including requested currencies and history dates, but not entered amounts or saved location. Their processing follows their policies: [Frankfurter](https://frankfurter.dev/), [ECB](https://www.ecb.europa.eu/services/data-protection/privacy-statements/website/html/index.en.html), [jsDelivr](https://www.jsdelivr.com/terms/privacy-policy-jsdelivr-net), [Cloudflare](https://www.cloudflare.com/privacypolicy/), [Coinbase](https://www.coinbase.com/legal/privacy). Apple services follow [Apple’s privacy policy](https://www.apple.com/legal/privacy/). +**Network requests.** Currency contacts Fawaz feed hosts (including jsDelivr/Cloudflare) for daily rates and history. When the Coinbase integration is enabled, it also contacts Coinbase for current crypto rates and 24-hour charts. They receive your IP address and request metadata, including requested currencies and history dates, but not entered amounts or saved location. Their processing follows their policies: [jsDelivr](https://www.jsdelivr.com/terms/privacy-policy-jsdelivr-net), [Cloudflare](https://www.cloudflare.com/privacypolicy/), [Coinbase](https://www.coinbase.com/legal/privacy). Apple services follow [Apple’s privacy policy](https://www.apple.com/legal/privacy/). **Support and links.** External websites apply their own policies. GitHub issue reports are public; avoid personal information. 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/ExchangeRates.docc/ExchangeRates.md b/Domain/ExchangeRates/Sources/ExchangeRates/ExchangeRates.docc/ExchangeRates.md index 222a88c..895a785 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/ExchangeRates.docc/ExchangeRates.md +++ b/Domain/ExchangeRates/Sources/ExchangeRates/ExchangeRates.docc/ExchangeRates.md @@ -17,9 +17,11 @@ The package does not know about UI, App Groups, widgets, or polling schedules. H - ``CurrencyCatalog`` - ``CurrencyCode`` - ``RateCache`` +- ``RateStore`` ### Refreshing quotes +- ``RateProviderPolicy`` - ``RateService`` - ``RefreshResult`` - ``RefreshWarning`` diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/FawazHistory.swift b/Domain/ExchangeRates/Sources/ExchangeRates/FawazHistory.swift new file mode 100644 index 0000000..605c910 --- /dev/null +++ b/Domain/ExchangeRates/Sources/ExchangeRates/FawazHistory.swift @@ -0,0 +1,93 @@ +import Foundation + +extension HistoryService { + static func dailyDates(start: Date, now: Date, monthly: Bool) -> [Date] { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + guard let archiveStart = calendar.date(from: DateComponents(year: 2024, month: 3, day: 2)) + else { return [] } + let end = calendar.startOfDay(for: now) + var cursor = max(calendar.startOfDay(for: start), archiveStart) + var dates: [Date] = [] + while cursor <= end { + if monthly { + guard let month = calendar.dateInterval(of: .month, for: cursor) else { break } + dates.append(min(month.end.addingTimeInterval(-86400), end)) + cursor = month.end + } else { + dates.append(cursor) + cursor = cursor.addingTimeInterval(86400) + } + } + return dates + } + + static func fetchDailyHistory( + client: any HTTPClient, directory: URL, base: String, quote: String, + start: Date, now: Date, monthly: Bool + ) async throws -> [HistoryPoint] { + let dates = dailyDates(start: start, now: now, monthly: monthly) + return try await withThrowingTaskGroup(of: HistoryPoint?.self) { group in + func enqueue(_ date: Date) { + group.addTask { + try Task.checkCancellation() + let day = String(date.ISO8601Format().prefix(10)) + let quotes = try await dailySnapshot(client: client, directory: directory, day: day) + guard let a = quotes[base]?.value, let b = quotes[quote]?.value else { return nil } + let value = NSDecimalNumber(decimal: b / a).doubleValue + guard value.isFinite && value > 0 else { throw RateError.invalidData } + return HistoryPoint(date: date, value: value) + } + } + var next = 0 + while next < min(3, dates.count) { + enqueue(dates[next]) + next += 1 + } + var points: [HistoryPoint] = [] + while let point = try await group.next() { + if let point { points.append(point) } + try Task.checkCancellation() + if next < dates.count { + enqueue(dates[next]) + next += 1 + } + } + return points.sorted { $0.date < $1.date } + } + } + + private static func dailySnapshot( + client: any HTTPClient, directory: URL, day: String + ) async throws -> [String: ExchangeRate] { + let archive = directory.appendingPathComponent("fawaz-daily-v1", isDirectory: true) + let file = archive.appendingPathComponent("\(day).json") + func decode(_ data: Data) throws -> [String: ExchangeRate] { + let quotes = try FawazProvider.decode(data) + guard quotes.values.allSatisfy({ $0.published == day }) else { throw RateError.invalidData } + return quotes + } + if let saved = try? Data(contentsOf: file), let quotes = try? decode(saved) { return quotes } + var missingEndpoints = 0 + for endpoint in [ + "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@\(day)/v1/currencies/eur.min.json", + "https://\(day).currency-api.pages.dev/v1/currencies/eur.min.json" + ] { + try Task.checkCancellation() + guard let url = URL(string: endpoint) else { throw RateError.invalidData } + do { + let data = try await client.get(url) + let quotes = try decode(data) + try Task.checkCancellation() + try? FileManager.default.createDirectory(at: archive, withIntermediateDirectories: true) + try? data.write(to: file, options: .atomic) + return quotes + } catch { + if case RateError.http(404) = error { missingEndpoints += 1 } + try Task.checkCancellation() + } + } + if missingEndpoints == 2 { return [:] } + throw RateError.unavailable + } +} diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryDecoding.swift b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryDecoding.swift index c6f915d..63941c8 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryDecoding.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryDecoding.swift @@ -15,31 +15,6 @@ extension HistoryService { return windows } - static func monthlyCloses(_ points: [HistoryPoint]) -> [HistoryPoint] { - var calendar = Calendar(identifier: .gregorian) - - calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .current - var months: [Date: HistoryPoint] = [:] - for point in points.sorted(by: { $0.date < $1.date }) { - if let month = calendar.dateInterval(of: .month, for: point.date)?.start { - months[month] = point - } - } - return months.values.sorted { $0.date < $1.date } - } - - static func convertDailyCloses( - _ candles: [HistoryPoint], rates: [HistoryPoint] - ) throws -> [HistoryPoint] { - let ratesByDate = Dictionary(uniqueKeysWithValues: rates.map { ($0.date, $0.value) }) - return try candles.compactMap { candle in - guard let rate = ratesByDate[candle.date] else { return nil } - let value = candle.value * rate - guard value.isFinite && value > 0 else { throw RateError.invalidData } - return HistoryPoint(date: candle.date, value: value) - } - } - static func divideAlignedCloses( _ base: [HistoryPoint], by quote: [HistoryPoint] ) throws -> [HistoryPoint] { @@ -52,21 +27,6 @@ extension HistoryService { } } - static func decodeFiat(_ data: Data, base: String, quote: String) throws -> [HistoryPoint] { - let rows = try JSONDecoder().decode([FrankfurterRow].self, from: data) - var values: [Date: Double] = [:] - for row in rows { - guard row.base == base, row.quote == quote, validDate(row.date), row.rate > 0, - !row.rate.isNaN, - let date = ISO8601DateFormatter().date(from: row.date + "T00:00:00Z") - else { throw RateError.invalidData } - let value = NSDecimalNumber(decimal: row.rate).doubleValue - guard value.isFinite else { throw RateError.invalidData } - values[date] = value - } - return values.map { HistoryPoint(date: $0.key, value: $0.value) }.sorted { $0.date < $1.date } - } - static func decodeCandles( _ data: Data, start: Date, end: Date, granularity: CandleGranularity = .day ) throws -> [HistoryPoint] { diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift index 185e17f..d2ee7df 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift @@ -29,54 +29,32 @@ public actor HistoryService { default: nil } } - /// Creates a history service with a cache directory and HTTP client. - public init(directory: URL, client: any HTTPClient = NetworkClient(timeout: 30)) { + /// The provider capabilities configured by the host. + public nonisolated let policy: RateProviderPolicy + + /// Whether the configured providers can supply hourly observations for this pair. + public nonisolated func supportsIntraday(base: String, quote: String) -> Bool { + policy == .coinbaseEnhanced && Self.supportsIntraday(base: base, quote: quote) + } + + /// Creates a history service, defaulting to Fawaz daily history. + public init( + directory: URL, client: any HTTPClient = NetworkClient(timeout: 30), + policy: RateProviderPolicy = .daily + ) { self.directory = directory self.client = client + self.policy = policy } - /// Loads a series, reusing a fresh cache or returning saved history when a request fails. - /// - /// Pairs without crypto use Frankfurter reference rates. Supported crypto/fiat `.day` pairs - /// use direct, completed Coinbase hourly candles. Crypto/crypto divides matching quote closes, - /// using USD or, for intraday pairs without both USD markets, EUR. Longer crypto/fiat or - /// crypto/metal ranges multiply Coinbase USD closes by Frankfurter USD/quote references on - /// matching UTC dates. `.all` samples the joined history by month. Supported longer crypto - /// ranges append the canonical `.day` endpoint when its close is at least as recent as the last daily - /// close. The long-range cache retains its original daily or monthly observations. - /// Frankfurter-based intraday history is unavailable. Failed pagination saves no partial data. - /// - Parameters: - /// - base: Uppercase currency whose historical value is requested. - /// - quote: Uppercase denomination of the returned values. - /// - range: Requested historical interval. - /// - now: Evaluation time for date windows and cache freshness; injectable for tests. - /// - cacheLifetime: Optional long-range cache cadence; nil keeps the six-hour or daily - /// (`.all`) policy. Hourly history expires at the next UTC hour or sooner if requested. - /// - Returns: A series and an optional recoverable issue. Cancellation returns the saved - /// series if available; callers should check cancellation before presenting the result. + /// Loads Fawaz daily history, or completed Coinbase hourly candles for an enabled day chart. + /// All-history requests sample available daily publications at each month end, starting + /// with the Fawaz archive in March 2024. Failed requests return only compatible saved series. public func load( base: String, quote: String, range: HistoryRange, now: Date = .now, cacheLifetime: TimeInterval? = nil ) async -> HistoryResult { - let historical = await loadSeries( - base: base, quote: quote, range: range, now: now, cacheLifetime: cacheLifetime) - guard range != .day, Self.supportsIntraday(base: base, quote: quote), - !Task.isCancelled, let series = historical.series, let daily = series.points.last - else { return historical } - let intraday = await loadSeries( - base: base, quote: quote, range: .day, now: now, cacheLifetime: nil) - guard !Task.isCancelled, let hourly = intraday.series, hourly.fetchedAt <= now, - let latest = hourly.points.last, - latest.date.addingTimeInterval(3600) <= now, - latest.date.addingTimeInterval(3600) >= daily.date.addingTimeInterval(86400) - else { return historical } - let source = RateSource( - provider: series.source.provider, observation: series.source.observation, - timeZone: series.source.timeZone, latestObservation: .hourlyClose) - return HistoryResult( - series: HistorySeries( - points: series.points + [latest], source: source, fetchedAt: hourly.fetchedAt), - issue: historical.issue ?? intraday.issue) + await loadSeries(base: base, quote: quote, range: range, now: now, cacheLifetime: cacheLifetime) } private func loadSeries( @@ -86,21 +64,28 @@ public actor HistoryService { guard CurrencyCatalog.codes.contains(base), CurrencyCatalog.codes.contains(quote), base != quote else { return HistoryResult(series: nil, issue: .unsupportedPair) } - let isCrypto = CurrencyCatalog.crypto.contains(base) - let quoteIsCrypto = CurrencyCatalog.crypto.contains(quote) - let needsReference = isCrypto && quote != "USD" && !quoteIsCrypto && range != .day let intradayQuote = Self.intradayCandleQuote(base: base, quote: quote) - guard range != .day || intradayQuote != nil else { + guard range != .day || supportsIntraday(base: base, quote: quote) else { return HistoryResult(series: nil, issue: .intradayUnavailable) } var calendar = Calendar(identifier: .gregorian) calendar.timeZone = .gmt let year = calendar.component(.year, from: now) let interval = range == .yearToDate ? "\(range.rawValue)-\(year)" : "\(range.rawValue)" - let file = directory.appendingPathComponent("history-\(base)-\(quote)-\(interval).json") + let file = directory.appendingPathComponent( + "history-\(policy.rawValue)-\(range == .day ? "coinbase" : "fawaz")-\(base)-\(quote)-\(interval).json" + ) let cached = (try? Data(contentsOf: file)) .flatMap { try? JSONDecoder().decode(HistorySeries.self, from: $0) } .flatMap { series -> HistorySeries? in + let expected = range == .day ? RateProviderID.coinbase : .fawaz + let observation: RateObservation = + range == .day ? .hourlyClose : range == .all ? .monthlyReference : .dailyReference + guard series.source.provider == expected, series.source.observation == observation, + series.source.latestObservation == nil, series.points.count >= 2, + series.points.allSatisfy({ $0.value.isFinite && $0.value > 0 && $0.date <= now }), + series.fetchedAt <= now + else { return nil } guard range == .yearToDate else { return series } guard calendar.component(.year, from: series.fetchedAt) == year, series.points.allSatisfy({ calendar.component(.year, from: $0.date) == year }) @@ -119,7 +104,7 @@ public actor HistoryService { if range == .all { guard let beginning = calendar.date( - from: DateComponents(year: isCrypto ? 2009 : 1948, month: 1, day: 1)) + from: DateComponents(year: 2024, month: 3, day: 2)) else { throw RateError.invalidData } start = beginning } else if range == .year { @@ -135,54 +120,31 @@ public actor HistoryService { start = now.addingTimeInterval(-Double(range.rawValue) * 86400) } let points: [HistoryPoint] - if isCrypto { - let references = - needsReference - ? try await Self.fetchFiatHistory( - client: client, base: "USD", quote: quote, start: start, now: now) - : [] - let granularity: CandleGranularity = range == .day ? .hour : .day - let end = range == .day ? now : calendar.startOfDay(for: now) - guard let candleQuote = range == .day ? intradayQuote : "USD" else { - throw RateError.invalidData - } + if range == .day { + guard let candleQuote = intradayQuote else { throw RateError.invalidData } let candles = try await Self.fetchCryptoHistory( - client: client, code: base, quote: candleQuote, - start: range == .day ? start : calendar.startOfDay(for: start), end: end, - granularity: granularity) - let combined: [HistoryPoint] - if quoteIsCrypto { + client: client, code: base, quote: candleQuote, start: start, end: now, + granularity: .hour) + if CurrencyCatalog.crypto.contains(quote) { let quoteCandles = try await Self.fetchCryptoHistory( - client: client, code: quote, quote: candleQuote, - start: range == .day ? start : calendar.startOfDay(for: start), end: end, - granularity: granularity) - combined = try Self.divideAlignedCloses(candles, by: quoteCandles) - } else if needsReference { - combined = try Self.convertDailyCloses(candles, rates: references) + client: client, code: quote, quote: candleQuote, start: start, end: now, + granularity: .hour) + points = try Self.divideAlignedCloses(candles, by: quoteCandles) } else { - combined = candles + points = candles } - points = range == .all ? Self.monthlyCloses(combined) : combined } else { - let references = try await Self.fetchFiatHistory( - client: client, base: base, quote: quote, start: start, now: now, - monthly: range == .all) - points = - range == .yearToDate - ? references.filter { $0.date >= start && $0.date <= now } : references + points = try await Self.fetchDailyHistory( + client: client, directory: directory, base: base, quote: quote, + start: start, now: now, monthly: range == .all) } try Task.checkCancellation() guard points.count >= 2 else { throw RateError.unavailable } let source = RateSource( - provider: needsReference - ? .custom("Coinbase + Frankfurter") : isCrypto ? .coinbase : .frankfurter, - observation: needsReference - ? .unspecified - : isCrypto - ? (range == .all ? .monthlyLastClose : (range == .day ? .hourlyClose : .dailyClose)) - : (range == .all ? .monthlyReference : .dailyReference), - timeZone: isCrypto ? .gmt : nil - ) + provider: range == .day ? .coinbase : .fawaz, + observation: range == .day + ? .hourlyClose : range == .all ? .monthlyReference : .dailyReference, + timeZone: .gmt) let series = HistorySeries(points: points, source: source, fetchedAt: now) do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -221,22 +183,4 @@ public actor HistoryService { .sorted { $0.date < $1.date } } - private static func fetchFiatHistory( - client: any HTTPClient, base: String, quote: String, start: Date, now: Date, - monthly: Bool = false - ) async throws -> [HistoryPoint] { - guard var components = URLComponents(string: "https://api.frankfurter.dev/v2/rates") - else { throw RateError.invalidData } - components.queryItems = [ - URLQueryItem(name: "base", value: base), URLQueryItem(name: "quotes", value: quote), - URLQueryItem(name: "from", value: String(start.ISO8601Format().prefix(10))), - URLQueryItem(name: "to", value: String(now.ISO8601Format().prefix(10))) - ] - if monthly { - components.queryItems?.append(URLQueryItem(name: "group", value: "month")) - } - guard let url = components.url else { throw RateError.invalidData } - let data = try await client.get(url) - return try decodeFiat(data, base: base, quote: quote) - } } diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/README.md b/Domain/ExchangeRates/Sources/ExchangeRates/README.md index c23b5f3..518a831 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/README.md +++ b/Domain/ExchangeRates/Sources/ExchangeRates/README.md @@ -16,9 +16,13 @@ let month = await history.load(base: "EUR", quote: "USD", range: .month) Quotes are currency units per EUR. Conversion returns an unrounded `Decimal?`; callers choose display precision. Missing/invalid rates return nil. Snapshots preserve publication dates and intraday timestamps. Refresh warnings, history issues, and provenance are typed so hosts can choose their own presentation. `RateSource` contains a `RateProviderID`, `RateObservation`, optional time zone, and optional `latestObservation` for a mixed historical endpoint; switch on these values to select your own strings or localization. Custom providers use `.custom("Provider name")`. Older string-based cache provenance decodes automatically. -The default service combines Frankfurter with ECB fallback, Fawaz daily rates, and Coinbase crypto overlays. Supply `RateProvider` implementations to `RateService`, or an `HTTPClient` to individual providers and history, to customize data sources and test without network access. Each daily provider retains its own six-hour success window, so one failed provider is retried without refetching its successful peer. Cached primary fiat is preferred within that window even when Fawaz publishes a newer date. Fawaz fills missing assets and becomes the noncrypto fallback when expired primary data cannot be refreshed. Separate provider datasets preserve primary data and per-quote retrieval ages through concurrent cache merges. Coinbase is the primary crypto feed; missing live assets revert to preserved Fawaz daily quotes. Legacy caches without provider clocks remain readable. The package does not schedule background work. +`RateService` and `HistoryService` default to `RateProviderPolicy.daily`: Fawaz supplies current daily rates and daily history for every supported asset. Hosts can explicitly select `.coinbaseEnhanced` to add current Coinbase crypto overlays and supported 24-hour charts. Pass the same policy to `RateService`, `RateStore`, and `HistoryService`. `RateStore` with an explicit policy filters all persisted rate dictionaries before reads and merges so disabled providers cannot return through old snapshots. Its default nil policy preserves arbitrary injected providers for reusable consumers. Its refresh cadence is six hours with `.daily`, and thirty minutes with `.coinbaseEnhanced` or no policy. Missing live crypto rates revert to preserved Fawaz quotes with their original provenance. Explicitly injected `RateProvider` implementations remain available for tests and custom consumers. The package does not schedule background work. -Crypto history uses completed Coinbase candles. `HistoryService.supportsIntraday(base:quote:)` defines the hourly routes used by the widget and details chart. `.day` uses direct USD, EUR, or supported GBP markets; crypto/crypto pairs divide matching USD candles, using matching EUR candles when either USD market is absent. USDC has no direct USD hourly route. Longer crypto/fiat and crypto/metal ranges combine daily USD closes with same-date Frankfurter USD/quote references. Other pairs use Frankfurter history directly. `.all` samples the joined daily series by month. Every supported crypto range appends the canonical `.day` endpoint when its completed hourly close is at least as recent as the last daily close. The long-range cache retains daily or monthly history at its existing cadence; only the shared `.day` cache refreshes at UTC hour boundaries. Mixed series keep their original aggregation in `source.observation`, name the endpoint with `source.latestObservation`, and use the endpoint cache fetch time as `fetchedAt`. Fiat and crypto pairs without hourly markets retain their existing history and cache cadence. Missing dates are omitted, and failed requests retain saved history for the requested pair. An unavailable hourly endpoint leaves truthful daily history intact; a newer saved hourly endpoint remains available with the cached-series issue. +History ranges longer than one day use dated Fawaz EUR snapshots and divide the quote rate by the base rate. The dated archive begins March 2, 2024; `.all` samples each month end and the current partial month. These are daily or monthly reference observations, not market closing prices. Missing assets and dates absent from both CDN endpoints are omitted. Other request failures return only a compatible complete saved chart. Immutable dated payloads are shared across pairs; chart caches are separated by policy and provider and reject incompatible provenance. + +With Coinbase enabled, `history.supportsIntraday(base:quote:)` exposes the configured hourly capability. `.day` uses completed hourly candles from direct USD, EUR, or supported GBP markets; crypto/crypto pairs divide matching USD candles, using EUR when either USD market is absent. USDC has no direct USD hourly route. Hourly caches expire at UTC hour boundaries. Longer ranges remain Fawaz daily history even when Coinbase is enabled. + +The [Fawaz endpoint documentation](https://github.com/fawazahmed0/exchange-api#readme) defines dated CDN URLs and the Cloudflare fallback. Daily archive requests run with at most three concurrent transfers. The EUR and GBP market policy follows the [Coinbase product catalog](https://api.exchange.coinbase.com/products), verified on September 30, 2026. GBP excludes AVAX, ICP, XLM, and XRP from the app's crypto catalog. [Coinbase candles](https://docs.cdp.coinbase.com/api-reference/exchange-api/rest-api/products/get-product-candles) provide completed hourly observations without applying a daily FX rate to intraday prices. diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/RateProviderPolicy.swift b/Domain/ExchangeRates/Sources/ExchangeRates/RateProviderPolicy.swift new file mode 100644 index 0000000..53ea06a --- /dev/null +++ b/Domain/ExchangeRates/Sources/ExchangeRates/RateProviderPolicy.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Provider capabilities selected by the host application. +public enum RateProviderPolicy: String, Sendable { + /// Fawaz daily rates and daily historical observations for every supported asset. + case daily + /// Fawaz daily data with Coinbase current crypto rates and supported hourly charts. + case coinbaseEnhanced + + /// Minimum spacing between background rate refresh attempts. + public var refreshInterval: TimeInterval { self == .daily ? 21600 : 1800 } + + func filter(_ snapshot: RateSnapshot) -> RateSnapshot { + let dictionaries = [ + snapshot.quotes, snapshot.dailyQuotes ?? [:], snapshot.fiatQuotes ?? [:], + snapshot.supplementalQuotes ?? [:] + ] + let daily = dictionaries.reduce(into: [String: ExchangeRate]()) { result, quotes in + result = RateSnapshot.mergeDaily( + result, + quotes.filter { + $0.value.source.provider == .fawaz && !$0.value.isLive + }) + } + var effective = daily + if self == .coinbaseEnhanced { + for (code, quote) in snapshot.quotes + where CurrencyCatalog.crypto.contains(code) && quote.source.provider == .coinbase + && quote.isLive && quote.published >= (daily[code]?.published ?? "") + { + effective[code] = quote + } + } + let excluded = dictionaries.contains { quotes in + quotes.contains { code, quote in + quote.source.provider != .fawaz + && !(self == .coinbaseEnhanced && CurrencyCatalog.crypto.contains(code) + && quote.source.provider == .coinbase && quote.isLive) + } + } + let dailyTime = daily.isEmpty ? nil : snapshot.supplementalFetchedAt ?? snapshot.dailyFetchedAt + let allowedRetrieval = + ([dailyTime] + effective.values.flatMap { [$0.cachedAt, $0.overlayTimestamp] }) + .compactMap { $0 }.max() + let fetchedAt = excluded ? allowedRetrieval ?? .distantPast : snapshot.fetchedAt + return RateSnapshot( + quotes: effective, fetchedAt: effective.isEmpty ? .distantPast : fetchedAt, + dailyQuotes: daily, dailyFetchedAt: dailyTime, + checkedAt: excluded ? nil : snapshot.checkedAt, + supplementalFetchedAt: dailyTime, fiatQuotes: [:], supplementalQuotes: daily) + } +} diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/RateService.swift b/Domain/ExchangeRates/Sources/ExchangeRates/RateService.swift index 9ccb4ca..ec8de5f 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/RateService.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/RateService.swift @@ -15,16 +15,27 @@ public struct RefreshResult: Sendable { /// Coordinates rate providers and preserves usable cached quotes. public actor RateService { - private let fiat: any RateProvider + private let fiat: (any RateProvider)? private let daily: any RateProvider private let crypto: (any RateProvider)? + private let policy: RateProviderPolicy? private let sleep: @Sendable (Duration) async throws -> Void - /// Creates a rate service from its providers. + /// Creates the provider set selected by the application, defaulting to daily Fawaz data. + public init(policy: RateProviderPolicy = .daily, client: any HTTPClient = NetworkClient()) { + self.policy = policy + self.fiat = nil + self.daily = FawazProvider(client: client) + self.crypto = policy == .coinbaseEnhanced ? CoinbaseProvider(client: client) : nil + self.sleep = { try await Task.sleep(for: $0) } + } + + /// Creates a rate service from explicitly supplied providers. public init( - fiat: any RateProvider = FallbackRateProvider(), daily: any RateProvider = FawazProvider(), - crypto: (any RateProvider)? = CoinbaseProvider() + fiat: any RateProvider, daily: any RateProvider = FawazProvider(), + crypto: (any RateProvider)? = nil ) { + self.policy = nil self.fiat = fiat self.daily = daily self.crypto = crypto @@ -35,6 +46,7 @@ public actor RateService { fiat: any RateProvider, daily: any RateProvider, crypto: (any RateProvider)?, sleep: @escaping @Sendable (Duration) async throws -> Void ) { + self.policy = nil self.fiat = fiat self.daily = daily self.crypto = crypto @@ -57,12 +69,14 @@ public actor RateService { previous: RateSnapshot, force: Bool = false, now: Date = .now, providerTimeout: Duration? = nil ) async -> RefreshResult { + let previous = policy?.filter(previous) ?? previous guard !Task.isCancelled else { return RefreshResult(snapshot: previous, warning: nil) } let refreshFiat = - force - || !RateSnapshot.isFresh( - previous.fiatFetchedAt ?? (previous.fiatQuotes == nil ? previous.dailyFetchedAt : nil), - now: now) + fiat != nil + && (force + || !RateSnapshot.isFresh( + previous.fiatFetchedAt ?? (previous.fiatQuotes == nil ? previous.dailyFetchedAt : nil), + now: now)) let refreshSupplemental = force || !RateSnapshot.isFresh( @@ -100,14 +114,15 @@ public actor RateService { } let success = fiatQuotes != nil || supplementalQuotes != nil || live != nil let warning: RefreshWarning? = - refreshFiat && fiatQuotes == nil && refreshSupplemental && supplementalQuotes == nil + (fiat == nil || refreshFiat && fiatQuotes == nil) && refreshSupplemental + && supplementalQuotes == nil ? .dailyRatesUnavailable : crypto != nil && !CurrencyCatalog.crypto.isSubset(of: Set(live?.keys.map { $0 } ?? [])) ? .partialCryptoFallback : nil return RefreshResult( snapshot: RateSnapshot( quotes: quotes, fetchedAt: success ? now : previous.fetchedAt, dailyQuotes: dailyQuotes, - dailyFetchedAt: fiatQuotes != nil && supplementalQuotes != nil + dailyFetchedAt: (fiat == nil || fiatQuotes != nil) && supplementalQuotes != nil ? now : previous.dailyFetchedAt, checkedAt: now, fiatFetchedAt: fiatTime, supplementalFetchedAt: supplementalTime, fiatQuotes: primary, supplementalQuotes: supplemental), warning: warning) @@ -117,8 +132,9 @@ public actor RateService { /// own the foreground deadline, so an uncooperative provider cannot keep recovery pending. /// The stream preserves daily fallbacks and the primary provider's freshness precedence. public func bootstrap(previous: RateSnapshot, now: Date = .now) -> AsyncStream { + let previous = policy?.filter(previous) ?? previous let providers: [(Int, any RateProvider)] = - [(0, daily), (1, fiat)] + [(0, daily)] + (fiat.map { [(1, $0)] } ?? []) + (crypto.map { [(2, $0)] } ?? []) return AsyncStream { continuation in let worker = Task { @@ -193,7 +209,8 @@ public actor RateService { snapshot: RateSnapshot( quotes: effective, fetchedAt: succeeded ? now : previous.fetchedAt, dailyQuotes: dailyQuotes, - dailyFetchedAt: results[0]?.quotes != nil && results[1]?.quotes != nil + dailyFetchedAt: results[0]?.quotes != nil + && (fiat == nil || results[1]?.quotes != nil) ? now : previous.dailyFetchedAt, checkedAt: now, fiatFetchedAt: fiatTime, supplementalFetchedAt: supplementalTime, fiatQuotes: primary, supplementalQuotes: supplemental), diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/RateStore.swift b/Domain/ExchangeRates/Sources/ExchangeRates/RateStore.swift index 0eef962..477deb7 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/RateStore.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/RateStore.swift @@ -5,15 +5,20 @@ import Foundation public struct RateStore: Sendable { /// Directory shared by the hosts that exchange rate snapshots. public let directory: URL + private let policy: RateProviderPolicy? /// Creates a coordinated rate store in a host-supplied directory. - public init(directory: URL) { self.directory = directory } + /// A nil policy preserves injected provider provenance and a thirty-minute refresh cadence. + public init(directory: URL, policy: RateProviderPolicy? = nil) { + self.directory = directory + self.policy = policy + } private var rates: RateCache { RateCache(directory: directory) } /// Loads the latest valid snapshot from coordinated shared storage. - public func loadRates() -> RateSnapshot { rates.load() } + public func loadRates() -> RateSnapshot { filter(rates.load()) } /// Refreshes shared rates, preserving newer results committed by another host. /// /// Network requests run outside file coordination. Unless forced, attempts are spaced - /// thirty minutes apart. The final commit merges publication and observation metadata. + /// according to the selected provider cadence. The final commit merges publication and observation metadata. public func refreshRates( using service: RateService, force: Bool = false, now: Date = .now, providerTimeout: Duration? = nil @@ -21,7 +26,8 @@ public struct RateStore: Sendable { let previous = loadRates() guard force || now < (previous.checkedAt ?? .distantPast) - || now.timeIntervalSince(previous.checkedAt ?? .distantPast) >= 1800 + || now.timeIntervalSince(previous.checkedAt ?? .distantPast) + >= (policy?.refreshInterval ?? 1800) else { return RefreshResult(snapshot: previous, warning: nil) } @@ -30,7 +36,7 @@ public struct RateStore: Sendable { try Task.checkCancellation() return try coordinate("rates.json") { let current = loadRates() - let merged = current.merging(result.snapshot) + let merged = filter(current.merging(filter(result.snapshot))) try rates.save(merged) return RefreshResult( snapshot: merged, @@ -38,6 +44,10 @@ public struct RateStore: Sendable { } } + private func filter(_ snapshot: RateSnapshot) -> RateSnapshot { + policy?.filter(snapshot) ?? snapshot + } + func coordinate(_ filename: String, action: () throws -> Value) throws -> Value { try FileCoordination.write(at: directory.appendingPathComponent(filename), action) } @@ -45,10 +55,9 @@ public struct RateStore: Sendable { public func saveBootstrapRates(_ snapshot: RateSnapshot, now: Date = .now) throws -> RateSnapshot { try coordinate("rates.json") { - // The incoming snapshot wins equal-attempt ties: it includes later provider deliveries. let saved = loadRates() let current = saved.hasValidFetchTimestamp(now: now) ? saved : RateSnapshot() - let merged = snapshot.merging(current) + let merged = filter(filter(snapshot).merging(current)) try RateCache(directory: directory).save(merged) return merged } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryCrossRateTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryCrossRateTests.swift index 2e02848..5d23581 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryCrossRateTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryCrossRateTests.swift @@ -3,44 +3,14 @@ import Testing @testable import ExchangeRates -private struct CrossRateRow: Encodable { - let date: String - let base: String - let quote: String - let rate: Double -} - private actor CrossRateHTTP: HTTPClient { var urls: [URL] = [] - private var failFiat = false private var failEthereum = false - func failFiatRequests() { failFiat = true } func failEthereumRequests() { failEthereum = true } func get(_ url: URL) async throws -> Data { urls.append(url) - if url.host == "api.frankfurter.dev" { - if failFiat { throw RateError.http(503) } - let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] - let base = query.first { $0.name == "base" }?.value ?? "" - let quote = query.first { $0.name == "quotes" }?.value ?? "" - let values: (Double, Double) = - switch (base, quote) { - case ("USD", "EUR"): (0.8, 0.9) - case ("USD", "XAU"): (0.0005, 0.0004) - case ("XAU", "EUR"): (1_500, 1_600) - case ("XAU", "XAG"): (50, 51) - default: throw RateError.invalidData - } - return try JSONEncoder() - .encode([ - CrossRateRow(date: "2026-01-02", base: base, quote: quote, rate: values.0), - CrossRateRow(date: "2026-01-04", base: base, quote: quote, rate: values.1) - ]) - } - let day = 86_400.0 - let start = Date(timeIntervalSince1970: 1_767_312_000).timeIntervalSince1970 let isEthereum = url.path == "/products/ETH-USD/candles" if isEthereum && failEthereum { throw RateError.http(503) } let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] @@ -60,78 +30,21 @@ private actor CrossRateHTTP: HTTPClient { [start + 7_200, 1, 200, 1, isEthereum ? 20 : 110, 1] ]) } - if isEthereum { - return try JSONEncoder() - .encode([ - [start, 1, 50, 1, 10, 1], [start + 2 * day, 1, 50, 1, 30, 1] - ]) - } - return try JSONEncoder() - .encode([ - [start, 1, 200, 1, 100, 1], - [start + day, 1, 200, 1, 110, 1], - [start + 2 * day, 1, 200, 1, 120, 1] - ]) + throw RateError.invalidData } } @Suite struct HistoryCrossRateTests { private let now = Date(timeIntervalSince1970: 1_767_571_200) - @Test func cryptoFiatUsesCoinbaseUsdAndMatchingHistoricalFiatDates() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let client = CrossRateHTTP() - let service = HistoryService(directory: directory, client: client) - let result = await service.load(base: "BTC", quote: "EUR", range: .month, now: now) - #expect(result.issue == nil) - #expect(result.series?.points.map(\.value) == [80, 108]) - #expect(result.series?.source.provider == .custom("Coinbase + Frankfurter")) - #expect(result.series?.source.observation == .unspecified) - let urls = await client.urls - #expect(urls.count == 3) - #expect(urls.contains { $0.path == "/products/BTC-USD/candles" }) - #expect(urls.contains { $0.path == "/products/BTC-EUR/candles" }) - #expect(result.series?.source.latestObservation == nil) - let fiatURL = try #require(urls.first { $0.host == "api.frankfurter.dev" }) - let query = try #require( - URLComponents(url: fiatURL, resolvingAgainstBaseURL: false)?.queryItems) - #expect(query.contains(URLQueryItem(name: "base", value: "USD"))) - #expect(query.contains(URLQueryItem(name: "quotes", value: "EUR"))) - #expect(!query.contains(where: { $0.name == "group" })) - let cached = await service.load(base: "BTC", quote: "EUR", range: .month, now: now) - #expect(cached.series?.points == result.series?.points) - #expect(await client.urls.count == 3) - } - - @Test func cryptoCryptoDividesOnlyMatchingDailyCloses() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let client = CrossRateHTTP() - let result = await HistoryService(directory: directory, client: client) - .load(base: "BTC", quote: "ETH", range: .month, now: now) - #expect(result.issue == nil) - #expect(result.series?.points.map(\.value) == [10, 4]) - #expect(result.series?.source.provider == .coinbase) - let urls = await client.urls - #expect(urls.count == 4) - #expect(urls.contains { $0.path == "/products/BTC-USD/candles" }) - #expect(urls.contains { $0.path == "/products/ETH-USD/candles" }) - #expect(result.series?.source.latestObservation == nil) - #expect( - urls.filter { - URLComponents(url: $0, resolvingAgainstBaseURL: false)?.queryItems? - .contains(URLQueryItem(name: "granularity", value: "3600")) == true - } - .count == 2) - } - @Test func cryptoCryptoDividesMatchingHourlyClosesForOneDay() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let client = CrossRateHTTP() - let result = await HistoryService(directory: directory, client: client) - .load(base: "BTC", quote: "ETH", range: .day, now: now) + let result = await HistoryService( + directory: directory, client: client, policy: .coinbaseEnhanced + ) + .load(base: "BTC", quote: "ETH", range: .day, now: now) #expect(result.issue == nil) #expect(result.series?.points.map(\.value) == [10, 5.5]) #expect(result.series?.source.observation == .hourlyClose) @@ -146,20 +59,6 @@ private actor CrossRateHTTP: HTTPClient { }) } - @Test func failedCryptoQuoteLegNeverSavesPartialSeries() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let client = CrossRateHTTP() - await client.failEthereumRequests() - let result = await HistoryService(directory: directory, client: client) - .load(base: "BTC", quote: "ETH", range: .month, now: now) - #expect(result.series == nil) - #expect(result.issue == .unavailable) - #expect( - !FileManager.default.fileExists( - atPath: directory.appendingPathComponent("history-BTC-ETH-30.json").path)) - } - @Test(arguments: [("USDC", "BTC"), ("BTC", "USDC")]) func cryptoWithoutBothDollarMarketsJoinsMatchingHourlyEuroCloses( base: String, quote: String @@ -168,8 +67,10 @@ private actor CrossRateHTTP: HTTPClient { defer { try? FileManager.default.removeItem(at: directory) } let client = CrossRateHTTP() #expect(HistoryService.supportsIntraday(base: base, quote: quote)) - let result = await HistoryService(directory: directory, client: client) - .load(base: base, quote: quote, range: .day, now: now) + let result = await HistoryService( + directory: directory, client: client, policy: .coinbaseEnhanced + ) + .load(base: base, quote: quote, range: .day, now: now) #expect(result.issue == nil) let values = try #require(result.series?.points.map(\.value)) #expect(values.count == 2) @@ -187,81 +88,31 @@ private actor CrossRateHTTP: HTTPClient { }) } - @Test func cryptoMetalUsesHistoricalUsdMetalReferences() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let result = await HistoryService(directory: directory, client: CrossRateHTTP()) - .load(base: "BTC", quote: "XAU", range: .month, now: now) - #expect(result.issue == nil) - let values = try #require(result.series?.points.map(\.value)) - #expect(values.count == 2) - #expect(abs(values[0] - 0.05) < 0.0000001) - #expect(abs(values[1] - 0.048) < 0.0000001) - } - - @Test func metalFiatUsesFrankfurterPairDirectly() async throws { + @Test func failedHourlyQuoteLegNeverSavesPartialSeries() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let client = CrossRateHTTP() - let result = await HistoryService(directory: directory, client: client) - .load(base: "XAU", quote: "EUR", range: .month, now: now) - #expect(result.issue == nil) - #expect(result.series?.points.map(\.value) == [1_500, 1_600]) - #expect(result.series?.source.provider == .frankfurter) - #expect(await client.urls.count == 1) - } - - @Test func metalMetalUsesFrankfurterPairDirectly() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let result = await HistoryService(directory: directory, client: CrossRateHTTP()) - .load(base: "XAU", quote: "XAG", range: .month, now: now) - #expect(result.issue == nil) - #expect(result.series?.points.map(\.value) == [50, 51]) - } - - @Test func monthlySamplingFollowsCrossRateJoin() throws { - let january = try #require(ISO8601DateFormatter().date(from: "2026-01-30T00:00:00Z")) - let next = january.addingTimeInterval(86_400) - let february = january.addingTimeInterval(2 * 86_400) - let candles = [ - HistoryPoint(date: january, value: 100), - HistoryPoint(date: next, value: 200), - HistoryPoint(date: february, value: 300) - ] - let rates = [ - HistoryPoint(date: january, value: 0.8), - HistoryPoint(date: february, value: 0.9) - ] - let joined = try HistoryService.convertDailyCloses(candles, rates: rates) - #expect(HistoryService.monthlyCloses(joined).map(\.value) == [80, 270]) + await client.failEthereumRequests() + let result = await HistoryService( + directory: directory, client: client, policy: .coinbaseEnhanced + ) + .load(base: "BTC", quote: "ETH", range: .day, now: now) + #expect(result.series == nil) + #expect(result.issue == .unavailable) + #expect(!FileManager.default.fileExists(atPath: directory.path)) } @Test func cryptoFiatWithoutHourlyMarketReturnsUnsupportedWithoutRequestOrCache() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let client = CrossRateHTTP() - let result = await HistoryService(directory: directory, client: client) - .load(base: "BTC", quote: "CZK", range: .day, now: now) + let result = await HistoryService( + directory: directory, client: client, policy: .coinbaseEnhanced + ) + .load(base: "BTC", quote: "CZK", range: .day, now: now) #expect(result.series == nil) #expect(result.issue == .intradayUnavailable) #expect(await client.urls.isEmpty) } - @Test func crossRateFailureUsesOnlyPreviousCompletePairCache() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let client = CrossRateHTTP() - let service = HistoryService(directory: directory, client: client) - let first = await service.load(base: "BTC", quote: "EUR", range: .month, now: now) - await client.failFiatRequests() - let expired = await service.load( - base: "BTC", quote: "EUR", range: .month, now: now.addingTimeInterval(21_600)) - #expect(expired.series?.points == first.series?.points) - #expect(expired.issue == .usingCachedSeries) - let file = directory.appendingPathComponent("history-BTC-EUR-30.json") - #expect( - try JSONDecoder().decode(HistorySeries.self, from: Data(contentsOf: file)).points - == first.series?.points) - } } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryFailureTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryFailureTests.swift index dd092a4..67e26da 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryFailureTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryFailureTests.swift @@ -3,50 +3,78 @@ import Testing @testable import ExchangeRates -private actor FailingPageClient: HTTPClient { - private(set) var calls = 0 - func get(_ url: URL) async throws -> Data { - calls += 1 - guard calls == 1 else { throw RateError.http(429) } - let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems - let raw = try #require(query?.first(where: { $0.name == "start" })?.value) - let start = try #require(ISO8601DateFormatter().date(from: raw)).timeIntervalSince1970 - return try JSONEncoder() - .encode([ - [start + 86400, 1, 5, 2, 3, 1], [start + 172800, 1, 5, 2, 4, 1] - ]) +@Suite struct HistoryFailureTests { + @Test func failedDailyRequestPreservesOnlyCompatibleCompleteChart() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let client = DailyHistoryHTTP() + let now = try historyDate("2026-01-08T12:00:00Z") + let service = HistoryService(directory: directory, client: client) + let first = await service.load(base: "BTC", quote: "USD", range: .week, now: now) + let file = directory.appendingPathComponent("history-daily-fawaz-BTC-USD-7.json") + let original = try Data(contentsOf: file) + await client.failRequests() + let failed = await service.load( + base: "BTC", quote: "USD", range: .week, + now: now.addingTimeInterval(86400)) + #expect(failed.series?.points == first.series?.points) + #expect(failed.issue == .usingCachedSeries) + #expect(try Data(contentsOf: file) == original) } -} -@Suite struct HistoryFailureTests { - @Test func failedPaginationDoesNotSaveAnIncompleteChart() async throws { + @Test(arguments: [RateProviderID.coinbase, .frankfurter, .custom("Coinbase + Frankfurter")]) + func incompatibleCacheNeverReturnsFreshOrOffline(provider: RateProviderID) async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let client = FailingPageClient() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let now = try historyDate("2026-01-08T12:00:00Z") + let saved = HistorySeries( + points: [ + .init(date: now.addingTimeInterval(-86400), value: 2), .init(date: now, value: 3) + ], source: .init(provider: provider, observation: .dailyReference), fetchedAt: now) + let data = try JSONEncoder().encode(saved) + try data.write(to: directory.appendingPathComponent("history-daily-fawaz-BTC-USD-7.json")) + try data.write(to: directory.appendingPathComponent("history-BTC-USD-7.json")) + let client = DailyHistoryHTTP() + await client.failRequests() let result = await HistoryService(directory: directory, client: client) - .load(base: "BTC", quote: "USD", range: .year) - #expect(await client.calls == 2) + .load(base: "BTC", quote: "USD", range: .week, now: now) #expect(result.series == nil) #expect(result.issue == .unavailable) - #expect( - !FileManager.default.fileExists( - atPath: directory.appendingPathComponent("history-BTC-USD-365.json").path)) + #expect(await !client.urls.isEmpty) } - @Test func failedPaginationPreservesExistingChart() async throws { + @Test func policyModeSeparatesSeriesCachesAndDisabledHourlyNeverReadsSavedCoinbase() async throws + { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let file = directory.appendingPathComponent("history-BTC-USD-365.json") + let now = try historyDate("2026-01-08T12:00:00Z") + let client = DailyHistoryHTTP() + let enhanced = HistoryService(directory: directory, client: client, policy: .coinbaseEnhanced) + #expect(enhanced.supportsIntraday(base: "BTC", quote: "USD")) + _ = await enhanced.load(base: "BTC", quote: "USD", range: .week, now: now) + let daily = HistoryService(directory: directory, client: client) + #expect(!daily.supportsIntraday(base: "BTC", quote: "USD")) + _ = await daily.load(base: "BTC", quote: "USD", range: .week, now: now) + let before = await client.urls.count let saved = HistorySeries( - points: [HistoryPoint(date: .distantPast, value: 5)], - source: .init(provider: .custom("saved")), fetchedAt: .distantPast) - let original = try JSONEncoder().encode(saved) - try original.write(to: file) - let result = await HistoryService(directory: directory, client: FailingPageClient()) - .load(base: "BTC", quote: "USD", range: .year) - #expect(result.series?.points == saved.points) - #expect(result.issue == .usingCachedSeries) - #expect(try Data(contentsOf: file) == original) + points: [ + .init(date: now.addingTimeInterval(-7200), value: 2), + .init(date: now.addingTimeInterval(-3600), value: 3) + ], + source: .init(provider: .coinbase, observation: .hourlyClose, timeZone: .gmt), fetchedAt: now) + try JSONEncoder().encode(saved) + .write( + to: directory.appendingPathComponent("history-coinbaseEnhanced-coinbase-BTC-USD-1.json")) + let result = await daily.load(base: "BTC", quote: "USD", range: .day, now: now) + #expect(result.series == nil) + #expect(result.issue == .intradayUnavailable) + #expect(await client.urls.count == before) + for mode in ["daily", "coinbaseEnhanced"] { + #expect( + FileManager.default.fileExists( + atPath: + directory.appendingPathComponent("history-\(mode)-fawaz-BTC-USD-7.json").path)) + } } } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift index 0d1a1b2..dbdf63a 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift @@ -19,40 +19,6 @@ private actor IntervalHistoryHTTP: HTTPClient { } } -private actor EndpointHistoryHTTP: HTTPClient { - private(set) var urls: [URL] = [] - private var failing = false - private let delay: Duration - - init(delay: Duration = .zero) { self.delay = delay } - func failRequests() { failing = true } - func resumeRequests() { failing = false } - - func get(_ url: URL) async throws -> Data { - urls.append(url) - if failing { throw RateError.http(503) } - try await Task.sleep(for: delay) - let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems - guard query?.contains(URLQueryItem(name: "granularity", value: "3600")) == true, - let end = query?.first(where: { $0.name == "end" })?.value, - let date = ISO8601DateFormatter().date(from: end) - else { throw RateError.invalidData } - let completed = floor(date.timeIntervalSince1970 / 3600) * 3600 - let value: Double = - switch url.path { - case "/products/ETH-USD/candles": 2_000 - case "/products/USDC-EUR/candles": 0.9 - default: 84_608.1 - } - return try JSONEncoder() - .encode([ - [completed - 7200, 1, 100_000, 2, value - 0.1, 1], - [completed - 3600, 1, 100_000, 2, value, 1], - [completed, 1, 100_000, 2, 99_999, 1] - ]) - } -} - private func intervalDate(_ value: String) throws -> Date { try #require(ISO8601DateFormatter().date(from: value)) } @@ -67,212 +33,6 @@ private func candleData(_ dates: [Date]) throws -> Data { } @Suite struct HistoryIntervalTests { - @Test func hourlyCachedEndpointIsSharedAcrossLongRanges() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let now = try intervalDate("2026-09-30T12:37:00Z") - let latest = HistoryPoint(date: try intervalDate("2026-09-30T11:00:00Z"), value: 84_608.1) - let hourly = HistorySeries( - points: [HistoryPoint(date: now.addingTimeInterval(-7200), value: 84_000), latest], - source: .init(provider: .coinbase, observation: .hourlyClose, timeZone: .gmt), fetchedAt: now) - try JSONEncoder().encode(hourly) - .write(to: directory.appendingPathComponent("history-BTC-USD-1.json")) - let client = IntervalHistoryHTTP(data: Data()) - let service = HistoryService(directory: directory, client: client) - for range in HistoryRange.allCases where range != .day { - let daily = HistorySeries( - points: [ - HistoryPoint(date: try intervalDate("2026-09-01T00:00:00Z"), value: 80_000), - HistoryPoint(date: try intervalDate("2026-09-29T00:00:00Z"), value: 83_638.4) - ], - source: .init( - provider: .coinbase, - observation: range == .all ? .monthlyLastClose : .dailyClose, timeZone: .gmt), - fetchedAt: now) - let interval = range == .yearToDate ? "-1-2026" : String(range.rawValue) - let file = directory.appendingPathComponent("history-BTC-USD-\(interval).json") - let saved = try JSONEncoder().encode(daily) - try saved.write(to: file) - let result = await service.load(base: "BTC", quote: "USD", range: range, now: now) - #expect(result.issue == nil) - #expect(result.series?.points == daily.points + [latest]) - #expect(result.series?.source.observation == daily.source.observation) - #expect(result.series?.source.latestObservation == .hourlyClose) - #expect(try Data(contentsOf: file) == saved) - } - #expect(await client.urls.isEmpty) - } - - @Test(arguments: [ - ("BTC", "USD"), ("BTC", "EUR"), ("ETH", "GBP"), ("BTC", "ETH"), ("USDC", "BTC") - ]) - func longRangesRefreshOnlyTheirCanonicalHourlyEndpoint(base: String, quote: String) async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let now = try intervalDate("2026-09-30T12:37:00Z") - let daily = HistorySeries( - points: [ - HistoryPoint(date: try intervalDate("2026-09-28T00:00:00Z"), value: 80_000), - HistoryPoint(date: try intervalDate("2026-09-29T00:00:00Z"), value: 83_638.4) - ], source: .init(provider: .coinbase, observation: .dailyClose, timeZone: .gmt), - fetchedAt: now) - let saved = try JSONEncoder().encode(daily) - let client = EndpointHistoryHTTP() - let service = HistoryService(directory: directory, client: client) - let ranges = HistoryRange.allCases.filter { $0 != .day } - for range in ranges { - let interval = range == .yearToDate ? "-1-2026" : String(range.rawValue) - try saved.write( - to: directory.appendingPathComponent("history-\(base)-\(quote)-\(interval).json")) - let result = await service.load( - base: base, quote: quote, range: range, now: now, cacheLifetime: 86_400) - #expect(result.issue == nil) - #expect(result.series?.points.last?.date == (try intervalDate("2026-09-30T11:00:00Z"))) - #expect(result.series?.source.observation == .dailyClose) - #expect(result.series?.source.latestObservation == .hourlyClose) - #expect(result.series?.fetchedAt == now) - } - let day = await service.load(base: base, quote: quote, range: .day, now: now) - let expected = try #require(day.series?.points.last) - let legs = CurrencyCatalog.crypto.contains(quote) ? 2 : 1 - #expect(await client.urls.count == legs) - for range in ranges { - let interval = range == .yearToDate ? "-1-2026" : String(range.rawValue) - let file = directory.appendingPathComponent("history-\(base)-\(quote)-\(interval).json") - let result = await service.load( - base: base, quote: quote, range: range, now: now, cacheLifetime: 86_400) - #expect(result.series?.points == daily.points + [expected]) - #expect(try Data(contentsOf: file) == saved) - } - let nextHour = try intervalDate("2026-09-30T13:00:00Z") - let refreshed = await service.load( - base: base, quote: quote, range: .year, now: nextHour, cacheLifetime: 86_400) - #expect(refreshed.series?.points.last?.date == (try intervalDate("2026-09-30T12:00:00Z"))) - #expect(refreshed.series?.fetchedAt == nextHour) - #expect(await client.urls.count == 2 * legs) - #expect( - try Data(contentsOf: directory.appendingPathComponent("history-\(base)-\(quote)-365.json")) - == saved) - let urls = await client.urls - #expect(urls.allSatisfy { $0.host == "api.exchange.coinbase.com" }) - if !CurrencyCatalog.crypto.contains(quote) { - #expect(urls.allSatisfy { $0.path == "/products/\(base)-\(quote)/candles" }) - } - } - - @Test(arguments: [true, false]) - func hourlyUnavailableRetainsTruthfulDailyOrSavedEndpoint(hasHourlyCache: Bool) async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let now = try intervalDate("2026-09-30T12:37:00Z") - let daily = HistorySeries( - points: [ - HistoryPoint(date: now.addingTimeInterval(-3 * 86400), value: 80_000), - HistoryPoint(date: try intervalDate("2026-09-29T00:00:00Z"), value: 83_638.4) - ], source: .init(provider: .coinbase, observation: .dailyClose, timeZone: .gmt), - fetchedAt: now) - try JSONEncoder().encode(daily) - .write(to: directory.appendingPathComponent("history-BTC-USD-30.json")) - let latest = HistoryPoint(date: try intervalDate("2026-09-30T10:00:00Z"), value: 84_000) - let hourly = HistorySeries( - points: [HistoryPoint(date: latest.date.addingTimeInterval(-3600), value: 83_000), latest], - source: .init(provider: .coinbase, observation: .hourlyClose, timeZone: .gmt), - fetchedAt: try intervalDate("2026-09-30T11:37:00Z")) - if hasHourlyCache { - try JSONEncoder().encode(hourly) - .write(to: directory.appendingPathComponent("history-BTC-USD-1.json")) - } - let client = EndpointHistoryHTTP() - await client.failRequests() - let result = await HistoryService(directory: directory, client: client) - .load(base: "BTC", quote: "USD", range: .month, now: now) - #expect(result.series?.points == daily.points + (hasHourlyCache ? [latest] : [])) - #expect(result.issue == (hasHourlyCache ? .usingCachedSeries : nil)) - #expect(result.series?.source.latestObservation == (hasHourlyCache ? .hourlyClose : nil)) - #expect(result.series?.fetchedAt == (hasHourlyCache ? hourly.fetchedAt : daily.fetchedAt)) - #expect(await client.urls.count == 1) - } - - @Test(arguments: [false, true]) - func midnightPrefersEqualHourlyCloseButRejectsOlderSavedClose( - hourlyUnavailable: Bool - ) async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let now = try intervalDate("2026-09-30T00:05:00Z") - let daily = HistorySeries( - points: [ - HistoryPoint(date: try intervalDate("2026-09-28T00:00:00Z"), value: 80_000), - HistoryPoint(date: try intervalDate("2026-09-29T00:00:00Z"), value: 83_638.4) - ], source: .init(provider: .coinbase, observation: .dailyClose, timeZone: .gmt), - fetchedAt: now) - let saved = try JSONEncoder().encode(daily) - let file = directory.appendingPathComponent("history-BTC-USD-7.json") - try saved.write(to: file) - let hourly = HistorySeries( - points: [ - HistoryPoint(date: try intervalDate("2026-09-29T21:00:00Z"), value: 81_000), - HistoryPoint(date: try intervalDate("2026-09-29T22:00:00Z"), value: 82_000) - ], source: .init(provider: .coinbase, observation: .hourlyClose, timeZone: .gmt), - fetchedAt: try intervalDate("2026-09-29T23:45:00Z")) - try JSONEncoder().encode(hourly) - .write(to: directory.appendingPathComponent("history-BTC-USD-1.json")) - let client = EndpointHistoryHTTP() - if hourlyUnavailable { await client.failRequests() } - let service = HistoryService(directory: directory, client: client) - let atMidnight = await service.load(base: "BTC", quote: "USD", range: .week, now: now) - let equalClose = HistoryPoint(date: try intervalDate("2026-09-29T23:00:00Z"), value: 84_608.1) - #expect(atMidnight.series?.points == daily.points + (hourlyUnavailable ? [] : [equalClose])) - #expect(atMidnight.series?.source.latestObservation == (hourlyUnavailable ? nil : .hourlyClose)) - #expect(await client.urls.count == 1) - if !hourlyUnavailable { - let day = await service.load(base: "BTC", quote: "USD", range: .day, now: now) - #expect(atMidnight.series?.points.last == day.series?.points.last) - } - await client.resumeRequests() - let afterHour = await service.load( - base: "BTC", quote: "USD", range: .week, now: try intervalDate("2026-09-30T01:00:00Z")) - #expect(afterHour.series?.points.last?.date == (try intervalDate("2026-09-30T00:00:00Z"))) - #expect(afterHour.series?.source.latestObservation == .hourlyClose) - #expect(await client.urls.count == 2) - #expect(try Data(contentsOf: file) == saved) - } - - @Test func cancellingHourlyRefreshPreservesTheLongRangeCache() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let now = try intervalDate("2026-09-30T12:37:00Z") - let daily = HistorySeries( - points: [ - HistoryPoint(date: now.addingTimeInterval(-3 * 86400), value: 80_000), - HistoryPoint(date: try intervalDate("2026-09-29T00:00:00Z"), value: 83_638.4) - ], source: .init(provider: .coinbase, observation: .dailyClose, timeZone: .gmt), - fetchedAt: now) - let saved = try JSONEncoder().encode(daily) - let file = directory.appendingPathComponent("history-BTC-USD-365.json") - try saved.write(to: file) - let client = EndpointHistoryHTTP(delay: .seconds(10)) - let service = HistoryService(directory: directory, client: client) - let task = Task { await service.load(base: "BTC", quote: "USD", range: .year, now: now) } - for _ in 0..<300 { - if await !client.urls.isEmpty { break } - try await Task.sleep(for: .milliseconds(10)) - } - #expect(await client.urls.count == 1) - task.cancel() - let result = await task.value - #expect(result.series?.points == daily.points) - #expect(try Data(contentsOf: file) == saved) - #expect( - !FileManager.default.fileExists( - atPath: directory.appendingPathComponent("history-BTC-USD-1.json").path)) - } - @Test func hourlyCandlesExcludeOlderAndUnfinishedBuckets() throws { let start = try intervalDate("2026-09-26T12:37:00Z") let end = start.addingTimeInterval(86400) @@ -312,8 +72,10 @@ private func candleData(_ dates: [Date]) throws -> Data { ] .map(intervalDate) let client = IntervalHistoryHTTP(data: try candleData(dates)) - let result = await HistoryService(directory: directory, client: client) - .load(base: "BTC", quote: "USD", range: .day, now: now) + let result = await HistoryService( + directory: directory, client: client, policy: .coinbaseEnhanced + ) + .load(base: "BTC", quote: "USD", range: .day, now: now) #expect(result.issue == nil) #expect(result.series?.points.map(\.date) == [dates[1], dates[2]]) #expect( @@ -339,7 +101,7 @@ private func candleData(_ dates: [Date]) throws -> Data { ] .map(intervalDate) let client = IntervalHistoryHTTP(data: try candleData(dates)) - let service = HistoryService(directory: directory, client: client) + let service = HistoryService(directory: directory, client: client, policy: .coinbaseEnhanced) let result = await service.load(base: base, quote: quote, range: .day, now: now) #expect(result.issue == nil) #expect(result.series?.points.map(\.date) == [dates[1], dates[2]]) @@ -355,7 +117,8 @@ private func candleData(_ dates: [Date]) throws -> Data { #expect(query.contains(URLQueryItem(name: "granularity", value: "3600"))) #expect(query.contains(URLQueryItem(name: "start", value: "2026-09-26T12:37:00Z"))) #expect(query.contains(URLQueryItem(name: "end", value: "2026-09-27T12:37:00Z"))) - let file = directory.appendingPathComponent("history-\(base)-\(quote)-1.json") + let file = directory.appendingPathComponent( + "history-coinbaseEnhanced-coinbase-\(base)-\(quote)-1.json") #expect( try JSONDecoder().decode(HistorySeries.self, from: Data(contentsOf: file)).points == result.series?.points) @@ -381,8 +144,10 @@ private func candleData(_ dates: [Date]) throws -> Data { defer { try? FileManager.default.removeItem(at: directory) } let client = IntervalHistoryHTTP(data: Data()) #expect(!HistoryService.supportsIntraday(base: base, quote: quote)) - let result = await HistoryService(directory: directory, client: client) - .load(base: base, quote: quote, range: .day) + let result = await HistoryService( + directory: directory, client: client, policy: .coinbaseEnhanced + ) + .load(base: base, quote: quote, range: .day) #expect(result.series == nil) #expect(result.issue == (base == quote ? .unsupportedPair : .intradayUnavailable)) #expect(await client.urls.isEmpty) @@ -395,7 +160,7 @@ private func candleData(_ dates: [Date]) throws -> Data { let now = try intervalDate("2026-09-27T12:00:00Z") let client = IntervalHistoryHTTP( data: try candleData([now.addingTimeInterval(-7200), now.addingTimeInterval(-3600)])) - let service = HistoryService(directory: directory, client: client) + let service = HistoryService(directory: directory, client: client, policy: .coinbaseEnhanced) for quote in ["EUR", "GBP", "EUR", "GBP"] { #expect(await service.load(base: "BTC", quote: quote, range: .day, now: now).issue == nil) } @@ -409,7 +174,7 @@ private func candleData(_ dates: [Date]) throws -> Data { let now = try intervalDate("2026-09-27T12:00:00Z") let client = IntervalHistoryHTTP( data: try candleData([now.addingTimeInterval(-7200), now.addingTimeInterval(-3600)])) - let service = HistoryService(directory: directory, client: client) + let service = HistoryService(directory: directory, client: client, policy: .coinbaseEnhanced) let first = await service.load(base: "BTC", quote: "USD", range: .day, now: now) await client.failRequests() let fresh = await service.load( @@ -435,106 +200,13 @@ private func candleData(_ dates: [Date]) throws -> Data { try JSONEncoder().encode(saved) .write(to: directory.appendingPathComponent("history-USD-EUR-1.json")) let client = IntervalHistoryHTTP(data: Data()) - let result = await HistoryService(directory: directory, client: client) - .load(base: "USD", quote: "EUR", range: .day, now: now) + let result = await HistoryService( + directory: directory, client: client, policy: .coinbaseEnhanced + ) + .load(base: "USD", quote: "EUR", range: .day, now: now) #expect(result.series == nil) #expect(result.issue == .intradayUnavailable) #expect(await client.urls.isEmpty) } - @Test func cryptoYearToDateStartsAtJanuaryFirstAndIncludesLeapDay() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let now = try intervalDate("2024-03-02T12:30:00Z") - let dates = try [ - "2023-12-31T00:00:00Z", "2024-01-01T00:00:00Z", "2024-02-29T00:00:00Z", - "2024-03-01T00:00:00Z", "2024-03-02T00:00:00Z" - ] - .map(intervalDate) - let client = IntervalHistoryHTTP(data: try candleData(dates)) - let result = await HistoryService(directory: directory, client: client) - .load(base: "BTC", quote: "USD", range: .yearToDate, now: now) - #expect(result.issue == nil) - #expect(result.series?.points.map(\.date) == Array(dates[1...3])) - #expect(result.series?.source.observation == .dailyClose) - let url = try #require(await client.urls.first) - let query = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) - #expect(query.contains(URLQueryItem(name: "start", value: "2024-01-01T00:00:00Z"))) - #expect(query.contains(URLQueryItem(name: "end", value: "2024-03-02T00:00:00Z"))) - #expect(query.contains(URLQueryItem(name: "granularity", value: "86400"))) - } - - @Test func cryptoYearToDateOnJanuaryFirstHasNoCompletedDays() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let client = IntervalHistoryHTTP(data: Data()) - let now = try intervalDate("2026-01-01T23:59:00Z") - let result = await HistoryService(directory: directory, client: client) - .load(base: "BTC", quote: "USD", range: .yearToDate, now: now) - #expect(result.series == nil) - #expect(result.issue == .unavailable) - #expect(await client.urls.isEmpty) - } - - @Test func fiatYearToDateRequestsJanuaryFirstAndFiltersPreviousYear() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let now = try intervalDate("2024-03-01T12:00:00Z") - let client = IntervalHistoryHTTP( - data: Data( - #"[{"date":"2023-12-31","base":"EUR","quote":"USD","rate":1.0},{"date":"2024-01-01","base":"EUR","quote":"USD","rate":1.1},{"date":"2024-02-29","base":"EUR","quote":"USD","rate":1.2},{"date":"2024-03-02","base":"EUR","quote":"USD","rate":1.3}]"# - .utf8)) - let result = await HistoryService(directory: directory, client: client) - .load(base: "EUR", quote: "USD", range: .yearToDate, now: now) - #expect(result.issue == nil) - #expect(result.series?.points.map(\.value) == [1.1, 1.2]) - #expect(result.series?.source.observation == .dailyReference) - let url = try #require(await client.urls.first) - let query = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) - #expect(query.contains(URLQueryItem(name: "from", value: "2024-01-01"))) - #expect(query.contains(URLQueryItem(name: "to", value: "2024-03-01"))) - #expect(!query.contains(where: { $0.name == "group" })) - } - - @Test func yearToDateRolloverNeverUsesPreviousYearCacheEvenOnFailure() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let now = try intervalDate("2025-12-31T23:59:00Z") - let client = IntervalHistoryHTTP( - data: Data( - #"[{"date":"2025-01-01","base":"EUR","quote":"USD","rate":1.1},{"date":"2025-12-31","base":"EUR","quote":"USD","rate":1.2}]"# - .utf8)) - let service = HistoryService(directory: directory, client: client) - let first = await service.load(base: "EUR", quote: "USD", range: .yearToDate, now: now) - #expect(first.series?.points.count == 2) - await client.failRequests() - let rollover = await service.load( - base: "EUR", quote: "USD", range: .yearToDate, now: now.addingTimeInterval(120)) - #expect(await client.urls.count == 2) - #expect(rollover.series == nil) - #expect(rollover.issue == .unavailable) - let saved = directory.appendingPathComponent("history-EUR-USD--1-2025.json") - #expect( - try JSONDecoder().decode(HistorySeries.self, from: Data(contentsOf: saved)).points - == first.series?.points) - } - - @Test func yearToDateRefreshCanFallBackWithinTheSameYear() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let now = try intervalDate("2026-09-27T12:00:00Z") - let client = IntervalHistoryHTTP( - data: Data( - #"[{"date":"2026-01-01","base":"EUR","quote":"USD","rate":1.1},{"date":"2026-09-27","base":"EUR","quote":"USD","rate":1.2}]"# - .utf8)) - let service = HistoryService(directory: directory, client: client) - let first = await service.load(base: "EUR", quote: "USD", range: .yearToDate, now: now) - await client.failRequests() - let expired = await service.load( - base: "EUR", quote: "USD", range: .yearToDate, now: now.addingTimeInterval(21600)) - #expect(await client.urls.count == 2) - #expect(expired.series?.points == first.series?.points) - #expect(expired.series?.fetchedAt == first.series?.fetchedAt) - #expect(expired.issue == .usingCachedSeries) - } } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift index e0e101d..b11a490 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift @@ -3,130 +3,144 @@ import Testing @testable import ExchangeRates -private let day = "2026-01-02" +actor DailyHistoryHTTP: HTTPClient { + private(set) var urls: [URL] = [] + var failing = false + var wrongDate = false + var failPrimary = false + var missingDay: String? + func failRequests() { failing = true } + func returnWrongDate() { wrongDate = true } + func setMissingDay(_ day: String) { missingDay = day } + func failPrimaryRequests() { failPrimary = true } -private actor LongHistoryHTTP: HTTPClient { - var urls: [URL] = [] func get(_ url: URL) async throws -> Data { urls.append(url) - guard let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems, - let raw = query.first(where: { $0.name == "start" })?.value, - let date = ISO8601DateFormatter().date(from: raw) - else { throw RateError.invalidData } - let start = date.timeIntervalSince1970 - if query.contains(URLQueryItem(name: "granularity", value: "3600")) { - return try JSONEncoder() - .encode([ - [start + 22 * 3600, 1, 10, 2, 5, 1], [start + 23 * 3600, 1, 10, 2, 6, 1] - ]) + if failing || (failPrimary && url.host == "cdn.jsdelivr.net") { throw RateError.http(503) } + let day: String + if let segment = url.absoluteString.components(separatedBy: "currency-api@").last, + segment != url.absoluteString + { + day = String(segment.prefix(10)) + } else { + day = String((url.host ?? "").prefix(10)) } - return try JSONEncoder() - .encode([[start + 86400, 1, 5, 2, 3, 1], [start + 172800, 1, 5, 2, 4, 1]]) - } -} - -private actor MaxFiatHTTP: HTTPClient { - var urls: [URL] = [] - func get(_ url: URL) async throws -> Data { - urls.append(url) + if day == missingDay { throw RateError.http(404) } + let date = wrongDate ? "2020-01-01" : day return Data( - #"[{"date":"1999-01-01","base":"EUR","quote":"USD","rate":1.1},{"date":"2026-01-01","base":"EUR","quote":"USD","rate":1.2}]"# + "{\"date\":\"\(date)\",\"eur\":{\"usd\":2,\"btc\":0.00002,\"eth\":0.0005,\"xau\":0.001,\"xag\":0.05}}" .utf8) } } -private actor HistoryHTTP: HTTPClient { - var calls = 0 - func get(_ url: URL) async throws -> Data { - calls += 1 - if calls > 1 { throw RateError.http(429) } - return Data( - #"[{"date":"2026-01-02","base":"USD","quote":"EUR","rate":0.8},{"date":"2026-01-03","base":"USD","quote":"EUR","rate":0.9}]"# - .utf8) - } +func historyDate(_ string: String) throws -> Date { + try #require(ISO8601DateFormatter().date(from: string)) } @Suite struct HistoryTests { - @Test func dailyWidgetCacheAvoidsRepeatedFetchesUntilExpiry() async throws { + @Test(arguments: [ + ("BTC", "USD", 100_000.0), ("BTC", "ETH", 25.0), + ("USD", "BTC", 0.00001), ("XAU", "EUR", 1_000.0), ("XAU", "XAG", 50.0) + ]) + func dailyHistoryCrossRatesUseOnlyFawaz(base: String, quote: String, value: Double) async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let client = HistoryHTTP() - let service = HistoryService(directory: directory, client: client) - let now = Date(timeIntervalSince1970: 1_767_398_400) + let client = DailyHistoryHTTP() + let now = try historyDate("2026-01-08T12:00:00Z") + let result = await HistoryService(directory: directory, client: client) + .load(base: base, quote: quote, range: .week, now: now) + let series = try #require(result.series) + #expect(result.issue == nil) + #expect(series.points.count == 8) + #expect(series.points.allSatisfy { abs($0.value - value) < 0.000001 }) + #expect(series.source == .init(provider: .fawaz, observation: .dailyReference, timeZone: .gmt)) + #expect(await client.urls.allSatisfy { $0.host == "cdn.jsdelivr.net" }) + } + + @Test func sharedDatedPayloadsAvoidRepeatedPairAndRangeRequests() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let client = DailyHistoryHTTP() + let now = try historyDate("2026-01-08T12:00:00Z") + let service = HistoryService(directory: directory, client: client, policy: .coinbaseEnhanced) + let first = await service.load(base: "BTC", quote: "EUR", range: .week, now: now) + #expect(first.series?.source.latestObservation == nil) + _ = await service.load(base: "USD", quote: "XAU", range: .week, now: now) _ = await service.load( - base: "USD", quote: "EUR", range: .month, now: now, cacheLifetime: 86_400) - let fresh = await service.load( - base: "USD", quote: "EUR", range: .month, - now: now.addingTimeInterval(86_399), cacheLifetime: 86_400) - #expect(await client.calls == 1) - #expect(fresh.issue == nil) - let expired = await service.load( - base: "USD", quote: "EUR", range: .month, - now: now.addingTimeInterval(86_400), cacheLifetime: 86_400) - #expect(await client.calls == 2) - #expect(expired.issue == .usingCachedSeries) - #expect(expired.series?.points.last?.value == 0.9) + base: "BTC", quote: "EUR", range: .week, now: now.addingTimeInterval(21600)) + #expect(await client.urls.count == 8) + #expect(await client.urls.allSatisfy { $0.host == "cdn.jsdelivr.net" }) } - @Test func longHistoryWindowsAndMonthlyCloses() { - let start = Date(timeIntervalSince1970: 0) - let end = start.addingTimeInterval(366 * 86400) - let windows = HistoryService.candleWindows(start: start, end: end) - #expect(windows.count == 2) - #expect(windows.first?.start == start) - #expect(windows.last?.end == end) - #expect(windows[0].end == windows[1].start) - #expect(windows.allSatisfy { $0.end.timeIntervalSince($0.start) <= 299 * 86400 }) - let points = [ - HistoryPoint(date: start, value: 1), - HistoryPoint(date: start.addingTimeInterval(86400), value: 2), - HistoryPoint(date: start.addingTimeInterval(40 * 86400), value: 3) - ] - #expect(HistoryService.monthlyCloses(points.reversed()).map(\.value) == [2, 3]) + @Test func allHistorySamplesAvailableMonthEndsWithoutDailyFanout() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let client = DailyHistoryHTTP() + let now = try historyDate("2026-10-09T12:00:00Z") + let result = await HistoryService(directory: directory, client: client) + .load(base: "EUR", quote: "USD", range: .all, now: now) + #expect(result.series?.source.observation == .monthlyReference) + #expect(result.series?.points.count == 32) + #expect(result.series?.points.first?.date == (try historyDate("2024-03-31T00:00:00Z"))) + #expect(result.series?.points.last?.date == (try historyDate("2026-10-09T00:00:00Z"))) + #expect(await client.urls.count == 32) } - @Test func yearlyCryptoFetchesEveryPageAndCaches() async throws { + @Test func yearToDateUsesJanuaryFirstAndArchiveBoundary() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let client = LongHistoryHTTP() + let client = DailyHistoryHTTP() let service = HistoryService(directory: directory, client: client) - let now = try #require(ISO8601DateFormatter().date(from: "2026-01-02T00:00:00Z")) - let result = await service.load(base: "BTC", quote: "USD", range: .year, now: now) - #expect(result.series?.points.count == 5) - #expect(result.series?.source.latestObservation == .hourlyClose) - #expect(await client.urls.count == 3) - let daily = try JSONDecoder() - .decode( - HistorySeries.self, - from: Data(contentsOf: directory.appendingPathComponent("history-BTC-USD-365.json"))) - #expect(daily.points.count == 4) - #expect(daily.source.latestObservation == nil) - _ = await service.load(base: "BTC", quote: "USD", range: .year, now: now) - #expect(await client.urls.count == 3) - let urls = await client.urls - #expect( - urls.filter { - URLComponents(url: $0, resolvingAgainstBaseURL: false)?.queryItems? - .contains(URLQueryItem(name: "granularity", value: "86400")) == true - } - .count == 2) + let result = await service.load( + base: "EUR", quote: "USD", range: .yearToDate, + now: try historyDate("2026-01-03T12:00:00Z")) + #expect(result.series?.points.count == 3) + #expect(result.series?.points.first?.date == (try historyDate("2026-01-01T00:00:00Z"))) + let archive = HistoryService.dailyDates( + start: .distantPast, + now: try historyDate("2024-03-03T12:00:00Z"), monthly: false) + #expect(archive.count == 2) + #expect(archive.first == (try historyDate("2024-03-02T00:00:00Z"))) + await client.failRequests() + let rollover = await service.load( + base: "EUR", quote: "USD", range: .yearToDate, + now: try historyDate("2027-01-01T12:00:00Z")) + #expect(rollover.series == nil) } - @Test func maxFiatRequestsMonthlyHistoryAndCachesForOneDay() async throws { + @Test func datedEndpointFallbackAndWrongDateRejection() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let client = MaxFiatHTTP() - let service = HistoryService(directory: directory, client: client) - let now = Date() - let result = await service.load(base: "EUR", quote: "USD", range: .all, now: now) - #expect(result.series?.points.count == 2) - let url = try #require(await client.urls.first) - let query = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) - #expect(query.contains(URLQueryItem(name: "from", value: "1948-01-01"))) - #expect(query.contains(URLQueryItem(name: "group", value: "month"))) - _ = await service.load( - base: "EUR", quote: "USD", range: .all, now: now.addingTimeInterval(43200)) - #expect(await client.urls.count == 1) + let client = DailyHistoryHTTP() + await client.failPrimaryRequests() + let now = try historyDate("2026-01-03T12:00:00Z") + let result = await HistoryService(directory: directory, client: client) + .load(base: "EUR", quote: "USD", range: .yearToDate, now: now) + #expect(result.series?.points.count == 3) + #expect(await client.urls.count == 6) + await client.returnWrongDate() + let invalid = await HistoryService( + directory: directory.appendingPathComponent("invalid"), client: client + ) + .load(base: "EUR", quote: "USD", range: .yearToDate, now: now) + #expect(invalid.series == nil) + #expect(invalid.issue == .unavailable) + } + + @Test func unavailableArchiveDatesAreSkippedWithoutInventingObservations() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let client = DailyHistoryHTTP() + await client.setMissingDay("2026-01-02") + let result = await HistoryService(directory: directory, client: client) + .load( + base: "EUR", quote: "USD", range: .yearToDate, + now: try historyDate("2026-01-03T12:00:00Z")) + #expect(result.issue == nil) + #expect( + result.series?.points.map { String($0.date.ISO8601Format().prefix(10)) } + == ["2026-01-01", "2026-01-03"]) + #expect(await client.urls.count == 4) } @Test func historySortsAndUsesCompletedClose() throws { @@ -137,11 +151,5 @@ private actor HistoryHTTP: HTTPClient { #expect(throws: (any Error).self) { try HistoryService.decodeCandles(Data("[[1,2]]".utf8), start: .distantPast, end: .now) } - let fiat = Data( - #"[{"date":"2026-01-03","base":"USD","quote":"EUR","rate":0.9},{"date":"2026-01-02","base":"USD","quote":"EUR","rate":0.8}]"# - .utf8) - #expect( - try HistoryService.decodeFiat(fiat, base: "USD", quote: "EUR").map(\.value) == [0.8, 0.9]) } - } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateProviderPolicyTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateProviderPolicyTests.swift new file mode 100644 index 0000000..8dc1a4c --- /dev/null +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateProviderPolicyTests.swift @@ -0,0 +1,157 @@ +import Foundation +import Testing + +@testable import ExchangeRates + +private actor PolicyHTTP: HTTPClient { + private(set) var urls: [URL] = [] + var failing = false + func failRequests() { failing = true } + func get(_ url: URL) async throws -> Data { + urls.append(url) + if failing { throw RateError.http(503) } + if url.host == "api.coinbase.com" { + return Data( + #"{"data":{"currency":"EUR","rates":{"EUR":"1","BTC":"0.00003","ETH":"0.0006"}}}"#.utf8) + } + guard url.host == "cdn.jsdelivr.net" || url.host == "latest.currency-api.pages.dev" else { + throw RateError.invalidData + } + let day = String(Date().ISO8601Format().prefix(10)) + return Data("{\"date\":\"\(day)\",\"eur\":{\"usd\":2,\"btc\":0.00002,\"eth\":0.0005}}".utf8) + } +} + +@Suite struct RateProviderPolicyTests { + @Test func dailyRefreshAndBootstrapNeverContactCoinbaseOrFrankfurter() async throws { + let client = PolicyHTTP() + let service = RateService(client: client) + let first = await service.refresh(previous: RateSnapshot(), force: true) + #expect(first.snapshot.quotes["BTC"]?.source.provider == .fawaz) + #expect(first.snapshot.quotes["USD"]?.value == 2) + #expect(first.warning == nil) + var deliveries = 0 + for await update in await service.bootstrap(previous: first.snapshot) { + deliveries += 1 + #expect(update.isFinal) + #expect(update.snapshot.quotes.values.allSatisfy { $0.source.provider == .fawaz }) + } + #expect(deliveries == 1) + #expect(await client.urls.count == 2) + #expect(await client.urls.allSatisfy { $0.host == "cdn.jsdelivr.net" }) + } + + @Test func enabledRefreshOverlaysCryptoAndFallsBackToUnmodifiedDailyOnFailure() async throws { + let client = PolicyHTTP() + let service = RateService(policy: .coinbaseEnhanced, client: client) + let now = Date() + let first = await service.refresh(previous: RateSnapshot(), force: true, now: now) + #expect(first.snapshot.quotes["BTC"]?.value == Decimal(string: "0.00003")) + #expect(first.snapshot.quotes["BTC"]?.source.provider == .coinbase) + #expect(first.snapshot.dailyQuotes?["BTC"]?.value == Decimal(string: "0.00002")) + #expect(first.snapshot.quotes["USD"]?.source.provider == .fawaz) + await client.failRequests() + let fallback = await service.refresh( + previous: first.snapshot, now: now.addingTimeInterval(1800)) + #expect(fallback.snapshot.quotes["BTC"]?.value == Decimal(string: "0.00002")) + #expect(fallback.snapshot.quotes["BTC"]?.source.provider == .fawaz) + #expect(fallback.warning == .partialCryptoFallback) + #expect(await client.urls.filter { $0.host == "api.coinbase.com" }.count == 2) + #expect(await client.urls.filter { $0.host == "cdn.jsdelivr.net" }.count == 1) + } + + @Test(arguments: [false, true]) + func disabledStoreFiltersAllCacheDictionariesAndBootstrap(legacy: Bool) throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let now = Date() + let daily = ExchangeRate(2, published: "2026-01-01", source: .init(provider: .fawaz)) + let live = ExchangeRate( + 3, published: "2026-01-02", + source: .init( + provider: .coinbase, + observation: .exchangeRate), retrievedAt: now) + let foreign = ExchangeRate(4, published: "2026-01-02", source: .init(provider: .frankfurter)) + let old = RateSnapshot( + quotes: ["USD": daily, "BTC": live, "GBP": foreign], fetchedAt: now, + dailyQuotes: legacy ? nil : ["USD": daily, "BTC": live, "GBP": foreign], + dailyFetchedAt: now, checkedAt: now, fiatFetchedAt: now, + supplementalFetchedAt: now, fiatQuotes: legacy ? nil : ["GBP": foreign], + supplementalQuotes: legacy ? nil : ["USD": daily, "BTC": live]) + try RateCache(directory: directory).save(old) + let store = RateStore(directory: directory, policy: .daily) + let loaded = store.loadRates() + #expect(loaded.quotes == ["USD": daily]) + #expect(loaded.dailyQuotes == ["USD": daily]) + #expect(loaded.fiatQuotes?.isEmpty == true) + #expect(loaded.supplementalQuotes == ["USD": daily]) + #expect(loaded.checkedAt == nil) + let saved = try store.saveBootstrapRates(old, now: now) + #expect(saved.quotes == ["USD": daily]) + #expect(RateCache(directory: directory).load().quotes == ["USD": daily]) + } + + @Test func disablingRestoresExistingDailyCryptoAndCannotMergeLiveBackIn() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let now = Date() + let daily = ExchangeRate(2, published: "2026-01-01", source: .init(provider: .fawaz)) + let live = ExchangeRate( + 3, published: "2026-01-02", + source: .init( + provider: .coinbase, + observation: .exchangeRate), retrievedAt: now) + let old = RateSnapshot( + quotes: ["BTC": live], fetchedAt: now, dailyQuotes: ["BTC": daily], + dailyFetchedAt: now, checkedAt: now) + try RateCache(directory: directory).save(old) + let store = RateStore(directory: directory, policy: .daily) + #expect(store.loadRates().quotes["BTC"] == daily) + let result = try await store.refreshRates( + using: RateService( + fiat: StubRateProvider(quotes: [:]), daily: StubRateProvider(quotes: ["BTC": daily]), + crypto: StubRateProvider(quotes: ["BTC": live])), force: true, now: now) + #expect(result.snapshot.quotes["BTC"]?.source.provider == .fawaz) + #expect(store.loadRates().quotes["BTC"]?.source.provider == .fawaz) + } + + @Test func disablingDoesNotAssignCoinbaseRetrievalTimeToDailyFallback() throws { + let now = Date() + let dailyTime = now.addingTimeInterval(-12 * 3600) + let daily = ExchangeRate( + 2, published: "2026-01-01", source: .init(provider: .fawaz), + cachedAt: dailyTime) + let live = ExchangeRate( + 3, published: "2026-01-02", + source: .init( + provider: .coinbase, + observation: .exchangeRate), retrievedAt: now) + let saved = RateSnapshot( + quotes: ["BTC": live], fetchedAt: now, dailyQuotes: ["BTC": daily], + dailyFetchedAt: dailyTime, checkedAt: now, supplementalFetchedAt: dailyTime) + let filtered = RateProviderPolicy.daily.filter(saved) + #expect(filtered.quotes["BTC"] == daily) + #expect(filtered.fetchedAt == dailyTime) + #expect(filtered.dailyFetchedAt == dailyTime) + #expect(filtered.checkedAt == nil) + #expect(RateProviderPolicy.coinbaseEnhanced.filter(saved).fetchedAt == now) + let unknown = ExchangeRate(2, published: "2026-01-01", source: .init(provider: .fawaz)) + let legacy = RateSnapshot(quotes: ["BTC": live, "USD": unknown], fetchedAt: now) + #expect(RateProviderPolicy.daily.filter(legacy).fetchedAt == .distantPast) + } + + @Test func dailyRefreshEarlyReturnUsesPolicyCadenceAndSanitizedSnapshot() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let client = PolicyHTTP() + let now = Date() + let store = RateStore(directory: directory, policy: .daily) + let service = RateService(client: client) + _ = try await store.refreshRates(using: service, now: now) + _ = try await store.refreshRates(using: service, now: now.addingTimeInterval(1800)) + #expect(await client.urls.count == 1) + _ = try await store.refreshRates(using: service, now: now.addingTimeInterval(21600)) + #expect(await client.urls.count == 2) + #expect(RateProviderPolicy.coinbaseEnhanced.refreshInterval == 1800) + } +} diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateStoreTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateStoreTests.swift index 1e97592..de3a949 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateStoreTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateStoreTests.swift @@ -120,6 +120,24 @@ private actor SlowWidgetProvider: RateProvider { } @Suite struct RateStoreTests { + @Test func unrestrictedStorePreservesCustomInjectedProviders() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let store = RateStore(directory: directory) + let quote = ExchangeRate( + 2, published: "2026-01-02", source: .init(provider: .custom("Example"))) + let result = try await store.refreshRates( + using: RateService( + fiat: StubRateProvider(quotes: ["USD": quote]), daily: StubRateProvider(quotes: [:]), + crypto: nil), now: Date(timeIntervalSince1970: 100)) + #expect(result.snapshot.quotes["USD"]?.source == quote.source) + #expect(store.loadRates().quotes["USD"]?.value == quote.value) + #expect(store.loadRates().quotes["USD"]?.source == quote.source) + let bootstrap = try store.saveBootstrapRates( + result.snapshot, now: Date(timeIntervalSince1970: 100)) + #expect(bootstrap.quotes["USD"]?.source == quote.source) + } + @Test func staleFailedRefreshCannotOverwriteNewerHostCommit() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } diff --git a/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift b/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift index 08c5eb2..c16fb0c 100644 --- a/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift +++ b/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift @@ -42,7 +42,8 @@ struct CryptoHistoryWidgetHarness: View { private func load() async { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let history = HistoryService(directory: directory, client: CryptoHistoryFixture()) + let history = HistoryService( + directory: directory, client: CryptoHistoryFixture(), policy: .coinbaseEnhanced) let pair = HistoryWidgetPair(app: ConverterState(), base: "BTC", quote: "USD") for range: HistoryRange in [.day, .week, .month] { let result = await history.load(base: "BTC", quote: "USD", range: range, now: date) diff --git a/Domain/Widgets/README.md b/Domain/Widgets/README.md index cc6dfc4..736af23 100644 --- a/Domain/Widgets/README.md +++ b/Domain/Widgets/README.md @@ -6,7 +6,7 @@ Keep canonical selections independent of widget size. Default widgets share app ## History widget -`HistoryWidgetPair` follows the app base and first displayed destination unless overridden. History supports distinct currency pairs across fiat, metals, and cryptocurrencies. The domain history service derives crypto cross-rates from observations on matching dates; the widget inverts the resulting series when crypto is the displayed comparison. Hourly 1D history follows the shared ExchangeRates capability for direct USD/EUR/GBP markets and supported crypto/crypto pairs. Longer supported crypto ranges keep daily or monthly observations and join the latest completed hourly endpoint from the same shared cache. Supported crypto pairs request timeline refresh at each UTC hour boundary across all ranges; fiat and crypto pairs without hourly markets retain daily refresh. `HistoryWidgetSnapshot` derives the value, observation date, and percentage change from one historical series; it never mixes current quotes with historical endpoints. Changes below half a basis point display as neutral. Unsupported or missing history has no synthetic graph. Saved history retains its original observation date and is marked in the layout. +`HistoryWidgetPair` follows the app base and first displayed destination unless overridden. History supports distinct currency pairs across fiat, metals, and cryptocurrencies. The domain history service derives crypto cross-rates from observations on matching dates; the widget inverts the resulting series when crypto is the displayed comparison. With Coinbase enhancement enabled, hourly 1D history follows the shared ExchangeRates capability for direct USD/EUR/GBP markets and supported crypto/crypto pairs. Daily mode hides 1D and resolves saved 1D selections to one month. Longer ranges use Fawaz daily or monthly observations. Only supported 1D series request timeline refresh at UTC hour boundaries; other ranges retain daily refresh. `HistoryWidgetSnapshot` derives the value, observation date, and percentage change from one historical series; it never mixes current quotes with historical endpoints. Changes below half a basis point display as neutral. Unsupported or missing history has no synthetic graph. Saved history retains its original observation date and is marked in the layout. `HistoryWidgetView` renders small and medium widgets with the shared rounded typography and currency icons. The full-bleed graph adapts to light, dark, increased-contrast, and accented rendering. The harness and widget extension use the same layout. diff --git a/Domain/Widgets/Sources/Widgets/HistoryWidgetSnapshot.swift b/Domain/Widgets/Sources/Widgets/HistoryWidgetSnapshot.swift index 2e75ee3..8373bed 100644 --- a/Domain/Widgets/Sources/Widgets/HistoryWidgetSnapshot.swift +++ b/Domain/Widgets/Sources/Widgets/HistoryWidgetSnapshot.swift @@ -10,9 +10,15 @@ public struct HistoryWidgetPair: Sendable, Equatable { public let quote: String? /// The app-selected mass unit for both metal currencies. public let metalUnit: MetalUnit + /// Provider capabilities selected by the host for chart ranges and scheduling. + public let policy: RateProviderPolicy /// Nil overrides follow the app base and first displayed destination independently. - public init(app: ConverterState, base: String? = nil, quote: String? = nil) { + public init( + app: ConverterState, base: String? = nil, quote: String? = nil, + policy: RateProviderPolicy = .coinbaseEnhanced + ) { + self.policy = policy metalUnit = app.metalUnit self.base = base ?? app.source self.quote = quote ?? app.destinationRows.first?.code @@ -42,9 +48,13 @@ public struct HistoryWidgetPair: Sendable, Equatable { /// Whether the supported pair can load completed hourly observations. public var supportsIntradayHistory: Bool { - guard let request = historyRequest else { return false } + guard policy == .coinbaseEnhanced, let request = historyRequest else { return false } return HistoryService.supportsIntraday(base: request.base, quote: request.quote) } + /// Replaces an unavailable saved intraday range with daily one-month history. + public func normalizedRange(_ range: HistoryRange) -> HistoryRange { + range == .day && !supportsIntradayHistory ? .month : range + } } /// Historical observations and their provenance, kept separate from live conversion rates. @@ -97,9 +107,9 @@ public struct HistoryWidgetSnapshot: Sendable { return value.rounded() / 10_000 } - /// Supported crypto refreshes follow UTC hours; other pairs follow daily cache expiry. + /// Intraday series refresh at UTC hour boundaries; longer ranges follow daily cache expiry. public func nextRefresh(after now: Date) -> Date { - let hourly = pair.supportsIntradayHistory + let hourly = range == .day && pair.supportsIntradayHistory let interval: TimeInterval = hourly ? 3600 : 86_400 if let fetchedAt = series?.fetchedAt, fetchedAt <= now, issue != .usingCachedSeries { let expiry = diff --git a/Domain/Widgets/Tests/WidgetsTests/HistoryWidgetTests.swift b/Domain/Widgets/Tests/WidgetsTests/HistoryWidgetTests.swift index 6f32a97..ba82666 100644 --- a/Domain/Widgets/Tests/WidgetsTests/HistoryWidgetTests.swift +++ b/Domain/Widgets/Tests/WidgetsTests/HistoryWidgetTests.swift @@ -20,6 +20,21 @@ import Testing }, source: .init(provider: .frankfurter), fetchedAt: now), issue: issue)) } + @Test func dailyPolicyNormalizesSavedIntradaySelectionAndRefreshesDaily() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let pair = HistoryWidgetPair( + app: ConverterState(), base: "BTC", quote: "USD", policy: .daily) + #expect(pair.isSupported) + #expect(!pair.supportsIntradayHistory) + #expect(pair.normalizedRange(.day) == .month) + #expect(pair.normalizedRange(.week) == .week) + let snapshot = HistoryWidgetSnapshot( + pair: pair, range: pair.normalizedRange(.day), + result: HistoryResult(series: nil, issue: .unavailable)) + #expect(snapshot.range == .month) + #expect(snapshot.nextRefresh(after: now) == now.addingTimeInterval(86_400)) + } + @Test func metalHistoryScalesRateToSavedMassUnitWithoutChangingMovement() throws { var app = ConverterState() app.setMetalUnit(.gram) @@ -151,13 +166,16 @@ import Testing } @Test(arguments: HistoryRange.allCases) - func supportedCryptoPairsScheduleNextHourEvenWhenHistoryIsUnavailable(range: HistoryRange) { + func cryptoPairsScheduleAccordingToTheirRangeEvenWhenHistoryIsUnavailable(range: HistoryRange) { let fetchedAt = now.addingTimeInterval(2220) for (base, quote) in [("USD", "BTC"), ("EUR", "BTC"), ("GBP", "ETH"), ("ETH", "BTC")] { let state = HistoryWidgetSnapshot( pair: HistoryWidgetPair(app: ConverterState(), base: base, quote: quote), range: range, result: HistoryResult(series: nil, issue: .unavailable)) - #expect(state.nextRefresh(after: fetchedAt) == now.addingTimeInterval(3600)) + #expect( + state.nextRefresh(after: fetchedAt) + == (range == .day + ? HistoryService.nextHour(after: fetchedAt) : fetchedAt.addingTimeInterval(86_400))) } } @@ -171,11 +189,10 @@ import Testing } } - @Test func invertedMixedHistoryPreservesHourlyEndpointAndItsFetchAnchor() { + @Test func invertedDailyHistoryPreservesEndpointAndItsFetchAnchor() { let latest = HistoryPoint(date: now.addingTimeInterval(-3600), value: 84_608.1) let source = RateSource( - provider: .coinbase, observation: .dailyClose, timeZone: .gmt, - latestObservation: .hourlyClose) + provider: .fawaz, observation: .dailyReference, timeZone: .gmt) let provider = HistorySeries( points: [HistoryPoint(date: now.addingTimeInterval(-3 * 86400), value: 80_000), latest], source: source, fetchedAt: now) @@ -187,29 +204,34 @@ import Testing #expect(state.series?.source == source) #expect(state.series?.fetchedAt == now) #expect(state.change == ((80_000 / latest.value - 1) * 10_000).rounded() / 10_000) - #expect(state.nextRefresh(after: now.addingTimeInterval(2220)) == now.addingTimeInterval(3600)) + #expect( + state.nextRefresh(after: now.addingTimeInterval(2220)) == now.addingTimeInterval(86_400)) } @Test(arguments: HistoryRange.allCases) - func supportedCryptoSchedulesHourlyAcrossRanges(range: HistoryRange) { + func cryptoRefreshCadenceFollowsSelectedRange(range: HistoryRange) { + let intraday = range == .day let series = HistorySeries( points: [ - HistoryPoint(date: now.addingTimeInterval(-3600), value: 24), + HistoryPoint(date: now.addingTimeInterval(-86_400), value: 24), HistoryPoint(date: now, value: 25) - ], source: .init(provider: .coinbase, observation: .hourlyClose, timeZone: .gmt), + ], + source: .init( + provider: intraday ? .coinbase : .fawaz, + observation: intraday ? .hourlyClose : .dailyReference, timeZone: .gmt), fetchedAt: now) let pair = HistoryWidgetPair(app: ConverterState(), base: "BTC", quote: "USD") let fresh = HistoryWidgetSnapshot( pair: pair, range: range, result: HistoryResult(series: series, issue: nil)) #expect( fresh.nextRefresh(after: now.addingTimeInterval(1200)) - == now.addingTimeInterval(3600)) + == now.addingTimeInterval(intraday ? 3600 : 86_400)) #expect( fresh.nextRefresh(after: now.addingTimeInterval(4000)) - == now.addingTimeInterval(7200)) + == now.addingTimeInterval(intraday ? 7200 : 86_400)) let stale = HistoryWidgetSnapshot( pair: pair, range: range, result: HistoryResult(series: series, issue: .usingCachedSeries)) - #expect(stale.nextRefresh(after: now) == now.addingTimeInterval(3600)) + #expect(stale.nextRefresh(after: now) == now.addingTimeInterval(intraday ? 3600 : 86_400)) } } diff --git a/Features/CurrencyDetails/Sources/CurrencyDetails/CurrencyDetailsModel.swift b/Features/CurrencyDetails/Sources/CurrencyDetails/CurrencyDetailsModel.swift index 19ddc54..58000be 100644 --- a/Features/CurrencyDetails/Sources/CurrencyDetails/CurrencyDetailsModel.swift +++ b/Features/CurrencyDetails/Sources/CurrencyDetails/CurrencyDetailsModel.swift @@ -41,8 +41,17 @@ public struct CurrencyDetailsDependencies { /// Loads a series for (base currency, quote currency, range), preserving typed issues. public var loadHistory: (String, String, HistoryRange) async -> HistoryResult + /// Whether configured providers offer hourly history for the requested pair. + public var supportsIntraday: (String, String) -> Bool + /// Creates explicit history loading without selecting a live provider. - public init(loadHistory: @escaping (String, String, HistoryRange) async -> HistoryResult) { + public init( + supportsIntraday: @escaping (String, String) -> Bool = { + HistoryService.supportsIntraday(base: $0, quote: $1) + }, + loadHistory: @escaping (String, String, HistoryRange) async -> HistoryResult + ) { + self.supportsIntraday = supportsIntraday self.loadHistory = loadHistory } } @@ -59,7 +68,7 @@ public final class CurrencyDetailsModel { public var availableRanges: [HistoryRange] { let request = historyRequest return Self.ranges.filter { - $0 != .day || HistoryService.supportsIntraday(base: request.base, quote: request.quote) + $0 != .day || dependencies.supportsIntraday(request.base, request.quote) } } diff --git a/Features/CurrencyDetails/Sources/CurrencyDetailsUI/Resources/Details.xcstrings b/Features/CurrencyDetails/Sources/CurrencyDetailsUI/Resources/Details.xcstrings index f2da45f..76e9f59 100644 --- a/Features/CurrencyDetails/Sources/CurrencyDetailsUI/Resources/Details.xcstrings +++ b/Features/CurrencyDetails/Sources/CurrencyDetailsUI/Resources/Details.xcstrings @@ -367,85 +367,85 @@ "en": { "stringUnit": { "state": "translated", - "value": "Crypto history uses completed Coinbase candles and, where needed, reference exchange rates. Historical and current conversion sources may differ." + "value": "Crypto history uses daily rates and completed hourly candles when available. Historical and current conversion sources may differ." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "加密货币历史使用 Coinbase 已收盘的 K 线,并在需要时使用参考汇率。历史和当前换算的数据来源可能不同。" + "value": "加密货币历史使用每日汇率,以及可用时已收盘的小时K线。历史和当前换算的数据来源可能不同。" } }, "ja": { "stringUnit": { "state": "translated", - "value": "暗号資産の履歴にはCoinbaseの確定済みローソク足を使用し、必要に応じて参考為替レートも使用します。過去と現在の換算データは異なる提供元を使う場合があります。" + "value": "暗号資産の履歴には日次レートと、利用可能な場合は確定済みの時間足を使用します。過去と現在の換算データは異なる提供元を使う場合があります。" } }, "es": { "stringUnit": { "state": "translated", - "value": "El historial de criptomonedas usa velas cerradas de Coinbase y, cuando es necesario, tipos de cambio de referencia. Las fuentes de las conversiones históricas y actuales pueden diferir." + "value": "El historial de criptomonedas usa tipos diarios y velas horarias cerradas cuando están disponibles. Las fuentes históricas y actuales pueden diferir." } }, "de": { "stringUnit": { "state": "translated", - "value": "Der Kryptoverlauf verwendet abgeschlossene Coinbase-Kerzen und bei Bedarf Referenzwechselkurse. Historische und aktuelle Umrechnungskurse können aus unterschiedlichen Quellen stammen." + "value": "Der Kryptoverlauf verwendet Tageskurse und, wenn verfügbar, abgeschlossene Stundenkerzen. Historische und aktuelle Umrechnungskurse können aus unterschiedlichen Quellen stammen." } }, "fr": { "stringUnit": { "state": "translated", - "value": "L’historique des cryptomonnaies utilise les bougies clôturées de Coinbase et, si nécessaire, des taux de change de référence. Les sources des conversions historiques et actuelles peuvent différer." + "value": "L’historique des cryptomonnaies utilise des taux quotidiens et, si disponibles, des bougies horaires clôturées. Les sources historiques et actuelles peuvent différer." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "O histórico de criptomoedas usa candles fechados da Coinbase e, quando necessário, taxas de câmbio de referência. As fontes das conversões históricas e atuais podem diferir." + "value": "O histórico de criptomoedas usa taxas diárias e candles horários fechados, quando disponíveis. As fontes históricas e atuais podem diferir." } }, "ko": { "stringUnit": { "state": "translated", - "value": "암호화폐의 과거 데이터는 Coinbase의 마감된 캔들을 사용하며, 필요한 경우 참고 환율도 적용합니다. 과거 환율과 현재 환율의 데이터 출처가 다를 수 있습니다." + "value": "암호화폐의 과거 데이터는 일별 환율과 사용 가능한 경우 마감된 시간별 캔들을 사용합니다. 과거 환율과 현재 환율의 데이터 출처가 다를 수 있습니다." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "加密貨幣歷史使用 Coinbase 已收盤的 K 線,並在需要時使用參考匯率。歷史和目前換算的資料來源可能不同。" + "value": "加密貨幣歷史使用每日匯率,以及可用時已收盤的小時K線。歷史和目前換算的資料來源可能不同。" } }, "it": { "stringUnit": { "state": "translated", - "value": "La cronologia delle criptovalute usa candele completate di Coinbase e, quando necessario, tassi di cambio di riferimento. Le fonti delle conversioni storiche e attuali possono differire." + "value": "La cronologia delle criptovalute usa tassi giornalieri e, se disponibili, candele orarie completate. Le fonti storiche e attuali possono differire." } }, "tr": { "stringUnit": { "state": "translated", - "value": "Kripto para geçmişi, tamamlanmış Coinbase mumlarını ve gerektiğinde referans döviz kurlarını kullanır. Geçmiş ve güncel çevirme kurları farklı kaynaklardan gelebilir." + "value": "Kripto para geçmişi günlük kurları ve varsa tamamlanmış saatlik mumları kullanır. Geçmiş ve güncel kurların kaynakları farklı olabilir." } }, "ru": { "stringUnit": { "state": "translated", - "value": "История криптовалют использует завершённые свечи Coinbase и, при необходимости, справочные курсы обмена. Источники исторических и текущих курсов могут отличаться." + "value": "История криптовалют использует ежедневные курсы и, если доступны, завершённые часовые свечи. Источники исторических и текущих курсов могут отличаться." } }, "uk": { "stringUnit": { "state": "translated", - "value": "Історія криптовалют використовує завершені свічки Coinbase та, за потреби, довідкові курси обміну. Джерела історичних і поточних курсів можуть відрізнятися." + "value": "Історія криптовалют використовує щоденні курси та, за наявності, завершені годинні свічки. Джерела історичних і поточних курсів можуть відрізнятися." } }, "et": { "stringUnit": { "state": "translated", - "value": "Krüptovaluutade ajalugu põhineb Coinbase’i suletud hinnaküünaldel ja vajaduse korral võrdluskurssidel. Ajalooliste ja praeguste vahetuskursside allikad võivad erineda." + "value": "Krüptovaluutade ajalugu kasutab päevakursse ja võimaluse korral suletud tunniküünlaid. Ajalooliste ja praeguste vahetuskursside allikad võivad erineda." } } } diff --git a/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift b/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift index 473303f..d48fe3b 100644 --- a/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift +++ b/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift @@ -120,6 +120,28 @@ struct CurrencyDetailsTests { CurrencyDetailsModel.ranges == [.day, .week, .month, .quarter, .yearToDate, .year, .all]) } + @Test func injectedDailyCapabilityKeepsCryptoHistoryWithoutIntradayControls() async { + var capabilityPair: [String] = [] + var requestedRange: HistoryRange? + let model = CurrencyDetailsModel( + input: .init( + code: "EUR", reference: "BTC", snapshot: RateSnapshot(), referencePolicy: .requestedPair), + dependencies: .init( + supportsIntraday: { base, quote in + capabilityPair = [base, quote] + return false + }, + loadHistory: { _, _, range in + requestedRange = range + return HistoryResult(series: nil, issue: .unavailable) + })) + #expect(!model.availableRanges.contains(.day)) + #expect(capabilityPair == ["BTC", "EUR"]) + #expect(model.availableRanges.contains(.month)) + await model.load() + #expect(requestedRange == .month) + } + @Test func supersededNoncooperatingRequestCannotReplaceNewerRange() async { let pending = PendingHistory() let model = CurrencyDetailsModel( diff --git a/Features/Settings/Sources/Settings/SettingsModel.swift b/Features/Settings/Sources/Settings/SettingsModel.swift index 402629e..06a006c 100644 --- a/Features/Settings/Sources/Settings/SettingsModel.swift +++ b/Features/Settings/Sources/Settings/SettingsModel.swift @@ -58,6 +58,8 @@ public enum SettingsIssue: Sendable, Equatable { /// The host supplies preference persistence and operations; Settings owns their presentation flow. @MainActor public struct SettingsDependencies { + /// Whether the host enables Coinbase conversion and hourly-history capabilities. + public var usesCoinbase: Bool /// Reads the host current rate information without starting network work. public var readState: () -> SettingsRateState /// Subscribes to committed host state changes; each flow owns its subscription. @@ -85,8 +87,10 @@ public struct SettingsDependencies { setAccent: @escaping (SettingsAccent) -> Void, setMetalUnit: @escaping (MetalUnit) throws -> Void, refresh: @escaping () async throws -> SettingsRateState, - replay: (() throws -> Void)?, output: @escaping (SettingsOutput) -> Void + replay: (() throws -> Void)?, usesCoinbase: Bool = true, + output: @escaping (SettingsOutput) -> Void ) { + self.usesCoinbase = usesCoinbase self.readState = readState; self.changes = changes; self.readPreferences = readPreferences self.setTheme = setTheme; self.setAccent = setAccent; self.setMetalUnit = setMetalUnit self.refresh = refresh @@ -115,6 +119,8 @@ public final class SettingsModel { } /// Whether the host supplies the optional setup replay capability. public var allowsReplay: Bool { dependencies.replay != nil } + /// Whether the host enables Coinbase conversion and hourly-history capabilities. + public var usesCoinbase: Bool { dependencies.usesCoinbase } /// Creates the owned value or model with explicit host inputs and operations. public init(dependencies: SettingsDependencies) { self.dependencies = dependencies diff --git a/Features/Settings/Sources/SettingsUI/Resources/Settings.xcstrings b/Features/Settings/Sources/SettingsUI/Resources/Settings.xcstrings index 69e23a3..de55111 100644 --- a/Features/Settings/Sources/SettingsUI/Resources/Settings.xcstrings +++ b/Features/Settings/Sources/SettingsUI/Resources/Settings.xcstrings @@ -3307,85 +3307,85 @@ "en": { "stringUnit": { "state": "translated", - "value": "Daily cryptocurrency rates and backup exchange rates." + "value": "Daily exchange rates and history." } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "加密货币每日汇率和备用汇率。" + "value": "每日汇率和历史数据。" } }, "ja": { "stringUnit": { "state": "translated", - "value": "暗号資産の日次レートと代替為替レート。" + "value": "日次の為替レートと履歴。" } }, "es": { "stringUnit": { "state": "translated", - "value": "Tipos de cambio diarios de criptomonedas y tipos de cambio de respaldo." + "value": "Tipos de cambio e historial diarios." } }, "de": { "stringUnit": { "state": "translated", - "value": "Tägliche Kryptowährungskurse und Ersatzwechselkurse." + "value": "Tägliche Wechselkurse und Kursverläufe." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Taux crypto quotidiens et taux de change de secours." + "value": "Taux de change et historique quotidiens." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Taxas de câmbio diárias de criptomoedas e taxas de câmbio de reserva." + "value": "Taxas de câmbio e histórico diários." } }, "ko": { "stringUnit": { "state": "translated", - "value": "암호화폐 일일 환율 및 대체 환율입니다." + "value": "일별 환율과 과거 데이터." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "加密貨幣每日匯率和備用匯率。" + "value": "每日匯率和歷史資料。" } }, "it": { "stringUnit": { "state": "translated", - "value": "Tassi giornalieri delle criptovalute e tassi di cambio di riserva." + "value": "Tassi di cambio e cronologia giornalieri." } }, "tr": { "stringUnit": { "state": "translated", - "value": "Günlük kripto para kurları ve yedek döviz kurları." + "value": "Günlük döviz kurları ve geçmiş veriler." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Ежедневные курсы криптовалют и резервные курсы обмена." + "value": "Ежедневные курсы валют и история." } }, "uk": { "stringUnit": { "state": "translated", - "value": "Щоденні курси криптовалют і резервні курси обміну." + "value": "Щоденні курси валют та історія." } }, "et": { "stringUnit": { "state": "translated", - "value": "Krüptovaluutade igapäevased kursid ja varuvahetuskursid." + "value": "Igapäevased vahetuskursid ja kursiajalugu." } } } diff --git a/Features/Settings/Sources/SettingsUI/SettingsScreen.swift b/Features/Settings/Sources/SettingsUI/SettingsScreen.swift index aa540f9..85e7160 100644 --- a/Features/Settings/Sources/SettingsUI/SettingsScreen.swift +++ b/Features/Settings/Sources/SettingsUI/SettingsScreen.swift @@ -722,20 +722,14 @@ struct SettingsScreen: View { private var sourceInformation: some View { Section(.Settings.sources) { - sourceCredit( - "Frankfurter", description: .Settings.frankfurterCredit, - website: "https://frankfurter.dev/") - sourceCredit( - "European Central Bank", description: .Settings.ecbCredit, - website: - "https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html" - ) sourceCredit( "Fawaz Exchange API", description: .Settings.fawazCredit, website: "https://github.com/fawazahmed0/exchange-api") - sourceCredit( - "Coinbase", description: .Settings.coinbaseCredit, - website: "https://docs.cdp.coinbase.com/coinbase-app/track-apis/exchange-rates") + if model.usesCoinbase { + sourceCredit( + "Coinbase", description: .Settings.coinbaseCredit, + website: "https://docs.cdp.coinbase.com/coinbase-app/track-apis/exchange-rates") + } } } diff --git a/Features/Settings/Tests/SettingsTests/SettingsModelTests.swift b/Features/Settings/Tests/SettingsTests/SettingsModelTests.swift index 9eea074..b760b9f 100644 --- a/Features/Settings/Tests/SettingsTests/SettingsModelTests.swift +++ b/Features/Settings/Tests/SettingsTests/SettingsModelTests.swift @@ -11,6 +11,7 @@ import Testing SettingsRateState(snapshot: RateSnapshot(), codes: []) }, replay: (() throws -> Void)? = nil, + usesCoinbase: Bool = true, output: @escaping (SettingsOutput) -> Void = { _ in } ) -> SettingsDependencies { SettingsDependencies( @@ -18,8 +19,14 @@ import Testing changes: { AsyncStream(bufferingPolicy: .unbounded) { _ in } }, readPreferences: { SettingsPreferences(theme: .system, accent: .primary) }, setTheme: { _ in }, setAccent: { _ in }, setMetalUnit: { _ in }, refresh: refresh, - replay: replay, output: output) + replay: replay, usesCoinbase: usesCoinbase, output: output) } + @Test(arguments: [false, true]) + func sourceCreditsFollowInjectedProviderCapability(usesCoinbase: Bool) { + let model = SettingsModel(dependencies: dependencies(usesCoinbase: usesCoinbase)) + #expect(model.usesCoinbase == usesCoinbase) + } + @Test func failedReplayDoesNotEmitNavigationAndSuccessfulRetryCommitsFirst() { var failing = true var saved = false diff --git a/Project.swift b/Project.swift index af89fc3..20917f5 100644 --- a/Project.swift +++ b/Project.swift @@ -177,6 +177,7 @@ let project = Project( entitlements: .file( path: "AppExtensions/CurrencyWatchWidgets/Configuration/CurrencyWatchWidgets.entitlements"), dependencies: [ + .target(name: "CurrencyApplication"), .target(name: "Conversion"), .target(name: "ExchangeRates"), .target(name: "ExchangeRatesUI"), .target(name: "Widgets") ], @@ -281,6 +282,7 @@ let project = Project( entitlements: .file( path: "AppExtensions/CurrencyWidgets/Configuration/Currency.entitlements"), dependencies: [ + .target(name: "CurrencyApplication"), .target(name: "Conversion"), .target(name: "ExchangeRates"), .target(name: "ExchangeRatesUI"), .target(name: "LocalCurrency"), .target(name: "Widgets"), .target(name: "WidgetsUI") @@ -453,6 +455,7 @@ let project = Project( infoPlist: .default, buildableFolders: ["AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests"], dependencies: [ + .target(name: "CurrencyApplication"), .target(name: "Conversion"), .target(name: "ExchangeRates"), .target(name: "ExchangeRatesUI"), .target(name: "LocalCurrency"), .target(name: "Widgets"), .target(name: "WidgetsUI") From 1e3b221b4f237a42997dd5da7ff8e04c607684dc Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Fri, 9 Oct 2026 22:52:57 +0200 Subject: [PATCH 02/17] =?UTF-8?q?=E2=9C=85=20e2e:=20make=20provider=20navi?= =?UTF-8?q?gation=20checks=20precise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * scroll to exact source explanation text * verify the details sheet dismisses before checking preserved state --- Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts index 79016b2..c557c68 100644 --- a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts @@ -35,10 +35,11 @@ test(`provider policy ${mode} preserves crypto fallback, ranges and credits afte } await source.tap(); await expect(source).toHaveValue('Expanded'); - await agent.act('Scroll in Details until the current conversion provider information below the expanded source disclosure is visible. Keep Details open.'); + await agent.act('Scroll in Details until the exact text "Crypto history uses daily rates and completed hourly candles when available. Historical and current conversion sources may differ." is visible. Use that exact text when scrolling to text. Keep Details open.'); await expect(screen.getByText(enhanced ? /Coinbase.*retrieved/i : /Fawaz · daily ·/i)).toBeVisible(); await app.screenshot(capture); - await screen.getByTestId('currency.details.close').tap(); + await agent.act('Close Details & history with the circular X at the top right and return to the converter.'); + await expect(screen.getByTestId('currency.details.close')).toBeHidden(); await expect(bitcoin).toHaveAccessibleName(expectedAmount); }; From 0140385c23bdf7f4a070c9ce89340140d638e064 Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Fri, 9 Oct 2026 23:07:59 +0200 Subject: [PATCH 03/17] =?UTF-8?q?=E2=9C=85=20widgets:=20isolate=20installe?= =?UTF-8?q?d=20display=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * configure the History pair before checking seeded values * reveal the installed Board after returning from the app --- Apps/Currency/Tests/E2E/README.md | 2 ++ Apps/Currency/Tests/E2E/tests/widget-display.e2e.ts | 4 +++- Apps/Currency/Tests/E2E/widget-gallery-fixtures.ts | 9 ++++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Apps/Currency/Tests/E2E/README.md b/Apps/Currency/Tests/E2E/README.md index 4531d9c..ca6adc0 100644 --- a/Apps/Currency/Tests/E2E/README.md +++ b/Apps/Currency/Tests/E2E/README.md @@ -88,6 +88,8 @@ The fixtures use the production provider decoders with a fixed HTTP client. Fawa Home Screen setup uses [widgetctl](https://github.com/DimaMishchenko/widgetctl), pinned as a GitHub dev dependency. It installs/configures the requested kind and size, preserves other apps’ widgets and unloads its helper after the module. Tests still validate rendered data and interactions. Set `CURRENCY_E2E_WIDGET_SETUP=gallery` to exercise system installation; private-configuration cases then skip. Lock Screen installation uses system UI. +Private History display fixtures explicitly configure EUR/USD/month because replacing a widget can retain its previous pair. Board checks reveal the installed widget again after returning from the app. + Cache remains enabled and local, ignored by Git. Fresh checkouts record their own. Inspect it with `npx e2e cache ls` or `stats`; `clear` removes all recordings. Unused entries are not automatically pruned. Keep goal/test identities stable and bump `app.identity` when fixture semantics change. Diagnose failed outcomes before accepting replacement recordings. `--no-cache` neither reads nor writes cache. Visual assertions and cache misses still require model access; these simulator tests run locally, not in CI. ## Limitations diff --git a/Apps/Currency/Tests/E2E/tests/widget-display.e2e.ts b/Apps/Currency/Tests/E2E/tests/widget-display.e2e.ts index 82f759c..7891506 100644 --- a/Apps/Currency/Tests/E2E/tests/widget-display.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/widget-display.e2e.ts @@ -1,4 +1,4 @@ -import { unloadWidgetHelper } from '../simulator-widgets.js'; +import { revealWidget, unloadWidgetHelper, widgetSetupMethod } from '../simulator-widgets.js'; import { expect } from 'e2e'; import { test } from '../widget-gallery-fixtures.js'; @@ -22,6 +22,8 @@ test('medium Currency board displays default amounts and follows app amount chan await device.home(); await device.openApp('com.apple.springboard'); + if (widgetSetupMethod() === 'private') await revealWidget('Currency board', 'Medium'); + else await agent.act('Show the Home Screen page containing the installed Currency board widget.'); await expect(board).toBeVisible(); await agent.waitFor(`${boardLayout} The medium Currency board now shows an EUR entry with exactly 42 and a USD entry with exactly 84. It no longer shows the old EUR 1 and USD 2 amounts.`, { vision: 'only', timeout: 30_000 }); await app.screenshot('widget-board-medium-eur-42-usd-84'); diff --git a/Apps/Currency/Tests/E2E/widget-gallery-fixtures.ts b/Apps/Currency/Tests/E2E/widget-gallery-fixtures.ts index a9b139e..4c80f1a 100644 --- a/Apps/Currency/Tests/E2E/widget-gallery-fixtures.ts +++ b/Apps/Currency/Tests/E2E/widget-gallery-fixtures.ts @@ -1,6 +1,6 @@ import { expect, type Locator } from 'e2e'; import { test as base } from './fixtures.js'; -import { placeWidget, prepareWidgetHelper, widgetSetupMethod } from './simulator-widgets.js'; +import { configureWidget, placeWidget, prepareWidgetHelper, widgetSetupMethod } from './simulator-widgets.js'; export const test = base.extend<{ installedWidget: (title: string, family: 'Small' | 'Medium' | 'Large') => Promise; @@ -16,6 +16,13 @@ export const test = base.extend<{ const widget = device.locator('id=Currency value=Widget'); if (widgetSetupMethod() === 'private') { await placeWidget(title, family); + if (title === 'History') { + await configureWidget(title, family, parameters => { + parameters.base = { identifier: 'EUR', title: { key: 'EUR' }, subtitle: { key: 'EUR' } }; + parameters.comparison = { identifier: 'USD', title: { key: 'USD' }, subtitle: { key: 'USD' } }; + parameters.range = { identifier: 'month', value: 'month', title: { key: '1 month' } }; + }); + } } else { while (await widget.count() > 0) { if (await widget.count() !== 1) throw new Error('Use one Currency widget at a time on the owned Home Screen.'); From 8490091f8e5b1c8f8b3f5203aa28afa2b21623af Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Sat, 10 Oct 2026 08:10:33 +0200 Subject: [PATCH 04/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rates:=20use=20a=20c?= =?UTF-8?q?ode-level=20Coinbase=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * replace the compiler switch with one shared Boolean * remove the separate CI configuration and environment mode * keep provider coverage in existing parameterized tests --- .github/workflows/tests.yml | 21 -------- .../Sources/CurrencyRateConfiguration.swift | 14 ++---- .../Tests/ConversionActionTests.swift | 28 +++++++++++ .../CurrencyRateConfigurationTests.swift | 50 ------------------- Apps/Currency/Tests/E2E/README.md | 9 ++-- Apps/Currency/Tests/E2E/e2e.config.ts | 3 +- .../Tests/E2E/provider-configuration.ts | 6 +++ .../Tests/E2E/tests/provider-policy.e2e.ts | 8 ++- Apps/CurrencyWatch/README.md | 6 +-- Documentation/Development.md | 4 +- Documentation/FeatureMap.md | 2 +- 11 files changed, 53 insertions(+), 98 deletions(-) delete mode 100644 Apps/Currency/Modules/CurrencyApplication/Tests/CurrencyRateConfigurationTests.swift create mode 100644 Apps/Currency/Tests/E2E/provider-configuration.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9ed21fe..84fd221 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -144,27 +144,6 @@ jobs: -derivedDataPath "$RUNNER_TEMP/CurrencyTestsDerivedData" \ CODE_SIGN_IDENTITY=- \ 2>&1 | xcbeautify - - name: Test Fawaz-only build configuration - if: needs.changes.outputs.xcode == 'true' - run: | - set -o pipefail - xcodebuild test \ - -workspace Currency.xcworkspace \ - -scheme CurrencyTests \ - -configuration Debug \ - -destination "platform=iOS Simulator,id=$CURRENCY_TEST_SIMULATOR" \ - -derivedDataPath "$RUNNER_TEMP/CurrencyDailyTestsDerivedData" \ - -collect-test-diagnostics never \ - -only-testing:ExchangeRatesPackageTests \ - -only-testing:CurrencyDetailsPackageTests \ - -only-testing:WidgetsPackageTests \ - -only-testing:SettingsPackageTests \ - -only-testing:CurrencyApplicationTests \ - -only-testing:ApplicationIntegrationTests/ProviderPolicyIntegrationTests \ - -only-testing:WidgetIntegrationTests \ - CODE_SIGN_IDENTITY=- \ - OTHER_SWIFT_FLAGS='$(inherited) -D CURRENCY_DISABLE_COINBASE' \ - 2>&1 | xcbeautify - name: Check compiler-extracted localization coverage if: needs.changes.outputs.xcode == 'true' timeout-minutes: 1 diff --git a/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift b/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift index cee2004..1aeec94 100644 --- a/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift +++ b/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift @@ -1,18 +1,12 @@ import ExchangeRates import Foundation -/// Shared build configuration for the app, extensions and independent Watch app. +/// Shared provider configuration for the app, extensions and independent Watch app. public enum CurrencyRateConfiguration { - /// Set CURRENCY_DISABLE_COINBASE when building to use only daily Fawaz data. - public static let coinbaseEnabled: Bool = { - #if CURRENCY_DISABLE_COINBASE - false - #else - true - #endif - }() + /// Enables Coinbase current crypto quotes and supported 24-hour charts. + public static let coinbaseEnabled = true - /// Provider capabilities selected once at each executable composition root. + /// Provider capabilities selected at each executable composition root. public static var policy: RateProviderPolicy { coinbaseEnabled ? .coinbaseEnhanced : .daily } diff --git a/Apps/Currency/Modules/CurrencyApplication/Tests/ConversionActionTests.swift b/Apps/Currency/Modules/CurrencyApplication/Tests/ConversionActionTests.swift index 5e0a515..729dbc3 100644 --- a/Apps/Currency/Modules/CurrencyApplication/Tests/ConversionActionTests.swift +++ b/Apps/Currency/Modules/CurrencyApplication/Tests/ConversionActionTests.swift @@ -81,6 +81,29 @@ import Testing #expect(evaluation.results.first?.amount == "12.5") } + @Test(arguments: [RateProviderPolicy.daily, .coinbaseEnhanced]) + func headlessConversionsRespectProviderAttemptCadence(policy: RateProviderPolicy) async throws { + let now = Date.now + let attempts = ProviderAttemptCounter() + let snapshot = RateSnapshot( + quotes: [ + "EUR": .init(1, published: "2026-10-09", source: .init(provider: .fawaz)), + "USD": .init(2, published: "2026-10-09", source: .init(provider: .fawaz)) + ], fetchedAt: now, checkedAt: now.addingTimeInterval(-1_800)) + let action = ConversionAction( + dependencies: .init( + readInput: { ConverterState() }, readLocal: { (nil, .notDetermined) }, + readRates: { snapshot }, + refresh: { _ in + await attempts.record() + return RefreshResult(snapshot: snapshot, warning: nil) + }, now: { now }, refreshInterval: policy.refreshInterval)) + let request = try action.request(amount: "10", source: "EUR", destination: "USD") + let result = try await action.perform(request) + #expect(result.results.first?.amount == "20") + #expect(await attempts.count == (policy == .coinbaseEnhanced ? 1 : 0)) + } + private func make( snapshot: RateSnapshot, refresh: @escaping @Sendable (Date) async throws -> RefreshResult ) -> ConversionAction { @@ -90,3 +113,8 @@ import Testing refresh: refresh, now: { now })) } } + +private actor ProviderAttemptCounter { + private(set) var count = 0 + func record() { count += 1 } +} diff --git a/Apps/Currency/Modules/CurrencyApplication/Tests/CurrencyRateConfigurationTests.swift b/Apps/Currency/Modules/CurrencyApplication/Tests/CurrencyRateConfigurationTests.swift deleted file mode 100644 index ff3e7c4..0000000 --- a/Apps/Currency/Modules/CurrencyApplication/Tests/CurrencyRateConfigurationTests.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Conversion -import CurrencyApplication -import ExchangeRates -import Foundation -import LocalCurrency -import Testing - -struct CurrencyRateConfigurationTests { - @Test func allExecutablesShareTheBuildFlagAndCadence() { - #if CURRENCY_DISABLE_COINBASE - #expect(!CurrencyRateConfiguration.coinbaseEnabled) - #expect(CurrencyRateConfiguration.policy == .daily) - #expect(CurrencyRateConfiguration.foregroundRefreshInterval == 21_600) - #expect(CurrencyRateConfiguration.widgetRefreshInterval == 21_600) - #else - #expect(CurrencyRateConfiguration.coinbaseEnabled) - #expect(CurrencyRateConfiguration.policy == .coinbaseEnhanced) - #expect(CurrencyRateConfiguration.foregroundRefreshInterval == 60) - #expect(CurrencyRateConfiguration.widgetRefreshInterval == 1_800) - #endif - } - @Test(arguments: [RateProviderPolicy.daily, .coinbaseEnhanced]) - func headlessConversionsRespectProviderAttemptCadence(policy: RateProviderPolicy) async throws { - let now = Date.now - let attempts = ProviderAttemptCounter() - let snapshot = RateSnapshot( - quotes: [ - "EUR": .init(1, published: "2026-10-09", source: .init(provider: .fawaz)), - "USD": .init(2, published: "2026-10-09", source: .init(provider: .fawaz)) - ], fetchedAt: now, checkedAt: now.addingTimeInterval(-1_800)) - let action = ConversionAction( - dependencies: .init( - readInput: { ConverterState() }, readLocal: { (nil, .notDetermined) }, - readRates: { snapshot }, - refresh: { _ in - await attempts.record() - return RefreshResult(snapshot: snapshot, warning: nil) - }, now: { now }, refreshInterval: policy.refreshInterval)) - let request = try action.request(amount: "10", source: "EUR", destination: "USD") - let result = try await action.perform(request) - #expect(result.results.first?.amount == "20") - #expect(await attempts.count == (policy == .coinbaseEnhanced ? 1 : 0)) - } - -} - -private actor ProviderAttemptCounter { - private(set) var count = 0 - func record() { count += 1 } -} diff --git a/Apps/Currency/Tests/E2E/README.md b/Apps/Currency/Tests/E2E/README.md index ca6adc0..a1b6ef9 100644 --- a/Apps/Currency/Tests/E2E/README.md +++ b/Apps/Currency/Tests/E2E/README.md @@ -75,16 +75,15 @@ 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 Fawaz history caches for EUR/USD, EUR/Gold and BTC/USD. `ready-crypto` starts USD 1 → Bitcoin: 0.00001 from Fawaz, or 0.00002 with Coinbase enabled. Its stored snapshot deliberately contains a Coinbase overlay plus the daily fallback, so the disabled build exercises cache exclusion. 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 Fawaz history caches for EUR/USD, EUR/Gold and BTC/USD. `ready-crypto` starts USD 1 → Bitcoin: 0.00001 from Fawaz, or 0.00002 with Coinbase enabled. Its stored snapshot deliberately contains a Coinbase overlay plus the daily fallback, so disabling the flag exercises cache exclusion. Restart omits fixture state and preserves the real stores. Keep `-CurrencyE2EState` out of default launch arguments. -Run provider coverage with both separately built apps. `CURRENCY_E2E_COINBASE` selects assertions and recording identity; it does not change app behavior. The off build must compile every target, including CurrencyApplication, with `CURRENCY_DISABLE_COINBASE` (append it to the existing Swift conditions). Use separate DerivedData directories, then select the app explicitly: +Run the same provider journey with the normal app build. It reads `CurrencyRateConfiguration.coinbaseEnabled` from source for its expected outcomes and cache identity. After changing that Boolean, rebuild the app before rerunning; no environment mode or special test suite is needed. ```sh -CURRENCY_E2E_COINBASE=on CURRENCY_E2E_APP_PATH= npm run test:e2e -- tests/provider-policy.e2e.ts -CURRENCY_E2E_COINBASE=off CURRENCY_E2E_APP_PATH= npm run test:e2e -- tests/provider-policy.e2e.ts +npm run test:e2e -- tests/provider-policy.e2e.ts ``` -The fixtures use the production provider decoders with a fixed HTTP client. Fawaz supplies every asset; Coinbase supplies crypto only in the enabled build. Fresh namespaced chart caches avoid live network timing. This journey proves same-build relaunch filtering of a deliberately incompatible cached overlay; changing installed builds on one retained app container is a separate upgrade check. +The fixtures use the production provider decoders with a fixed HTTP client. Fawaz supplies every asset; Coinbase supplies crypto only when the flag is enabled. Fresh namespaced chart caches avoid live network timing. This journey proves same-build relaunch filtering of a deliberately incompatible cached overlay; changing the flag and reinstalling on one retained app container is a separate upgrade check. Home Screen setup uses [widgetctl](https://github.com/DimaMishchenko/widgetctl), pinned as a GitHub dev dependency. It installs/configures the requested kind and size, preserves other apps’ widgets and unloads its helper after the module. Tests still validate rendered data and interactions. Set `CURRENCY_E2E_WIDGET_SETUP=gallery` to exercise system installation; private-configuration cases then skip. Lock Screen installation uses system UI. diff --git a/Apps/Currency/Tests/E2E/e2e.config.ts b/Apps/Currency/Tests/E2E/e2e.config.ts index 117a511..eed3d39 100644 --- a/Apps/Currency/Tests/E2E/e2e.config.ts +++ b/Apps/Currency/Tests/E2E/e2e.config.ts @@ -2,6 +2,7 @@ import { mobile } from '@e2e-dev/mobile'; import type { E2EConfig } from 'e2e'; import { chatgpt } from 'e2e/oauth/chatgpt'; import { targetName } from './device-profile.js'; +import { coinbaseEnabled } from './provider-configuration.js'; const udid = process.env.CURRENCY_E2E_UDID?.trim(); if (!udid) { @@ -15,7 +16,7 @@ export default { name: targetName, engine: mobile({ platform: 'ios', device: udid, session: `currency-e2e-${targetName}` }), app: { - identity: `currency-fixtures-v5-${process.env.CURRENCY_E2E_COINBASE ?? 'on'}`, + identity: `currency-fixtures-v6-${coinbaseEnabled}`, 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/provider-configuration.ts b/Apps/Currency/Tests/E2E/provider-configuration.ts new file mode 100644 index 0000000..0a32699 --- /dev/null +++ b/Apps/Currency/Tests/E2E/provider-configuration.ts @@ -0,0 +1,6 @@ +import { readFileSync } from 'node:fs'; + +const source = readFileSync(new URL('../../Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift', import.meta.url), 'utf8'); +const flag = source.match(/coinbaseEnabled\s*=\s*(true|false)\b/); +if (!flag) throw new Error('Expected the Boolean CurrencyRateConfiguration.coinbaseEnabled in source.'); +export const coinbaseEnabled = flag[1] === 'true'; diff --git a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts index c557c68..9088341 100644 --- a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts @@ -1,11 +1,9 @@ import { expect } from 'e2e'; import { test } from '../fixtures.js'; +import { coinbaseEnabled } from '../provider-configuration.js'; -const mode = process.env.CURRENCY_E2E_COINBASE ?? 'on'; -if (mode !== 'on' && mode !== 'off') { - throw new Error('CURRENCY_E2E_COINBASE must be on or off and match the installed build.'); -} -const enhanced = mode === 'on'; +const enhanced = coinbaseEnabled; +const mode = enhanced ? 'on' : 'off'; test(`provider policy ${mode} preserves crypto fallback, ranges and credits after restart`, { tags: ['core', 'providers'] }, async ({ start, app, agent, screen }) => { await start('ready-crypto'); diff --git a/Apps/CurrencyWatch/README.md b/Apps/CurrencyWatch/README.md index d9c7ed0..85884f6 100644 --- a/Apps/CurrencyWatch/README.md +++ b/Apps/CurrencyWatch/README.md @@ -31,11 +31,11 @@ Confirm `TeamIdentifier` matches the project team. Verify a custom pair such as ## Provider mode acceptance -Use one explicitly owned Watch simulator, separate enabled/disabled builds and device-targeted controls. `CURRENCY_DISABLE_COINBASE` must reach CurrencyApplication and both Watch executables; changing an assertion environment variable does not change the binary. Keep the independent Watch networking path active. +Use one explicitly owned Watch simulator and device-targeted controls. Set `CurrencyRateConfiguration.coinbaseEnabled` in code, then rebuild normally; the same value reaches both Watch executables. Keep the independent Watch networking path active. -1. Install the enabled CurrencyWatch app. Launch from a fresh container, add BTC to My Currencies and open Bitcoin details. The History range picker must offer One day alongside the daily ranges. Select it and verify a rendered BTC/USD chart or an explicit network issue; availability alone does not prove downloaded candles. +1. With the flag set to `true`, install CurrencyWatch. Launch from a fresh container, add BTC to My Currencies and open Bitcoin details. The History range picker must offer One day alongside the daily ranges. Select it and verify a rendered BTC/USD chart or an explicit network issue; availability alone does not prove downloaded candles. 2. Return, terminate and relaunch the app. Verify the selected pair and available One day range persist. Capture native accessibility state and screenshots with the owned UDID. -3. Terminate and replace the app with the disabled build while retaining its container. Open the same Bitcoin details. One day must be absent; daily ranges remain available. A saved Coinbase overlay must be excluded in favor of its Fawaz daily fallback, and an old hourly cache must not appear as a daily chart. +3. Set the flag to `false`, rebuild normally, then terminate and replace the app while retaining its container. Open the same Bitcoin details. One day must be absent; daily ranges remain available. A saved Coinbase overlay must be excluded in favor of its Fawaz daily fallback, and an old hourly cache must not appear as a daily chart. 4. Repeat a warm launch and verify the disabled capability and saved pair. If checking deterministic amounts, seed a coherent real `rates.json` with distinct Coinbase/Fawaz values and both daily fallbacks before launch; record whether direct network refresh replaced it. Seed chart data only in the matching `history---BTC-USD-.json` namespace. Use `serve-sim` with the Watch UDID for native controls and screenshots; the iPhone E2E runner does not support this surface. If the Watch accessibility tree contains only an application root, stop automated picker navigation and record that gap; `simctl openurl` may also be unsupported. Save local evidence outside tracked documentation. A compiled Watch target, fixture inspection or failed driver connection does not count as a completed native journey. Spoken Siri, physical companion transport and installed complication configuration remain separate acceptance checks. diff --git a/Documentation/Development.md b/Documentation/Development.md index 49e4f4e..fa5afd1 100644 --- a/Documentation/Development.md +++ b/Documentation/Development.md @@ -22,9 +22,9 @@ For command-line Xcode builds, use `set -o pipefail` and pipe combined output th ## Rate provider configuration -`CurrencyRateConfiguration` supplies one internal build flag to the phone app, widgets, independent Watch app and Watch widgets. Coinbase is enabled by default. Build every executable with `OTHER_SWIFT_FLAGS='$(inherited) -D CURRENCY_DISABLE_COINBASE'` to disable it; do not mix configurations across embedded targets. +`CurrencyRateConfiguration.coinbaseEnabled` is the shared code-level feature flag for the phone app, widgets, independent Watch app and Watch widgets. It is `true` by default; set it to `false` and rebuild normally to use only Fawaz. There are no compiler options or separate build configurations. -Both modes use Fawaz for daily rates and daily history. Enabled Coinbase adds current crypto rates and supported 24-hour hourly charts. Disabled builds hide 1D, normalize saved 1D widgets to 1M, exclude incompatible cached data, and reduce automatic rate attempts from 30 minutes to six hours. Foreground polling is one minute when enabled and six hours when disabled; WidgetKit controls actual timeline delivery. Explicit refresh remains available. +Both modes use Fawaz for daily rates and daily history. Enabled Coinbase adds current crypto rates and supported 24-hour hourly charts. When disabled, the app hides 1D, normalizes saved 1D widgets to 1M, excludes incompatible cached data, and reduces automatic rate attempts from 30 minutes to six hours. Foreground polling is one minute when enabled and six hours when disabled; WidgetKit controls actual timeline delivery. Explicit refresh remains available. Fawaz history starts on 2 March 2024. All uses month-end/current-month reference samples; the other daily ranges use available dated publications. Neither mode fabricates missing history or labels daily reference samples as exchange closes. diff --git a/Documentation/FeatureMap.md b/Documentation/FeatureMap.md index fcc3955..6cfada3 100644 --- a/Documentation/FeatureMap.md +++ b/Documentation/FeatureMap.md @@ -35,7 +35,7 @@ Owners: ExchangeRates domain, [CurrencyDetails](../Features/CurrencyDetails/Sour | ID | Capability / entry | Observable outcome | | --- | --- | --- | -| RATE-01 | Initial bootstrap, foreground refresh, explicit rate refresh | Fawaz supplies daily fiat, crypto and metal rates. The internal Coinbase build flag is enabled by default and adds current crypto rates with more frequent refresh attempts. Disabling it excludes Coinbase quotes from cached reads across the phone, widgets, Watch and system actions; loading, failures and daily fallback remain explicit. | +| RATE-01 | Initial bootstrap, foreground refresh, explicit rate refresh | Fawaz supplies daily fiat, crypto and metal rates. The internal Coinbase code-level flag is enabled by default and adds current crypto rates with more frequent refresh attempts. Disabling it excludes Coinbase quotes from cached reads across the phone, widgets, Watch and system actions; loading, failures and daily fallback 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 daily history supports 1W, 1M, 3M, 1Y, YTD and All, limited to its archive from March 2024; All uses monthly reference samples. Enabled Coinbase adds 1D completed hourly observations for supported crypto pairs. Disabled mode hides 1D and rejects incompatible cached history. Missing observations and cached fallback remain explicit. | From 6cd064e08b1a2c407a4d466a8e1d344e5326bfc2 Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Thu, 15 Oct 2026 11:37:07 +0200 Subject: [PATCH 05/17] =?UTF-8?q?=E2=9C=A8=20rates:=20replace=20Coinbase?= =?UTF-8?q?=20with=20Coin=20Metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add keyless crypto reference prices and widget history * bundle licensed monthly history and preserve daily fallbacks * update source notices, privacy terms and maintained validation --- .../HistoryWidgetAdapterTests.swift | 16 +- .../Sources/Composition/AppComposition.swift | 4 +- .../Composition/CurrencyE2EStartup.swift | 62 ++- .../Sources/CurrencyRateConfiguration.swift | 8 +- .../Tests/ConversionActionTests.swift | 4 +- .../LocalizationTests.swift | 14 +- .../ProviderPolicyIntegrationTests.swift | 7 +- Apps/Currency/Tests/E2E/README.md | 12 +- Apps/Currency/Tests/E2E/e2e.config.ts | 4 +- .../Tests/E2E/provider-configuration.ts | 6 +- .../Tests/E2E/tests/history-archive.e2e.ts | 32 ++ .../Tests/E2E/tests/provider-policy.e2e.ts | 31 +- .../Tests/E2E/tests/widget-display.e2e.ts | 2 +- .../E2E/tests/widget-live-provider.e2e.ts | 56 +++ Apps/CurrencyWatch/README.md | 8 +- Documentation/DataSources.md | 107 +++++ Documentation/Decisions.md | 2 +- Documentation/Development.md | 6 +- Documentation/FeatureMap.md | 10 +- Documentation/PrivacyPolicy.md | 6 +- Documentation/TermsOfUse.md | 10 +- Documentation/TestAudit.md | 72 ---- Documentation/ThirdPartyAssets.md | 4 + .../ExternalConversionTests.swift | 2 +- Domain/ExchangeRates/Package.swift | 3 +- Domain/ExchangeRates/README.md | 2 + .../ExchangeRates/BundledHistory.swift | 83 ++++ .../ExchangeRates/CoinMetricsClient.swift | 138 ++++++ .../ExchangeRates/CoinMetricsProvider.swift | 53 +++ .../ExchangeRates/CoinbaseProvider.swift | 55 --- .../ExchangeRates.docc/ExchangeRates.md | 2 +- .../Sources/ExchangeRates/FawazHistory.swift | 2 +- .../ExchangeRates/FrankfurterProvider.swift | 18 +- .../Sources/ExchangeRates/HTTPClient.swift | 23 - .../ExchangeRates/HistoryDecoding.swift | 46 -- .../Sources/ExchangeRates/HistoryModels.swift | 2 +- .../ExchangeRates/HistoryRequests.swift | 50 --- .../ExchangeRates/HistoryService.swift | 277 +++++++----- .../Sources/ExchangeRates/README.md | 10 +- .../ExchangeRates/RateProviderPolicy.swift | 14 +- .../Sources/ExchangeRates/RateService.swift | 8 +- .../Sources/ExchangeRates/RateSource.swift | 114 ++++- .../ExchangeRates/Resources/CC-BY-4.0.txt | 395 +++++++++++++++++ .../ExchangeRates/Resources/CC-BY-NC-4.0.txt | 407 ++++++++++++++++++ .../ExchangeRates/Resources/DataSources.md | 107 +++++ .../ExchangeRates/Resources/Fawaz-CC0-1.0.txt | 121 ++++++ .../Resources/history-monthly-v1.json | 1 + .../ExchangeRatesUI/CurrencyDisplay.swift | 5 +- .../ExchangeRatesUI/RateMessages.swift | 39 +- .../Resources/Support.xcstrings | 267 ++++++++++++ .../BatchRefreshTests.swift | 30 +- .../ExchangeRatesTests/BootstrapTests.swift | 6 +- .../CoinMetricsNormalizationTests.swift | 113 +++++ .../ExchangeRatesTests/CoinMetricsTests.swift | 209 +++++++++ .../FrankfurterPolicyTests.swift | 35 ++ .../HistoryConcurrencyTests.swift | 17 +- .../HistoryCrossRateTests.swift | 139 +++--- .../HistoryFailureTests.swift | 17 +- .../HistoryIntervalTests.swift | 260 ++++------- .../ExchangeRatesTests/HistoryTests.swift | 19 +- .../NetworkClientTests.swift | 27 +- .../ExchangeRatesTests/ProviderTests.swift | 24 -- .../RateProviderPolicyTests.swift | 40 +- .../ExchangeRatesTests/RateServiceTests.swift | 4 +- .../ExchangeRatesTests/RateSourceTests.swift | 22 +- .../SnapshotMergeTests.swift | 2 +- .../ExchangeRatesUITests/ResourceTests.swift | 31 +- .../CryptoHistoryWidgetHarness.swift | 43 +- Domain/Widgets/README.md | 4 +- .../Widgets/HistoryWidgetSnapshot.swift | 6 +- .../WidgetsTests/HistoryWidgetTests.swift | 8 +- .../CurrencyDetailsHarnessApp.swift | 5 +- .../CurrencyDetailsUI/RateDetailsScreen.swift | 3 + .../Resources/Details.xcstrings | 156 +++---- .../CurrencyDetailsTests.swift | 4 +- .../OnboardingTests/OnboardingTests.swift | 2 +- .../Sources/Settings/SettingsModel.swift | 7 +- .../SettingsUI/Resources/Settings.xcstrings | 230 +++++----- .../Sources/SettingsUI/SettingsScreen.swift | 22 +- .../SettingsTests/SettingsModelTests.swift | 9 +- LICENSES/CC-BY-4.0.txt | 395 +++++++++++++++++ LICENSES/CC-BY-NC-4.0.txt | 407 ++++++++++++++++++ LICENSES/Fawaz-CC0-1.0.txt | 121 ++++++ LICENSES/README.md | 9 + Project.swift | 1 + Scripts/History/generate_archive.py | 57 +++ 86 files changed, 4100 insertions(+), 1111 deletions(-) create mode 100644 Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts create mode 100644 Apps/Currency/Tests/E2E/tests/widget-live-provider.e2e.ts create mode 100644 Documentation/DataSources.md delete mode 100644 Documentation/TestAudit.md create mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/BundledHistory.swift create mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsClient.swift create mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsProvider.swift delete mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/CoinbaseProvider.swift delete mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/HistoryDecoding.swift delete mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/HistoryRequests.swift create mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/Resources/CC-BY-4.0.txt create mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/Resources/CC-BY-NC-4.0.txt create mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/Resources/DataSources.md create mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/Resources/Fawaz-CC0-1.0.txt create mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/Resources/history-monthly-v1.json create mode 100644 Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsNormalizationTests.swift create mode 100644 Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsTests.swift create mode 100644 Domain/ExchangeRates/Tests/ExchangeRatesTests/FrankfurterPolicyTests.swift create mode 100644 LICENSES/CC-BY-4.0.txt create mode 100644 LICENSES/CC-BY-NC-4.0.txt create mode 100644 LICENSES/Fawaz-CC0-1.0.txt create mode 100644 LICENSES/README.md create mode 100644 Scripts/History/generate_archive.py diff --git a/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift b/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift index 345ecf8..211f82f 100644 --- a/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift +++ b/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift @@ -11,12 +11,12 @@ import Widgets @Suite struct HistoryWidgetAdapterTests { private let now = Date(timeIntervalSince1970: 1_790_035_200) private var cryptoRanges: [HistoryWidgetRange] { - CurrencyRateConfiguration.coinbaseEnabled + CurrencyRateConfiguration.intradayEnabled ? HistoryWidgetRange.allCases : HistoryWidgetRange.allCases.filter { $0 != .day } } private var effectiveDay: HistoryRange { - CurrencyRateConfiguration.coinbaseEnabled ? .day : .month + CurrencyRateConfiguration.intradayEnabled ? .day : .month } @Test func liveBuildPolicyControlsSavedDayRangeAndTimeline() async { @@ -34,7 +34,7 @@ import Widgets settings.comparison = HistoryCurrency("USD") settings.range = .day let timeline = await provider.loadTimeline(settings) - let enhanced = CurrencyRateConfiguration.coinbaseEnabled + let enhanced = CurrencyRateConfiguration.intradayEnabled #expect(requests.withLock { $0 } == [enhanced ? .day : .month]) #expect(settings.range == .day) #expect(settings.availableRanges(input: ConverterState()).contains(.day) == enhanced) @@ -198,7 +198,7 @@ import Widgets #expect( settings.availableRanges(input: input, localCurrency: "USD") == cryptoRanges) - #expect(settings.availableRanges(input: input, localCurrency: "CZK") == fiatRanges) + #expect(settings.availableRanges(input: input, localCurrency: "CZK") == cryptoRanges) #expect(settings.availableRanges(input: input) == fiatRanges) } @@ -376,7 +376,7 @@ import Widgets points: [ HistoryPoint(date: now.addingTimeInterval(-86_400), value: 50_000), HistoryPoint(date: now, value: 100_000) - ], source: .init(provider: .coinbase), fetchedAt: now), issue: nil) + ], source: .init(provider: .coinMetrics), fetchedAt: now), issue: nil) }, now: { now })) let settings = HistorySettings() settings.base = HistoryCurrency("USD") @@ -405,7 +405,7 @@ import Widgets points: [ HistoryPoint(date: now.addingTimeInterval(-86_400), value: 100), HistoryPoint(date: now, value: 120) - ], source: .init(provider: .custom("Coinbase + Frankfurter")), fetchedAt: now), + ], source: .init(provider: .custom("Coin Metrics + Frankfurter")), fetchedAt: now), issue: nil) }, now: { now })) let settings = HistorySettings() @@ -446,7 +446,7 @@ import Widgets #expect(crypto.entries.first?.snapshot.range == effectiveDay) #expect( crypto.policy - == .after(now.addingTimeInterval(CurrencyRateConfiguration.coinbaseEnabled ? 3600 : 86_400)) + == .after(now.addingTimeInterval(CurrencyRateConfiguration.intradayEnabled ? 3600 : 86_400)) ) settings.base = HistoryCurrency("EUR") @@ -482,7 +482,7 @@ import Widgets #expect( timeline.policy == .after( - range == .day && CurrencyRateConfiguration.coinbaseEnabled + range == .day && CurrencyRateConfiguration.intradayEnabled ? HistoryService.nextHour(after: now) : now.addingTimeInterval(86_400))) } diff --git a/Apps/Currency/App/Sources/Composition/AppComposition.swift b/Apps/Currency/App/Sources/Composition/AppComposition.swift index 623d6c2..47dd489 100644 --- a/Apps/Currency/App/Sources/Composition/AppComposition.swift +++ b/Apps/Currency/App/Sources/Composition/AppComposition.swift @@ -47,7 +47,7 @@ final class AppComposition { refreshLocalCurrency: { [weak self] in await self?.foregroundLocation.refreshIfNeeded() }, changed: { [weak self] in self?.changed() }, sleepRates: { [policy] in - try await Task.sleep(for: .seconds(policy == .coinbaseEnhanced ? 60 : 21_600)) + try await Task.sleep(for: .seconds(policy == .intradayEnhanced ? 60 : 21_600)) }, sleepLocalCurrency: { try await Task.sleep(for: .seconds(300)) })) } @@ -226,7 +226,7 @@ final class AppComposition { refresh: { [self] in _ = try await refresh(force: true); return settingsState }, - replay: { try scene.restartOnboarding() }, usesCoinbase: policy == .coinbaseEnhanced, + replay: { try scene.restartOnboarding() }, output: { [self] output in if case .manageLocation = output { routeLocationPermission( diff --git a/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift b/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift index 48a84ad..71dbaa3 100644 --- a/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift +++ b/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift @@ -77,7 +77,7 @@ let rates = RateStore(directory: directory, policy: policy) let daily = FixedRateClient.quotes(now: now) var effective = daily - effective["BTC"] = FixedRateClient.coinbaseQuote(now: now) + effective["BTC"] = FixedRateClient.coinMetricsQuote(now: now) try FileCoordination.write(at: directory.appendingPathComponent("rates.json")) { try RateCache(directory: directory) .save( @@ -85,7 +85,7 @@ quotes: effective, fetchedAt: now, dailyQuotes: daily, dailyFetchedAt: now, checkedAt: now, supplementalFetchedAt: now, supplementalQuotes: daily)) } - for mode in [RateProviderPolicy.daily, .coinbaseEnhanced] { + for mode in [RateProviderPolicy.daily, .intradayEnhanced] { try seedHistory( base: "EUR", quote: "USD", values: [1.8, 2], mode: mode, directory: directory, now: now) @@ -96,14 +96,27 @@ base: "BTC", quote: "USD", values: [90_000, 100_000], mode: mode, directory: directory, now: now) } - let hourly = HistorySeries( + let end = Date(timeIntervalSince1970: floor(now.timeIntervalSince1970 / 300) * 300) + 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) + }, source: .init(provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt), + fetchedAt: now) + try writeHistory( + intraday, filename: "history-v2-intradayEnhanced-BTC-USD-1.json", + directory: directory) + let legacy = HistorySeries( points: [ - HistoryPoint(date: now.addingTimeInterval(-2 * 3600), value: 45_000), - HistoryPoint(date: now.addingTimeInterval(-3600), value: 50_000) - ], source: .init(provider: .coinbase, observation: .hourlyClose, timeZone: .gmt), + HistoryPoint(date: now.addingTimeInterval(-3600), value: 1), + HistoryPoint(date: now, value: 2) + ], + source: .init(provider: .custom("Coinbase"), observation: .hourlyClose, timeZone: .gmt), fetchedAt: now) try writeHistory( - hourly, filename: "history-coinbaseEnhanced-coinbase-BTC-USD-1.json", + legacy, filename: "history-coinbaseEnhanced-coinbase-BTC-USD-1.json", directory: directory) let files = [ "input.json", "onboarding.json", "widget-location-refresh.json", @@ -153,9 +166,9 @@ let seededRates = rates.loadRates() guard seededRates.quotes["EUR"]?.value == 1, seededRates.quotes["USD"]?.value == 2, seededRates.quotes["CHF"]?.value == 0.5, seededRates.quotes["CZK"]?.value == 25, - seededRates.quotes["BTC"]?.value == (policy == .coinbaseEnhanced ? 0.00004 : 0.00002), + seededRates.quotes["BTC"]?.value == (policy == .intradayEnhanced ? 0.00004 : 0.00002), seededRates.quotes["BTC"]?.source.provider - == (policy == .coinbaseEnhanced ? .coinbase : .fawaz), + == (policy == .intradayEnhanced ? .coinMetrics : .fawaz), discoveryStore.load() == discovery else { throw StartupError.seedVerificationFailed } } @@ -182,14 +195,15 @@ ], source: .init( provider: .fawaz, - observation: range == .all ? .monthlyReference : .dailyReference, timeZone: .gmt), + observation: range == .all ? .monthlyReference : .dailyReference, timeZone: .gmt, + contributingProviders: range == .all ? [.coinMetrics, .frankfurter] : nil), fetchedAt: now) let interval = range == .yearToDate ? "\(range.rawValue)-\(calendar.component(.year, from: now))" : "\(range.rawValue)" try writeHistory( series, - filename: "history-\(mode.rawValue)-fawaz-\(base)-\(quote)-\(interval).json", + filename: "history-v2-\(mode.rawValue)-\(base)-\(quote)-\(interval).json", directory: directory) } } @@ -212,15 +226,17 @@ private struct FixedRateClient: HTTPClient { func get(_ url: URL) async throws -> Data { try Task.checkCancellation() - if url.host == "api.coinbase.com", url.path == "/v2/exchange-rates", - CurrencyRateConfiguration.coinbaseEnabled + if url.host == "community-api.coinmetrics.io", + url.path == "/v4/timeseries/asset-metrics", + CurrencyRateConfiguration.intradayEnabled { - var values = Dictionary( - uniqueKeysWithValues: CurrencyCatalog.crypto.map { ($0, "0.00004") }) - values["EUR"] = "1" - return try JSONSerialization.data(withJSONObject: [ - "data": ["currency": "EUR", "rates": values] - ]) + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + 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"] + } + return try JSONSerialization.data(withJSONObject: ["data": rows]) } guard ["cdn.jsdelivr.net", "latest.currency-api.pages.dev"].contains(url.host ?? ""), url.path.hasSuffix("/currencies/eur.min.json") @@ -250,10 +266,14 @@ } } - static func coinbaseQuote(now: Date) -> ExchangeRate { + static func coinMetricsQuote(now: Date) -> ExchangeRate { ExchangeRate( 0.00004, published: day(now), - source: .init(provider: .coinbase, observation: .exchangeRate), retrievedAt: now) + source: .init( + provider: .coinMetrics, observation: .minuteReference, + conversion: RateConversion( + provider: .fawaz, published: day(now), value: 2, base: "EUR", quote: "USD")), + observedAt: now, retrievedAt: now) } } #endif diff --git a/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift b/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift index 1aeec94..99317a5 100644 --- a/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift +++ b/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift @@ -3,17 +3,17 @@ import Foundation /// Shared provider configuration for the app, extensions and independent Watch app. public enum CurrencyRateConfiguration { - /// Enables Coinbase current crypto quotes and supported 24-hour charts. - public static let coinbaseEnabled = true + /// Enables Coin Metrics current crypto reference prices and 24-hour charts. + public static let intradayEnabled = true /// Provider capabilities selected at each executable composition root. public static var policy: RateProviderPolicy { - coinbaseEnabled ? .coinbaseEnhanced : .daily + intradayEnabled ? .intradayEnhanced : .daily } /// Foreground polling respects the available provider update frequency. public static var foregroundRefreshInterval: TimeInterval { - coinbaseEnabled ? 60 : 21_600 + intradayEnabled ? 60 : 21_600 } /// Requested widget refresh cadence; the system decides actual execution times. diff --git a/Apps/Currency/Modules/CurrencyApplication/Tests/ConversionActionTests.swift b/Apps/Currency/Modules/CurrencyApplication/Tests/ConversionActionTests.swift index 729dbc3..e35fb8a 100644 --- a/Apps/Currency/Modules/CurrencyApplication/Tests/ConversionActionTests.swift +++ b/Apps/Currency/Modules/CurrencyApplication/Tests/ConversionActionTests.swift @@ -81,7 +81,7 @@ import Testing #expect(evaluation.results.first?.amount == "12.5") } - @Test(arguments: [RateProviderPolicy.daily, .coinbaseEnhanced]) + @Test(arguments: [RateProviderPolicy.daily, .intradayEnhanced]) func headlessConversionsRespectProviderAttemptCadence(policy: RateProviderPolicy) async throws { let now = Date.now let attempts = ProviderAttemptCounter() @@ -101,7 +101,7 @@ import Testing let request = try action.request(amount: "10", source: "EUR", destination: "USD") let result = try await action.perform(request) #expect(result.results.first?.amount == "20") - #expect(await attempts.count == (policy == .coinbaseEnhanced ? 1 : 0)) + #expect(await attempts.count == (policy == .intradayEnhanced ? 1 : 0)) } private func make( diff --git a/Apps/Currency/Tests/ApplicationIntegrationTests/LocalizationTests.swift b/Apps/Currency/Tests/ApplicationIntegrationTests/LocalizationTests.swift index 986e204..4a75d26 100644 --- a/Apps/Currency/Tests/ApplicationIntegrationTests/LocalizationTests.swift +++ b/Apps/Currency/Tests/ApplicationIntegrationTests/LocalizationTests.swift @@ -77,8 +77,8 @@ import Testing == "24-hour history is unavailable for fiat currencies. Choose 1W or a longer range.") #expect( RateMessages.providerDescription( - .init(provider: .coinbase, observation: .hourlyClose, timeZone: .gmt), - locale: Locale(identifier: "en_US")) == "Coinbase · hourly closes · GMT") + .init(provider: .coinMetrics, observation: .hourlyClose, timeZone: .gmt), + locale: Locale(identifier: "en_US")) == "Coin Metrics · hourly closes · GMT") } @Test func customProviderUsesLocalizedObservationTemplate() { @@ -88,13 +88,13 @@ import Testing RateMessages.providerDescription(source, locale: Locale(identifier: "en")) == "Example feed · monthly reference rates") #expect( - RateMessages.providerDescription(.init(provider: .coinbase, observation: .exchangeRate)) - == "Coinbase · retrieved") + RateMessages.providerDescription(.init(provider: .coinMetrics, observation: .exchangeRate)) + == "Coin Metrics · retrieved") #expect( String(localized: .Details.rateRetrieved("today")) - == "Retrieved today · market timestamp unavailable") - let daily = RateSource(provider: .coinbase, observation: .dailyClose, timeZone: .gmt) - #expect(RateMessages.providerDescription(daily).hasPrefix("Coinbase · daily closes · ")) + == "Retrieved today") + let daily = RateSource(provider: .coinMetrics, observation: .dailyClose, timeZone: .gmt) + #expect(RateMessages.providerDescription(daily).hasPrefix("Coin Metrics · daily closes · ")) } @Test(arguments: [ diff --git a/Apps/Currency/Tests/ApplicationIntegrationTests/ProviderPolicyIntegrationTests.swift b/Apps/Currency/Tests/ApplicationIntegrationTests/ProviderPolicyIntegrationTests.swift index 5b18462..b1aada1 100644 --- a/Apps/Currency/Tests/ApplicationIntegrationTests/ProviderPolicyIntegrationTests.swift +++ b/Apps/Currency/Tests/ApplicationIntegrationTests/ProviderPolicyIntegrationTests.swift @@ -6,7 +6,7 @@ import Testing @MainActor struct ProviderPolicyIntegrationTests { - @Test(arguments: [RateProviderPolicy.daily, .coinbaseEnhanced]) + @Test(arguments: [RateProviderPolicy.daily, .intradayEnhanced]) func everyPhoneEntryUsesTheSelectedPolicy(policy: RateProviderPolicy) throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let suite = UUID().uuidString @@ -23,7 +23,7 @@ struct ProviderPolicyIntegrationTests { ] var quotes = daily quotes["BTC"] = .init( - 0.02, published: day, source: .init(provider: .coinbase, observation: .exchangeRate), + 0.02, published: day, source: .init(provider: .coinMetrics, observation: .exchangeRate), retrievedAt: now) try RateCache(directory: directory) .save( @@ -41,8 +41,7 @@ struct ProviderPolicyIntegrationTests { .convert(100, from: "EUR", to: "BTC") == expected) let settings = composition.settings(scene: composition.makeScene()) #expect(settings.readState().snapshot.convert(100, from: "EUR", to: "BTC") == expected) - #expect(settings.usesCoinbase == (policy == .coinbaseEnhanced)) - #expect(composition.details.supportsIntraday("BTC", "USD") == (policy == .coinbaseEnhanced)) + #expect(composition.details.supportsIntraday("BTC", "USD") == (policy == .intradayEnhanced)) #expect(!composition.details.supportsIntraday("EUR", "USD")) #expect(composition.history.policy == policy) } diff --git a/Apps/Currency/Tests/E2E/README.md b/Apps/Currency/Tests/E2E/README.md index a1b6ef9..9b9b41a 100644 --- a/Apps/Currency/Tests/E2E/README.md +++ b/Apps/Currency/Tests/E2E/README.md @@ -57,7 +57,9 @@ These are representative outcomes, not exhaustive coverage of each feature ID. | ONB-01–02 | [onboarding](tests/onboarding.e2e.ts) | Currency selection, Back, completion and restart | | CONV-01, CONV-05 | [converter](tests/converter.e2e.ts) | EUR 42 → USD 84 persists after restart | | 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/Coinbase conversion, history provenance, 1D capability, source credits and saved-cache filtering after restart (run both builds) | +| 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, approximate conversion notices, 1D capability, source credits and saved-cache filtering after restart (run both builds) | +| 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, 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 and the intraday flag | | HIST-01–04 | [converter-details](tests/converter-details.e2e.ts) | Rate, period selection, source explanation and return | | 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 | @@ -75,15 +77,15 @@ 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 Fawaz history caches for EUR/USD, EUR/Gold and BTC/USD. `ready-crypto` starts USD 1 → Bitcoin: 0.00001 from Fawaz, or 0.00002 with Coinbase enabled. Its stored snapshot deliberately contains a Coinbase overlay plus the daily fallback, so disabling the flag exercises cache exclusion. 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 and BTC/USD. `ready-crypto` starts USD 1 → Bitcoin: 0.00001 from Fawaz, or 0.00002 with Coin Metrics enabled. Its stored snapshot deliberately contains a Coin Metrics overlay plus the daily fallback, and an old-provider chart cache is retained as a rejection fixture, so disabling the flag exercises cache exclusion. Restart omits fixture state and preserves the real stores. Keep `-CurrencyE2EState` out of default launch arguments. -Run the same provider journey with the normal app build. It reads `CurrencyRateConfiguration.coinbaseEnabled` from source for its expected outcomes and cache identity. After changing that Boolean, rebuild the app before rerunning; no environment mode or special test suite is needed. +Run the same provider journey with the normal app build. It reads `CurrencyRateConfiguration.intradayEnabled` from source for its expected outcomes and cache identity. After changing that Boolean, rebuild the app before rerunning; no environment mode or special test suite is needed. ```sh npm run test:e2e -- tests/provider-policy.e2e.ts ``` -The fixtures use the production provider decoders with a fixed HTTP client. Fawaz supplies every asset; Coinbase supplies crypto only when the flag is enabled. Fresh namespaced chart caches avoid live network timing. This journey proves same-build relaunch filtering of a deliberately incompatible cached overlay; changing the flag and reinstalling on one retained app container is a separate upgrade check. +The fixtures use the production provider decoders with a fixed HTTP client. Fawaz supplies every asset; Coin Metrics supplies crypto only when the flag is enabled. Fresh v2 chart caches include a full day of five-minute sampled reference observations and monthly All provenance; they avoid live network timing. These fixture journeys do not prove live provider coverage or provider-shortfall handling. This journey proves same-build relaunch filtering of a deliberately incompatible cached overlay; changing the flag and reinstalling on one retained app container is a separate upgrade check. Home Screen setup uses [widgetctl](https://github.com/DimaMishchenko/widgetctl), pinned as a GitHub dev dependency. It installs/configures the requested kind and size, preserves other apps’ widgets and unloads its helper after the module. Tests still validate rendered data and interactions. Set `CURRENCY_E2E_WIDGET_SETUP=gallery` to exercise system installation; private-configuration cases then skip. Lock Screen installation uses system UI. @@ -113,3 +115,5 @@ xcrun simctl shutdown "$CURRENCY_E2E_UDID" ``` Watch metal measurement (WATCH-14) uses the [maintained native Watch journey](../../../CurrencyWatch/README.md#metal-measurement-acceptance). The iPhone E2E driver does not cover Watch. Numerical, stale-draft and preference-sync regressions remain in the native unit test targets. + +The `live-providers` journey is an explicit network acceptance check: `npx e2e run tests/widget-live-provider.e2e.ts`. It terminates the app, removes only the test-owned BTC/USD 1D cache, configures the installed History widget, and asserts the extension wrote positive minute-reference data spanning at least 23h45m before capturing its UI. Provider availability failures remain failures; no fabricated or daily history replaces 1D. diff --git a/Apps/Currency/Tests/E2E/e2e.config.ts b/Apps/Currency/Tests/E2E/e2e.config.ts index eed3d39..b7f8502 100644 --- a/Apps/Currency/Tests/E2E/e2e.config.ts +++ b/Apps/Currency/Tests/E2E/e2e.config.ts @@ -2,7 +2,7 @@ import { mobile } from '@e2e-dev/mobile'; import type { E2EConfig } from 'e2e'; import { chatgpt } from 'e2e/oauth/chatgpt'; import { targetName } from './device-profile.js'; -import { coinbaseEnabled } from './provider-configuration.js'; +import { intradayEnabled } from './provider-configuration.js'; const udid = process.env.CURRENCY_E2E_UDID?.trim(); if (!udid) { @@ -16,7 +16,7 @@ export default { name: targetName, engine: mobile({ platform: 'ios', device: udid, session: `currency-e2e-${targetName}` }), app: { - identity: `currency-fixtures-v6-${coinbaseEnabled}`, + identity: `currency-fixtures-v7-CM-${intradayEnabled}`, 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/provider-configuration.ts b/Apps/Currency/Tests/E2E/provider-configuration.ts index 0a32699..943b961 100644 --- a/Apps/Currency/Tests/E2E/provider-configuration.ts +++ b/Apps/Currency/Tests/E2E/provider-configuration.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; const source = readFileSync(new URL('../../Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift', import.meta.url), 'utf8'); -const flag = source.match(/coinbaseEnabled\s*=\s*(true|false)\b/); -if (!flag) throw new Error('Expected the Boolean CurrencyRateConfiguration.coinbaseEnabled in source.'); -export const coinbaseEnabled = flag[1] === 'true'; +const flag = source.match(/intradayEnabled\s*=\s*(true|false)\b/); +if (!flag) throw new Error('Expected the Boolean CurrencyRateConfiguration.intradayEnabled in source.'); +export const intradayEnabled = flag[1] === 'true'; diff --git a/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts b/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts new file mode 100644 index 0000000..4b9a281 --- /dev/null +++ b/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts @@ -0,0 +1,32 @@ +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'; +import { intradayEnabled } from '../provider-configuration.js'; + +test('All history renders the actual bundled Bitcoin archive from 2010', { tags: ['core', 'providers'] }, async ({ start, screen, agent, app }) => { + await start('ready-crypto'); + 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 cache = join(directory, `history-v2-${intradayEnabled ? 'intradayEnhanced' : 'daily'}-BTC-USD-0.json`); + if (existsSync(cache)) unlinkSync(cache); + await agent.act('Open Details & history for the Bitcoin destination currency.'); + await expect(screen.getByTestId('currency.details.BTC')).toBeVisible(); + const all = screen.getByTestId('currency.details.historyRange').getByRole('tab', 'All', { exact: true }); + await all.tap(); + await expect(all).toBeSelected(); + const source = screen.getByTestId('currency.details.sourceDisclosure').filter({ hasText: /Fawaz.*Coin Metrics.*monthly reference/i }); + await expect(source).toBeVisible(); + assert.ok(existsSync(cache)); + const saved = JSON.parse(readFileSync(cache, 'utf8')) as { points: { date: number; value: number }[] }; + assert.ok(saved.points.length >= 190); + assert.equal(new Date((saved.points[0]!.date + 978_307_200) * 1000).getUTCFullYear(), 2010); + assert.ok(saved.points.every(point => point.value > 0 && Number.isFinite(point.value))); + await app.screenshot('bitcoin-actual-bundled-all-history-from-2010'); + await source.tap(); + await expect(source).toHaveValue('Expanded'); + await agent.waitFor('Bitcoin All history is shown with a visible historical chart and the expanded source information identifies Fawaz and Coin Metrics monthly reference data. The date range starts in 2010.', { vision: 'only', timeout: 30_000 }); + await app.screenshot('bitcoin-actual-bundled-all-history-source-dates'); +}); diff --git a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts index 9088341..26fa503 100644 --- a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts @@ -1,8 +1,8 @@ import { expect } from 'e2e'; import { test } from '../fixtures.js'; -import { coinbaseEnabled } from '../provider-configuration.js'; +import { intradayEnabled } from '../provider-configuration.js'; -const enhanced = coinbaseEnabled; +const enhanced = intradayEnabled; const mode = enhanced ? 'on' : 'off'; test(`provider policy ${mode} preserves crypto fallback, ranges and credits after restart`, { tags: ['core', 'providers'] }, async ({ start, app, agent, screen }) => { @@ -20,21 +20,31 @@ test(`provider policy ${mode} preserves crypto fallback, ranges and credits afte const ranges = screen.getByTestId('currency.details.historyRange'); const day = ranges.getByRole('tab', 'One day', { exact: true }); const source = screen.getByTestId('currency.details.sourceDisclosure') - .filter({ hasText: /daily reference rates|hourly closes/i }); + .filter({ hasText: /daily reference rates|monthly reference rates|minute reference prices/i }); await expect(source.filter({ hasText: /Fawaz.*daily reference rates/i })).toBeVisible(); if (enhanced) { await expect(day).toBeVisible(); await day.tap(); await expect(day).toBeSelected(); - await expect(source.filter({ hasText: /Coinbase.*hourly closes/i })).toBeVisible(); + await expect(source.filter({ hasText: /Coin Metrics.*minute reference prices/i })).toBeVisible(); } else { await expect(day).toBeHidden(); - await expect(screen.getByText(/Coinbase/i)).toBeHidden(); + await expect(screen.getByText(/Coin Metrics/i)).toBeHidden(); + } + const all = ranges.getByRole('tab', 'All available history', { exact: true }); + await all.tap(); + await expect(all).toBeSelected(); + await expect(source.filter({ hasText: /monthly reference rates/i })).toBeVisible(); + await expect(source.filter({ hasText: /Coin Metrics.*Frankfurter|Frankfurter.*Coin Metrics/i })).toBeVisible(); + await app.screenshot(`${capture}-all-reference-history`); + if (enhanced) { + await day.tap(); + await expect(day).toBeSelected(); } await source.tap(); await expect(source).toHaveValue('Expanded'); - await agent.act('Scroll in Details until the exact text "Crypto history uses daily rates and completed hourly candles when available. Historical and current conversion sources may differ." is visible. Use that exact text when scrolling to text. Keep Details open.'); - await expect(screen.getByText(enhanced ? /Coinbase.*retrieved/i : /Fawaz · daily ·/i)).toBeVisible(); + 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.'); + await expect(screen.getByText(enhanced ? /Coin Metrics.*minute reference prices.*approximate conversion/i : /Fawaz · daily ·/i)).toBeVisible(); await app.screenshot(capture); await agent.act('Close Details & history with the circular X at the top right and return to the converter.'); await expect(screen.getByTestId('currency.details.close')).toBeHidden(); @@ -51,9 +61,8 @@ test(`provider policy ${mode} preserves crypto fallback, ranges and credits afte await screen.getByTestId('converter.settings').tap(); await screen.getByTestId('settings.sources').tap(); await expect(screen.getByText('Fawaz Exchange API', { exact: true })).toBeVisible(); - const coinbase = screen.getByText('Coinbase', { exact: true }); - if (enhanced) await expect(coinbase).toBeVisible(); - else await expect(coinbase).toBeHidden(); - await expect(screen.getByText('Frankfurter', { exact: true })).toBeHidden(); + const coinMetrics = screen.getByText('Coin Metrics', { exact: true }); + await expect(coinMetrics).toBeVisible(); + await expect(screen.getByText('Frankfurter', { exact: true })).toBeVisible(); await app.screenshot(`provider-${mode}-credits`); }); diff --git a/Apps/Currency/Tests/E2E/tests/widget-display.e2e.ts b/Apps/Currency/Tests/E2E/tests/widget-display.e2e.ts index 7891506..0cd4092 100644 --- a/Apps/Currency/Tests/E2E/tests/widget-display.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/widget-display.e2e.ts @@ -36,7 +36,7 @@ test('medium Currency board displays default amounts and follows app amount chan test('medium Know Your Cash displays the CZK 100 banknote estimate in EUR', { tags: displayTags }, async ({ installedWidget, app, agent }) => { const cash = await installedWidget('Know Your Cash', 'Medium'); await expect(cash).toBeVisible(); - await agent.waitFor(`${loadedContent} The medium Know Your Cash widget is configured with base CZK and target EUR. Its main banknote amount is exactly 100 CZK and the adjacent converted estimate is exactly approximately 4 EUR, shown with the approximation sign. This is the main estimate, not just a preset button.`, { vision: 'only', timeout: 30_000 }); + await agent.waitFor(`${loadedContent} Know Your Cash shows a main banknote amount of exactly 100 CZK and an adjacent converted estimate of approximately 4 EUR, including the approximation sign. This is the main estimate, not just a preset button.`, { vision: 'only', timeout: 30_000 }); await app.screenshot('widget-cash-medium-czk-100-eur-4'); }); diff --git a/Apps/Currency/Tests/E2E/tests/widget-live-provider.e2e.ts b/Apps/Currency/Tests/E2E/tests/widget-live-provider.e2e.ts new file mode 100644 index 0000000..2c8df49 --- /dev/null +++ b/Apps/Currency/Tests/E2E/tests/widget-live-provider.e2e.ts @@ -0,0 +1,56 @@ +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'; +import { intradayEnabled } from '../provider-configuration.js'; +import { configureWidget, placeWidget, prepareWidgetHelper, unloadWidgetHelper, widgetSetupMethod } from '../simulator-widgets.js'; + +test('History widget fetches a complete live Coin Metrics day with the app terminated', { tags: ['extended', 'live-providers'] }, async ({ start, app, device, screen, agent }) => { + if (!intradayEnabled || widgetSetupMethod() !== 'private') { + test.skip('Requires the intraday code flag and device-targeted widget configuration.'); + return; + } + await prepareWidgetHelper(); + await start('ready-crypto'); + await expect(screen.getByRole('button', /^Bitcoin,/i)).toHaveAccessibleName('Bitcoin, 0.00002'); + 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 cache = join(directory, 'history-v2-intradayEnhanced-BTC-USD-1.json'); + await device.closeApp(); + if (existsSync(cache)) unlinkSync(cache); + assert.equal(existsSync(cache), false); + const started = Date.now(); + await device.openApp('com.apple.springboard'); + await placeWidget('History', 'Medium'); + await configureWidget('History', 'Medium', parameters => { + parameters.base = { identifier: 'BTC', title: { key: 'BTC' }, subtitle: { key: 'BTC' } }; + parameters.comparison = { identifier: 'USD', title: { key: 'USD' }, subtitle: { key: 'USD' } }; + parameters.range = { identifier: 'day', value: 'day', title: { key: '1 day' } }; + }); + const widget = device.locator('id=Currency value=Widget'); + await expect(widget).toBeVisible(); + for (let attempt = 0; attempt < 180 && !existsSync(cache); attempt++) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + assert.ok(existsSync(cache), 'The independent widget must persist the fetched live chart.'); + const saved = JSON.parse(readFileSync(cache, 'utf8')) as { + points: { date: number; value: number }[]; + fetchedAt: number; + source: { provider: { coinMetrics?: object }; observation: string }; + }; + assert.ok(saved.source.provider.coinMetrics); + assert.equal(saved.source.observation, 'minuteReference'); + assert.ok(saved.points.length >= 270); + assert.ok(saved.points.at(-1)!.date - saved.points[0]!.date >= 85_500); + assert.ok(saved.points.every(point => point.value > 0 && Number.isFinite(point.value))); + assert.ok((saved.fetchedAt + 978_307_200) * 1000 >= started); + await expect(widget.getByLabel('One day', { exact: true })).toBeVisible(); + await agent.waitFor('The installed History widget shows BTC and USD, a loaded 1D chart with a visible trend and readable values. It does not show History unavailable or missing-value dashes.', { vision: 'only', timeout: 30_000 }); + await app.screenshot('widget-live-coin-metrics-day-app-terminated'); +}); + +test.afterAll(async () => { + await unloadWidgetHelper(); +}); diff --git a/Apps/CurrencyWatch/README.md b/Apps/CurrencyWatch/README.md index 85884f6..5fe87a9 100644 --- a/Apps/CurrencyWatch/README.md +++ b/Apps/CurrencyWatch/README.md @@ -31,12 +31,12 @@ Confirm `TeamIdentifier` matches the project team. Verify a custom pair such as ## Provider mode acceptance -Use one explicitly owned Watch simulator and device-targeted controls. Set `CurrencyRateConfiguration.coinbaseEnabled` in code, then rebuild normally; the same value reaches both Watch executables. Keep the independent Watch networking path active. +Use one explicitly owned Watch simulator and device-targeted controls. Set `CurrencyRateConfiguration.intradayEnabled` in code, then rebuild normally; the same value reaches both Watch executables. Keep the independent Watch networking path active. -1. With the flag set to `true`, install CurrencyWatch. Launch from a fresh container, add BTC to My Currencies and open Bitcoin details. The History range picker must offer One day alongside the daily ranges. Select it and verify a rendered BTC/USD chart or an explicit network issue; availability alone does not prove downloaded candles. +1. With the flag set to `true`, install CurrencyWatch. Launch from a fresh container, add BTC to My Currencies and open Bitcoin details. The History range picker must offer One day alongside the daily ranges. Select it and verify a rendered BTC/USD chart or an explicit network issue; availability alone does not prove downloaded reference observations. 2. Return, terminate and relaunch the app. Verify the selected pair and available One day range persist. Capture native accessibility state and screenshots with the owned UDID. -3. Set the flag to `false`, rebuild normally, then terminate and replace the app while retaining its container. Open the same Bitcoin details. One day must be absent; daily ranges remain available. A saved Coinbase overlay must be excluded in favor of its Fawaz daily fallback, and an old hourly cache must not appear as a daily chart. -4. Repeat a warm launch and verify the disabled capability and saved pair. If checking deterministic amounts, seed a coherent real `rates.json` with distinct Coinbase/Fawaz values and both daily fallbacks before launch; record whether direct network refresh replaced it. Seed chart data only in the matching `history---BTC-USD-.json` namespace. +3. Set the flag to `false`, rebuild normally, then terminate and replace the app while retaining its container. Open the same Bitcoin details. One day must be absent; daily ranges remain available. A saved Coin Metrics overlay must be excluded in favor of its Fawaz daily fallback, and an old intraday cache must not appear as a daily chart. +4. Repeat a warm launch and verify the disabled capability and saved pair. If checking deterministic amounts, seed a coherent real `rates.json` with distinct Coin Metrics/Fawaz values and both daily fallbacks before launch; record whether direct network refresh replaced it. Seed chart data only in the matching `history-v2--BTC-USD-.json` namespace. Use `serve-sim` with the Watch UDID for native controls and screenshots; the iPhone E2E runner does not support this surface. If the Watch accessibility tree contains only an application root, stop automated picker navigation and record that gap; `simctl openurl` may also be unsupported. Save local evidence outside tracked documentation. A compiled Watch target, fixture inspection or failed driver connection does not count as a completed native journey. Spoken Siri, physical companion transport and installed complication configuration remain separate acceptance checks. diff --git a/Documentation/DataSources.md b/Documentation/DataSources.md new file mode 100644 index 0000000..cfc6b26 --- /dev/null +++ b/Documentation/DataSources.md @@ -0,0 +1,107 @@ +# Data sources and licences + +Currency uses third-party reference data for informational estimates. Providers do not endorse Currency or its calculations. These notices apply to data displayed in the app, widgets and Apple Watch, retained in caches, and distributed as bundled history. The repository’s MIT licence applies to project code, not third-party data. + +## Coin Metrics + +Crypto reference data: **Coin Metrics, Inc.**, [Community API](https://docs.coinmetrics.io/api-reference) and [Community data archives](https://github.com/coinmetrics/data), licensed under [Creative Commons Attribution–NonCommercial 4.0 International](https://creativecommons.org/licenses/by-nc/4.0/) ([full text](../LICENSES/CC-BY-NC-4.0.txt)). The archive’s [source licence notice](https://github.com/coinmetrics/data/blob/master/LICENSE) applies to its data. The API specification’s separate software licence is not a licence for commercial use of Community data. + +Coin Metrics publishes its data without warranty; use is at your own risk. See the licence’s disclaimer of warranties and limitation of liability. Currency selects asset/date subsets, reformats values into app resources, samples historical observations, calculates cross-rates and chart changes, and may convert USD crypto prices using a separately dated fiat reference rate. These changes are made by Currency / Dmytro Mishchenko. Calculated prices and charts are Currency’s reference estimates. Original asset, metric, date and source metadata identify the underlying data; gaps are not evidence of a zero price. The bundled `history-monthly-v1.json` records its source, acquisition date, input hashes and sampling metadata. Pre-March-2024 crypto history selects each month’s last available Coin Metrics daily observation, with additional dated samples used to join the available monthly fiat reference observations. These monthly fiat references do not establish the exact FX rate at every crypto observation time. + +Recipients receive the licensor’s offer to reuse the licensed data under CC BY-NC 4.0, including its noncommercial, attribution and modification-notice conditions. Currency’s contributions to adapted Coin Metrics datasets are offered under the same licence. Preserve supplied source, copyright, licence and warranty notices and identify further modifications. No app EULA or repository MIT licence restricts these licensed rights. Distributors must not apply additional legal restrictions or effective technological measures to this material that restrict recipients’ licensed rights. Packaging and distribution must preserve these rights; a separate source download does not by itself resolve restrictions applied to another distributed copy. + +The source archives are publicly downloadable at the link above. Bundled data files and manifests are available with this repository. CC BY-NC 4.0 does not promise future data access, unchanged coverage, or a particular rolling intraday history window. Currency remains free and noncommercial without advertising; commercial reuse of the licensed data requires a separate permission or another applicable legal basis. + +## Fawaz Exchange API + +Daily rates, fallback history and bundled monthly references: [Fawaz Ahmed’s Exchange API](https://github.com/fawazahmed0/exchange-api), distributed through its documented CDN and Cloudflare endpoints. Its repository declares [CC0 1.0 Universal](https://github.com/fawazahmed0/exchange-api/blob/main/LICENSE) ([full text](../LICENSES/Fawaz-CC0-1.0.txt)). The bundled completed-month history samples March 2, 2024 (the archive boundary), then the first day of each completed month from April 2024. The incomplete current month is omitted. Later missing completed months may be fetched as month-end references. Currency selects dates/assets and calculates cross-rates and reference conversions. CC0 applies only to rights the affirmer can waive or license; it does not warrant third-party source rights or grant trademark rights. + +## Frankfurter and original fiat sources + +[Frankfurter](https://frankfurter.dev/) aggregates institutional reference rates. Its [provider and licensing inventory](https://frankfurter.dev/license/) identifies the original sources; its software licence does not replace their data terms. Currency uses an explicit selection of provider IDs. A displayed calculation or blend is produced by Currency, rather than an official quote from any contributing institution. Dates refer to the source observations; monthly samples are reference observations, not market closes. Bundled metadata records the selected source IDs. The pre-March-2024 fiat archive requests USD-based monthly reference buckets from July 2010 through February 2024; actual coverage differs by currency. GGP, IMP and JEP use an explicit GBP-parity assumption. These are Currency’s reference calculations. + +The following notices accompany data from these sources, where present: + +- **Banco Central de la República Argentina (BCRA)**: source [BCRA](https://www.bcra.gob.ar/); [informational reuse terms](https://www.bcra.gob.ar/aviso-legal/). Currency’s noncommercial estimates preserve the source context; conversions, selection and formatting are Currency’s work. +- **Bank for International Settlements (BIS)**: underlying statistics sourced from [BIS Data Portal](https://data.bis.org/), under its [statistical-data terms](https://data.bis.org/help/legal). Currency’s calculations are identified as such; no additional charge is made for the included statistics. +- **Bank of Canada**: underlying data sourced from [Bank of Canada](https://www.bankofcanada.ca/), under its [terms](https://www.bankofcanada.ca/terms/). Currency selects/reformats reference observations and calculates cross-rates; the Bank of Canada does not endorse these changes. +- **Bank of Jamaica**: source [Bank of Jamaica](https://boj.org.jm/), used for noncommercial general reference with source acknowledgment under its [notice](https://boj.org.jm/disclaimer/). +- **European Central Bank (ECB)**: underlying reference rates sourced from [ECB](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html), under its [copyright and reuse terms](https://www.ecb.europa.eu/services/using-our-site/disclaimer/html/index.en.html). Selection, reformatting, cross-rates and converted prices are modifications by Currency. +- **HM Revenue & Customs**: [monthly customs exchange rates](https://www.trade-tariff.service.gov.uk/exchange_rates/monthly). Contains public sector information licensed under the [Open Government Licence v3.0](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/). Currency’s calculated reference conversions are not HMRC customs assessments. +- **European Commission / InforEuro**: underlying data from [InforEuro](https://commission.europa.eu/funding-tenders/procedures-guidelines-tenders/information-contractors-and-beneficiaries/exchange-rate-inforeuro_en), © European Union, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) ([full text](../LICENSES/CC-BY-4.0.txt)) as described in the [Commission legal notice](https://commission.europa.eu/legal-notice_en), except separately identified third-party material. Currency selects/reformats observations and calculates reference cross-rates. Retain the source/licence/warranty notices and identify changes; no additional legal or effective technological restriction may obstruct the licensed rights. See the licence for its warranty disclaimer. +- **Japan Customs**: underlying [customs exchange rates](https://www.customs.go.jp/english/kawase/index_e.htm); [copyright notice](https://www.customs.go.jp/copyright_e.htm) and [Public Data Licence 1.0](https://www.digital.go.jp/en/resources/open_data/public_data_license_v1.0). Numerical data are freely usable as described there. Selection, formatting and calculated estimates are by Currency / Dmytro Mishchenko; they are not unedited government publications. +- **Sveriges riksbank**: original statistics and their observation dates are identified through the source metadata and [original series](https://www.riksbank.se/en-gb/statistics/search-interest--exchange-rates/). See [open-data reuse terms](https://www.riksbank.se/sv/om-riksbanken/om-webbplatsen/oppna-data--information-tillganglig-for-vidareutnyttjande/). Processed/blended statistics and cross-rates are Currency’s calculations; Sveriges riksbank is credited only for the underlying original statistics, not as the source of those processed results. +- **U.S. Department of the Treasury**: underlying [Treasury reporting rates](https://fiscaldata.treasury.gov/datasets/treasury-reporting-rates-exchange/treasury-reporting-rates-of-exchange), available for copying, adaptation and redistribution under its [data authorization](https://fiscaldata.treasury.gov/api-documentation/#license-and-authorization). Currency’s calculations do not imply Treasury endorsement. + +### Source directory + +The selected source directory below links each original dataset. Source-specific notices and third-party exclusions continue to apply. Inclusion here is attribution and provenance, not a claim that every source uses the same licence. No linked licence in an aggregator’s inventory is not a public-domain dedication. + +| ID | Original institution and dataset | +| --- | --- | +| AMCM | [Autoridade Monetária de Macau](https://www.amcm.gov.mo/en/financial-information/interbank-midpoint) | +| BAM | [Bank Al-Maghrib](https://apihelpdesk.centralbankofmorocco.ma/apis) | +| BANREP | [Banco de la República](https://www.datos.gov.co/Econom-a-y-Finanzas/Tasa-de-Cambio-Representativa-del-Mercado-TRM/32sa-8pi3/about_data) | +| BCB | [Banco Central do Brasil](https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/) | +| BCBO | [Banco Central de Bolivia](https://www.bcb.gob.bo/?q=cotizaciones_tc) | +| BCC | [Banco Central de Cuba](https://api.bc.gob.cu/v1/tasas-de-cambio/activas) | +| BCCH | [Banco Central de Chile](https://si3.bcentral.cl/Siete/es/Siete/API) | +| BCP | [Banco Central del Paraguay](https://www.bcp.gov.py/webapps/web/cotizacion/monedas) | +| BCRA | [Banco Central de la República Argentina](https://www.bcra.gob.ar/en/monetary-and-financial-statistics/exchange-statistics/) | +| BCT | [Banque Centrale de Tunisie](https://www.bct.gov.tn/bct/siteprod/cours_archive.jsp) | +| BDI | [Banca d'Italia](https://tassidicambio.bancaditalia.it/) | +| BDP | [Banco de Portugal](https://bpstat.bportugal.pt/) | +| BI | [Bank Indonesia](https://www.bi.go.id/en/statistik/informasi-kurs/transaksi-bi/default.aspx) | +| BIS | [Bank for International Settlements](https://data.bis.org/topics/XRU) | +| BNA | [Banco Nacional de Angola](https://www.bna.ao/Conteudos/Mercados/listaTaxasCambioReferencia.aspx) | +| BNM | [Bank Negara Malaysia](https://www.bnm.gov.my/exchange-rates) | +| BNR | [Banca Națională a României](https://www.bnr.ro/en/561-exchange-rates) | +| BOA | [Bank of Algeria](https://www.bank-of-algeria.dz/donnees-historiques/) | +| BOC | [Bank of Canada](https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/) | +| BOJA | [Bank of Jamaica](https://boj.org.jm/market/foreign-exchange/counter-rates/) | +| BOM | [Bank of Mongolia](https://www.mongolbank.mn/en/currency-rate-movement) | +| BOT | [Bank of Thailand](https://www.bot.or.th/en/statistics/exchange-rate.html) | +| BOTA | [Bank of Tanzania](https://www.bot.go.tz/exchangerate/excrates) | +| BOZ | [Bank of Zambia](https://www.boz.zm/average-exchange-rates) | +| BRB | [Banque de la Republique du Burundi](https://www.brb.bi/en/affichagetoustauxchange) | +| CBA | [Central Bank of Armenia](https://www.cba.am/en/SitePage/ExchangeArchive.aspx) | +| CBAR | [Central Bank of the Republic of Azerbaijan](https://www.cbar.az/currency/rates) | +| CBC | [Central Bank of the Republic of China (Taiwan)](https://www.cbc.gov.tw/en/lp-4237-2.html) | +| CBG | [Central Bank of The Gambia](https://www.cbg.gm/indicative-exchange-rates-latest) | +| CBI | [Central Bank of Iraq](https://cbi.iq/page/144) | +| CBK | [Central Bank of Kenya](https://www.centralbank.go.ke/forex/) | +| CBLLR | [Central Bank of Liberia](https://www.cbl.org.lr/research/buying-selling-rates) | +| CBM | [Central Bank of Myanmar](https://forex.cbm.gov.mm/api/latest) | +| CBS | [Central Bank of Samoa](https://cbs.gov.ws/daily-exchange-rates) | +| CBSL | [Central Bank of Sri Lanka](https://www.cbsl.gov.lk/en/rates-and-indicators/exchange-rates/daily-indicative-exchange-rates) | +| CBU | [Central Bank of Uzbekistan](https://cbu.uz/en/arkhiv-kursov-valyut/json/) | +| DAB | [Da Afghanistan Bank](https://www.dab.gov.af/exchange-rates) | +| DNB | [Danmarks Nationalbank](https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates) | +| ECB | [European Central Bank](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html) | +| HMRC | [HM Revenue & Customs](https://www.trade-tariff.service.gov.uk/exchange_rates/monthly) | +| HNB | [Hrvatska Narodna Banka](https://api.hnb.hr/) | +| INFOREURO | [European Commission](https://commission.europa.eu/funding-and-tenders/procedures-guidelines-tenders/information-contractors-and-beneficiaries/exchange-rate-inforeuro_en) | +| JPC | [Japan Customs](https://www.customs.go.jp/tetsuzuki/kawase/) | +| LB | [Lietuvos Bankas](https://www.lb.lt/webservices/FxRates/FxRates.asmx) | +| MAS | [Monetary Authority of Singapore](https://eservices.mas.gov.sg/statistics/msb/ExchangeRates.aspx) | +| MMA | [Maldives Monetary Authority](https://www.mma.gov.mv/JSON/referencerates.json) | +| MNB | [Magyar Nemzeti Bank](https://www.mnb.hu/en/monetary-policy/the-mpc/exchange-rates) | +| NBC | [National Bank of Cambodia](https://www.nbc.gov.kh/english/economic_research/exchange_rate.php) | +| NBE | [National Bank of Ethiopia](https://nbe.gov.et/exchange-rate/) | +| NBG | [National Bank of Georgia](https://nbg.gov.ge/en/monetary-policy/currency) | +| NBK | [National Bank of Kazakhstan](https://nationalbank.kz/en/exchangerates/ezhednevnye-oficialnye-rynochnye-kursy-valyut) | +| NBKR | [National Bank of the Kyrgyz Republic](https://www.nbkr.kg/index1.jsp?item=1562&lang=ENG) | +| NBM | [National Bank of Moldova](https://www.bnm.md/en/official_exchange_rates) | +| NBP | [Narodowy Bank Polski](https://nbp.pl/en/statistic-and-financial-reporting/rates/) | +| NBRB | [Natsyyanalny Bank Respubliki Belarus](https://www.nbrb.by/engl/statistics/rates/ratesdaily.asp) | +| NBRM | [Narodna Banka na Republika Severna Makedonija](https://www.nbrm.mk) | +| NBT | [National Bank of Tajikistan](https://www.nbt.tj/en/kurs/) | +| NRB | [Nepal Rastra Bank](https://www.nrb.org.np/api/forex/v1/rates) | +| NRBT | [National Reserve Bank of Tonga](https://www.reservebank.to/index.php/financial-system/financial-markets/exchange-rates) | +| RB | [Sveriges Riksbank](https://www.riksbank.se/en-gb/statistics/) | +| RBF | [Reserve Bank of Fiji](https://www.rbf.gov.fj/statistics/economic-and-financial-statistics/) | +| RBM | [Reserve Bank of Malawi](https://www.rbm.mw/Statistics/MajorRates) | +| RBV | [Reserve Bank of Vanuatu](https://www.rbv.gov.vu/index.php/en/exchange-rates) | +| SARB | [South African Reserve Bank](https://custom.resbank.co.za/SarbWebApi/WebIndicators/HistoricalExchangeRatesDaily) | +| SBI | [Seðlabanki Íslands](https://www.sedlabanki.is/hagtolur/opinbert-gengi/) | +| UST | [U.S. Department of the Treasury](https://fiscaldata.treasury.gov/datasets/treasury-reporting-rates-exchange/treasury-reporting-rates-of-exchange) | diff --git a/Documentation/Decisions.md b/Documentation/Decisions.md index c90dc97..0c3db95 100644 --- a/Documentation/Decisions.md +++ b/Documentation/Decisions.md @@ -12,7 +12,7 @@ Document rationale and non-obvious constraints here. Architecture principles and ## Data and platform limits - Use public, credential-free rate sources with offline fallbacks. Provider availability and terms can change; recheck them when changing integrations. Rates are indicative, not executable trading quotes. -- Publication, retrieval, and trade times have different meanings. Coinbase batch receipt time does not establish market freshness. Crypto history is USD-denominated; applying today's FX rate would misrepresent historical prices. +- Publication, retrieval, and trade times have different meanings. A crypto batch receipt time does not establish the observation time. Crypto reference history is USD-denominated; any conversion using a daily fiat reference must retain its date and remain clearly identified as approximate, including when applied to intraday observations. - WidgetKit controls execution and rendering. Timeline requests cannot guarantee refresh deadlines or calculator-like input latency. Tests with supplied widget IDs do not prove native per-placement identity. - Local preserves location intent separately from a fixed ISO currency, even when both currently resolve to the same code. Permission requests require explicit user action; automatic app/widget lookups must never prompt. Persist only country, currency, and observation time; keep the map region in memory. Allow Once cannot be distinguished from persistent foreground authorization at grant time, so reconcile permission on return. diff --git a/Documentation/Development.md b/Documentation/Development.md index fa5afd1..c58a713 100644 --- a/Documentation/Development.md +++ b/Documentation/Development.md @@ -22,11 +22,11 @@ For command-line Xcode builds, use `set -o pipefail` and pipe combined output th ## Rate provider configuration -`CurrencyRateConfiguration.coinbaseEnabled` is the shared code-level feature flag for the phone app, widgets, independent Watch app and Watch widgets. It is `true` by default; set it to `false` and rebuild normally to use only Fawaz. There are no compiler options or separate build configurations. +`CurrencyRateConfiguration.intradayEnabled` is the shared code-level feature flag for the phone app, widgets, independent Watch app and Watch widgets. It is `true` by default; set it to `false` and rebuild to use daily rates without intraday crypto overlays. There are no compiler options or separate build configurations. -Both modes use Fawaz for daily rates and daily history. Enabled Coinbase adds current crypto rates and supported 24-hour hourly charts. When disabled, the app hides 1D, normalizes saved 1D widgets to 1M, excludes incompatible cached data, and reduces automatic rate attempts from 30 minutes to six hours. Foreground polling is one minute when enabled and six hours when disabled; WidgetKit controls actual timeline delivery. Explicit refresh remains available. +Enabled mode uses Coin Metrics Community for current crypto reference prices and supported 1D charts. Fawaz supplies daily fallback and daily history; selected Frankfurter fiat sources and Coin Metrics archives extend bundled monthly history before March 2024 where observations exist. Disabled mode hides 1D, normalizes saved 1D widgets to 1M and excludes incompatible cached data. WidgetKit controls timeline delivery; no requested refresh cadence guarantees execution. -Fawaz history starts on 2 March 2024. All uses month-end/current-month reference samples; the other daily ranges use available dated publications. Neither mode fabricates missing history or labels daily reference samples as exchange closes. +Crypto prices converted from USD using a dated daily fiat reference are approximate estimates, not intraday FX. All uses bundled completed-month samples where available and omits the incomplete current month. Bundled Fawaz observations use March 2, 2024 and then each completed month’s first day; missing completed months may be fetched. These are reference observations, not exchange closes. Preserve source metadata, gaps and [data licence notices](DataSources.md) when refreshing bundled data with `Scripts/History/generate_archive.py`. ## Dependency cache diff --git a/Documentation/FeatureMap.md b/Documentation/FeatureMap.md index 6cfada3..b5dbc63 100644 --- a/Documentation/FeatureMap.md +++ b/Documentation/FeatureMap.md @@ -35,10 +35,10 @@ Owners: ExchangeRates domain, [CurrencyDetails](../Features/CurrencyDetails/Sour | ID | Capability / entry | Observable outcome | | --- | --- | --- | -| RATE-01 | Initial bootstrap, foreground refresh, explicit rate refresh | Fawaz supplies daily fiat, crypto and metal rates. The internal Coinbase code-level flag is enabled by default and adds current crypto rates with more frequent refresh attempts. Disabling it excludes Coinbase quotes from cached reads across the phone, widgets, Watch and system actions; loading, failures and daily fallback remain explicit. | +| RATE-01 | Initial bootstrap, foreground refresh, explicit rate refresh | Fawaz supplies daily fiat, crypto and metal rates. The shared intraday code-level flag is enabled by default and adds Coin Metrics crypto reference prices with more frequent refresh attempts. Disabled mode excludes intraday and legacy provider quotes from cached reads across the phone, widgets, Watch and system actions; observation dates, approximate daily FX conversion, failures and Fawaz fallback 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 daily history supports 1W, 1M, 3M, 1Y, YTD and All, limited to its archive from March 2024; All uses monthly reference samples. Enabled Coinbase adds 1D completed hourly observations for supported crypto pairs. Disabled mode hides 1D and rejects incompatible cached history. Missing observations and cached fallback remain explicit. | +| 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. Enabled intraday adds Coin Metrics 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. Disabled mode hides 1D and rejects incompatible caches; no synthetic history is substituted. | | HIST-03 | Rate source disclosure | Expanded content explains current/historical sources and reference-rate gaps; 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. | @@ -74,7 +74,7 @@ Owners: [widget guide](../Features/WidgetOnboarding/Sources/WidgetOnboardingUI/O | WID-05 | Cash: medium | Configured base/comparison and cash presets/keypad produce the corresponding amounts, including Local setup/unavailable states where applicable. | | WID-06 | Pocket Rate: small | Show a convenient reference amount and conversion for the configured pair. | | WID-07 | Mental Math: small | Show the configured pair’s approximate mental-conversion rule and its accuracy information. | -| WID-08 | History: small/medium | Configured base/quote/range show history and its current, cached or unavailable state. Disabling Coinbase turns a saved 1D configuration into 1M; 1D requests hourly refresh and other ranges daily refresh. | +| WID-08 | History: small/medium | Configured base/quote/range show history and its current, cached or unavailable state. Disabling intraday turns a saved 1D configuration into 1M; 1D requests hourly refresh and other ranges daily refresh. | | WID-09 | Currency Icon: accessory circular Lock Screen widget | The configured currency symbol appears in the system accessory presentation. | | WID-10 | System gallery, Edit Widget, taps, removal and reload | Installation/configuration produce the selected widget; intents execute in the real extension; removal works and shared data remains consistent. | @@ -103,13 +103,13 @@ These capabilities target watchOS 26+. Ownership follows the Watch application c | WATCH-01 | Open the independent Watch app | The source amount and first conversion appear together on the small Watch; the app fetches rates directly and remains useful without an iPhone connection. | | WATCH-02 | Tap source amount → decimal keypad | Digits, locale decimal separator and deletion edit a local draft; Done commits the amount and updates conversions, Cancel keeps the previous amount. The keypad fits without scrolling; there are no in-app 1/10/100 presets. | | WATCH-03 | Source picker, Add currency and favorite row actions | Flag/icon currency choices update the source or destinations without duplicates. Favorite rows can become the base or be removed; selections and custom amount survive restart. | -| WATCH-04 | Favorite row → currency details/history | Show currency identity, inverse rate, selected history range/chart and the reference-rate note. The shared Coinbase flag controls 1D availability; daily ranges use Fawaz. No provider/source sections, saved-rate label or standalone swap action are shown in the app. Metal conversion rates and history use the shared troy-ounce/gram/kilogram preference. | +| WATCH-04 | Favorite row → currency details/history | Show currency identity, inverse rate, selected history range/chart and the reference-rate note. The shared intraday flag controls 1D availability; daily ranges use Fawaz. No provider/source sections, saved-rate label or standalone swap action are shown in the app. Metal conversion rates and history use the shared troy-ounce/gram/kilogram preference. | | WATCH-05 | Refresh rates, offline and unavailable history | Explicit refresh preserves cached rates/history; loading, stale, missing and failed-refresh states remain distinguishable without claiming a successful fetch. | | WATCH-06 | iPhone preferred-currency synchronization | Preferred currencies and metal measurement synchronize opportunistically; Watch base and physical metal amount remain local, and connectivity is not required for direct fetch/cache use. | | WATCH-07 | Pocket Rate in Smart Stack | Configure source/comparison currencies, then use 1/10/100 and swap buttons to update the displayed pair/conversion. Setup has no amount field; taps on conversion content open the corresponding Watch route. | | WATCH-14 | Metal measurement from iPhone settings | Follow the iPhone app’s troy-ounce/gram/kilogram setting through companion preference sync, without a Watch picker; conversions, details, history, widgets and Siri show that unit, and source-metal quantity is preserved. Cash keeps the same physical gram presets as the iPhone widget and translates routes into the selected app unit. | | WATCH-08 | Currency Board and Favorite Pairs | Configure base, destinations and amount. Board aligns flag/code left and value right, with a bold source row; Favorite Pairs centers the flagged source above three flagged destination links. | -| WATCH-09 | History widget | Configured pair/range displays trend, period/change, current value and date, with explicit cached/unavailable behavior. The shared Coinbase flag controls 1D availability; saved 1D falls back to 1M when disabled. The chart uses the space previously occupied by the daily-reference footer. | +| WATCH-09 | History widget | Configured pair/range displays trend, period/change, current value and date, with explicit cached/unavailable behavior. The shared intraday flag controls 1D availability; saved 1D falls back to 1M when disabled. The chart uses the space previously occupied by the daily-reference footer. | | WATCH-10 | Know Your Cash widget | Configure currencies only; banknote or metal-weight buttons choose the amount and update conversion with the correct units. Opening the app preserves the displayed conversion semantics. | | WATCH-11 | Mental Math widget | Display a simple rounded conversion rule for the configured pair without an error percentage. | | WATCH-12 | Watch-face complications | Pocket Rate supports rectangular, circular, inline and corner families. Cash, Board, History and Mental Math support the first three; Favorite Pairs is rectangular only. Corner selection offers only Pocket Rate, with both compact values on the outer curve, one arrow, names/units inside and full values in accessibility. Verify slot fitting, including bottom-right 100-unit conversions. | diff --git a/Documentation/PrivacyPolicy.md b/Documentation/PrivacyPolicy.md index 5709e7e..7eefc07 100644 --- a/Documentation/PrivacyPolicy.md +++ b/Documentation/PrivacyPolicy.md @@ -1,6 +1,6 @@ # Privacy Policy -Effective 9 October 2026. +Effective 15 October 2026. Dmytro Mishchenko, an individual, operates Currency. The app has no accounts, advertising or analytics. We do not sell personal data or track you across apps. @@ -10,9 +10,9 @@ 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.** Currency contacts Fawaz feed hosts (including jsDelivr/Cloudflare) for daily rates and history. When the Coinbase integration is enabled, it also contacts Coinbase for current crypto rates and 24-hour charts. They receive your IP address and request metadata, including requested currencies and history dates, but not entered amounts or saved location. Their processing follows their policies: [jsDelivr](https://www.jsdelivr.com/terms/privacy-policy-jsdelivr-net), [Cloudflare](https://www.cloudflare.com/privacypolicy/), [Coinbase](https://www.coinbase.com/legal/privacy). 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) 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. GitHub issue reports are public; avoid personal information. 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. +**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. Contact: [dimasike.dev@gmail.com](mailto:dimasike.dev@gmail.com). diff --git a/Documentation/TermsOfUse.md b/Documentation/TermsOfUse.md index f97ba4e..f9502e2 100644 --- a/Documentation/TermsOfUse.md +++ b/Documentation/TermsOfUse.md @@ -1,13 +1,13 @@ # Terms of Use -Effective 7 October 2026. +Effective 15 October 2026. -Currency, by Dmytro Mishchenko, provides informational estimates, not financial, investment, tax or legal advice or transaction quotes. It does not execute transactions. +Dmytro Mishchenko, an individual, operates Currency as a free, noncommercial app without advertising. It provides informational currency and crypto reference estimates, not financial, investment, tax or legal advice or transaction quotes. It does not execute transactions. -Rates and charts may be delayed, missing, revised or inaccurate. Conversions are calculated from third-party data and exclude fees, spreads and taxes. Verify figures with your bank, exchange or other provider before relying on them. +Rates and charts may be delayed, missing, revised or inaccurate. Conversions use third-party data and exclude fees, spreads and taxes. Converted crypto reference prices may apply a daily fiat rate to intraday USD observations; these are approximate reference estimates, not intraday FX or exchange trading prices. Historical charts may use monthly samples and omit unavailable observations. Verify figures with your bank, exchange or other provider before relying on them. -The app and data are provided as available, without guarantees of accuracy or uninterrupted availability. To the extent permitted by law, the operator is not responsible for losses caused by reliance on estimates or service interruptions. Nothing excludes liability, statutory guarantees or consumer rights that cannot legally be excluded. +The app and data are provided as available, without guarantees of accuracy or uninterrupted availability. Free access, coverage, history windows and refresh timing depend on providers and the operating system and may change. To the extent permitted by law, the operator is not responsible for losses caused by reliance on estimates or service interruptions. Nothing excludes liability, statutory guarantees or consumer rights that cannot legally be excluded. -Apple’s applicable app licence and third-party licences remain applicable. The repository’s [MIT licence](../LICENSE) governs its source code; these terms do not restrict those rights. Third-party names and artwork do not imply endorsement. +Apple’s applicable app licence governs the app. The repository’s [MIT licence](../LICENSE) governs its source code. Third-party data and artwork retain their separate licences and notices in [Data sources](DataSources.md), [Third-party assets](ThirdPartyAssets.md) and [LICENSES](../LICENSES). Neither these terms nor an app licence imposes additional restrictions on rights granted in that material, including copying, extracting, sharing or adapting Creative Commons data as its licence permits. Those data licences do not relicense unrelated app code. Coin Metrics data is CC BY-NC 4.0; its licensed reuse is for noncommercial purposes only. No provider endorses Currency or its calculated results. See the [Privacy Policy](PrivacyPolicy.md) for data handling and contact information. Material changes will be dated here and apply prospectively, subject to applicable law. diff --git a/Documentation/TestAudit.md b/Documentation/TestAudit.md deleted file mode 100644 index 624fbfc..0000000 --- a/Documentation/TestAudit.md +++ /dev/null @@ -1,72 +0,0 @@ -# Test value and runtime audit - -Baseline: `dbd63034403b997fa8a110d0298e3d10e3c81b36`, 2026-09-30. This audit applies the [test-audit value bar](https://github.com/openclaw/openclaw/blob/main/.agents/skills/test-audit/SKILL.md). The 20% removal budget is a ceiling, not a target. The repository has 353 Swift Testing declarations and 12 native XCTest cases before this change. Parameterized executions are counted separately by Xcode. - -## Removed tests and remaining owners - -All paths below are test files. No production API or test-support seam becomes unused through these cuts. Each cut is low risk because its observable contract has stronger remaining proof, or the asserted persistence path never receives the fixture being inspected. The full `CurrencyTests` scheme includes all keepers. - -| Removed declaration and file | Failure actually detected; production owner and callers | Remaining proof | History | -| --- | --- | --- | --- | -| `HistoryTests.historyCachesAndFallsBackOffline`, `Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift` | Immediate cache reuse and generic offline fallback. `HistoryService.load` is consumed by CurrencyDetails and HistoryTimeline. | `dailyWidgetCacheAvoidsRepeatedFetchesUntilExpiry`, `yearToDateRefreshCanFallBackWithinTheSameYear`, and `crossRateFailureUsesOnlyPreviousCompletePairCache` check exact expiry, complete data, and typed failure. | Architecture extraction `22f4bd3`; stronger widget cadence coverage added in `18b23b7`. | -| `HistoryWidgetTests.defaultPairTracksBaseAndFirstDisplayedDestination`, `Domain/Widgets/Tests/WidgetsTests/HistoryWidgetTests.swift` | Base/destination ordering and overrides. `HistoryWidgetPair` is consumed by `HistorySettings.resolvePair` and previews. | `HistoryWidgetAdapterTests.nativeDefaultsFollowAppAndOverridesAreIndependent` executes the same rules through shipping settings. | Both tests introduced in `18b23b7`. | -| `AppAppearanceTests.brightAndDarkFillsChooseOppositeLabelColors`, `Foundation/DesignSystem/Tests/DesignSystemTests/AppAppearanceTests.swift` | Exact black/white helper choices. `AppAccentLabel` consumes `accentForeground`. | `prominentLabelsHaveReadableContrastForEveryAccentAndAppearance` independently calculates contrast through the public boundary for every accent/appearance. | Both introduced in `22f4bd3`. | -| `OnboardingWidgetTests.previewEditsNeverWriteAppOrInstalledWidgetState`, `Features/WidgetOnboarding/Tests/WidgetOnboardingTests/OnboardingWidgetTests.swift` | Temporary input editing; persistence assertions inspect stores never supplied to the preview. `WidgetPreviewState` is consumed by WidgetPreview and onboarding showcase/configuration. | `editedPreviewUsesLiveStateWithExplicitCodesAndNewRates`, `calculatorPreviewUsesTemporaryCanonicalStateAcrossSizes`, and WidgetCommand/WidgetStore tests own temporary versus persistent reduction. Preview state has no store, path, or persistence key. | Added in `22f4bd3` during preview extraction. | -| `WidgetTests.staleLocationUsabilityIsSeparateFromPrivacyStatus`, `Domain/Widgets/Tests/WidgetsTests/WidgetTests.swift` | Freshness/usability and permission helper flags. Owners are WidgetLocation/WidgetLocationStatus, consumed by selection resolution and LocalCurrencyStore/controller. | `listOwnershipAndLocalPositionAreIndependentOfResolution`, `staleAndFailedObservationsAreExplicitlyMarked`, and `localCurrencyRequiresFreshSupportedCountry` exercise resolved behavior, every disallowed status, and exact expiry. | Initial widget overhaul `1e43d5c`, extracted in `22f4bd3`. | -| `WidgetTests.synchronizedValuePreservesWidgetSelectionAndCustomRemainsIndependent`, same WidgetTests file | Synchronized selection and value changes; custom assertion checks an untouched local value. `WidgetInput.synchronize` is consumed by SuiteTimeline/WidgetStore. | `defaultTypingSurvivesConversionAndTimelineReload` covers six currencies, persistence cycles, and subsequent app amount changes. | Added in `1e43d5c`. | -| `HomeEditingTests.removingActiveCurrencyClosesEditor`, `Features/Home/Tests/HomeTests/HomeEditingTests.swift` | Removing the edited destination closes editing and makes keypad input inert. `HomeModel.removeDestinations` is consumed by Home destination management. | `localEditorTracksSelectionRatherThanMatchingFixedCurrency` covers fixed/Local identity confusion; `missingRatePairClosesEditorWithoutMisreportingSaveFailure` and `concurrentlyRemovedEditingRowEndsEditingWithoutSaveError` cover inert input and unchanged storage. | Added in `22f4bd3`; stronger Local and concurrent-removal regressions followed. | - -The tutorial Local preview test now executes once. Its previous five kinds supplied identical explicit codes and amount, bypassing every kind-dependent default. No distinct branch was exercised by the extra four invocations. - -## Retained contracts - -Keep cancellation, stale-result rejection, cross-process persistence/coordination, corrupt-record recovery, migrations, precision and rounding, provider wire parsing, catalog additions, resource/localization boundaries, and native App Intents transport/lifecycle. Static schema checks and round trips are independently meaningful storage contracts here. Refresh and bootstrap precedence remain separate because they use distinct merge implementations. Thirty-second fake providers normally terminate on cancellation; their configured delay is not elapsed suite runtime. - -## Runtime evidence and root causes - -Recent successful CI runs inspected: [36742396365](https://github.com/DimaMishchenko/currency/actions/runs/36742396365), [36696981366](https://github.com/DimaMishchenko/currency/actions/runs/36696981366), and [36647386827](https://github.com/DimaMishchenko/currency/actions/runs/36647386827). History suites take 6.4–12.3 seconds. Native test bodies take 90.0–209.7 seconds, with first-launch setup charged to the alphabetically first case. Compile, simulator boot, app install, and launch costs are separate from test-body time; deleting fast assertions will not remove those costs. - -Local baseline uses an owned iPhone 18 Pro simulator on iOS 27.0 with isolated DerivedData and signed simulator binaries. Native baseline passes 12 cases in 77.173 seconds (102.542 seconds including build/runner). Unit baseline passes 352 declarations and fails one retained declaration: `cancellingHourlyRefreshPreservesTheLongRangeCache`. Its bounded readiness polling expires while unrelated mock requests occupy a process-global 150 ms CandlePacer. It remains covered and is repaired, not removed. - -Move Coinbase candle pacing to `NetworkClient` immediately before the real URLSession transfer, after request coalescing. Both shipping clients (app timeout 30, widget timeout 10) retain process-wide pacing. Injected fixture clients and preview harnesses avoid transport delays through their existing HTTPClient boundary. Pagination still owns its three-page concurrency bound and structured cancellation. No public test-only bypass is added. - -Bootstrap precedence uses its existing controlled provider to release the fallback after the primary update, replacing a 20 ms scheduling assumption and removing unconditional zero-duration sleeps. Native app readiness skips the absent-onboarding wait only when the primary onboarding button is absent and the Home toolbar exists. XCTest can expose the preloaded Home toolbar despite SwiftUI accessibility hiding, so onboarding takes priority and its disappearance is explicitly confirmed after completion. An initial candidate that relied on Home existence alone failed native validation and was corrected before delivery. - -## Reproduce - -Generate with `tuist install` and `tuist generate --no-open --cache-profile none`. Allocate a task-owned iOS 27.0 simulator and use its explicit UDID in both commands: - -```sh -set -o pipefail -xcodebuild test -workspace Currency.xcworkspace -scheme CurrencyTests \ - -destination 'platform=iOS Simulator,id=' \ - -derivedDataPath -resultBundlePath \ - -collect-test-diagnostics never CODE_SIGN_IDENTITY=- 2>&1 | xcbeautify -xcodebuild test -workspace Currency.xcworkspace -scheme NativeIntentTests \ - -destination 'platform=iOS Simulator,id=' \ - -derivedDataPath -resultBundlePath \ - -parallel-testing-enabled NO -collect-test-diagnostics never \ - CODE_SIGN_IDENTITY=- 2>&1 | xcbeautify -``` - -Use `-only-testing:ExchangeRatesPackageTests` for pacing/history proof; `-only-testing:WidgetsPackageTests`, `-only-testing:WidgetIntegrationTests`, `-only-testing:ConversionPackageTests`, `-only-testing:LocalCurrencyPackageTests`, `-only-testing:HomePackageTests`, `-only-testing:DesignSystemPackageTests`, and `-only-testing:WidgetOnboardingPackageTests` cover the cut owners and siblings. Run strict Swift formatting, Python CI skip rules, shell syntax checks, and `git diff --check` before delivery. - -## Validation results - -The optimized full unit run passes all 347 declarations. Seven redundant declarations were removed and one independent transport contract was added, a net reduction of six (1.7%). Native coverage remains 12 cases. Test/support Swift LOC drops from 8,318 to 8,272 after the final review, including the new transport fixture proof. Production changes add 22 lines and remove 19, net +3, with no new public seam. - -| Local history suite | Baseline seconds | Optimized seconds | -| --- | ---: | ---: | -| HistoryConcurrencyTests | 3.504 | 0.556 | -| HistoryTests | 7.394 | 0.542 | -| HistoryFailureTests | 8.022 | 0.541 | -| HistoryCrossRateTests | 10.196 | 0.547 | -| HistoryIntervalTests | 10.356, failed | 0.569, passed | - -The slowest history suite improves by 94.5%. The new concrete transport suite takes 0.856 seconds and retains real scheduling proof. Replacing HTTPClient with its baseline source makes both independent 100 ms spacing assertions fail; restoring the optimized transport passes. The test uses the [Coinbase public request budget](https://docs.cdp.coinbase.com/exchange/rest-api/rate-limits), rather than pinning the implementation's extra 150 ms headroom. All requests are intercepted by URLProtocol. - -These are local suite durations on the same owned simulator; cold compile and platform startup differences are not credited as test optimizations. Bootstrap signal control improves determinism, with no isolated speed claim. Strict Swift formatting, six CI skip-rule checks, all CI shell syntax checks, and diff whitespace checks pass. - -The corrected native suite passes all 12 cases from a fresh app install in 71.264 seconds, versus 77.173 seconds on the local baseline. Total build/runner time is 85.745 seconds versus 102.542, but those totals include unequal compilation work. The native body improvement is 7.7% in this local pair; repeated CI runs are needed before treating it as a stable platform-wide gain. - -Independent preservation and architecture review found no remaining P1/P2 findings after correcting the native readiness helper. The scoped comment audit removed eight non-exempt narrative lines across the changed files; public transport documentation remains. Raw baseline, failed readiness attempt, final runs, negative-control logs, and CI history are retained locally under ignored `.validation/test-audit/`. diff --git a/Documentation/ThirdPartyAssets.md b/Documentation/ThirdPartyAssets.md index 216bf65..abcbacf 100644 --- a/Documentation/ThirdPartyAssets.md +++ b/Documentation/ThirdPartyAssets.md @@ -10,3 +10,7 @@ Assets are bundled locally for offline use. Preserve their notices when replacin ## Original artwork The Au, Ag, Pt, and Pd metal badges are original project artwork, not third-party assets. Their colors are illustrative. + +## Reference data + +Rates, history and bundled reference datasets have separate source and licence notices in [Data sources](DataSources.md). These data are not covered by the repository MIT licence. Full redistributable licence texts are in [LICENSES](../LICENSES). diff --git a/Domain/Conversion/Tests/ConversionTests/ExternalConversionTests.swift b/Domain/Conversion/Tests/ConversionTests/ExternalConversionTests.swift index 42ca5f8..d71749b 100644 --- a/Domain/Conversion/Tests/ConversionTests/ExternalConversionTests.swift +++ b/Domain/Conversion/Tests/ConversionTests/ExternalConversionTests.swift @@ -61,7 +61,7 @@ import Testing quotes: [ "EUR": quote(1), "CZK": quote(25), "BTC": .init( - 0.00001, published: "2027-01-15", source: .init(provider: .coinbase), + 0.00001, published: "2027-01-15", source: .init(provider: .coinMetrics), retrievedAt: now) ], fetchedAt: now, dailyFetchedAt: oldDaily, checkedAt: now) let evaluation = try ConversionEvaluation(request: request, snapshot: rates, now: now) diff --git a/Domain/ExchangeRates/Package.swift b/Domain/ExchangeRates/Package.swift index 8d653b3..2cfee7e 100644 --- a/Domain/ExchangeRates/Package.swift +++ b/Domain/ExchangeRates/Package.swift @@ -15,7 +15,8 @@ let package = Package( .target( name: "ExchangeRates", dependencies: [.product(name: "CoordinatedFiles", package: "CoordinatedFiles")], - exclude: ["README.md"]), + exclude: ["README.md"], + resources: [.process("Resources")]), .target( name: "ExchangeRatesUI", dependencies: ["ExchangeRates"], diff --git a/Domain/ExchangeRates/README.md b/Domain/ExchangeRates/README.md index 7085050..09b3d74 100644 --- a/Domain/ExchangeRates/README.md +++ b/Domain/ExchangeRates/README.md @@ -5,3 +5,5 @@ Reusable current rates, conversion, offline snapshots, and historical series. `E The core is independent of UI and app configuration. The app integrates this package through `Tuist/Package.swift`; core and UI tests run in the `CurrencyTests` scheme. The [core usage guide](Sources/ExchangeRates/README.md) covers rate and history APIs. + +Current crypto reference prices and intraday history use Coin Metrics Community; daily Fawaz fallback and selected Frankfurter fiat sources support reference conversions and longer history. Bundled monthly history extends coverage where observations exist. [Data sources and licences](../../Documentation/DataSources.md) describe attribution, sampling and reuse conditions. diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/BundledHistory.swift b/Domain/ExchangeRates/Sources/ExchangeRates/BundledHistory.swift new file mode 100644 index 0000000..31fe235 --- /dev/null +++ b/Domain/ExchangeRates/Sources/ExchangeRates/BundledHistory.swift @@ -0,0 +1,83 @@ +import Foundation + +struct BundledHistory: Decodable { + let version: Int + let crypto: [String: [[Double]]] + let cryptoFX: [String: [[Double]]] + let fiat: [String: [[Double]]] + let fawaz: [String: [[Double]]] + + static let archive: BundledHistory? = { + guard let url = Bundle.module.url(forResource: "history-monthly-v1", withExtension: "json"), + let data = try? Data(contentsOf: url), + let archive = try? JSONDecoder().decode(BundledHistory.self, from: data), archive.version == 1 + else { return nil } + return archive + }() + + static func points(base: String, quote: String, through now: Date) throws -> [HistoryPoint] { + guard let archive else { return [] } + let baseCrypto = CurrencyCatalog.crypto.contains(base) + let quoteCrypto = CurrencyCatalog.crypto.contains(quote) + let requiresFX = baseCrypto != quoteCrypto && base != "USD" && quote != "USD" + func values(_ code: String, crypto: Bool) -> [Date: Double] { + let canonical = ["GGP", "IMP", "JEP"].contains(code) ? "GBP" : code + if canonical == "USD" && baseCrypto != quoteCrypto { + let other = baseCrypto ? base : quote + let rows = requiresFX ? archive.cryptoFX[other] : archive.crypto[other] + return Dictionary( + (rows ?? []) + .compactMap { row in + guard row.count == 2, row[0] * 86400 <= now.timeIntervalSince1970 else { return nil } + return (Date(timeIntervalSince1970: row[0] * 86400), 1.0) + }, uniquingKeysWith: { _, last in last }) + } + let rows = + crypto + ? (requiresFX ? archive.cryptoFX[canonical] : archive.crypto[canonical]) + : archive.fiat[canonical] + return Dictionary( + (rows ?? []) + .compactMap { row -> (Date, Double)? in + guard row.count == 2, row[0].isFinite, row[1].isFinite, row[1] > 0 else { return nil } + let date = Date(timeIntervalSince1970: row[0] * 86400) + guard date <= now else { return nil } + return (date, crypto ? row[1] : 1 / row[1]) + }, uniquingKeysWith: { _, latest in latest }) + } + let numerator = values(base, crypto: baseCrypto) + let denominator = values(quote, crypto: quoteCrypto) + return + try numerator.compactMap { date, value in + guard let divisor = denominator[date] else { return nil } + let result = value / divisor + guard result.isFinite && result > 0 else { throw RateError.invalidData } + return HistoryPoint(date: date, value: result) + } + .sorted { $0.date < $1.date } + } + static func recentPoints(base: String, quote: String, through now: Date) -> [HistoryPoint] { + guard let archive else { return [] } + func values(_ code: String) -> [Double: Double] { + let canonical = ["GGP", "IMP", "JEP"].contains(code) ? "GBP" : code + return Dictionary( + (archive.fawaz[canonical] ?? []) + .compactMap { row in + guard row.count == 2, row[0].isFinite, row[1].isFinite, row[1] > 0, + row[0] * 86400 <= now.timeIntervalSince1970 + else { return nil } + return (row[0], row[1]) + }, uniquingKeysWith: { _, last in last }) + } + let quotes = values(quote) + return values(base) + .compactMap { day, baseValue in + guard let quoteValue = quotes[day] else { return nil } + let value = quoteValue / baseValue + guard value.isFinite && value > 0 else { return nil } + return HistoryPoint(date: Date(timeIntervalSince1970: day * 86400), value: value) + } + .sorted { $0.date < $1.date } + } + +} diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsClient.swift b/Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsClient.swift new file mode 100644 index 0000000..aa5bdcb --- /dev/null +++ b/Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsClient.swift @@ -0,0 +1,138 @@ +import Foundation + +/// Keyless Community reference-rate observations, expressed in USD per asset. +public struct CoinMetricsClient: Sendable { + private let client: any HTTPClient + private let acquire: @Sendable () async throws -> Void + private let sleep: @Sendable (Duration) async throws -> Void + + public init(client: any HTTPClient = NetworkClient()) { + self.client = client + self.acquire = { try await CoinMetricsPacer.shared.acquire() } + self.sleep = { try await Task.sleep(for: $0) } + } + + init( + client: any HTTPClient, acquire: @escaping @Sendable () async throws -> Void, + sleep: @escaping @Sendable (Duration) async throws -> Void + ) { + self.client = client + self.acquire = acquire + self.sleep = sleep + } + + /// Returns ascending, unique observations within the inclusive requested time range. + public func observations( + assets: [String], start: Date, end: Date, frequency: String = "1m" + ) async throws -> [String: [HistoryPoint]] { + let assets = Set(assets.map { $0.lowercased() }) + guard !assets.isEmpty, + assets.allSatisfy({ + $0.range(of: #"^[a-z0-9]+$"#, options: .regularExpression) != nil + }), start <= end, ["1m", "1h", "1d"].contains(frequency) + else { + throw RateError.invalidData + } + var components = URLComponents( + string: "https://community-api.coinmetrics.io/v4/timeseries/asset-metrics")! + components.queryItems = [ + .init(name: "assets", value: assets.sorted().joined(separator: ",")), + .init(name: "metrics", value: "ReferenceRateUSD"), + .init(name: "frequency", value: frequency), + .init(name: "start_time", value: start.ISO8601Format()), + .init(name: "end_time", value: end.ISO8601Format()), + .init(name: "page_size", value: "10000") + ] + var next = components.url + var visited: Set = [] + var points: [String: [Date: Double]] = [:] + while let url = next { + try Task.checkCancellation() + guard Self.safe(url), visited.count < 100, visited.insert(url).inserted else { + throw RateError.invalidData + } + let page = try JSONDecoder().decode(Page.self, from: await load(url)) + for row in page.data { + guard let asset = row.asset?.lowercased(), assets.contains(asset), + let rawTime = row.time, let date = Self.date(rawTime), date >= start, date <= end, + let raw = row.rate, + raw.range(of: #"^[0-9]+(?:\.[0-9]+)?$"#, options: .regularExpression) != nil, + let value = Double(raw), value.isFinite, value > 0 + else { continue } + points[asset.uppercased(), default: [:]][date] = value + } + if let raw = page.nextPageURL, !raw.isEmpty { + guard let candidate = URL(string: raw), Self.safe(candidate) else { + throw RateError.invalidData + } + next = candidate + } else { + next = nil + } + } + return points.mapValues { values in + values.map { HistoryPoint(date: $0.key, value: $0.value) }.sorted { $0.date < $1.date } + } + } + + private func load(_ url: URL) async throws -> Data { + for attempt in 0..<3 { + try Task.checkCancellation() + try await acquire() + do { + let data = try await client.get(url) + try Task.checkCancellation() + return data + } catch RateError.http(429) { + guard attempt < 2 else { throw RateError.http(429) } + try await sleep(.seconds(6)) + } + } + throw RateError.unavailable + } + + private static func safe(_ url: URL) -> Bool { + url.scheme == "https" && url.host == "community-api.coinmetrics.io" + && url.path == "/v4/timeseries/asset-metrics" && url.port == nil + && url.user == nil && url.password == nil && url.fragment == nil + } + + static func date(_ raw: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return fractional.date(from: raw) ?? ISO8601DateFormatter().date(from: raw) + } + + private struct Page: Decodable { + let data: [Row] + let nextPageURL: String? + enum CodingKeys: String, CodingKey { case data; case nextPageURL = "next_page_url" } + } + + private struct Row: Decodable { + let asset: String? + let time: String? + let rate: String? + enum CodingKeys: String, CodingKey { case asset, time; case rate = "ReferenceRateUSD" } + init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + asset = try? values.decode(String.self, forKey: .asset) + time = try? values.decode(String.self, forKey: .time) + rate = try? values.decode(String.self, forKey: .rate) + } + } +} + +private actor CoinMetricsPacer { + static let shared = CoinMetricsPacer() + private let clock = ContinuousClock() + private var lastStart: ContinuousClock.Instant? + + func acquire() async throws { + while let lastStart, clock.now < lastStart.advanced(by: .milliseconds(650)) { + try await clock.sleep(until: lastStart.advanced(by: .milliseconds(650))) + } + try Task.checkCancellation() + lastStart = clock.now + } +} diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsProvider.swift b/Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsProvider.swift new file mode 100644 index 0000000..48a9aeb --- /dev/null +++ b/Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsProvider.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Minute cryptocurrency reference rates normalized using the Fawaz daily EUR/USD quote. +public struct CoinMetricsProvider: RateProvider { + private let metrics: CoinMetricsClient + private let daily: any RateProvider + private let now: @Sendable () -> Date + + public init(client: any HTTPClient = NetworkClient()) { + metrics = CoinMetricsClient(client: client) + daily = FawazProvider(client: client) + now = { .now } + } + + init(metrics: CoinMetricsClient, daily: any RateProvider, now: @escaping @Sendable () -> Date) { + self.metrics = metrics + self.daily = daily + self.now = now + } + + public func fetch() async throws -> [String: ExchangeRate] { + let time = now() + async let observations = metrics.observations( + assets: Array(CurrencyCatalog.crypto), start: time.addingTimeInterval(-600), end: time) + async let dailyQuotes = daily.fetch() + let (points, quotes) = try await (observations, dailyQuotes) + try Task.checkCancellation() + guard let usd = quotes["USD"], usd.value > 0, !usd.value.isNaN, + let published = CoinMetricsClient.date(usd.published + "T00:00:00Z"), + published <= time, time.timeIntervalSince(published) < 3 * 86400 + else { throw RateError.invalidData } + let conversion = RateConversion( + provider: usd.source.provider, published: usd.published, value: usd.value, + base: "EUR", quote: "USD") + var result: [String: ExchangeRate] = [:] + for (code, values) in points { + guard let latest = values.last, + latest.date <= time, time.timeIntervalSince(latest.date) <= 600, + let price = Decimal(string: String(latest.value)), price > 0, !price.isNaN + else { continue } + let value = usd.value / price + guard value > 0, !value.isNaN else { continue } + result[code] = ExchangeRate( + value, published: String(latest.date.ISO8601Format().prefix(10)), + source: .init( + provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt, + conversion: conversion), + observedAt: latest.date, retrievedAt: time) + } + guard !result.isEmpty else { throw RateError.invalidData } + return result + } +} diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/CoinbaseProvider.swift b/Domain/ExchangeRates/Sources/ExchangeRates/CoinbaseProvider.swift deleted file mode 100644 index d1e526a..0000000 --- a/Domain/ExchangeRates/Sources/ExchangeRates/CoinbaseProvider.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation - -/// A provider for Coinbase's current cryptocurrency exchange rates. -public struct CoinbaseProvider: RateProvider { - let client: any HTTPClient - /// Creates a Coinbase provider. - public init(client: any HTTPClient = NetworkClient()) { self.client = client } - /// Fetches all supported cryptocurrency rates in a single unauthenticated request. - public func fetch() async throws -> [String: ExchangeRate] { - guard let url = URL(string: "https://api.coinbase.com/v2/exchange-rates?currency=EUR") else { - throw RateError.invalidData - } - let data = try await client.get(url) - try Task.checkCancellation() - return try Self.decode(data) - } - - /// Validates EUR normalization and skips individual missing or malformed quotes. - /// Coinbase supplies no market observation timestamp; `now` records retrieval only. - static func decode(_ data: Data, now: Date = .now) throws -> [String: ExchangeRate] { - struct Payload: Decodable { - struct Rates: Decodable { - let currency: String - let rates: [String: String] - } - - let data: Rates - } - let payload = try JSONDecoder().decode(Payload.self, from: data).data - guard payload.currency == "EUR", decimal(payload.rates["EUR"]) == 1 else { - throw RateError.invalidData - } - var result: [String: ExchangeRate] = [:] - for code in CurrencyCatalog.crypto { - guard let value = decimal(payload.rates[code]) else { continue } - // Already coins per EUR; no inversion or intermediate fiat conversion is needed. - result[code] = ExchangeRate( - value, published: String(now.ISO8601Format().prefix(10)), - source: .init(provider: .coinbase, observation: .exchangeRate), retrievedAt: now) - } - guard !result.isEmpty else { throw RateError.invalidData } - return result - } - - private static func decimal(_ raw: String?) -> Decimal? { - guard let raw, - raw.range(of: #"[0-9]+(?:\.[0-9]+)?"#, options: .regularExpression) - == raw.startIndex.. 0, !value.isNaN - else { return nil } - return value - } - -} diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/ExchangeRates.docc/ExchangeRates.md b/Domain/ExchangeRates/Sources/ExchangeRates/ExchangeRates.docc/ExchangeRates.md index 895a785..18e1732 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/ExchangeRates.docc/ExchangeRates.md +++ b/Domain/ExchangeRates/Sources/ExchangeRates/ExchangeRates.docc/ExchangeRates.md @@ -41,7 +41,7 @@ The package does not know about UI, App Groups, widgets, or polling schedules. H - ``FrankfurterProvider`` - ``ECBProvider`` - ``FawazProvider`` -- ``CoinbaseProvider`` +- ``CoinMetricsProvider`` - ``FallbackRateProvider`` ### Historical series diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/FawazHistory.swift b/Domain/ExchangeRates/Sources/ExchangeRates/FawazHistory.swift index 605c910..abcac3f 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/FawazHistory.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/FawazHistory.swift @@ -57,7 +57,7 @@ extension HistoryService { } } - private static func dailySnapshot( + static func dailySnapshot( client: any HTTPClient, directory: URL, day: String ) async throws -> [String: ExchangeRate] { let archive = directory.appendingPathComponent("fawaz-daily-v1", isDirectory: true) diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/FrankfurterProvider.swift b/Domain/ExchangeRates/Sources/ExchangeRates/FrankfurterProvider.swift index a618508..b98b7e2 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/FrankfurterProvider.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/FrankfurterProvider.swift @@ -9,12 +9,17 @@ struct FrankfurterRow: Decodable { /// A provider backed by the Frankfurter reference-rate API. public struct FrankfurterProvider: RateProvider { + static let allowedProviders = + "AMCM,BAM,BANREP,BCB,BCBO,BCC,BCCH,BCP,BCRA,BCT,BDI,BDP,BI,BIS,BNA,BNM,BNR,BOA,BOC,BOJA,BOM,BOT,BOTA,BOZ,BRB,CBA,CBAR,CBC,CBG,CBI,CBK,CBLLR,CBM,CBS,CBSL,CBU,DAB,DNB,ECB,HMRC,HNB,INFOREURO,JPC,LB,MAS,MMA,MNB,NBC,NBE,NBG,NBK,NBKR,NBM,NBP,NBRB,NBRM,NBT,NRB,NRBT,RB,RBF,RBM,RBV,SARB,SBI,UST" let client: any HTTPClient /// Creates a Frankfurter provider. public init(client: any HTTPClient = NetworkClient()) { self.client = client } /// Fetches current Frankfurter reference rates. public func fetch() async throws -> [String: ExchangeRate] { - guard let url = URL(string: "https://api.frankfurter.dev/v2/rates?base=EUR") else { + guard + let url = URL( + string: "https://api.frankfurter.dev/v2/rates?base=EUR&providers=\(Self.allowedProviders)") + else { throw RateError.invalidData } return try Self.decode(await client.get(url)) @@ -42,6 +47,17 @@ public struct FrankfurterProvider: RateProvider { guard let day = result["USD"]?.published else { throw RateError.invalidData } result["EUR"] = ExchangeRate( 1, published: day, source: .init(provider: .frankfurter, observation: .dailyRate)) + if let sterling = result["GBP"] { + for code in ["GGP", "IMP", "JEP"] { + result[code] = ExchangeRate( + sterling.value, published: sterling.published, + source: RateSource( + provider: .frankfurter, observation: .dailyRate, + conversion: RateConversion( + provider: .frankfurter, published: sterling.published, + value: 1, base: "GBP", quote: code))) + } + } return result } } diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/HTTPClient.swift b/Domain/ExchangeRates/Sources/ExchangeRates/HTTPClient.swift index 94fc71c..49f662d 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/HTTPClient.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/HTTPClient.swift @@ -47,28 +47,6 @@ public struct NetworkClient: HTTPClient { } } -actor CandlePacer { - static let shared = CandlePacer() - private let clock = ContinuousClock() - private var lastStart: ContinuousClock.Instant? - - @discardableResult - func acquire(for url: URL) async throws -> ContinuousClock.Instant? { - let path = url.path.split(separator: "/", omittingEmptySubsequences: false) - guard - url.host == "api.exchange.coinbase.com", path.count == 4, path[0].isEmpty, - path[1] == "products", !path[2].isEmpty, path[3] == "candles" - else { return nil } - while let lastStart, clock.now < lastStart.advanced(by: .milliseconds(150)) { - try await clock.sleep(until: lastStart.advanced(by: .milliseconds(150))) - } - try Task.checkCancellation() - let start = clock.now - lastStart = start - return start - } -} - private actor HTTPTransport { struct Key: Hashable { let url: URL @@ -104,7 +82,6 @@ private actor HTTPTransport { request.timeoutInterval = timeout request.setValue( "application/json, application/xml, text/xml", forHTTPHeaderField: "Accept") - try await CandlePacer.shared.acquire(for: url) try Task.checkCancellation() let (data, response) = try await session.data(for: request) try Task.checkCancellation() diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryDecoding.swift b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryDecoding.swift deleted file mode 100644 index 63941c8..0000000 --- a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryDecoding.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation - -extension HistoryService { - static func candleWindows( - start: Date, end: Date, granularity: CandleGranularity = .day - ) -> [(start: Date, end: Date)] { - var windows: [(Date, Date)] = [] - var cursor = start - // 299 buckets leaves room for Coinbase's inclusive boundary candle (300 maximum). - while cursor < end { - let next = min(cursor.addingTimeInterval(299 * Double(granularity.rawValue)), end) - windows.append((cursor, next)) - cursor = next - } - return windows - } - - static func divideAlignedCloses( - _ base: [HistoryPoint], by quote: [HistoryPoint] - ) throws -> [HistoryPoint] { - let quotesByDate = Dictionary(uniqueKeysWithValues: quote.map { ($0.date, $0.value) }) - return try base.compactMap { point in - guard let quote = quotesByDate[point.date] else { return nil } - let value = point.value / quote - guard value.isFinite && value > 0 else { throw RateError.invalidData } - return HistoryPoint(date: point.date, value: value) - } - } - - static func decodeCandles( - _ data: Data, start: Date, end: Date, granularity: CandleGranularity = .day - ) throws -> [HistoryPoint] { - let rows = try JSONDecoder().decode([[Double]].self, from: data) - var values: [Date: Double] = [:] - for row in rows { - guard row.count >= 5, row.allSatisfy(\.isFinite), row[4] > 0 else { - throw RateError.invalidData - } - let date = Date(timeIntervalSince1970: row[0]) - if date >= start && date.addingTimeInterval(Double(granularity.rawValue)) <= end { - values[date] = row[4] - } - } - return values.map { HistoryPoint(date: $0.key, value: $0.value) }.sorted { $0.date < $1.date } - } -} diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryModels.swift b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryModels.swift index 11c9ce7..a8159ed 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryModels.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryModels.swift @@ -18,7 +18,7 @@ public struct HistorySeries: Codable, Sendable { public let points: [HistoryPoint] /// The provider and aggregation description. public let source: RateSource - /// The fetch time of the endpoint, including a joined hourly observation. + /// The series fetch time, or the final bundled observation date when refresh fails. public let fetchedAt: Date /// Creates an owned history value from validated provider or fixture data. public init(points: [HistoryPoint], source: RateSource, fetchedAt: Date) { diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryRequests.swift b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryRequests.swift deleted file mode 100644 index 1a86af4..0000000 --- a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryRequests.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation - -enum CandleGranularity: Int, Sendable { - case hour = 3600 - case day = 86400 -} - -extension HistoryService { - static func fetchCandles( - client: any HTTPClient, components: URLComponents, start: Date, end: Date, - granularity: CandleGranularity = .day - ) async throws -> [Date: Double] { - let windows = candleWindows(start: start, end: end, granularity: granularity) - return try await withThrowingTaskGroup(of: [HistoryPoint].self) { group in - func enqueue(_ window: (start: Date, end: Date)) { - group.addTask { - try Task.checkCancellation() - var request = components - request.queryItems = [ - URLQueryItem(name: "granularity", value: String(granularity.rawValue)), - URLQueryItem(name: "start", value: window.start.ISO8601Format()), - URLQueryItem(name: "end", value: window.end.ISO8601Format()) - ] - guard let url = request.url else { throw RateError.invalidData } - let data = try await client.get(url) - try Task.checkCancellation() - return try decodeCandles( - data, start: window.start, end: end, granularity: granularity - ) - .filter { $0.date < window.end } - } - } - var next = 0 - while next < min(3, windows.count) { - enqueue(windows[next]) - next += 1 - } - var combined: [Date: Double] = [:] - while let points = try await group.next() { - for point in points { combined[point.date] = point.value } - try Task.checkCancellation() - if next < windows.count { - enqueue(windows[next]) - next += 1 - } - } - return combined - } - } -} diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift index d2ee7df..b9cb512 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift @@ -1,40 +1,23 @@ import Foundation -/// Loads and caches fiat or cryptocurrency history. +/// Loads dated daily publications, bundled monthly references, and optional intraday crypto references. public actor HistoryService { private let client: any HTTPClient private let directory: URL - private static let euroAssets: Set = [ - "AAVE", "ADA", "ALGO", "ATOM", "AVAX", "BCH", "BTC", "DOGE", "DOT", "ETC", "ETH", - "FIL", "ICP", "LINK", "LTC", "SHIB", "SOL", "UNI", "USDC", "USDT", "XLM", "XRP" - ] - private static let dollarAssets = euroAssets.subtracting(["USDC"]) - private static let sterlingAssets = euroAssets.subtracting(["AVAX", "ICP", "XLM", "XRP"]) - /// Whether a distinct, crypto-first pair has direct hourly candles or two supported quote legs. + /// Whether a distinct catalog pair contains a cryptocurrency and no precious metal. public static func supportsIntraday(base: String, quote: String) -> Bool { - intradayCandleQuote(base: base, quote: quote) != nil + base != quote && CurrencyCatalog.codes.contains(base) && CurrencyCatalog.codes.contains(quote) + && (CurrencyCatalog.crypto.contains(base) || CurrencyCatalog.crypto.contains(quote)) + && !CurrencyCatalog.metals.contains(base) && !CurrencyCatalog.metals.contains(quote) } - private static func intradayCandleQuote(base: String, quote: String) -> String? { - guard base != quote else { return nil } - if CurrencyCatalog.crypto.contains(quote) { - if dollarAssets.contains(base) && dollarAssets.contains(quote) { return "USD" } - return euroAssets.contains(base) && euroAssets.contains(quote) ? "EUR" : nil - } - return switch quote { - case "USD": dollarAssets.contains(base) ? quote : nil - case "EUR": euroAssets.contains(base) ? quote : nil - case "GBP": sterlingAssets.contains(base) ? quote : nil - default: nil - } - } /// The provider capabilities configured by the host. public nonisolated let policy: RateProviderPolicy - /// Whether the configured providers can supply hourly observations for this pair. + /// Whether the configured providers can supply intraday references for this pair. public nonisolated func supportsIntraday(base: String, quote: String) -> Bool { - policy == .coinbaseEnhanced && Self.supportsIntraday(base: base, quote: quote) + policy == .intradayEnhanced && Self.supportsIntraday(base: base, quote: quote) } /// Creates a history service, defaulting to Fawaz daily history. @@ -47,24 +30,13 @@ public actor HistoryService { self.policy = policy } - /// Loads Fawaz daily history, or completed Coinbase hourly candles for an enabled day chart. - /// All-history requests sample available daily publications at each month end, starting - /// with the Fawaz archive in March 2024. Failed requests return only compatible saved series. + /// Loads provider observations or returns compatible saved observations with an explicit stale issue. public func load( base: String, quote: String, range: HistoryRange, now: Date = .now, cacheLifetime: TimeInterval? = nil ) async -> HistoryResult { - await loadSeries(base: base, quote: quote, range: range, now: now, cacheLifetime: cacheLifetime) - } - - private func loadSeries( - base: String, quote: String, range: HistoryRange, now: Date, - cacheLifetime: TimeInterval? - ) async -> HistoryResult { - guard CurrencyCatalog.codes.contains(base), CurrencyCatalog.codes.contains(quote), - base != quote + guard CurrencyCatalog.codes.contains(base), CurrencyCatalog.codes.contains(quote), base != quote else { return HistoryResult(series: nil, issue: .unsupportedPair) } - let intradayQuote = Self.intradayCandleQuote(base: base, quote: quote) guard range != .day || supportsIntraday(base: base, quote: quote) else { return HistoryResult(series: nil, issue: .intradayUnavailable) } @@ -73,114 +45,203 @@ public actor HistoryService { let year = calendar.component(.year, from: now) let interval = range == .yearToDate ? "\(range.rawValue)-\(year)" : "\(range.rawValue)" let file = directory.appendingPathComponent( - "history-\(policy.rawValue)-\(range == .day ? "coinbase" : "fawaz")-\(base)-\(quote)-\(interval).json" - ) + "history-v2-\(policy.rawValue)-\(base)-\(quote)-\(interval).json") let cached = (try? Data(contentsOf: file)) .flatMap { try? JSONDecoder().decode(HistorySeries.self, from: $0) } .flatMap { series -> HistorySeries? in - let expected = range == .day ? RateProviderID.coinbase : .fawaz + let expected: RateProviderID = range == .day ? .coinMetrics : .fawaz let observation: RateObservation = - range == .day ? .hourlyClose : range == .all ? .monthlyReference : .dailyReference - guard series.source.provider == expected, series.source.observation == observation, + range == .day + ? .minuteReference : range == .all ? .monthlyReference : .dailyReference + guard + (series.source.provider == expected + || (range == .all && [.coinMetrics, .frankfurter].contains(series.source.provider))), + series.source.observation == observation, series.source.latestObservation == nil, series.points.count >= 2, series.points.allSatisfy({ $0.value.isFinite && $0.value > 0 && $0.date <= now }), - series.fetchedAt <= now + series.fetchedAt <= now, + zip(series.points, series.points.dropFirst()).allSatisfy({ $0.date < $1.date }) else { return nil } + if range == .day && !Self.coversDay(series.points, ending: series.fetchedAt) { return nil } guard range == .yearToDate else { return series } guard calendar.component(.year, from: series.fetchedAt) == year, series.points.allSatisfy({ calendar.component(.year, from: $0.date) == year }) else { return nil } return series } - let defaultCacheLifetime: TimeInterval = range == .day ? 3600 : (range == .all ? 86400 : 21600) - if let cached, now >= cached.fetchedAt, - now.timeIntervalSince(cached.fetchedAt) < (cacheLifetime ?? defaultCacheLifetime), - range != .day || now < Self.nextHour(after: cached.fetchedAt) - { + let lifetime: TimeInterval = range == .day ? 1800 : (range == .all ? 86400 : 21600) + if let cached, now.timeIntervalSince(cached.fetchedAt) < (cacheLifetime ?? lifetime) { return HistoryResult(series: cached, issue: nil) } do { - let start: Date - if range == .all { - guard - let beginning = calendar.date( - from: DateComponents(year: 2024, month: 3, day: 2)) - else { throw RateError.invalidData } - start = beginning - } else if range == .year { - guard let beginning = calendar.date(byAdding: .year, value: -1, to: now) else { - throw RateError.invalidData - } - start = beginning - } else if range == .yearToDate { - guard let beginning = calendar.date(from: DateComponents(year: year, month: 1, day: 1)) - else { throw RateError.invalidData } - start = beginning - } else { - start = now.addingTimeInterval(-Double(range.rawValue) * 86400) - } let points: [HistoryPoint] + let source: RateSource if range == .day { - guard let candleQuote = intradayQuote else { throw RateError.invalidData } - let candles = try await Self.fetchCryptoHistory( - client: client, code: base, quote: candleQuote, start: start, end: now, - granularity: .hour) - if CurrencyCatalog.crypto.contains(quote) { - let quoteCandles = try await Self.fetchCryptoHistory( - client: client, code: quote, quote: candleQuote, start: start, end: now, - granularity: .hour) - points = try Self.divideAlignedCloses(candles, by: quoteCandles) + (points, source) = try await intraday(base: base, quote: quote, now: now) + } else { + let start: Date + if range == .all { + start = calendar.date(from: DateComponents(year: 2024, month: 3, day: 2))! + } else if range == .year { + start = calendar.date(byAdding: .year, value: -1, to: now)! + } else if range == .yearToDate { + start = calendar.date(from: DateComponents(year: year, month: 1, day: 1))! } else { - points = candles + start = now.addingTimeInterval(-Double(range.rawValue) * 86400) } - } else { - points = try await Self.fetchDailyHistory( - client: client, directory: directory, base: base, quote: quote, - start: start, now: now, monthly: range == .all) + let older = + range == .all ? try BundledHistory.points(base: base, quote: quote, through: now) : [] + let bundledRecent = + range == .all + ? BundledHistory.recentPoints( + base: base, quote: quote, + through: calendar.dateInterval(of: .month, for: now)!.start.addingTimeInterval(-1)) : [] + let recent: [HistoryPoint] + if range == .all { + let monthStart = calendar.dateInterval(of: .month, for: now)!.start + let lastBundled = bundledRecent.last?.date ?? start.addingTimeInterval(-86400) + let missingStart = calendar.date(byAdding: .month, value: 1, to: lastBundled)! + let latest: [HistoryPoint]? + if missingStart >= monthStart { + latest = [] + } else { + latest = try? await Self.fetchDailyHistory( + client: client, directory: directory, base: base, quote: quote, + start: max(missingStart, start), now: monthStart.addingTimeInterval(-86400), + monthly: true) + } + try Task.checkCancellation() + if latest == nil && cached == nil && older.count + bundledRecent.count < 2 { + throw RateError.unavailable + } + recent = bundledRecent + (latest ?? []) + if latest == nil { + let fallback = + cached + ?? HistorySeries( + points: older + bundledRecent, + source: Self.monthlySource( + base: base, quote: quote, hasOlder: !older.isEmpty, + hasRecent: !bundledRecent.isEmpty), + fetchedAt: bundledRecent.last?.date ?? older.last?.date ?? now) + return HistoryResult(series: fallback, issue: .usingCachedSeries) + } + } else { + recent = try await Self.fetchDailyHistory( + client: client, directory: directory, base: base, quote: quote, + start: start, now: now, monthly: false) + } + points = older + recent + source = + range == .all + ? Self.monthlySource( + base: base, quote: quote, hasOlder: !older.isEmpty, hasRecent: !recent.isEmpty) + : RateSource(provider: .fawaz, observation: .dailyReference, timeZone: .gmt) + } try Task.checkCancellation() guard points.count >= 2 else { throw RateError.unavailable } - let source = RateSource( - provider: range == .day ? .coinbase : .fawaz, - observation: range == .day - ? .hourlyClose : range == .all ? .monthlyReference : .dailyReference, - timeZone: .gmt) let series = HistorySeries(points: points, source: source, fetchedAt: now) do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try JSONEncoder().encode(series).write(to: file, options: .atomic) } catch { - return HistoryResult( - series: series, issue: .cacheWriteFailed) + return HistoryResult(series: series, issue: .cacheWriteFailed) } return HistoryResult(series: series, issue: nil) } catch { - return HistoryResult( - series: cached, - issue: cached == nil - ? .unavailable - : .usingCachedSeries) + return HistoryResult(series: cached, issue: cached == nil ? .unavailable : .usingCachedSeries) } } - /// The next UTC boundary at which a completed hourly observation may become available. - public static func nextHour(after date: Date) -> Date { - Date(timeIntervalSince1970: (floor(date.timeIntervalSince1970 / 3600) + 1) * 3600) + private static func monthlySource( + base: String, quote: String, hasOlder: Bool, hasRecent: Bool + ) -> RateSource { + var providers: [RateProviderID] = hasRecent ? [.fawaz] : [] + if hasOlder { + if CurrencyCatalog.crypto.contains(base) || CurrencyCatalog.crypto.contains(quote) { + providers.append(.coinMetrics) + } + if (!CurrencyCatalog.crypto.contains(base) && base != "USD") + || (!CurrencyCatalog.crypto.contains(quote) && quote != "USD") + { + providers.append(.frankfurter) + } + } + return RateSource( + provider: providers.first ?? .fawaz, observation: .monthlyReference, + timeZone: .gmt, + contributingProviders: providers.count > 1 ? Array(providers.dropFirst()) : nil) } - private static func fetchCryptoHistory( - client: any HTTPClient, code: String, quote: String = "USD", start: Date, end: Date, - granularity: CandleGranularity - ) async throws -> [HistoryPoint] { - guard - let components = URLComponents( - string: "https://api.exchange.coinbase.com/products/\(code)-\(quote)/candles") - else { throw RateError.invalidData } - let combined = try await fetchCandles( - client: client, components: components, start: start, end: end, - granularity: granularity) - return combined.map { HistoryPoint(date: $0.key, value: $0.value) } + private func intraday( + base: String, quote: String, now: Date + ) async throws -> ([HistoryPoint], RateSource) { + let end = Date(timeIntervalSince1970: floor(now.timeIntervalSince1970 / 60) * 60) + let start = end.addingTimeInterval(-86400) + let assets = [base, quote].filter { CurrencyCatalog.crypto.contains($0) } + let observations = try await CoinMetricsClient(client: client) + .observations( + assets: assets, start: start, end: end, frequency: "1m") + for asset in assets { + guard let values = observations[asset], values.count >= 1380, + Self.coversDay(values, ending: end) + else { + throw RateError.unavailable + } + } + var conversion: RateConversion? + let fiat = [base, quote].first { !CurrencyCatalog.crypto.contains($0) && $0 != "USD" } + var fiatUSD = 1.0 + if let fiat { + let day = String(end.ISO8601Format().prefix(10)) + let rates = try await Self.dailySnapshot(client: client, directory: directory, day: day) + let canonical = ["GGP", "IMP", "JEP"].contains(fiat) ? "GBP" : fiat + guard let usd = rates["USD"], let fx = rates[canonical] else { throw RateError.unavailable } + let rate = fx.value / usd.value + fiatUSD = NSDecimalNumber(decimal: 1 / rate).doubleValue + conversion = RateConversion( + provider: .fawaz, published: day, value: rate, base: "USD", quote: fiat) + } + let dates = observations[assets[0], default: []].map(\.date) + let byAsset = observations.mapValues { + Dictionary($0.map { ($0.date, $0.value) }, uniquingKeysWith: { _, last in last }) + } + let points = + dates.compactMap { date -> HistoryPoint? in + guard Int(date.timeIntervalSince1970) % 300 == 0 else { return nil } + let a = CurrencyCatalog.crypto.contains(base) ? byAsset[base]?[date] : fiatUSD + let b = CurrencyCatalog.crypto.contains(quote) ? byAsset[quote]?[date] : fiatUSD + guard let a, let b, a > 0, b > 0 else { return nil } + return HistoryPoint(date: date, value: a / b) + } .sorted { $0.date < $1.date } + guard Self.coversDay(points, ending: end), + points.allSatisfy({ $0.value.isFinite && $0.value > 0 }) + else { + throw RateError.unavailable + } + return ( + points, + RateSource( + provider: .coinMetrics, observation: .minuteReference, + timeZone: .gmt, conversion: conversion) + ) + } + + static func coversDay(_ points: [HistoryPoint], ending end: Date) -> Bool { + guard let first = points.first, let last = points.last, points.count >= 270, + first.date <= end.addingTimeInterval(-85500), last.date >= end.addingTimeInterval(-900), + first.date >= end.addingTimeInterval(-86400), last.date <= end + else { return false } + return zip(points, points.dropFirst()) + .allSatisfy { a, b in + b.date > a.date && b.date.timeIntervalSince(a.date) <= 900 + } } + /// The next UTC hourly boundary, retained for widget scheduling compatibility. + public static func nextHour(after date: Date) -> Date { + Date(timeIntervalSince1970: (floor(date.timeIntervalSince1970 / 3600) + 1) * 3600) + } } diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/README.md b/Domain/ExchangeRates/Sources/ExchangeRates/README.md index 518a831..c79847b 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/README.md +++ b/Domain/ExchangeRates/Sources/ExchangeRates/README.md @@ -16,14 +16,12 @@ let month = await history.load(base: "EUR", quote: "USD", range: .month) Quotes are currency units per EUR. Conversion returns an unrounded `Decimal?`; callers choose display precision. Missing/invalid rates return nil. Snapshots preserve publication dates and intraday timestamps. Refresh warnings, history issues, and provenance are typed so hosts can choose their own presentation. `RateSource` contains a `RateProviderID`, `RateObservation`, optional time zone, and optional `latestObservation` for a mixed historical endpoint; switch on these values to select your own strings or localization. Custom providers use `.custom("Provider name")`. Older string-based cache provenance decodes automatically. -`RateService` and `HistoryService` default to `RateProviderPolicy.daily`: Fawaz supplies current daily rates and daily history for every supported asset. Hosts can explicitly select `.coinbaseEnhanced` to add current Coinbase crypto overlays and supported 24-hour charts. Pass the same policy to `RateService`, `RateStore`, and `HistoryService`. `RateStore` with an explicit policy filters all persisted rate dictionaries before reads and merges so disabled providers cannot return through old snapshots. Its default nil policy preserves arbitrary injected providers for reusable consumers. Its refresh cadence is six hours with `.daily`, and thirty minutes with `.coinbaseEnhanced` or no policy. Missing live crypto rates revert to preserved Fawaz quotes with their original provenance. Explicitly injected `RateProvider` implementations remain available for tests and custom consumers. The package does not schedule background work. +`RateService` and `HistoryService` default to `RateProviderPolicy.daily`. Hosts select `.intradayEnhanced` to add Coin Metrics Community crypto reference prices and supported 24-hour charts. Pass the same policy to `RateService`, `RateStore`, and `HistoryService`. `RateStore` filters persisted data for an explicit policy, keeping incompatible provider caches out of current reads. Explicitly injected providers remain available for tests and custom consumers. The package does not schedule background work. -History ranges longer than one day use dated Fawaz EUR snapshots and divide the quote rate by the base rate. The dated archive begins March 2, 2024; `.all` samples each month end and the current partial month. These are daily or monthly reference observations, not market closing prices. Missing assets and dates absent from both CDN endpoints are omitted. Other request failures return only a compatible complete saved chart. Immutable dated payloads are shared across pairs; chart caches are separated by policy and provider and reject incompatible provenance. +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. -With Coinbase enabled, `history.supportsIntraday(base:quote:)` exposes the configured hourly capability. `.day` uses completed hourly candles from direct USD, EUR, or supported GBP markets; crypto/crypto pairs divide matching USD candles, using EUR when either USD market is absent. USDC has no direct USD hourly route. Hourly caches expire at UTC hour boundaries. Longer ranges remain Fawaz daily history even when Coinbase is enabled. +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. Cache compatibility includes the provider policy and source provenance. -The [Fawaz endpoint documentation](https://github.com/fawazahmed0/exchange-api#readme) defines dated CDN URLs and the Cloudflare fallback. Daily archive requests run with at most three concurrent transfers. - -The EUR and GBP market policy follows the [Coinbase product catalog](https://api.exchange.coinbase.com/products), verified on September 30, 2026. GBP excludes AVAX, ICP, XLM, and XRP from the app's crypto catalog. [Coinbase candles](https://docs.cdp.coinbase.com/api-reference/exchange-api/rest-api/products/get-product-candles) provide completed hourly observations without applying a daily FX rate to intraday prices. +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. Run `ExchangeRatesPackageTests` in the `CurrencyTests` scheme. API reference is available through Xcode’s Build Documentation action and the included DocC catalog. diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/RateProviderPolicy.swift b/Domain/ExchangeRates/Sources/ExchangeRates/RateProviderPolicy.swift index 53ea06a..055823b 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/RateProviderPolicy.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/RateProviderPolicy.swift @@ -4,8 +4,8 @@ import Foundation public enum RateProviderPolicy: String, Sendable { /// Fawaz daily rates and daily historical observations for every supported asset. case daily - /// Fawaz daily data with Coinbase current crypto rates and supported hourly charts. - case coinbaseEnhanced + /// Fawaz daily data with Coin Metrics current crypto reference rates and minute reference-rate charts. + case intradayEnhanced /// Minimum spacing between background rate refresh attempts. public var refreshInterval: TimeInterval { self == .daily ? 21600 : 1800 } @@ -23,19 +23,19 @@ public enum RateProviderPolicy: String, Sendable { }) } var effective = daily - if self == .coinbaseEnhanced { + if self == .intradayEnhanced { for (code, quote) in snapshot.quotes - where CurrencyCatalog.crypto.contains(code) && quote.source.provider == .coinbase + where CurrencyCatalog.crypto.contains(code) && quote.source.provider == .coinMetrics && quote.isLive && quote.published >= (daily[code]?.published ?? "") { - effective[code] = quote + if let aligned = quote.alignedToDailyUSD(daily["USD"]) { effective[code] = aligned } } } let excluded = dictionaries.contains { quotes in quotes.contains { code, quote in quote.source.provider != .fawaz - && !(self == .coinbaseEnhanced && CurrencyCatalog.crypto.contains(code) - && quote.source.provider == .coinbase && quote.isLive) + && !(self == .intradayEnhanced && CurrencyCatalog.crypto.contains(code) + && quote.source.provider == .coinMetrics && quote.isLive) } } let dailyTime = daily.isEmpty ? nil : snapshot.supplementalFetchedAt ?? snapshot.dailyFetchedAt diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/RateService.swift b/Domain/ExchangeRates/Sources/ExchangeRates/RateService.swift index ec8de5f..56d0574 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/RateService.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/RateService.swift @@ -26,7 +26,7 @@ public actor RateService { self.policy = policy self.fiat = nil self.daily = FawazProvider(client: client) - self.crypto = policy == .coinbaseEnhanced ? CoinbaseProvider(client: client) : nil + self.crypto = policy == .intradayEnhanced ? CoinMetricsProvider(client: client) : nil self.sleep = { try await Task.sleep(for: $0) } } @@ -110,7 +110,7 @@ public actor RateService { where CurrencyCatalog.crypto.contains(code) && quote.published >= (dailyQuotes[code]?.published ?? "") { - quotes[code] = quote + if let aligned = quote.alignedToDailyUSD(dailyQuotes["USD"]) { quotes[code] = aligned } } let success = fiatQuotes != nil || supplementalQuotes != nil || live != nil let warning: RefreshWarning? = @@ -196,7 +196,9 @@ public actor RateService { where CurrencyCatalog.crypto.contains(code) && quote.published >= (dailyQuotes[code]?.published ?? "") { - effective[code] = quote + if let aligned = quote.alignedToDailyUSD(dailyQuotes["USD"]) { + effective[code] = aligned + } } let succeeded = results.values.contains { !($0.quotes ?? [:]).isEmpty } let final = results.count == providers.count diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/RateSource.swift b/Domain/ExchangeRates/Sources/ExchangeRates/RateSource.swift index 6d0c5f9..11cce57 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/RateSource.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/RateSource.swift @@ -8,10 +8,50 @@ public enum RateProviderID: Codable, Sendable, Equatable { case frankfurter /// Fawaz daily exchange-rate feed. case fawaz - /// Coinbase market data. - case coinbase + /// Coin Metrics reference rates. + case coinMetrics /// A consumer-supplied provider identity or display name. case custom(String) + + private enum CodingKeys: String, CodingKey { + case ecb, frankfurter, fawaz, coinMetrics, custom, coinbase + } + private enum ValueKeys: String, CodingKey { case _0 } + + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + guard values.allKeys.count == 1, let key = values.allKeys.first else { + throw RateError.invalidData + } + switch key { + case .ecb: self = .ecb + case .frankfurter: self = .frankfurter + case .fawaz: self = .fawaz + case .coinMetrics: self = .coinMetrics + case .coinbase: self = .custom("Coinbase") + case .custom: + self = .custom( + try values.nestedContainer(keyedBy: ValueKeys.self, forKey: key) + .decode(String.self, forKey: ._0)) + } + } + + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + let key: CodingKeys + switch self { + case .ecb: key = .ecb + case .frankfurter: key = .frankfurter + case .fawaz: key = .fawaz + case .coinMetrics: key = .coinMetrics + case .custom(let name): + var nested = values.nestedContainer(keyedBy: ValueKeys.self, forKey: .custom) + try nested.encode(name, forKey: ._0) + return + } + _ = values.nestedContainer(keyedBy: ValueKeys.self, forKey: key) + } + } /// How a provider's observation was sampled or aggregated. @@ -26,6 +66,8 @@ public enum RateObservation: String, Codable, Sendable { case trade /// A historical daily reference rate. case dailyReference + /// A minute reference-rate observation. + case minuteReference /// A historical monthly reference rate. case monthlyReference /// A completed hourly closing price. @@ -36,6 +78,28 @@ public enum RateObservation: String, Codable, Sendable { case monthlyLastClose } +/// Daily FX input used to normalize reference rates. +public struct RateConversion: Codable, Sendable, Equatable { + /// The daily FX provider. + public let provider: RateProviderID + /// Publication date of the FX observation. + public let published: String + /// Quote currency units per one base currency unit. + public let value: Decimal + public let base: String + public let quote: String + + public init( + provider: RateProviderID, published: String, value: Decimal, base: String, quote: String + ) { + self.provider = provider + self.published = published + self.value = value + self.base = base + self.quote = quote + } +} + /// Structured provenance that consumers can format or localize without parsing text. public struct RateSource: Codable, Sendable, Equatable { /// The provider responsible for the observation. @@ -47,20 +111,28 @@ public struct RateSource: Codable, Sendable, Equatable { /// The time zone used for date buckets, when specified by the provider. public let timeZone: TimeZone? + /// The daily FX approximation used to convert market observations, when applicable. + public let conversion: RateConversion? + /// Additional providers represented in a stitched historical series. + public let contributingProviders: [RateProviderID]? + /// Creates provider context without prescribing its display wording. public init( provider: RateProviderID, observation: RateObservation = .unspecified, timeZone: TimeZone? = nil, - latestObservation: RateObservation? = nil + latestObservation: RateObservation? = nil, + conversion: RateConversion? = nil, contributingProviders: [RateProviderID]? = nil ) { self.provider = provider self.observation = observation self.latestObservation = latestObservation self.timeZone = timeZone + self.conversion = conversion + self.contributingProviders = contributingProviders } private enum CodingKeys: String, CodingKey { - case provider, observation, timeZone, latestObservation + case provider, observation, timeZone, latestObservation, conversion, contributingProviders } /// Decodes structured provenance or migrates the former source-string cache format. @@ -70,11 +142,11 @@ public struct RateSource: Codable, Sendable, Equatable { case "ECB": self.init(provider: .ecb, observation: .dailyRate) case "Frankfurter": self.init(provider: .frankfurter, observation: .dailyRate) case "Fawaz · daily": self.init(provider: .fawaz, observation: .dailyRate) - case "Coinbase": self.init(provider: .coinbase, observation: .trade) + case "Coinbase": self.init(provider: .custom("Coinbase"), observation: .trade) case "Coinbase · daily closes · UTC": - self.init(provider: .coinbase, observation: .dailyClose, timeZone: .gmt) + self.init(provider: .custom("Coinbase"), observation: .dailyClose, timeZone: .gmt) case "Coinbase · monthly last available close · UTC": - self.init(provider: .coinbase, observation: .monthlyLastClose, timeZone: .gmt) + self.init(provider: .custom("Coinbase"), observation: .monthlyLastClose, timeZone: .gmt) case "Frankfurter · daily reference rates": self.init(provider: .frankfurter, observation: .dailyReference) case "Frankfurter · monthly reference rates": @@ -89,6 +161,32 @@ public struct RateSource: Codable, Sendable, Equatable { observation: try values.decode(RateObservation.self, forKey: .observation), timeZone: try values.decodeIfPresent(TimeZone.self, forKey: .timeZone), latestObservation: try values.decodeIfPresent( - RateObservation.self, forKey: .latestObservation)) + RateObservation.self, forKey: .latestObservation), + conversion: try values.decodeIfPresent(RateConversion.self, forKey: .conversion), + contributingProviders: try values.decodeIfPresent( + [RateProviderID].self, forKey: .contributingProviders)) + } +} + +extension ExchangeRate { + func alignedToDailyUSD(_ usd: ExchangeRate?) -> ExchangeRate? { + guard source.provider == .coinMetrics, let conversion = source.conversion else { return self } + guard conversion.base == "EUR", conversion.quote == "USD", + conversion.value > 0, !conversion.value.isNaN, + let usd, usd.value > 0, !usd.value.isNaN, !usd.isLive + else { return nil } + let selected = RateConversion( + provider: usd.source.provider, published: usd.published, value: usd.value, + base: "EUR", quote: "USD") + if selected == conversion { return self } + let aligned = value * usd.value / conversion.value + guard aligned > 0, !aligned.isNaN else { return nil } + return ExchangeRate( + aligned, published: published, + source: RateSource( + provider: source.provider, observation: source.observation, timeZone: source.timeZone, + latestObservation: source.latestObservation, conversion: selected, + contributingProviders: source.contributingProviders), + observedAt: observedAt, retrievedAt: retrievedAt, cachedAt: cachedAt) } } diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/Resources/CC-BY-4.0.txt b/Domain/ExchangeRates/Sources/ExchangeRates/Resources/CC-BY-4.0.txt new file mode 100644 index 0000000..4ea99c2 --- /dev/null +++ b/Domain/ExchangeRates/Sources/ExchangeRates/Resources/CC-BY-4.0.txt @@ -0,0 +1,395 @@ +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/Resources/CC-BY-NC-4.0.txt b/Domain/ExchangeRates/Sources/ExchangeRates/Resources/CC-BY-NC-4.0.txt new file mode 100644 index 0000000..c657cab --- /dev/null +++ b/Domain/ExchangeRates/Sources/ExchangeRates/Resources/CC-BY-NC-4.0.txt @@ -0,0 +1,407 @@ +Attribution-NonCommercial 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-NonCommercial 4.0 International Public +License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-NonCommercial 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. NonCommercial means not primarily intended for or directed towards + commercial advantage or monetary compensation. For purposes of + this Public License, the exchange of the Licensed Material for + other material subject to Copyright and Similar Rights by digital + file-sharing or similar means is NonCommercial provided there is + no payment of monetary compensation in connection with the + exchange. + + j. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + k. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + l. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part, for NonCommercial purposes only; and + + b. produce, reproduce, and Share Adapted Material for + NonCommercial purposes only. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties, including when + the Licensed Material is used other than for NonCommercial + purposes. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database for NonCommercial purposes + only; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/Resources/DataSources.md b/Domain/ExchangeRates/Sources/ExchangeRates/Resources/DataSources.md new file mode 100644 index 0000000..89a2865 --- /dev/null +++ b/Domain/ExchangeRates/Sources/ExchangeRates/Resources/DataSources.md @@ -0,0 +1,107 @@ +# Data sources and licences + +Currency uses third-party reference data for informational estimates. Providers do not endorse Currency or its calculations. These notices apply to data displayed in the app, widgets and Apple Watch, retained in caches, and distributed as bundled history. The repository’s MIT licence applies to project code, not third-party data. + +## Coin Metrics + +Crypto reference data: **Coin Metrics, Inc.**, [Community API](https://docs.coinmetrics.io/api-reference) and [Community data archives](https://github.com/coinmetrics/data), licensed under [Creative Commons Attribution–NonCommercial 4.0 International](https://creativecommons.org/licenses/by-nc/4.0/) ([full text](https://github.com/DimaMishchenko/currency/blob/main/LICENSES/CC-BY-NC-4.0.txt)). The archive’s [source licence notice](https://github.com/coinmetrics/data/blob/master/LICENSE) applies to its data. The API specification’s separate software licence is not a licence for commercial use of Community data. + +Coin Metrics publishes its data without warranty; use is at your own risk. See the licence’s disclaimer of warranties and limitation of liability. Currency selects asset/date subsets, reformats values into app resources, samples historical observations, calculates cross-rates and chart changes, and may convert USD crypto prices using a separately dated fiat reference rate. These changes are made by Currency / Dmytro Mishchenko. Calculated prices and charts are Currency’s reference estimates. Original asset, metric, date and source metadata identify the underlying data; gaps are not evidence of a zero price. The bundled `history-monthly-v1.json` records its source, acquisition date, input hashes and sampling metadata. Pre-March-2024 crypto history selects each month’s last available Coin Metrics daily observation, with additional dated samples used to join the available monthly fiat reference observations. These monthly fiat references do not establish the exact FX rate at every crypto observation time. + +Recipients receive the licensor’s offer to reuse the licensed data under CC BY-NC 4.0, including its noncommercial, attribution and modification-notice conditions. Currency’s contributions to adapted Coin Metrics datasets are offered under the same licence. Preserve supplied source, copyright, licence and warranty notices and identify further modifications. No app EULA or repository MIT licence restricts these licensed rights. Distributors must not apply additional legal restrictions or effective technological measures to this material that restrict recipients’ licensed rights. Packaging and distribution must preserve these rights; a separate source download does not by itself resolve restrictions applied to another distributed copy. + +The source archives are publicly downloadable at the link above. Bundled data files and manifests are available with this repository. CC BY-NC 4.0 does not promise future data access, unchanged coverage, or a particular rolling intraday history window. Currency remains free and noncommercial without advertising; commercial reuse of the licensed data requires a separate permission or another applicable legal basis. + +## Fawaz Exchange API + +Daily rates, fallback history and bundled monthly references: [Fawaz Ahmed’s Exchange API](https://github.com/fawazahmed0/exchange-api), distributed through its documented CDN and Cloudflare endpoints. Its repository declares [CC0 1.0 Universal](https://github.com/fawazahmed0/exchange-api/blob/main/LICENSE) ([full text](https://github.com/DimaMishchenko/currency/blob/main/LICENSES/Fawaz-CC0-1.0.txt)). The bundled completed-month history samples March 2, 2024 (the archive boundary), then the first day of each completed month from April 2024. The incomplete current month is omitted. Later missing completed months may be fetched as month-end references. Currency selects dates/assets and calculates cross-rates and reference conversions. CC0 applies only to rights the affirmer can waive or license; it does not warrant third-party source rights or grant trademark rights. + +## Frankfurter and original fiat sources + +[Frankfurter](https://frankfurter.dev/) aggregates institutional reference rates. Its [provider and licensing inventory](https://frankfurter.dev/license/) identifies the original sources; its software licence does not replace their data terms. Currency uses an explicit selection of provider IDs. A displayed calculation or blend is produced by Currency, rather than an official quote from any contributing institution. Dates refer to the source observations; monthly samples are reference observations, not market closes. Bundled metadata records the selected source IDs. The pre-March-2024 fiat archive requests USD-based monthly reference buckets from July 2010 through February 2024; actual coverage differs by currency. GGP, IMP and JEP use an explicit GBP-parity assumption. These are Currency’s reference calculations. + +The following notices accompany data from these sources, where present: + +- **Banco Central de la República Argentina (BCRA)**: source [BCRA](https://www.bcra.gob.ar/); [informational reuse terms](https://www.bcra.gob.ar/aviso-legal/). Currency’s noncommercial estimates preserve the source context; conversions, selection and formatting are Currency’s work. +- **Bank for International Settlements (BIS)**: underlying statistics sourced from [BIS Data Portal](https://data.bis.org/), under its [statistical-data terms](https://data.bis.org/help/legal). Currency’s calculations are identified as such; no additional charge is made for the included statistics. +- **Bank of Canada**: underlying data sourced from [Bank of Canada](https://www.bankofcanada.ca/), under its [terms](https://www.bankofcanada.ca/terms/). Currency selects/reformats reference observations and calculates cross-rates; the Bank of Canada does not endorse these changes. +- **Bank of Jamaica**: source [Bank of Jamaica](https://boj.org.jm/), used for noncommercial general reference with source acknowledgment under its [notice](https://boj.org.jm/disclaimer/). +- **European Central Bank (ECB)**: underlying reference rates sourced from [ECB](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html), under its [copyright and reuse terms](https://www.ecb.europa.eu/services/using-our-site/disclaimer/html/index.en.html). Selection, reformatting, cross-rates and converted prices are modifications by Currency. +- **HM Revenue & Customs**: [monthly customs exchange rates](https://www.trade-tariff.service.gov.uk/exchange_rates/monthly). Contains public sector information licensed under the [Open Government Licence v3.0](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/). Currency’s calculated reference conversions are not HMRC customs assessments. +- **European Commission / InforEuro**: underlying data from [InforEuro](https://commission.europa.eu/funding-tenders/procedures-guidelines-tenders/information-contractors-and-beneficiaries/exchange-rate-inforeuro_en), © European Union, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) ([full text](https://github.com/DimaMishchenko/currency/blob/main/LICENSES/CC-BY-4.0.txt)) as described in the [Commission legal notice](https://commission.europa.eu/legal-notice_en), except separately identified third-party material. Currency selects/reformats observations and calculates reference cross-rates. Retain the source/licence/warranty notices and identify changes; no additional legal or effective technological restriction may obstruct the licensed rights. See the licence for its warranty disclaimer. +- **Japan Customs**: underlying [customs exchange rates](https://www.customs.go.jp/english/kawase/index_e.htm); [copyright notice](https://www.customs.go.jp/copyright_e.htm) and [Public Data Licence 1.0](https://www.digital.go.jp/en/resources/open_data/public_data_license_v1.0). Numerical data are freely usable as described there. Selection, formatting and calculated estimates are by Currency / Dmytro Mishchenko; they are not unedited government publications. +- **Sveriges riksbank**: original statistics and their observation dates are identified through the source metadata and [original series](https://www.riksbank.se/en-gb/statistics/search-interest--exchange-rates/). See [open-data reuse terms](https://www.riksbank.se/sv/om-riksbanken/om-webbplatsen/oppna-data--information-tillganglig-for-vidareutnyttjande/). Processed/blended statistics and cross-rates are Currency’s calculations; Sveriges riksbank is credited only for the underlying original statistics, not as the source of those processed results. +- **U.S. Department of the Treasury**: underlying [Treasury reporting rates](https://fiscaldata.treasury.gov/datasets/treasury-reporting-rates-exchange/treasury-reporting-rates-of-exchange), available for copying, adaptation and redistribution under its [data authorization](https://fiscaldata.treasury.gov/api-documentation/#license-and-authorization). Currency’s calculations do not imply Treasury endorsement. + +### Source directory + +The selected source directory below links each original dataset. Source-specific notices and third-party exclusions continue to apply. Inclusion here is attribution and provenance, not a claim that every source uses the same licence. No linked licence in an aggregator’s inventory is not a public-domain dedication. + +| ID | Original institution and dataset | +| --- | --- | +| AMCM | [Autoridade Monetária de Macau](https://www.amcm.gov.mo/en/financial-information/interbank-midpoint) | +| BAM | [Bank Al-Maghrib](https://apihelpdesk.centralbankofmorocco.ma/apis) | +| BANREP | [Banco de la República](https://www.datos.gov.co/Econom-a-y-Finanzas/Tasa-de-Cambio-Representativa-del-Mercado-TRM/32sa-8pi3/about_data) | +| BCB | [Banco Central do Brasil](https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/) | +| BCBO | [Banco Central de Bolivia](https://www.bcb.gob.bo/?q=cotizaciones_tc) | +| BCC | [Banco Central de Cuba](https://api.bc.gob.cu/v1/tasas-de-cambio/activas) | +| BCCH | [Banco Central de Chile](https://si3.bcentral.cl/Siete/es/Siete/API) | +| BCP | [Banco Central del Paraguay](https://www.bcp.gov.py/webapps/web/cotizacion/monedas) | +| BCRA | [Banco Central de la República Argentina](https://www.bcra.gob.ar/en/monetary-and-financial-statistics/exchange-statistics/) | +| BCT | [Banque Centrale de Tunisie](https://www.bct.gov.tn/bct/siteprod/cours_archive.jsp) | +| BDI | [Banca d'Italia](https://tassidicambio.bancaditalia.it/) | +| BDP | [Banco de Portugal](https://bpstat.bportugal.pt/) | +| BI | [Bank Indonesia](https://www.bi.go.id/en/statistik/informasi-kurs/transaksi-bi/default.aspx) | +| BIS | [Bank for International Settlements](https://data.bis.org/topics/XRU) | +| BNA | [Banco Nacional de Angola](https://www.bna.ao/Conteudos/Mercados/listaTaxasCambioReferencia.aspx) | +| BNM | [Bank Negara Malaysia](https://www.bnm.gov.my/exchange-rates) | +| BNR | [Banca Națională a României](https://www.bnr.ro/en/561-exchange-rates) | +| BOA | [Bank of Algeria](https://www.bank-of-algeria.dz/donnees-historiques/) | +| BOC | [Bank of Canada](https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/) | +| BOJA | [Bank of Jamaica](https://boj.org.jm/market/foreign-exchange/counter-rates/) | +| BOM | [Bank of Mongolia](https://www.mongolbank.mn/en/currency-rate-movement) | +| BOT | [Bank of Thailand](https://www.bot.or.th/en/statistics/exchange-rate.html) | +| BOTA | [Bank of Tanzania](https://www.bot.go.tz/exchangerate/excrates) | +| BOZ | [Bank of Zambia](https://www.boz.zm/average-exchange-rates) | +| BRB | [Banque de la Republique du Burundi](https://www.brb.bi/en/affichagetoustauxchange) | +| CBA | [Central Bank of Armenia](https://www.cba.am/en/SitePage/ExchangeArchive.aspx) | +| CBAR | [Central Bank of the Republic of Azerbaijan](https://www.cbar.az/currency/rates) | +| CBC | [Central Bank of the Republic of China (Taiwan)](https://www.cbc.gov.tw/en/lp-4237-2.html) | +| CBG | [Central Bank of The Gambia](https://www.cbg.gm/indicative-exchange-rates-latest) | +| CBI | [Central Bank of Iraq](https://cbi.iq/page/144) | +| CBK | [Central Bank of Kenya](https://www.centralbank.go.ke/forex/) | +| CBLLR | [Central Bank of Liberia](https://www.cbl.org.lr/research/buying-selling-rates) | +| CBM | [Central Bank of Myanmar](https://forex.cbm.gov.mm/api/latest) | +| CBS | [Central Bank of Samoa](https://cbs.gov.ws/daily-exchange-rates) | +| CBSL | [Central Bank of Sri Lanka](https://www.cbsl.gov.lk/en/rates-and-indicators/exchange-rates/daily-indicative-exchange-rates) | +| CBU | [Central Bank of Uzbekistan](https://cbu.uz/en/arkhiv-kursov-valyut/json/) | +| DAB | [Da Afghanistan Bank](https://www.dab.gov.af/exchange-rates) | +| DNB | [Danmarks Nationalbank](https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates) | +| ECB | [European Central Bank](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html) | +| HMRC | [HM Revenue & Customs](https://www.trade-tariff.service.gov.uk/exchange_rates/monthly) | +| HNB | [Hrvatska Narodna Banka](https://api.hnb.hr/) | +| INFOREURO | [European Commission](https://commission.europa.eu/funding-and-tenders/procedures-guidelines-tenders/information-contractors-and-beneficiaries/exchange-rate-inforeuro_en) | +| JPC | [Japan Customs](https://www.customs.go.jp/tetsuzuki/kawase/) | +| LB | [Lietuvos Bankas](https://www.lb.lt/webservices/FxRates/FxRates.asmx) | +| MAS | [Monetary Authority of Singapore](https://eservices.mas.gov.sg/statistics/msb/ExchangeRates.aspx) | +| MMA | [Maldives Monetary Authority](https://www.mma.gov.mv/JSON/referencerates.json) | +| MNB | [Magyar Nemzeti Bank](https://www.mnb.hu/en/monetary-policy/the-mpc/exchange-rates) | +| NBC | [National Bank of Cambodia](https://www.nbc.gov.kh/english/economic_research/exchange_rate.php) | +| NBE | [National Bank of Ethiopia](https://nbe.gov.et/exchange-rate/) | +| NBG | [National Bank of Georgia](https://nbg.gov.ge/en/monetary-policy/currency) | +| NBK | [National Bank of Kazakhstan](https://nationalbank.kz/en/exchangerates/ezhednevnye-oficialnye-rynochnye-kursy-valyut) | +| NBKR | [National Bank of the Kyrgyz Republic](https://www.nbkr.kg/index1.jsp?item=1562&lang=ENG) | +| NBM | [National Bank of Moldova](https://www.bnm.md/en/official_exchange_rates) | +| NBP | [Narodowy Bank Polski](https://nbp.pl/en/statistic-and-financial-reporting/rates/) | +| NBRB | [Natsyyanalny Bank Respubliki Belarus](https://www.nbrb.by/engl/statistics/rates/ratesdaily.asp) | +| NBRM | [Narodna Banka na Republika Severna Makedonija](https://www.nbrm.mk) | +| NBT | [National Bank of Tajikistan](https://www.nbt.tj/en/kurs/) | +| NRB | [Nepal Rastra Bank](https://www.nrb.org.np/api/forex/v1/rates) | +| NRBT | [National Reserve Bank of Tonga](https://www.reservebank.to/index.php/financial-system/financial-markets/exchange-rates) | +| RB | [Sveriges Riksbank](https://www.riksbank.se/en-gb/statistics/) | +| RBF | [Reserve Bank of Fiji](https://www.rbf.gov.fj/statistics/economic-and-financial-statistics/) | +| RBM | [Reserve Bank of Malawi](https://www.rbm.mw/Statistics/MajorRates) | +| RBV | [Reserve Bank of Vanuatu](https://www.rbv.gov.vu/index.php/en/exchange-rates) | +| SARB | [South African Reserve Bank](https://custom.resbank.co.za/SarbWebApi/WebIndicators/HistoricalExchangeRatesDaily) | +| SBI | [Seðlabanki Íslands](https://www.sedlabanki.is/hagtolur/opinbert-gengi/) | +| UST | [U.S. Department of the Treasury](https://fiscaldata.treasury.gov/datasets/treasury-reporting-rates-exchange/treasury-reporting-rates-of-exchange) | diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/Resources/Fawaz-CC0-1.0.txt b/Domain/ExchangeRates/Sources/ExchangeRates/Resources/Fawaz-CC0-1.0.txt new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/Domain/ExchangeRates/Sources/ExchangeRates/Resources/Fawaz-CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/Resources/history-monthly-v1.json b/Domain/ExchangeRates/Sources/ExchangeRates/Resources/history-monthly-v1.json new file mode 100644 index 0000000..ae8caad --- /dev/null +++ b/Domain/ExchangeRates/Sources/ExchangeRates/Resources/history-monthly-v1.json @@ -0,0 +1 @@ +{"crypto":{"AAVE":[[18566,29.67053862786206],[18596,76.64973384555694],[18627,88.3442327462347],[18658,301.040867868098],[18686,357.036949563706],[18717,381.846290067283],[18747,442.110477649175],[18778,382.833967741397],[18808,249.161866769102],[18839,331.688296589839],[18870,394.207466024315],[18900,275.32767617757],[18931,314.657924356324],[18961,257.256778931086],[18992,255.964447437585],[19023,157.475465996988],[19051,145.135039807638],[19082,206.351763793201],[19112,142.635177176844],[19143,113.882361803999],[19173,55.6828132353886],[19204,98.2803866496344],[19235,84.7728693764264],[19265,74.4315144983171],[19296,83.9260776794942],[19326,65.3954173656035],[19357,51.8806488724147],[19388,82.980392341184],[19416,77.6498764992687],[19447,74.3086570422959],[19477,70.6900627090084],[19508,63.7906851664697],[19538,70.4890709334198],[19569,65.3335078767923],[19600,55.5238591805366],[19630,67.5600248391714],[19661,81.2569686853286],[19691,98.1716057593052],[19722,108.958719663814],[19753,86.0886347228629],[19782,105.553683335637]],"ADA":[[17531,0.727614209054702],[17562,0.49564754719133],[17590,0.302282240305768],[17621,0.15129384569272],[17651,0.336566161881449],[17682,0.222011722954296],[17712,0.13661825948501],[17743,0.141770557409555],[17774,0.102092038679637],[17804,0.0849049772880258],[17835,0.0698561756895381],[17865,0.0384071473776521],[17896,0.0405049574078278],[17927,0.0379781810526311],[17955,0.0426238273186],[17986,0.069670317451648],[18016,0.0689180320774049],[18047,0.0892549791979985],[18077,0.0818005734842006],[18108,0.0600608222180562],[18139,0.0449130426740551],[18169,0.0388554304703753],[18200,0.0413495860484251],[18230,0.0404590455379839],[18261,0.0326805414884496],[18292,0.0539469783496549],[18321,0.0474744344768084],[18352,0.0305395761528434],[18382,0.0479635774572968],[18413,0.0736358722660046],[18443,0.0832328343048556],[18474,0.139476561243434],[18505,0.122478996467715],[18535,0.101108024639493],[18566,0.0929478039551219],[18596,0.171951158415698],[18627,0.181690694927974],[18658,0.346960142690219],[18686,1.31323974383273],[18717,1.1927419656388],[18747,1.35019021984986],[18778,1.74219373321916],[18808,1.38105127746453],[18839,1.32861206435455],[18870,2.77975558463071],[18900,2.11103371657402],[18931,1.96780020598428],[18961,1.55695491407844],[18992,1.31172975353777],[19023,1.05137552031217],[19051,0.956931638884253],[19082,1.14467593599106],[19112,0.753340346441921],[19143,0.630326262387554],[19173,0.451774042557152],[19204,0.516900650536708],[19235,0.446594693852058],[19265,0.433940182475872],[19296,0.406140628028702],[19326,0.319363497229938],[19357,0.245857181540895],[19388,0.390736723449631],[19416,0.352012396145585],[19447,0.399413929072605],[19477,0.397991316369483],[19508,0.374156112850791],[19538,0.287142026421017],[19569,0.307358326984925],[19600,0.255087187526006],[19630,0.253941724053758],[19661,0.292520275966666],[19691,0.375593023900436],[19722,0.592148262903645],[19753,0.498983586327001],[19782,0.65766268857218]],"ALGO":[[18077,1.4250257801208],[18108,0.562659114950503],[18139,0.409136298500302],[18169,0.253687157254172],[18200,0.218705523257908],[18230,0.265557759436423],[18261,0.215458561557161],[18292,0.247569985583779],[18321,0.331883979192636],[18352,0.154520268876873],[18382,0.211343980683525],[18413,0.231692952523522],[18443,0.208440531669855],[18474,0.328781326808267],[18505,0.50577428886352],[18535,0.345034231487219],[18566,0.249451683040341],[18596,0.329238806666961],[18627,0.329903869471228],[18658,0.648809971080635],[18686,1.03168761866685],[18717,1.35581729539239],[18747,1.39763123685734],[18778,0.933495917622917],[18808,0.881547597061307],[18839,0.845448727094441],[18870,1.10972882890906],[18900,1.61822160346421],[18931,1.82914793347428],[18961,1.82359946671715],[18992,1.66864755208973],[19023,0.954634570323105],[19051,0.863279300293046],[19082,0.928563209850857],[19112,0.565662704165782],[19143,0.412346950161432],[19173,0.309968061289635],[19204,0.335320556283709],[19235,0.288342584642436],[19265,0.352457045747451],[19296,0.358642742823028],[19326,0.248256836950209],[19357,0.172036917096201],[19388,0.241725236608039],[19416,0.244099633967539],[19447,0.225519594390541],[19477,0.181102661154642],[19508,0.147716711715593],[19538,0.121551490501791],[19569,0.109864901333206],[19600,0.0931714316295508],[19630,0.10328170439683],[19661,0.109369279721898],[19691,0.133500459924533],[19722,0.222622850393727],[19753,0.159257096785238],[19782,0.211550306639817]],"BCH":[[17409,593.880407244615],[17439,433.598211911027],[17470,436.507081926566],[17500,1346.58134166823],[17531,2384.95066946072],[17562,1460.35053207376],[17590,1199.27258799109],[17621,683.045280521779],[17651,1344.12197132676],[17682,992.933151212948],[17712,741.462833610866],[17743,772.405968839889],[17774,543.393207554411],[17804,529.085069413975],[17835,423.198182739283],[17865,170.631976341979],[17896,148.443845654791],[17927,112.933407456682],[17955,129.376482322795],[17986,168.42365336725],[18016,263.22046372419],[18047,443.001547706769],[18077,403.243862886353],[18108,328.5469020422],[18139,279.086619107708],[18169,226.998378669319],[18200,283.145057533315],[18230,217.579652709681],[18261,203.784699574805],[18292,376.27966421584],[18321,308.793981136636],[18352,219.903314681138],[18382,250.361704267839],[18413,238.431808807065],[18443,222.41832290185],[18474,300.800804246454],[18505,274.592260050469],[18535,227.708521732642],[18566,262.335282011194],[18596,318.11860091453644],[18627,343.468572246843],[18658,401.027153156402],[18686,462.328395087411],[18717,541.537572262169],[18747,993.67171059674],[18778,707.354606735018],[18808,526.184893779491],[18839,549.6564917479],[18870,636.438789605398],[18900,501.120702019527],[18931,597.355771959462],[18961,571.566336938756],[18992,431.16907473861],[19023,285.169749368022],[19051,336.187368069528],[19082,383.236202041038],[19112,277.29520519382],[19143,204.204242424633],[19173,101.086271719165],[19204,139.944474902545],[19235,115.004151278691],[19265,120.225882221021],[19296,115.117367807326],[19326,113.459029611794],[19357,96.8915117201753],[19388,133.144336315849],[19416,131.913861560181],[19447,124.150064839983],[19477,118.326941611896],[19508,113.199334128074],[19538,305.651283700076],[19569,248.147067704508],[19600,207.529175600998],[19630,234.618366264704],[19661,244.629207581684],[19691,221.477324371351],[19722,258.812317176344],[19753,234.359675458432],[19782,298.517021100696]],"BTC":[[14821,0.0675457989479836],[14852,0.06],[14882,0.0619],[14913,0.1925],[14943,0.2082],[14974,0.3],[15005,0.525136870251315],[15033,0.860220923436587],[15064,0.78461],[15094,3.5],[15125,8.72157642197545],[15155,16.1852627808299],[15186,13.3729061022794],[15217,8.19915176446523],[15247,5.14368590999416],[15278,3.26509821274109],[15308,2.9660415458796],[15339,4.71430633138515],[15370,5.53822029573349],[15399,4.87274875569842],[15430,4.88977687317358],[15460,4.9421168100526],[15491,5.18163972121566],[15521,6.68042762302747],[15552,9.32099840210403],[15583,10.1024098205728],[15613,12.3916224728229],[15644,11.1460718381064],[15674,12.5861389275278],[15705,13.5455524436002],[15736,20.5116430306838],[15764,33.3752939365868],[15795,93.8992384231444],[15825,139.129647545295],[15856,127.816488895383],[15886,89.4809076563413],[15917,98.0244199298656],[15948,128.377978959673],[15978,126.090261835184],[16009,203.283649912332],[16039,1119.29671478667],[16070,729.557582910579],[16101,803.237598784921],[16129,551.29477030976],[16160,455.355354056108],[16190,448.875715514009],[16221,627.892420689655],[16251,638.94611396844],[16282,581.975561952075],[16313,478.511425482174],[16343,389.264040411455],[16374,337.182912624196],[16404,378.690397136178],[16435,320.662287258913],[16466,217.332156049094],[16494,254.948448568089],[16525,244.712485680888],[16555,236.327976037405],[16586,229.45054792519],[16616,263.854356864991],[16647,284.597324371712],[16678,230.689466101695],[16708,236.739848042081],[16739,314.119508182349],[16769,377.389839859731],[16800,429.677153419053],[16831,366.341687609585],[16860,437.775872589129],[16891,415.956811221508],[16921,449.900484511981],[16952,530.943692811221],[16982,674.284814728229],[17013,624.804487142022],[17044,572.272758328463],[17074,608.865840736412],[17105,698.897727995325],[17135,742.20445131502],[17166,968.970597019287],[17197,968.499105669199],[17225,1188.39994073641],[17256,1081.74353109293],[17286,1381.9563722969],[17317,2299.43261025716],[17347,2452.71206428989],[17378,2862.61129088252],[17409,4740.35621770894],[17439,4334.54461250731],[17470,6428.51463705435],[17500,9996.71497194623],[17531,13921.4806414378],[17562,10050.0957971946],[17590,10307.0249663939],[17621,6922.2812969024],[17651,9228.7264836353],[17682,7478.74186703682],[17712,6375.5412314436],[17743,7725.13303594389],[17774,7025.14078024547],[17804,6604.485028346],[17835,6306.31693132671],[17865,3973.33405230859],[17896,3687.19994009351],[17927,3410.13161601403],[17955,3792.94772296902],[17986,4094.31781472823],[18016,5266.61820251315],[18047,8556.222185564],[18077,10842.6167109877],[18108,10059.0040189947],[18139,9605.02446142607],[18169,8282.3654541204],[18200,9155.27729877265],[18230,7556.19372121567],[18261,7167.39505464641],[18292,9357.28478240795],[18321,8582.09029964933],[18352,6432.3832421391],[18382,8652.4109912332],[18413,9433.94384663939],[18443,9143.96758530099],[18474,11338.1973407949],[18505,11678.3482268849],[18535,10772.351766803],[18566,13807.2336110462],[18596,19664.407606662764],[18627,29022.6714126242],[18658,33157.8325745178],[18686,45359.4641642314],[18717,58792.1948275862],[18747,57741.44441654],[18778,37312.9748970193],[18808,35065.4661533606],[18839,41793.3137481005],[18870,47219.2443424898],[18900,43781.546015488],[18931,61432.9700628287],[18961,57097.0127942724],[18992,46355.1173302747],[19023,38462.6724029807],[19051,43179.2545140269],[19082,45562.2106665693],[19112,37714.0949804208],[19143,31831.4095251315],[19173,19332.9121294565],[19204,23356.1722215079],[19235,20024.6716059614],[19265,19435.1946917008],[19296,20490.8581808884],[19326,17176.8986914085],[19357,16524.2178024547],[19388,23130.0519132087],[19416,23145.9491277031],[19447,28514.6813062537],[19477,29362.0867974868],[19508,27217.6832580362],[19538,30484.503257744],[19569,29219.8201887785],[19600,25954.8665935126],[19630,26977.6933126826],[19661,34636.4264736996],[19691,37712.7114789597],[19722,42217.1587913501],[19753,42589.4708059614],[19782,61394.7818515488]],"DOGE":[[16101,0.00191973786109596],[16129,0.00103842540236149],[16160,0.000499262149095369],[16190,0.000500041775448625],[16221,0.000334454182313162],[16251,0.00027185308649495],[16282,0.000203691446683226],[16313,0.000131405110458992],[16343,0.000401523264107525],[16374,0.00023368667695214],[16404,0.000239864268161588],[16435,0.000187021115837441],[16466,0.000135669185596869],[16494,0.000146304931263235],[16525,0.000124677593202859],[16555,0.000103255118174229],[16586,0.000152097565016067],[16616,0.00019876529602851],[16647,0.000185445845435036],[16678,0.000127311664837827],[16708,0.000121262453270178],[16739,0.000133942008636186],[16769,0.000127006128239924],[16800,0.000135994602054085],[16831,0.000263807787880335],[16860,0.000247487519343435],[16891,0.000230956360663934],[16921,0.000226196211540538],[16952,0.000226713049923967],[16982,0.00030952185307945],[17013,0.000227440518733833],[17044,0.000223675249200591],[17074,0.000233041461138687],[17105,0.000221810981470367],[17135,0.000211419128566364],[17166,0.000230273625424382],[17197,0.00020824284559173],[17225,0.000209392457822109],[17256,0.000334770919684301],[17286,0.00075548530077087],[17317,0.00251277305397699],[17347,0.00246570785449003],[17378,0.00167471795058399],[17409,0.00207808683389065],[17439,0.00115358850848731],[17470,0.00112764450075458],[17500,0.00206461669378526],[17531,0.00903469055713837],[17562,0.0058350484259325],[17590,0.00592298658606961],[17621,0.00275246804477945],[17651,0.00528122368200905],[17682,0.00340190537260365],[17712,0.00250967128998787],[17743,0.00298487279673965],[17774,0.00479914414568294],[17804,0.00588496554464235],[17835,0.00376341607420632],[17865,0.00216175029125436],[17896,0.00231174280839299],[17927,0.00187855396510081],[17955,0.00191460287932921],[17986,0.00206712410398376],[18016,0.0024734788863166],[18047,0.00343392550785411],[18077,0.00323949631239454],[18108,0.00289250746485418],[18139,0.00244137154773603],[18169,0.00236458871353361],[18200,0.00257975488126176],[18230,0.00229706522627301],[18261,0.00197591383595918],[18292,0.00235659744494601],[18321,0.0022439833771228],[18352,0.00184180636157068],[18382,0.00244502418939553],[18413,0.00255108508060021],[18443,0.00231788088205376],[18474,0.00323009998903651],[18505,0.00322165363901993],[18535,0.00263743331338929],[18566,0.0025576132599634],[18596,0.0035693349013689764],[18627,0.00473462890847553],[18658,0.0381205174042878],[18686,0.0485867450040584],[18717,0.0535690825011988],[18747,0.338295064038278],[18778,0.326518387701382],[18808,0.253546042678888],[18839,0.20884920827101],[18870,0.278126724347015],[18900,0.203766747506382],[18931,0.281145996962567],[18961,0.215615430973181],[18992,0.170559483939401],[19023,0.141651604577392],[19051,0.133011462995243],[19082,0.137959558050115],[19112,0.126959664067029],[19143,0.0860726366671687],[19173,0.064741019386528],[19204,0.0682270183474027],[19235,0.0611569596445761],[19265,0.0617702827493816],[19296,0.126580437956239],[19326,0.107351938400373],[19357,0.0701822629603795],[19388,0.0957784733271092],[19416,0.0807388540639806],[19447,0.0772785298886713],[19477,0.0798480411410231],[19508,0.0716358329323697],[19538,0.0664456233938167],[19569,0.0778182159286374],[19600,0.06378656252524],[19630,0.0621288637481772],[19661,0.0681014484931328],[19691,0.0833488985842817],[19722,0.0892560888625986],[19753,0.0787325598668585],[19782,0.118223304289547]],"DOT":[[18505,6.30414655500884],[18535,4.36122279681102],[18566,4.18716075142028],[18596,5.37892708430273],[18627,9.29455971912234],[18658,16.1540726297912],[18686,33.9310326608219],[18717,36.951165839154],[18747,36.5252084000565],[18778,23.2902543574046],[18808,16.3217463019492],[18839,16.9239341139952],[18870,31.3185045670012],[18900,28.5327903609866],[18931,42.9103585651628],[18961,38.1068307951775],[18992,26.7858600062284],[19023,19.4092005607297],[19051,18.8992738307125],[19082,21.3653378038197],[19112,14.5040144009556],[19143,10.4405820150016],[19173,6.8858108617861],[19204,8.67226094415819],[19235,7.01805446802395],[19265,6.31277240557471],[19296,6.63184626095243],[19326,5.47462930015051],[19357,4.31057839948399],[19388,6.2613994266839],[19416,6.34663831261642],[19447,6.34036993330054],[19477,5.9106885492356],[19508,5.3125279223677],[19538,5.1721052938364],[19569,5.10845855835666],[19600,4.26629901645995],[19630,4.10504404712289],[19661,4.44511761158117],[19691,5.46302432910446],[19722,8.17661762464676],[19753,6.65091439262203],[19782,8.26062286508356]],"ETC":[[17013,1.8011298182573847],[17044,1.1817294812187193],[17074,1.2526943848048553],[17105,0.8955905818958387],[17135,0.7461290623981671],[17166,1.4193584356705273],[17197,1.352264622863671],[17225,1.235770666824388],[17256,2.812308313602288],[17286,6.682517614713755],[17317,16.92351582484079],[17347,17.313609473173067],[17378,13.78854070396001],[17409,16.85094246737046],[17439,13.14475688432445],[17470,10.488678219999013],[17500,26.230123911299632],[17531,26.355145726717463],[17562,28.05732351583912],[17590,33.063675197159334],[17621,14.284939311747383],[17651,21.44197808984787],[17682,15.249473751094703],[17712,16.10989268829415],[17743,16.14908941108195],[17774,12.75732934387871],[17804,11.303994501259822],[17835,8.990634012467028],[17865,4.552724662737577],[17896,4.958184484881727],[17927,3.9017214766390405],[17955,4.329061247810386],[17986,4.811781393459252],[18016,5.81539096196956],[18047,8.656347643149731],[18077,7.759488929072194],[18108,6.049678821039636],[18139,6.279735301942394],[18169,4.70950126967306],[18200,4.810474888149468],[18230,3.949763279384189],[18261,4.4815599287636125],[18292,11.34854368368468],[18321,7.406015240267053],[18352,4.95022268731908],[18382,6.479053838842568],[18413,6.897226141347415],[18443,5.733679905251292],[18474,7.3808019685158355],[18505,6.635686272122001],[18535,5.4962651133377625],[18566,5.276042271499424],[18596,6.785620349885238],[18627,5.6697846530796],[18658,7.42328063177734],[18686,10.3936247716192],[18717,14.1616195811822],[18747,36.2933357834171],[18778,69.9668249744248],[18808,57.9249413850978],[18839,51.7670786021679],[18870,63.9655746595986],[18900,46.8883322320913],[18931,54.440267800661],[18961,48.4781416307336],[18992,34.1803170087896],[19023,25.6822600468365],[19051,30.0042199342133],[19082,47.2351427124311],[19112,25.8118292267722],[19143,23.8150760922713],[19173,14.6363951123734],[19204,36.7539527023459],[19235,32.3416508744119],[19265,27.7068059986098],[19296,24.2654145092381],[19326,20.3096371597169],[19357,15.6831222755988],[19388,21.6832304712681],[19416,20.7614039951442],[19447,20.7779938090431],[19477,19.5076873081604],[19508,17.997655464408],[19538,20.8457047981722],[19569,18.4952002068996],[19600,15.4909938083938],[19630,16.3768486652],[19661,17.4185078332939],[19691,18.6392955381632],[19722,21.8938776760739],[19753,24.3926775893188],[19782,28.9310244783749]],"ETH":[[16678,1.30651],[16708,0.713644313851549],[16739,0.889342571595558],[16769,0.88],[16800,0.94884],[16831,2.24267177966102],[16860,6.33325343074226],[16891,11.3805020005845],[16921,8.81711589421391],[16952,14.0467417369959],[16982,12.4567760923437],[17013,11.9461159415546],[17044,11.6065258129749],[17074,13.2596977603741],[17105,10.976982628872],[17135,8.60721732904734],[17166,8.12632962185856],[17197,10.7060901987142],[17225,15.6533547393337],[17256,50.0319167153711],[17286,80.5040275862069],[17317,229.102651646406],[17347,276.846878461718],[17378,200.760819609001],[17409,388.628983109293],[17439,301.653574810053],[17470,304.758790181181],[17500,438.558600233781],[17531,742.255464932788],[17562,1096.25005698422],[17590,851.323646405611],[17621,394.512378141438],[17651,667.063407656341],[17682,575.469864699006],[17712,451.723416715371],[17743,431.798523670368],[17774,282.215765341905],[17804,232.215880479252],[17835,196.897931911163],[17865,111.631448568089],[17896,130.790460549386],[17927,105.739236119229],[17955,134.754724137931],[17986,141.27678515488],[18016,159.938501461134],[18047,266.573922559906],[18077,291.660696317943],[18108,218.08824050263],[18139,172.177609877265],[18169,179.508727936879],[18200,182.430567212157],[18230,152.016210111046],[18261,128.511079777908],[18292,180.163670368206],[18321,220.10229421391],[18352,133.508133372297],[18382,206.88486528346],[18413,231.444047457627],[18443,225.722754821742],[18474,346.179992285213],[18505,436.09076943308],[18535,359.976752483928],[18566,386.585236119229],[18596,613.469492402104],[18627,739.025849853887],[18658,1317.346364173],[18686,1425.38586674459],[18717,1916.88001928697],[18747,2770.29005821157],[18778,2699.21445762712],[18808,2272.2690578609],[18839,2544.37537592051],[18870,3431.94775306838],[18900,3001.52751344243],[18931,4293.96550701344],[18961,4645.05362711864],[18992,3686.9517918761],[19023,2686.26782933957],[19051,2914.46506487434],[19082,3281.78751081239],[19112,2729.73245441262],[19143,1947.31703623612],[19173,1041.53905172414],[19204,1682.69250409117],[19235,1551.72922092344],[19265,1327.0759956166],[19296,1571.20387054354],[19326,1298.03941963764],[19357,1195.33087931034],[19388,1584.82108328463],[19416,1606.66273436587],[19447,1824.00190356517],[19477,1885.55236995909],[19508,1872.94246113384],[19538,1934.61751548802],[19569,1856.18514465225],[19600,1645.67690531853],[19630,1671.13527235535],[19661,1813.54269783752],[19691,2051.0453308007],[19722,2279.07572355348],[19753,2284.19509848042],[19782,3350.23638281707]],"ICP":[[18778,114.32986554929],[18808,50.0434045501535],[18839,42.1723885378329],[18870,63.1389436474947],[18900,44.9719291462464],[18931,45.1071002975532],[18961,40.9415500004143],[18992,24.5231458369226],[19023,19.7931056386699],[19051,20.0632264356812],[19082,20.8973732644505],[19112,12.276556969805],[19143,8.30438791249043],[19173,5.25825056329566],[19204,9.0428804461398],[19235,6.22500773515907],[19265,6.06189256329461],[19296,5.28476106055065],[19326,4.11739399286534],[19357,3.94215455264657],[19388,5.87875020354396],[19416,5.80417194694042],[19447,5.20223713063033],[19477,6.45080833406571],[19508,4.67291519745518],[19538,4.09554051126906],[19569,4.21036290801346],[19600,3.34063714042061],[19630,3.16299038997536],[19661,3.93992417372035],[19691,4.54861963920938],[19722,13.3560907115633],[19753,11.4780997736345],[19782,12.6507251787048]],"LINK":[[17439,0.446579790093441],[17470,0.192096210308663],[17500,0.16941261242644],[17531,0.600312060332108],[17562,0.616853200045115],[17590,0.662323958481374],[17621,0.279878827164228],[17651,0.542428447653946],[17682,0.344620117753412],[17712,0.224832405644941],[17743,0.293487767880552],[17774,0.328400079944413],[17804,0.331256440941408],[17835,0.510377255552091],[17865,0.319140945463494],[17896,0.284602097715742],[17927,0.382128718720355],[17955,0.421275152527098],[17986,0.507418888728613],[18016,0.465729807312044],[18047,1.01048226842163],[18077,3.42266966590206],[18108,2.19655931213165],[18139,1.7803113895174],[18169,1.76529889457278],[18200,2.73304264687443],[18230,2.24320943762106],[18261,1.76150066677179],[18292,2.82315715418464],[18321,4.12512985409879],[18352,2.26032018662648],[18382,3.7189827211501],[18413,4.1236755097068],[18443,4.56997683644353],[18474,7.77810922475239],[18505,15.799207463136],[18535,9.86095463645622],[18566,11.2323645209009],[18596,14.272947604323846],[18627,11.2409561710827],[18658,22.5962272128693],[18686,24.8594108692746],[18717,29.0723471775326],[18747,38.0084456021567],[18778,31.8881903994165],[18808,19.4679502814482],[18839,22.9042786211838],[18870,26.686954687529],[18900,23.8352168163604],[18931,30.0468094396488],[18961,25.4261868379102],[18992,19.6210084526711],[19023,17.2118250320641],[19051,15.0977520728522],[19082,16.9254218036087],[19112,10.9439825175455],[19143,7.63792291811705],[19173,6.15773566478524],[19204,7.65822265703498],[19235,6.61828808902097],[19265,7.58623232841003],[19296,7.84857163989802],[19326,7.68980211353264],[19357,5.56126247641535],[19388,6.94414759615923],[19416,7.20277657831328],[19447,7.6050676447914],[19477,7.05923465961938],[19508,6.4805114352984],[19538,6.30906210834869],[19569,7.54660359966648],[19600,5.87364180739178],[19630,8.17920376554093],[19661,11.3292281016982],[19691,14.3885646835432],[19722,14.9312784494098],[19753,15.3786124096176],[19782,19.2862607935654]],"LTC":[[15825,4.35004728755561],[15856,2.95034305990649],[15886,2.64365968979263],[15917,2.77644222837424],[15948,2.41],[15978,2.16875250356517],[16009,2.28910286382233],[16039,40.2958772144917],[16070,23.7897999127725],[16101,21.0785133008667],[16129,13.5681414586242],[16160,12.7589670673443],[16190,11.0274375082632],[16221,10.9440774676837],[16251,8.85945518512807],[16282,7.55011093322762],[16313,4.87499746035658],[16343,4.5450838924948],[16374,3.71387917074933],[16404,3.56648701544807],[16435,2.75986527783426],[16466,1.86621999016937],[16494,1.83657843741337],[16525,1.65155135295934],[16555,1.44285923027405],[16586,1.62611247941144],[16616,4.12412226834344],[16647,4.63128791550371],[16678,2.85194017377098],[16708,3.01446886900104],[16739,3.65331305272237],[16769,3.60940062471686],[16800,3.47202529643109],[16831,3.01264401187473],[16860,3.42553565714836],[16891,3.26104681020341],[16921,3.66755484891188],[16952,4.6292846220459],[16982,4.20129638523495],[17013,3.95043420916497],[17044,3.76903735530641],[17074,3.83445758331656],[17105,3.97326153686592],[17135,3.85894911007789],[17166,4.35411157408208],[17197,4.06855959183281],[17225,3.78537682533442],[17256,7.23894011582957],[17286,16.1966163283669],[17317,25.9264844231747],[17347,39.3691282540767],[17378,42.2450062139683],[17409,73.3643036922992],[17439,55.2610071126625],[17470,55.4302437547112],[17500,87.5163490030609],[17531,229.03527344673],[17562,160.959500680466],[17590,202.408565780345],[17621,116.087309016232],[17651,148.04890718879],[17682,118.026844467697],[17712,80.7568393352382],[17743,78.6732701099962],[17774,62.1095630725427],[17804,61.0281947826419],[17835,49.4558908709903],[17865,31.6219369833635],[17896,29.8134807549924],[17927,31.1843187645176],[17955,45.5048397999987],[17986,60.3509547792998],[18016,73.2848268136065],[18047,114.088975542856],[18077,121.964818448198],[18108,98.8587560634597],[18139,64.3769474515234],[18169,55.9272084156358],[18200,58.3457601058471],[18230,47.3469763199582],[18261,41.1140178685009],[18292,67.9639584259378],[18321,58.545402209987],[18352,39.2997202065394],[18382,46.4636456434803],[18413,45.5426370610498],[18443,41.2300748602659],[18474,58.1382756369397],[18505,61.1134363698195],[18535,46.2761559767649],[18566,55.8267433823726],[18596,87.93967441643287],[18627,124.822577039248],[18658,129.779675792711],[18686,165.953098528752],[18717,196.931402373199],[18747,271.678779260227],[18778,187.327497108187],[18808,144.103735328788],[18839,145.323591649627],[18870,171.597822864396],[18900,152.941524774188],[18931,192.08986563231],[18961,208.418012331304],[18992,146.489834772915],[19023,109.502425808186],[19051,113.268299442556],[19082,123.640702669617],[19112,95.8553190382277],[19143,68.6565143298027],[19173,52.5800392709399],[19204,59.9393838135231],[19235,53.7703084901273],[19265,53.4338246472275],[19296,55.1038305952445],[19326,79.3623445855213],[19357,70.0035650054964],[19388,94.2089324506811],[19416,93.8081433417619],[19447,89.7369729344437],[19477,88.6993672122224],[19508,90.5446717285243],[19538,107.955395413412],[19569,92.1901692235338],[19600,63.916242695463],[19630,66.0698296729229],[19661,68.8342165795714],[19691,69.418189617106],[19722,72.7032010841009],[19753,66.7492706994982],[19782,79.9762725982793]],"UNI":[[18535,4.16427939605593],[18566,2.26517989056535],[18596,3.77665118830359],[18627,5.15100108781704],[18658,17.7945334987345],[18686,22.4169896048252],[18717,28.1027037740702],[18747,40.3676732958108],[18778,28.2280224158604],[18808,19.1268490218019],[18839,21.8852271161221],[18870,29.4824024854328],[18900,23.3477997398092],[18931,25.0177969549941],[18961,21.1582631820302],[18992,17.0942229320505],[19023,11.7755148145453],[19051,10.4828553076961],[19082,11.2806649031429],[19112,6.74728907515906],[19143,5.70779675126255],[19173,4.87182938042486],[19204,8.38907678085305],[19235,6.13475103607941],[19265,6.44360493437593],[19296,6.95481573885789],[19326,5.87128559879802],[19357,5.16369461915388],[19388,6.56497580844808],[19416,6.49106966215912],[19447,6.06480877458847],[19477,5.48196266866552],[19508,5.01545742559645],[19538,5.28184780046625],[19569,6.53824308757687],[19600,4.36203363623213],[19630,4.46092893020808],[19661,4.14490301198926],[19691,5.94434649471139],[19722,7.2211166000813],[19753,5.99939076478262],[19782,11.1893296846203]],"USDC":[[17804,0.999215291428544],[17835,0.998549554792925],[17865,1.00026802026508],[17896,0.998259432314006],[17927,1.0000503488485],[17955,0.99991279946299],[17986,1.00003883155391],[18016,1.00041948466855],[18047,0.998263020811716],[18077,0.999871194120506],[18108,0.999968208646765],[18139,1.0006140729016],[18169,0.999957808724205],[18200,1.00030760525781],[18230,1.0007486997655],[18261,0.998870661405362],[18292,0.999625556094547],[18321,1.00059900723103],[18352,0.999811356076397],[18382,0.99991416275764],[18413,1.00027594882477],[18443,1.00003248341351],[18474,1.00011640650823],[18505,1.00045150851926],[18535,1.00001896198978],[18566,1.00011491284805],[18596,0.999985898028841],[18627,1.00009830716208],[18658,0.99990746017309],[18686,0.999669820439189],[18717,1.00002670028581],[18747,1.00008410429478],[18778,0.999830793080442],[18808,1.00000322335879],[18839,0.999787683834584],[18870,1.0000803333732],[18900,1.00005313571427],[18931,1.00004260609411],[18961,0.999878451219489],[18992,0.999840633305895],[19023,0.999997212830382],[19051,1.0000142135688],[19082,0.99989767911981],[19112,1.00006800667641],[19143,0.999617846323197],[19173,1.00102958229025],[19204,1.00019405540651],[19235,0.999931277537707],[19265,0.999888411707143],[19296,0.999956997001226],[19326,0.99986859998571],[19357,0.999851528309991],[19388,0.999948282588452],[19416,0.999925943308007],[19447,1.00001664905215],[19477,1.00032686753882],[19508,0.999927068531844],[19538,1.00004648897891],[19569,0.999924754948727],[19600,0.999984841483486],[19630,0.999992312551289],[19661,1.00010500989364],[19691,1.00005155655391],[19722,0.999940645228095],[19753,0.999994364979471],[19782,1.00002807672231]],"USDT":[[16374,1.00658889591523],[16404,0.997141635454759],[16435,0.983006255490731],[16466,0.93277567359715],[16494,0.96943923299078],[16525,0.933772188976623],[16555,0.865343493488781],[16586,0.854519639638856],[16616,0.890179786601291],[16647,0.953026563608997],[16678,0.955495142318799],[16708,0.957322255034545],[16739,0.967051655386987],[16769,0.977572107701226],[16800,0.972672437535313],[16831,0.95345370025154],[16860,0.996604452328925],[16891,1.00075619732128],[16921,1.00342282448206],[16952,1.00350081214593],[16982,1.0032604244202],[17013,1.00206589327257],[17044,0.996868089783361],[17074,0.998599877576656],[17105,0.997003029858099],[17135,1.00288259505284],[17166,1.00849220403582],[17197,1.00384038785063],[17225,0.999354668176047],[17256,1.00630113426714],[17286,0.962559466007828],[17317,1.04915012969655],[17347,1.00970443350171],[17378,1.00365012631912],[17409,1.00593935168097],[17439,0.998459532437171],[17470,0.999499822996161],[17500,1.00828964323901],[17531,1.0134093550282],[17562,0.981454555417157],[17590,0.998384833823883],[17621,1.00129045085917],[17651,0.999604835289672],[17682,0.998257681989405],[17712,0.997349204185112],[17743,1.00042259484527],[17774,1.00021019136909],[17804,0.996910036736422],[17835,0.990591378180952],[17865,0.982421984828225],[17896,0.998697971005702],[17927,0.99398063105179],[17955,0.994218005723719],[17986,0.997868510067777],[18016,0.99024145656417],[18047,0.998897724987076],[18077,0.988871458685276],[18108,1.00100751802955],[18139,1.00071273405372],[18169,1.00349152509385],[18200,1.00107409610557],[18230,1.00161882794621],[18261,0.995358245906391],[18292,0.998517376481879],[18321,1.00021852600252],[18352,1.0020741328901],[18382,1.00044914294557],[18413,0.999251670109904],[18443,0.99985629897774],[18474,1.00146239205388],[18505,1.00009069131494],[18535,1.00035334670009],[18566,1.00092937311261],[18596,1.000387231511],[18627,1.00110999415546],[18658,1.00092086616014],[18686,1.00191105552309],[18717,1.00036219053185],[18747,1.00114534190532],[18778,1.00032645236704],[18808,1.00020690648743],[18839,1.00062502279369],[18870,1.00028406195208],[18900,1.00009505552309],[18931,1.00070279076563],[18961,1.00086217708942],[18992,1.00016921683226],[19023,1.00051172998247],[19051,1.00031610169492],[19082,1.00031809760374],[19112,1.00020705143191],[19143,0.999197267095266],[19173,0.998747609585038],[19204,1.00039866130918],[19235,0.999950238457043],[19265,1.00005721537113],[19296,0.999945839275278],[19326,1.00011047545295],[19357,0.999485695207481],[19388,1.0000275327294],[19416,1.00015071244886],[19447,1.00024253243717],[19477,1.00043217533606],[19508,1.0002385905903],[19538,0.999807894798364],[19569,0.999891289596727],[19600,0.999719571307773],[19630,1.00008297951477],[19661,1.00048417024803],[19691,1.00022709656566],[19722,0.999766321531861],[19753,0.999313122281047],[19782,1.00062768713527]],"XLM":[[16708,0.00192578360813816],[16739,0.0021465645743146],[16769,0.00168426659294308],[16800,0.00175776604090831],[16831,0.001648200371474],[16860,0.00189119176958504],[16891,0.0019916481318624],[16921,0.00188966486291795],[16952,0.00153187433606969],[16982,0.00212314987688389],[17013,0.00191403103221188],[17044,0.0018999455576505],[17074,0.00282853608267209],[17105,0.00206568393733445],[17135,0.00161162257207183],[17166,0.00246398915829097],[17197,0.00235975797671072],[17225,0.00184623938776737],[17256,0.00232709808429113],[17286,0.00497556536594568],[17317,0.0342614621230139],[17347,0.026831944375121],[17378,0.0162281824812247],[17409,0.0225254704202518],[17439,0.0138033256517804],[17470,0.0292201426282228],[17500,0.0721759899666811],[17531,0.355560273226506],[17562,0.523111946306845],[17590,0.330602710107211],[17621,0.204630054893596],[17651,0.419759926118558],[17682,0.294369468696581],[17712,0.192358010732494],[17743,0.276079601273924],[17774,0.22235449584643],[17804,0.259290912002601],[17835,0.224334188607158],[17865,0.15683922486525],[17896,0.109661805634126],[17927,0.0808045613451878],[17955,0.0839848011814879],[17986,0.107747551383569],[18016,0.0997113879495783],[18047,0.133796577989673],[18077,0.104443153357319],[18108,0.0837274149172378],[18139,0.0620723150235576],[18169,0.0615566560962896],[18200,0.0649318410792586],[18230,0.0573463753012881],[18261,0.0447600943801314],[18292,0.060652157285195],[18321,0.0571918904671012],[18352,0.0409238129050733],[18382,0.0682113654854018],[18413,0.0702438859800458],[18443,0.0668934394866532],[18474,0.0967623956778336],[18505,0.0972175395652769],[18535,0.0746805227896672],[18566,0.0777036397764259],[18596,0.20257144571905072],[18627,0.12817079829215],[18658,0.306441340678725],[18686,0.408510091219068],[18717,0.404720109668439],[18747,0.528172495374202],[18778,0.400711999567378],[18808,0.281524704369058],[18839,0.286932262895662],[18870,0.342119554146339],[18900,0.277959576145885],[18931,0.373031261409423],[18961,0.337048191603162],[18992,0.267536578229185],[19023,0.19978710567034],[19051,0.196260952414055],[19082,0.228455575213029],[19112,0.168906574399274],[19143,0.15053177229348],[19173,0.110403276493388],[19204,0.117518104264125],[19235,0.104084275659878],[19265,0.114326437844071],[19296,0.111253924975418],[19326,0.0898846226004107],[19357,0.0710034592597814],[19388,0.0906239108441598],[19416,0.087021591442457],[19447,0.112104114191785],[19477,0.0948190047157165],[19508,0.092585175697275],[19538,0.110817612091561],[19569,0.15054663962058],[19600,0.11504333145332],[19630,0.112344474680879],[19661,0.121148461083391],[19691,0.118249336406655],[19722,0.128604905433537],[19753,0.10996507466412],[19782,0.122003718900526]],"XRP":[[16313,0.00473726311227352],[16343,0.00471071784902981],[16374,0.00481375564056108],[16404,0.0110111304363129],[16435,0.0245750796069814],[16466,0.0140322533801029],[16494,0.0125371431533072],[16525,0.00764943984342185],[16555,0.00820168685524962],[16586,0.00822020105542946],[16616,0.010677077048027],[16647,0.00823139444075439],[16678,0.00768786303786772],[16708,0.005602168871401],[16739,0.00463123391185861],[16769,0.00420369998943132],[16800,0.00595504760066529],[16831,0.00635778569240029],[16860,0.00801762741284884],[16891,0.00745605793132617],[16921,0.00679426196289244],[16952,0.00559404353835758],[16982,0.00662904475230838],[17013,0.00583136929547374],[17044,0.00596325907473943],[17074,0.00864127310938663],[17105,0.00805808166548292],[17135,0.00672080315403073],[17166,0.0065208057100051],[17197,0.00627372320592904],[17225,0.00552991853659276],[17256,0.0211172098734115],[17286,0.0528044415243429],[17317,0.246739665195784],[17347,0.246807797717917],[17378,0.164794644547203],[17409,0.260323941730651],[17439,0.198252942418231],[17470,0.198456159521494],[17500,0.237187655106378],[17531,1.9810830802894],[17562,1.10894771629237],[17590,0.883394268070772],[17621,0.500415818278695],[17651,0.826351325431972],[17682,0.610638273473476],[17712,0.465709902667374],[17743,0.433726537402802],[17774,0.335427223147796],[17804,0.580981011530273],[17835,0.44944993069705],[17865,0.358598160784571],[17896,0.347623564724175],[17927,0.307163335062226],[17955,0.312256365869617],[17986,0.308098727012062],[18016,0.305453454469357],[18047,0.438675382000201],[18077,0.394969507274984],[18108,0.31950657134645],[18139,0.257875467072348],[18169,0.256201356757745],[18200,0.294503366524885],[18230,0.225977685902189],[18261,0.192421166018367],[18292,0.239021657662533],[18321,0.23113777125572],[18352,0.174651617900005],[18382,0.212122660292835],[18413,0.202163200439167],[18443,0.175262094265829],[18474,0.259443030571508],[18505,0.282265516446168],[18535,0.241598620336591],[18566,0.239914016436995],[18596,0.667397353869868],[18627,0.221018257652563],[18658,0.492976040281657],[18686,0.417608829151184],[18717,0.569782295366818],[18747,1.58983267739894],[18778,1.0381628897667],[18808,0.698574780685304],[18839,0.751946266533025],[18870,1.18562612173481],[18900,0.95138500938944],[18931,1.11464817951803],[18961,1.00079397053078],[18992,0.830969097308865],[19023,0.617942912853578],[19051,0.782051241912373],[19082,0.815361565813986],[19112,0.584691660929242],[19143,0.423001985537447],[19173,0.327807216486286],[19204,0.38036149736809],[19235,0.327224914827107],[19265,0.479430105462393],[19296,0.46430757383735],[19326,0.409192467091315],[19357,0.339407913724412],[19388,0.406189506910933],[19416,0.377556425651262],[19447,0.538954817297498],[19477,0.473130601887746],[19508,0.518338278172614],[19538,0.47476976316474],[19569,0.698876418532658],[19600,0.510655841437611],[19630,0.515398893001986],[19661,0.599270392374807],[19691,0.606015955951999],[19722,0.613564570738795],[19753,0.502911266738053],[19782,0.587796415792781]]},"cryptoFX":{"AAVE":[[18567,30.679240378779063],[18597,74.80181706052755],[18628,89.7314066857571],[18659,296.176756345017],[18687,380.96258469585],[18718,383.910091304919],[18748,501.578861271042],[18779,372.315486441781],[18809,231.715391281159],[18840,317.68177268579],[18871,411.257717402429],[18901,300.920828979817],[18932,323.420680045379],[18962,251.020790000047],[18993,263.829026943076],[19024,164.049943411928],[19052,143.364558578195],[19083,245.322400770896],[19113,146.328117158432],[19144,107.040693056735],[19174,56.5502727178294],[19205,96.6983905729448],[19236,86.3769720121065],[19266,74.4735054310923],[19297,84.3984839566447],[19327,63.5278527127937],[19358,51.9968112171744],[19389,88.1667286386395],[19417,82.6563339948657],[19448,74.253034991707],[19478,68.9336462927577],[19509,62.6887946266305],[19539,68.1316230105966],[19570,65.3567260810398],[19601,54.5477474606089],[19631,72.103941820903],[19662,88.7760530343564],[19692,100.207962761723],[19723,115.638882036263],[19754,83.7170170405029]],"ADA":[[17501,0.129954409329643],[17532,0.737354625327598],[17563,0.409766089482554],[17591,0.300379752716559],[17622,0.14712129265775],[17652,0.35453851397843],[17683,0.220139391341818],[17713,0.143596870959972],[17744,0.139604305692188],[17775,0.106946653298954],[17805,0.0838262458485187],[17836,0.0705522346282337],[17866,0.0409663463224324],[17897,0.0420688000066255],[17928,0.0384885854054424],[17956,0.0429677067975229],[17987,0.072171453517419],[18017,0.0680553684104764],[18048,0.0908958887178157],[18078,0.0833000885900177],[18109,0.0587868994693122],[18140,0.0445545894107888],[18170,0.0383913274117968],[18201,0.0422546852920317],[18231,0.0396175086923128],[18262,0.033375059986576],[18293,0.0561389522408602],[18322,0.0458360333782551],[18353,0.0308947699520196],[18383,0.0509583344381624],[18414,0.0818279645470936],[18444,0.0958186930490063],[18475,0.145386910589914],[18506,0.125147962587675],[18536,0.0977318818238084],[18567,0.098006309922874],[18597,0.155857978390921],[18628,0.175673967082855],[18659,0.403673435668573],[18687,1.29482327746002],[18718,1.18460184555952],[18748,1.35511963105601],[18779,1.73843379275499],[18809,1.33485251313543],[18840,1.31422719621538],[18871,2.86061470424827],[18901,2.2432389342243],[18932,1.94979325539621],[18962,1.54728801982891],[18993,1.37117654810974],[19024,1.08566209519727],[19052,0.962436043075203],[19083,1.16472941625003],[19113,0.788772543171677],[19144,0.552505810315826],[19174,0.450053394049554],[19205,0.512610687039393],[19236,0.457494289557889],[19266,0.43047159371605],[19297,0.401166394672501],[19327,0.314153581548358],[19358,0.249510492024522],[19389,0.397487455436926],[19417,0.359935805994851],[19448,0.39390784537596],[19478,0.386749200399659],[19509,0.364687181776266],[19539,0.291856329907621],[19570,0.309168898428802],[19601,0.255214163121116],[19631,0.266111687725365],[19662,0.307160405571637],[19692,0.383571695920855],[19723,0.622637718338931],[19754,0.506007623023935]],"ALGO":[[18078,1.33957621411089],[18109,0.554217511309683],[18140,0.391806362579861],[18170,0.233343128347707],[18201,0.262318307030539],[18231,0.267234851922023],[18262,0.21921999321541],[18293,0.247120414459535],[18322,0.31891165961185],[18353,0.154823709007202],[18383,0.214343760935348],[18414,0.250022194153555],[18444,0.214462767971942],[18475,0.329085645850506],[18506,0.51954932137196],[18536,0.334357110939833],[18567,0.257950552598435],[18597,0.30956716131904],[18628,0.399610928782505],[18659,0.644515188379577],[18687,1.08548200370314],[18718,1.32755104322138],[18748,1.39478714752828],[18779,0.897333572305464],[18809,0.837319662313439],[18840,0.816350332153075],[18871,1.15692452606583],[18901,1.76336893321776],[18932,1.83372610365224],[18962,1.95798860866446],[18993,1.73823968582127],[19024,0.967723382258821],[19052,0.854621390475224],[19083,0.937939626629959],[19113,0.601794560309433],[19144,0.385201702662122],[19174,0.306257497954413],[19205,0.3414139529355],[19236,0.29309313927509],[19266,0.355062210028966],[19297,0.359818054370672],[19327,0.240261441791993],[19358,0.176859193702466],[19389,0.250570784580782],[19417,0.250293864093746],[19448,0.222952489308768],[19478,0.177217534083993],[19509,0.148172707302368],[19539,0.127090231376398],[19570,0.110535770357793],[19601,0.0920031135857797],[19631,0.10623950718061],[19662,0.114204047134457],[19692,0.139030336941733],[19723,0.238914856250366],[19754,0.158576476291873]],"BCH":[[17379,328.327900656494],[17410,633.427670164305],[17440,417.397378371909],[17471,531.797112552887],[17501,1429.45341148286],[17532,2328.6739826676],[17563,1260.65861939925],[17591,1285.40873486681],[17622,641.149179549518],[17652,1336.44572958854],[17683,1000.06963254774],[17713,738.035825482854],[17744,765.796234394574],[17775,614.487485389512],[17805,530.487054442616],[17836,422.700225524482],[17866,171.009909584711],[17897,162.912782498655],[17928,114.838950492512],[17956,130.755334083768],[17987,167.283182866527],[18017,267.282323912787],[18048,432.661917911795],[18078,416.278793671836],[18109,329.547540185209],[18140,281.654573682644],[18170,223.770456232589],[18201,278.387393496717],[18231,215.372040137859],[18262,203.557708324018],[18293,380.201963002034],[18322,313.352045526069],[18353,224.022333057901],[18383,255.192153498154],[18414,253.488145025869],[18444,223.93756059639],[18475,320.010277756553],[18506,292.70175845416],[18536,226.074143720849],[18567,267.885573009461],[18597,287.706628672202],[18628,341.435139002539],[18659,414.739639482273],[18687,499.796821494578],[18718,556.141763246339],[18748,1006.88006521197],[18779,690.786405375661],[18809,498.035729853435],[18840,529.107106584862],[18871,653.70031562066],[18901,542.022614567683],[18932,587.691507879176],[18962,570.776828616222],[18993,443.445586349065],[19024,288.51549903037],[19052,328.061030006484],[19083,376.375360137512],[19113,284.040696897378],[19144,186.352717076644],[19174,101.88151954971],[19205,138.090591561368],[19236,116.782820769059],[19266,117.073644969458],[19297,115.040166152289],[19327,110.211011764326],[19358,96.8616869292724],[19389,136.361309115648],[19417,134.096011111419],[19448,126.372035268385],[19478,116.958187803742],[19509,113.358279397441],[19539,290.21862130828],[19570,244.129054580677],[19601,199.383983745375],[19631,242.150668208661],[19662,244.88209125061],[19692,225.214452785745],[19723,266.369891191201],[19754,236.561074157895]],"BTC":[[14822,0.0611],[14853,0.0620248275862069],[14883,0.0619733138515488],[14914,0.194525061367621],[14944,0.2275],[14975,0.3],[15006,0.707510116890707],[15034,0.921096891876096],[15065,0.77411],[15095,3.0942275534775],[15126,9.62377723202805],[15156,15.4203573991818],[15187,13.0191138468732],[15218,8.23428166510813],[15248,5.03407054471069],[15279,3.17100774284044],[15309,3.08258945002922],[15340,5.2948427773232],[15371,6.07523265225015],[15400,4.92207212361192],[15431,4.79332567738165],[15461,4.98713722735243],[15492,5.26694375803624],[15522,6.62686009234366],[15553,9.56802090385739],[15584,10.0065329374635],[15614,12.3893951601403],[15645,10.6135357510228],[15675,12.5923868702513],[15706,13.3313714219755],[15737,20.443091956166],[15765,34.6062570341204],[15796,103.580346911163],[15826,116.113801040327],[15857,128.791151081239],[15887,82.8675701344243],[15918,96.5680178258329],[15949,130.536372004676],[15979,127.575424313267],[16010,203.037109849386],[16040,964.485793687902],[16071,752.404549944594],[16102,813.219549216833],[16130,567.534235441262],[16161,479.008465867913],[16191,460.194698255991],[16222,630.074953535944],[16252,642.95659672706],[16283,596.506759205143],[16314,474.541489772063],[16344,382.909365867914],[16375,324.371315020456],[16405,379.111978959673],[16436,314.77647194623],[16467,227.111301285798],[16495,260.651330800701],[16526,247.185819403857],[16556,232.746582700175],[16587,223.282701928697],[16617,258.042647574518],[16648,281.66884628872],[16679,227.955570426651],[16709,238.229813851549],[16740,329.854227644652],[16770,362.532375803624],[16801,434.678006428989],[16832,371.22343509059],[16861,432.807867153711],[16892,417.20066025716],[16922,455.010731151374],[16953,537.613678842782],[16983,677.512385739334],[17014,606.50924792519],[17045,572.221702045587],[17075,615.408478842782],[17106,730.578480011689],[17136,753.674910111046],[17167,997.257357977791],[17198,987.532590414962],[17226,1228.45965061368],[17257,1089.52477182934],[17287,1436.9414880187],[17318,2399.88763194039],[17348,2412.3662131502],[17379,2727.38918199883],[17410,4918.16624488603],[17440,4382.08689801286],[17471,6726.41183459965],[17501,10808.9583325541],[17532,13464.6536116306],[17563,9039.86507510228],[17591,10923.6308617767],[17622,6804.52487931035],[17652,9072.16875745178],[17683,7519.83573728814],[17713,6361.40253886616],[17744,7596.6980119813],[17775,7192.35254734074],[17805,6561.81383226184],[17836,6342.97750058445],[17866,4148.07372559906],[17897,3808.11783167738],[17928,3445.04989129164],[17956,3810.63269257744],[17987,4138.41780771479],[18017,5315.17532904734],[18048,8554.80732963179],[18078,10569.5517282291],[18109,10400.3070143776],[18140,9762.93655689655],[18170,8314.55139012273],[18201,9251.38129345412],[18231,7417.72720601987],[18262,7170.63186879018],[18293,9381.88602863822],[18322,8541.77389392169],[18353,6643.11000870836],[18383,8855.53525827002],[18414,10199.1350542373],[18444,9241.33961239041],[18475,11797.5821265342],[18506,11970.364856166],[18536,10606.6173325541],[18567,13736.89941128],[18597,18820.60778959673],[18628,29380.6937327878],[18659,33570.2718468732],[18687,49634.4471092928],[18718,58818.9770985973],[18748,57863.5903927528],[18779,36661.3781184687],[18809,33505.0804935126],[18840,39968.9689544126],[18871,48688.5375572764],[18901,48078.1315543542],[18932,61092.0950592051],[18962,57180.6098439509],[18993,47560.0093816482],[19024,38787.9640824079],[19052,44334.457582408],[19083,46250.3362878434],[19113,38459.226979135],[19144,29825.9221992987],[19174,19341.8631240795],[19205,23331.4426969608],[19236,20105.4626072472],[19266,19311.861629398],[19297,20483.966530976],[19327,16961.2918042081],[19358,16606.7520432496],[19389,23727.2026075395],[19417,23610.6590902981],[19448,28494.5638863238],[19478,28114.9531873174],[19509,26825.3713451198],[19539,30581.6889655172],[19570,29499.5875385739],[19601,25802.7885298071],[19631,27950.1453594389],[19662,35431.9794532437],[19692,38703.5536820573],[19723,44049.4735534775],[19754,43020.3596548802]],"DOGE":[[16102,0.00149632397055897],[16130,0.00105855749226297],[16161,0.000521416252776859],[16191,0.000504394906282055],[16222,0.000404013561045237],[16252,0.000254544675330787],[16283,0.000208786778744885],[16314,0.000132061707107871],[16344,0.000373139716495601],[16375,0.000224994276206118],[16405,0.000238944808078141],[16436,0.00018132243335213],[16467,0.000143437790243977],[16495,0.000148073019957617],[16526,0.00012625578431351],[16556,9.69617366464654e-05],[16587,0.000146473739561627],[16617,0.000188231946546203],[16648,0.000177112613283522],[16679,0.000127450545635073],[16709,0.00012264922930731],[16740,0.000135274028451845],[16770,0.000121534243395763],[16801,0.000137061774734364],[16832,0.000259633627109789],[16861,0.000245569922336378],[16892,0.000223190624802888],[16922,0.000229726754026566],[16953,0.000231923273266534],[16983,0.000302080875410794],[17014,0.000220739590220908],[17045,0.000221369366593839],[17075,0.000231051534588791],[17106,0.000222018829076216],[17136,0.000214708723202855],[17167,0.000227751956805271],[17198,0.000213569073308737],[17226,0.0001994548915255],[17257,0.000334780524461418],[17287,0.000677593075986077],[17318,0.00267230980347365],[17348,0.00228552636632158],[17379,0.00179315852104369],[17410,0.00250876436250343],[17440,0.00107253958676214],[17471,0.00110314765910258],[17501,0.00211454116747613],[17532,0.00880373030629959],[17563,0.00494533288413318],[17591,0.00619730809354717],[17622,0.00268911721226017],[17652,0.00511197515297193],[17683,0.00341789823384634],[17713,0.00250667739081159],[17744,0.0030626667227728],[17775,0.00618950676546258],[17805,0.00592244963771821],[17836,0.00377027846731256],[17866,0.0022165366840767],[17897,0.00236759562083666],[17928,0.00190619313979016],[17956,0.001950931775696],[17987,0.00244623267736758],[18017,0.00248787328994537],[18048,0.00336751665718446],[18078,0.00322696568446517],[18109,0.0029029499842229],[18140,0.00245100490824951],[18170,0.00239044567333268],[18201,0.00259243060911568],[18231,0.002279869454286],[18262,0.00201984671857517],[18293,0.00239048098200823],[18322,0.00225842554770007],[18353,0.00187226601490903],[18383,0.00251075903514867],[18414,0.00266019789483322],[18444,0.0023609381239208],[18475,0.0037249416715533],[18506,0.00331058595745631],[18536,0.00261833908003447],[18567,0.00255646073372241],[18597,0.0033201534209897944],[18628,0.00556486823067082],[18659,0.0347046223422425],[18687,0.0506025585945601],[18718,0.0613272619786743],[18748,0.391756718617906],[18779,0.374069481195317],[18809,0.244908107653592],[18840,0.204456121418929],[18871,0.293037887828062],[18901,0.222020546269938],[18932,0.271876385556308],[18962,0.209047880344401],[18993,0.172773918884291],[19024,0.14276018721579],[19052,0.13500218961464],[19083,0.141246988391901],[19113,0.13268991743771],[19144,0.0810465377953998],[19174,0.0663422444266336],[19205,0.0686080294417261],[19236,0.0622422346046428],[19266,0.0606235283937538],[19297,0.142428405515249],[19327,0.101437012667025],[19358,0.0700602132723938],[19389,0.0941885989080306],[19417,0.0819049473152554],[19448,0.0819913329488012],[19478,0.078792823489242],[19509,0.0715062978796422],[19539,0.0684508242751881],[19570,0.0778262767361584],[19601,0.0637837781425279],[19631,0.0632013252061268],[19662,0.0687338484523936],[19692,0.083902856560493],[19723,0.0919552987003159],[19754,0.0792201041606392]],"DOT":[[18506,6.27155820798611],[18536,4.32991544446733],[18567,4.21184006385968],[18597,5.09044077912046],[18628,8.3011384673381],[18659,16.44433900281],[18687,35.4177080428287],[18718,37.7185711471117],[18748,36.961709853227],[18779,22.8615340175859],[18809,15.2280069532254],[18840,18.4133932624631],[18871,33.0153214737422],[18901,31.9041461360861],[18932,50.1588476879194],[18962,36.7575152330043],[18993,28.4544244272147],[19024,19.6555092567504],[19052,18.8646270206124],[19083,21.9619507026865],[19113,15.3819915952469],[19144,9.47448593628203],[19174,6.78046653735911],[19205,8.19157513455933],[19236,7.18363229473696],[19266,6.27104547218297],[19297,6.47296833450626],[19327,5.39451813404024],[19358,4.36852875386201],[19389,6.46842035521869],[19417,6.52000461083566],[19448,6.35328598612757],[19478,5.6664300364079],[19509,5.21095296409396],[19539,5.34473811206607],[19570,5.18758715085599],[19601,4.21316909825418],[19631,4.25847223559898],[19662,4.72545071887785],[19692,5.46324156414785],[19723,8.58682143050754],[19754,6.77965394656323]],"ETC":[[17014,2.299635978023185],[17045,1.4694207803315207],[17075,1.194029087921899],[17106,0.881988079303929],[17136,0.7636407082111365],[17167,1.3886690838158207],[17198,1.3495646087003732],[17226,1.40261402571014],[17257,2.7400851252548843],[17287,6.752223151690188],[17318,16.244522205346975],[17348,16.542039986476585],[17379,14.565106150809306],[17410,22.400640622810954],[17440,12.8114284173417],[17471,10.209807070029653],[17501,29.627229543438432],[17532,29.674498149680012],[17563,24.495493112892348],[17591,33.50473721959575],[17622,13.540340327577612],[17652,21.459255962139025],[17683,15.466990790322308],[17713,16.066170858997182],[17744,15.444885548890031],[17775,13.170292747679806],[17805,11.1752963130319],[17836,9.019507272078298],[17866,4.781892803198285],[17897,5.152290273502764],[17928,3.8925999706159273],[17956,4.299077112992368],[17987,4.805903793167399],[18017,5.739260561800939],[18048,8.584545985441823],[18078,7.766258722336484],[18109,5.988328500549233],[18140,6.253715068310309],[18170,4.63607593536591],[18201,4.878874256401512],[18231,3.9481230747220244],[18262,4.484210561116023],[18293,11.54518685001358],[18322,7.7547588099686555],[18353,5.027333105266462],[18383,6.661093216887661],[18414,7.2172098007570895],[18444,5.717425362501094],[18475,7.851326457885793],[18506,6.768207139644076],[18536,5.377305527221881],[18567,5.2262786956357035],[18597,6.229302673922187],[18628,5.71104349168985],[18659,7.52282490495241],[18687,11.0184395014598],[18718,14.3629776415246],[18748,44.8416593619092],[18779,67.7425362216492],[18809,53.4396054940671],[18840,50.4226339940655],[18871,68.6010445756868],[18901,51.7832061446095],[18932,53.9224610249967],[18962,47.8434858507842],[18993,34.7517629902019],[19024,26.8566907584464],[19052,30.2789922487061],[19083,47.0997817752301],[19113,27.6849161232904],[19144,22.0713559381252],[19174,14.6581896258203],[19205,35.0106381198717],[19236,32.8360178184576],[19266,27.5440277675735],[19297,24.047603044677],[19327,19.7268736835295],[19358,15.7582771456872],[19389,22.3873387579714],[19417,21.2485119290814],[19448,21.286621630564],[19478,19.199386518679],[19509,17.8184580827656],[19539,20.838257572777],[19570,18.4676592679675],[19601,15.4023003125636],[19631,16.6823836276859],[19662,17.7847365616656],[19692,18.876262651032],[19723,22.4695573875658],[19754,24.3633690698822]],"ETH":[[16679,1.36],[16709,0.654722555230859],[16740,0.99],[16770,0.863293395675044],[16801,0.92037],[16832,2.16739736703682],[16861,7.52637569959088],[16892,11.634682565751],[16922,8.77974883343074],[16953,13.9132192115722],[16983,12.2765263693746],[17014,11.0070294517826],[17045,12.1157511525424],[17075,13.2374148895383],[17106,10.8072025587376],[17136,8.45638715955582],[17167,8.17785176797195],[17198,10.7228105669199],[17226,17.2656843547633],[17257,50.6856101426067],[17287,78.3656599269433],[17318,220.986285654588],[17348,261.219362042665],[17379,225.735534699591],[17410,391.600977030976],[17440,302.921215371128],[17471,290.520131209819],[17501,461.586232320281],[17532,756.071765634132],[17563,1019.17100789012],[17591,869.302266803039],[17622,378.066090298071],[17652,671.531682349503],[17683,578.827563997662],[17713,451.798441554646],[17744,419.019926651081],[17775,294.940272063121],[17805,229.918147282291],[17836,197.8192106955],[17866,116.974022793688],[17897,139.154644360023],[17928,106.634947691409],[17956,134.864564582116],[17987,141.295688778492],[18017,158.222859731151],[18048,265.140144360023],[18078,293.862392752776],[18109,217.353194915254],[18140,171.211480479252],[18170,176.635159555815],[18201,183.362267679719],[18231,151.380223378141],[18262,129.963875336061],[18293,183.562507656341],[18322,218.6236685564],[18353,135.85480654588],[18383,212.713617767387],[18414,249.07495295149],[18444,231.145918468732],[18475,387.185885447107],[18506,480.749175862069],[18536,352.360856049094],[18567,395.57778264173],[18597,588.2515552308593],[18628,730.91432063121],[18659,1369.95499357101],[18687,1563.23598036236],[18718,1971.24078258329],[18748,2949.31864056107],[18779,2626.20154821742],[18809,2114.55608357686],[18840,2563.73753068381],[18871,3793.82968188194],[18901,3299.44177030976],[18932,4325.78806049094],[18962,4584.53445119813],[18993,3761.05964026885],[19024,2795.5781528346],[19052,2969.53468030392],[19083,3446.51983682057],[19113,2829.09136440678],[19144,1827.6371081239],[19174,1066.1029628872],[19205,1635.83942051432],[19236,1586.34916686148],[19266,1311.22253331385],[19297,1580.25594681473],[19327,1275.71093571011],[19358,1200.00591934541],[19389,1641.90211075395],[19417,1661.70797282291],[19448,1822.41595762712],[19478,1831.77346551724],[19509,1861.87719082408],[19539,1923.13508825248],[19570,1866.98985067212],[19601,1628.75344476914],[19631,1731.54860958504],[19662,1846.08104295733],[19692,2086.10865020456],[19723,2347.43826125073],[19754,2300.17150613676]],"ICP":[[18779,107.795214611814],[18809,45.7123190716818],[18840,41.0141925691504],[18871,66.0103337137795],[18901,51.9067038666162],[18932,46.2988627524781],[18962,40.9459679767918],[18993,25.523222436492],[19024,20.1907001957167],[19052,19.2993949759385],[19083,21.5655829985028],[19113,13.0148478958595],[19144,7.63095985429252],[19174,5.24168408134115],[19205,8.32037762205672],[19236,6.31473183960942],[19266,6.10544672613912],[19297,5.18086871049262],[19327,4.11719121023426],[19358,4.00810012567417],[19389,6.0761420902602],[19417,6.15337840125152],[19448,5.24749755182425],[19478,5.76651710263027],[19509,4.67111737650859],[19539,4.21269056316449],[19570,4.22620378891058],[19601,3.23460464299022],[19631,3.31246155915944],[19662,4.04588611729002],[19692,4.68469529313442],[19723,12.9762866451889],[19754,11.6427304129314]],"LINK":[[17440,0.437717912612124],[17471,0.157751686714108],[17501,0.175288959653328],[17532,0.722198644973961],[17563,0.508051468895138],[17591,0.661764601861949],[17622,0.262099608668543],[17652,0.545128985314829],[17683,0.351049384006963],[17713,0.225820352961657],[17744,0.28893926529776],[17775,0.329427342271836],[17805,0.32479206078902],[17836,0.515801598597675],[17866,0.328105510148089],[17897,0.296566024568419],[17928,0.414134131773824],[17956,0.428614009640609],[17987,0.553758564275534],[18017,0.46883103606539],[18048,0.960116588723847],[18078,3.64112989646447],[18109,2.17311827109089],[18140,1.77531299584302],[18170,1.83808006191998],[18201,2.71614792632855],[18231,2.17040160587163],[18262,1.79848114867717],[18293,2.83331089600107],[18322,3.8884147311786],[18353,2.28273250516707],[18383,3.77450226185457],[18414,4.39656773708147],[18444,4.7005219201765],[18475,8.3828111068962],[18506,16.1224423641168],[18536,9.61843849803392],[18567,11.5554470702729],[18597,13.381799837687685],[18628,11.8683914967615],[18659,22.9498982723288],[18687,27.5841494343956],[18718,30.1195040573033],[18748,41.101453618286],[18779,30.7430846928218],[18809,18.2723734312165],[18840,22.2033605762351],[18871,29.6089469759463],[18901,26.224785700602],[18932,31.6599069960143],[18962,25.3891076033025],[18993,20.569716014139],[19024,17.0387322233892],[19052,15.3404578884383],[19083,17.2525098189422],[19113,11.3041607365909],[19144,6.92731023056973],[19174,6.10999864689385],[19205,7.58225677284467],[19236,6.88965281312639],[19266,7.39053957107476],[19297,7.67554386076303],[19327,7.68958610776288],[19358,5.62000570218591],[19389,7.22122502529183],[19417,7.52126507691925],[19448,7.54923837050734],[19478,6.90085672929756],[19509,6.34736660716501],[19539,6.49864233306016],[19570,7.60675313348885],[19601,5.92434092433942],[19631,8.06110409614762],[19662,11.6092634357197],[19692,14.9683677762361],[19723,15.5194150339939],[19754,17.1211820115749]],"LTC":[[15796,1.51668030262339],[15826,4.35004728755561],[15857,2.82303779147072],[15887,2.66701592051432],[15918,2.74253170625365],[15949,2.42928188300701],[15979,2.30475210683811],[16010,2.39985534774985],[16040,34.9478332806612],[16071,23.8710529600653],[16102,22.3106257274197],[16130,13.8409334484842],[16161,13.0804731492345],[16191,11.3031386827645],[16222,11.0573045472466],[16252,8.2667629889596],[16283,7.74290594160766],[16314,4.77562089465747],[16344,4.45595558972776],[16375,3.55187778196685],[16405,3.56089814120495],[16436,2.71012865406824],[16467,1.83135911247273],[16495,1.90452479270074],[16526,1.66845312463551],[16556,1.40921345486602],[16587,1.60177205809437],[16617,3.94867666011891],[16648,4.20775148700536],[16679,2.82417780442012],[16709,2.99924387056037],[16740,3.87110969703018],[16770,3.43821965980781],[16801,3.52111320708616],[16832,3.04231044169548],[16861,3.40799646157701],[16892,3.25357511659609],[16922,3.72755624230189],[16953,4.72533015668994],[16983,4.2900667711269],[17014,3.85697299842913],[17045,3.81623145609474],[17075,3.84546993344443],[17106,4.09373574920293],[17136,3.89623627255244],[17167,4.4752383412542],[17198,4.04812841374875],[17226,3.8539021293783],[17257,7.60325938375114],[17287,16.1070871195055],[17318,27.3021387613472],[17348,38.1163571232574],[17379,43.1233744223086],[17410,87.8758229250781],[17440,54.7210811284143],[17471,53.286288090393],[17501,98.6648122106854],[17532,224.817223941702],[17563,141.184088210593],[17591,209.268883483766],[17622,114.628859257421],[17652,147.990156165236],[17683,119.946189754747],[17713,79.8942987280615],[17744,77.5656731330169],[17775,66.2803406279616],[17805,60.0140053279579],[17836,50.1080857054919],[17866,33.762602108851],[17897,31.6459548832744],[17928,32.53457137152],[17956,46.9952179872841],[17987,60.383903838126],[18017,72.4984031130236],[18048,112.710640705494],[18078,122.677113941165],[18109,98.8407426921756],[18140,66.297114000002],[18170,55.7659061908359],[18201,58.3717089475198],[18231,47.7391861956304],[18262,41.4157459364492],[18293,70.724743444118],[18322,57.878229684686],[18353,39.3324821147803],[18383,47.1433193743382],[18414,48.4074594650632],[18444,41.8026585385416],[18475,61.6911162275468],[18506,63.0699094721517],[18536,46.1684856847052],[18567,55.5392803285107],[18597,85.29460974799822],[18628,126.167019193361],[18659,132.619334970747],[18687,175.664684991833],[18718,202.666612759354],[18748,277.16695992993],[18779,183.130563219048],[18809,137.563302593712],[18840,140.950515690519],[18871,181.278064088618],[18901,165.798480204083],[18932,197.802055180334],[18962,208.631799345419],[18993,150.236871457458],[19024,115.669001034634],[19052,112.458513450944],[19083,124.848558363221],[19113,99.3340346341725],[19144,62.8947366856452],[19174,51.3324253150406],[19205,59.4893170969893],[19236,57.4196785303743],[19266,52.896531842067],[19297,55.1400244238588],[19327,77.55617432306],[19358,70.7463513434069],[19389,100.585004054338],[19417,97.6581433364831],[19448,92.764227388218],[19478,86.6817766076599],[19509,93.9975254124839],[19539,106.641649566133],[19570,93.6889428230656],[19601,62.97075097746],[19631,68.2412554842434],[19662,69.8700358490525],[19692,71.5223840533682],[19723,74.6571181969348],[19754,67.4849596530038]],"UNI":[[18536,4.23817846322974],[18567,2.380214695043],[18597,3.55843749347188],[18628,4.74714672642158],[18659,20.4463578915744],[18687,25.3007032594584],[18718,28.7422963647468],[18748,40.0977024360797],[18779,27.3405007646258],[18809,17.8758945967729],[18840,22.0540126999524],[18871,31.0660468274147],[18901,25.4934694261129],[18932,25.651723526515],[18962,20.9944360630989],[18993,17.3708742393533],[19024,11.129115903342],[19052,10.321970660832],[19083,11.5691054700883],[19113,7.11102852562123],[19144,5.16843607416401],[19174,4.8732949200774],[19205,8.36308408600032],[19236,6.31112981975363],[19266,6.40027952385925],[19297,7.13442399742118],[19327,5.8890367555615],[19358,5.2475715410911],[19389,6.81615619579463],[19417,6.84026896244834],[19448,6.09655641835356],[19478,5.33070955313042],[19509,4.98626851278549],[19539,5.58007656161169],[19570,6.65918348107919],[19601,4.25907229924881],[19631,4.65548599738005],[19662,4.78558897556651],[19692,6.01805861740189],[19723,7.60186791545686],[19754,6.03922696985092]],"USDC":[[17805,0.997593762618682],[17836,0.999750637335279],[17866,0.999568377811161],[17897,0.99987894028713],[17928,1.00099355795419],[17956,1.00016241315413],[17987,1.00014182487375],[18017,1.00100241473059],[18048,0.999518590684789],[18078,0.999489320119992],[18109,1.00011930290344],[18140,1.00060622453588],[18170,1.00019933300079],[18201,1.00110628616089],[18231,1.00029499652108],[18262,0.999537529626506],[18293,1.0006743761784],[18322,0.999735359697551],[18353,1.00042626688977],[18383,0.999747889093119],[18414,1.0002261510799],[18444,1.00009318831886],[18475,1.00013396780065],[18506,0.9999428496647],[18536,0.999998259984451],[18567,0.999889153949157],[18597,1.00015178973727],[18628,0.999919132721322],[18659,0.999944996068738],[18687,1.00014829837414],[18718,1.00018240691849],[18748,0.999986455210038],[18779,0.999822715737678],[18809,1.00035232034689],[18840,0.99985928858876],[18871,1.00012561806175],[18901,0.999828411254972],[18932,0.9999105113282],[18962,1.00013377737128],[18993,1.00007851271514],[19024,0.999924239168405],[19052,1.00025187861331],[19083,0.999978358386185],[19113,1.00002899467949],[19144,0.999849745232035],[19174,0.9999138264173],[19205,0.999910764631378],[19236,1.00011128196945],[19266,0.999877671411723],[19297,0.999999153374133],[19327,0.999872419947802],[19358,0.999863782207962],[19389,1.00003046050027],[19417,0.999777276819955],[19448,1.00009251375954],[19478,1.00044342402923],[19509,0.99985971152186],[19539,1.00013206480069],[19570,0.999671488554802],[19601,1.00008287280765],[19631,1.00002095649909],[19662,1.00010745313218],[19692,1.00005805043431],[19723,1.00050893513643],[19754,1.00005033704885]],"USDT":[[16375,1.00386025919846],[16405,0.996720242533806],[16436,0.982056819721496],[16467,0.966513513175842],[16495,0.965412917349886],[16526,0.936543335713532],[16556,0.866726375050683],[16587,0.843816692208444],[16617,0.872226953726836],[16648,0.93540745860758],[16679,0.953720772610678],[16709,0.953681405705682],[16740,0.965150293602767],[16770,0.98098098280127],[16801,0.982883189005746],[16832,1.00208782586203],[16861,0.971827151138991],[16892,0.998439159687513],[16922,1.00990082414049],[16953,1.00059838211718],[16983,1.00133146996787],[17014,0.998068704354677],[17045,1.00153329491882],[17075,1.00175522113062],[17106,0.996986505419346],[17136,1.0003723280757],[17167,0.998875179043391],[17198,1.00406854815037],[17226,0.999667051140372],[17257,0.999618441511337],[17287,0.946114185452603],[17318,1.04329478561425],[17348,1.02516052587645],[17379,0.998357384766834],[17410,1.00698378674584],[17440,0.9942542789146],[17471,1.00374588042174],[17501,1.01641845916605],[17532,1.00575523856431],[17563,0.976231706562339],[17591,0.998274428260334],[17622,1.0004620092486],[17652,0.998694115984573],[17683,0.998485292797536],[17713,0.997453415608901],[17744,0.999203343006687],[17775,0.997761111570874],[17805,0.994868928768796],[17836,0.990411097690413],[17866,0.988632587626636],[17897,1.00433131594522],[17928,0.992230383967872],[17956,0.996011895164459],[17987,0.998340632468941],[18017,0.987238125873487],[18048,1.00130088614993],[18078,0.995470562689737],[18109,1.00274709772187],[18140,1.00156105780609],[18170,1.00283620991052],[18201,1.00194552142744],[18231,1.00147726530405],[18262,0.996259696727989],[18293,0.999328989260872],[18322,0.998378177946671],[18353,1.00321163527273],[18383,1.00062663584711],[18414,1.00175667479169],[18444,1.00068022277357],[18475,1.00114732535593],[18506,1.00039589413101],[18536,0.999977948154244],[18567,1.00098012476066],[18597,1.00064425758757],[18628,1.00236073348919],[18659,1.0003284465225],[18687,1.00064350087668],[18718,0.999729409701929],[18748,1.00081484511981],[18779,0.999911987142022],[18809,1.00015347399182],[18840,1.00034684687317],[18871,1.00038080070134],[18901,1.00050757451783],[18932,1.00063098188194],[18962,1.00062661601403],[18993,1.00018648158971],[19024,1.00053040327294],[19052,1.0004072063121],[19083,1.0003451899474],[19113,1.0000268264173],[19144,0.999087658971362],[19174,0.998763371127995],[19205,1.00032997194623],[19236,0.99998728024547],[19266,1.00002981618936],[19297,0.999883448568089],[19327,1.00006830187025],[19358,0.999573851256575],[19389,1.00006082028054],[19417,1.00013140853302],[19448,1.00039632904734],[19478,1.00021033852883],[19509,1.00005592635885],[19539,0.999977921975453],[19570,0.999484855639976],[19601,0.99957938270854],[19631,0.99985321213762],[19662,1.00039954712691],[19692,1.0004801357965],[19723,1.00058857478638],[19754,0.999750659745267]],"XLM":[[16709,0.00215206816813831],[16740,0.002124989951327],[16770,0.00159514245353594],[16801,0.00175609914597312],[16832,0.00177956790904995],[16861,0.00186387177605411],[16892,0.00202886802442346],[16922,0.00188691806698163],[16953,0.00150028151100418],[16983,0.00204893842140113],[17014,0.00188721609001698],[17045,0.00190628352588101],[17075,0.00274043229984765],[17106,0.00198623494342311],[17136,0.00156010706392987],[17167,0.00246669713231392],[17198,0.00234507072893191],[17226,0.0017311265881429],[17257,0.00267260681240379],[17287,0.00514524099988733],[17318,0.0424352549341558],[17348,0.0243543215632606],[17379,0.0178736510709318],[17410,0.0240438095492015],[17440,0.0136752459402473],[17471,0.0274481927979331],[17501,0.0891746106263028],[17532,0.487672943892147],[17563,0.446529722459582],[17591,0.337107437324396],[17622,0.20496253288541],[17652,0.437518957920008],[17683,0.288525395588405],[17713,0.198814624450297],[17744,0.277531206937847],[17775,0.229606181223182],[17805,0.257107902315371],[17836,0.223841501241699],[17866,0.162662270766016],[17897,0.113944503008533],[17928,0.0806107116552126],[17956,0.0847754363230359],[17987,0.110289594149487],[18017,0.0997215800608242],[18048,0.133190122671762],[18078,0.105600817423927],[18109,0.0829003616452506],[18140,0.0623688126345219],[18170,0.0588827861765905],[18201,0.0693915049693541],[18231,0.0574723896200194],[18262,0.0450786086991967],[18293,0.0621312361506256],[18322,0.0560742995301172],[18353,0.0408963797412181],[18383,0.0724551482056439],[18414,0.0760530024201514],[18444,0.0684580342650992],[18475,0.107656602821513],[18506,0.0993508389855587],[18536,0.0729345752772981],[18567,0.078621786143327],[18597,0.1843091729634286],[18628,0.13239159548302],[18659,0.321875328839494],[18687,0.430067080809665],[18718,0.426754950792462],[18748,0.537305037903557],[18779,0.418654867221254],[18809,0.270345057823592],[18840,0.277686037337425],[18871,0.353653397988608],[18901,0.299418953529521],[18932,0.366667195227518],[18962,0.328021109144915],[18993,0.275673318527507],[19024,0.202477876392436],[19052,0.198393086444228],[19083,0.232465946952974],[19113,0.175722805657165],[19144,0.13711471624555],[19174,0.109851170530231],[19205,0.118590165125237],[19236,0.104939491723855],[19266,0.121318294420907],[19297,0.109894588940546],[19327,0.0874423548695454],[19358,0.0724903519973208],[19389,0.0922384165021026],[19417,0.0879755225478903],[19448,0.108396338166805],[19478,0.0926393227593952],[19509,0.0911219187741585],[19539,0.107197692416624],[19570,0.152499869498865],[19601,0.113887496370001],[19631,0.11431624220159],[19662,0.122382459465401],[19692,0.119730458278198],[19723,0.131973721814403],[19754,0.10978487653634]],"XRP":[[16314,0.00443150750499485],[16344,0.00502406208099941],[16375,0.0045568261159585],[16405,0.0135582811899629],[16436,0.024006243476785],[16467,0.013761983583785],[16495,0.0115840088800282],[16526,0.00767486344611901],[16556,0.00808105997278344],[16587,0.00738254474200421],[16617,0.0108952280830848],[16648,0.0081357208161128],[16679,0.00782382154241627],[16709,0.00541910045785759],[16740,0.00494124409108053],[16770,0.00407434902754342],[16801,0.00592817167806127],[16832,0.0066049364500271],[16861,0.00803510891435143],[16892,0.0073655757340605],[16922,0.00674361059860116],[16953,0.00577270150150057],[16983,0.00682777421207332],[17014,0.00578748440545997],[17045,0.00592676069450689],[17075,0.00806405697593849],[17106,0.00799429459855951],[17136,0.00666677611768414],[17167,0.00630819017745269],[17198,0.00652134316796222],[17226,0.00532952664969905],[17257,0.0225808666982499],[17287,0.0554317932914995],[17318,0.335326395875111],[17348,0.238385430630685],[17379,0.176988270841242],[17410,0.253909015965032],[17440,0.203982451280367],[17471,0.190921504393644],[17501,0.248288629463957],[17532,2.00983954051858],[17563,0.941342184929464],[17591,0.915277915922474],[17622,0.478626898379302],[17652,0.834820220449863],[17683,0.620257246345806],[17713,0.462216609223616],[17744,0.446825085986251],[17775,0.346471598217745],[17805,0.572776258157581],[17836,0.455463968760248],[17866,0.368014736940822],[17897,0.361322280070156],[17928,0.305512049429079],[17956,0.315224493490289],[17987,0.312174061668765],[18017,0.299293836541641],[18048,0.429236845004533],[18078,0.406365901551403],[18109,0.3161648170601],[18140,0.256850459144175],[18170,0.248965284669462],[18201,0.292085910484508],[18231,0.225215972061006],[18262,0.192124072326654],[18293,0.241220059192128],[18322,0.227721573513626],[18353,0.175452738572212],[18383,0.218154249570071],[18414,0.211422372695308],[18444,0.176752426419596],[18475,0.291860523700988],[18506,0.296651573143944],[18536,0.237979070011914],[18567,0.239895595726171],[18597,0.612945110502018],[18628,0.23677925583855],[18659,0.37853473294013],[18687,0.446851721382437],[18718,0.569471115262451],[18748,1.6533901606154],[18779,1.00997403846321],[18809,0.661130670765],[18840,0.726978873804026],[18871,1.22907129662889],[18901,1.03980872979934],[18932,1.09316312790826],[18962,0.990655994357366],[18993,0.8478111437456],[19024,0.629350136567746],[19052,0.785533010042834],[19083,0.826628462027144],[19113,0.607360496381212],[19144,0.398326317176333],[19174,0.314596165247155],[19205,0.379900027294803],[19236,0.332782404479535],[19266,0.474668468436861],[19297,0.464779808005878],[19327,0.396240733082251],[19358,0.338407613943332],[19389,0.413804765749546],[19417,0.383783631975884],[19448,0.509750278567867],[19478,0.464879109970235],[19509,0.507203786366481],[19539,0.473027958335023],[19570,0.705889081432071],[19601,0.497571068081951],[19631,0.524164211910005],[19662,0.609206011700807],[19692,0.612988075057107],[19723,0.629882784726034],[19754,0.505636956181253]]},"fawaz":{"AAVE":[[19784,0.008977008],[19814,0.00780982],[19844,0.012139159],[19875,0.0096461002],[19905,0.010184105],[19936,0.0093665811],[19967,0.0077800856],[19997,0.0063361491],[20028,0.0069989675],[20058,0.0047465576],[20089,0.0032181389],[20120,0.0030105691],[20148,0.0052055367],[20179,0.0062550911],[20209,0.0059778981],[20240,0.0041870385],[20270,0.0036279174],[20301,0.0038270761],[20332,0.0031793008],[20362,0.0036471196],[20393,0.0044383139],[20423,0.0058454257],[20454,0.0068434336],[20485,0.0077200196],[20513,0.0085312932],[20544,0.010220068],[20574,0.010685362],[20605,0.012199678],[20635,0.01143954],[20666,0.010838161],[20697,0.007915586]],"ADA":[[19784,1.33600228],[19814,1.53937874],[19844,2.27878029],[19875,2.23834717],[19905,2.49423928],[19936,2.57534182],[19967,2.92044113],[19997,2.63613165],[20028,2.93798765],[20058,0.94221115],[20089,1.16731739],[20120,1.12210164],[20148,1.56137251],[20179,1.43674782],[20209,1.39145153],[20240,1.42457553],[20270,1.65500274],[20301,1.3163217],[20332,1.20465941],[20362,1.2677609],[20393,1.65543627],[20423,2.55900451],[20454,3.00043677],[20485,3.37190745],[20513,3.43736955],[20544,4.10162378],[20574,4.01651425],[20605,4.27848555],[20635,6.6991455],[20666,5.8786785],[20697,4.96661371]],"AED":[[19784,3.6725],[19814,3.67230074],[19844,3.6725],[19875,3.6725],[19905,3.6725],[19936,3.6725],[19967,3.6725],[19997,3.6725],[20028,3.6725],[20058,3.6725],[20089,3.6725],[20120,3.6725],[20148,3.6725],[20179,3.6725],[20209,3.6725],[20240,3.6725],[20270,3.6725],[20301,3.6725],[20332,3.6725],[20362,3.6725],[20393,3.6725],[20423,3.6725],[20454,3.6725],[20485,3.6725],[20513,3.6725],[20544,3.6725],[20574,3.67314879],[20605,3.6725],[20635,3.6725],[20666,3.6725],[20697,3.6725]],"AFN":[[19784,72.50549832],[19814,71.26839367],[19844,72.49844913],[19875,71.61611219],[19905,71.05192807],[19936,70.92177804],[19967,70.43674669],[19997,68.53497688],[20028,67.00759885],[20058,68.04259824],[20089,70.45002278],[20120,75.49691534],[20148,73.56409107],[20179,71.17066503],[20209,70.81954121],[20240,69.41896266],[20270,70.21760691],[20301,68.7578417],[20332,68.45948132],[20362,67.47363066],[20393,66.44552457],[20423,66.35569717],[20454,65.51055132],[20485,65.61958689],[20513,63.75150146],[20544,63.54326448],[20574,63.49859856],[20605,63.00581426],[20635,63.88582256],[20666,65.91758473],[20697,65.99451282]],"ALGO":[[19784,4.35631852],[19814,3.72421496],[19844,5.72752347],[19875,5.33172461],[19905,6.84226377],[19936,7.39433175],[19967,8.14967492],[19997,7.35800306],[20028,8.79990072],[20058,2.32494438],[20089,2.97661193],[20120,2.60131397],[20148,4.19408006],[20179,5.56513692],[20209,4.52525708],[20240,5.20760026],[20270,5.25576944],[20301,4.07717319],[20332,4.35083132],[20362,4.79637458],[20393,5.70284031],[20423,7.54754104],[20454,9.06692864],[20485,9.59121108],[20513,11.11282944],[20544,10.26317296],[20574,9.06747343],[20605,8.10497772],[20635,11.99189634],[20666,12.57685618],[20697,11.09214248]],"ALL":[[19784,95.88149828],[19814,95.23941815],[19844,93.97545484],[19875,93.10189979],[19905,93.6445564],[19936,92.46295505],[19967,89.60541836],[19997,88.67933602],[20028,90.38595313],[20058,92.95180244],[20089,92.89492356],[20120,96.01074389],[20148,95.08580821],[20179,91.69427514],[20209,87.03138296],[20240,86.82017467],[20270,83.3494138],[20301,85.40439007],[20332,83.73197354],[20362,82.42218576],[20393,83.83953853],[20423,83.31363458],[20454,82.37497066],[20485,81.46859787],[20513,81.49390004],[20544,82.96601446],[20574,81.44995326],[20605,81.85334095],[20635,82.36997579],[20666,81.18405552],[20697,79.63948384]],"AMD":[[19784,402.37439688],[19814,394.28266648],[19844,386.28800106],[19875,386.36827434],[19905,386.15528347],[19936,386.23682417],[19967,386.47711995],[19997,384.82026935],[20028,387.61846795],[20058,389.62996292],[20089,396.32270067],[20120,399.68912341],[20148,392.23028484],[20179,393.89962528],[20209,388.16870433],[20240,383.94179252],[20270,382.6655646],[20301,383.8110128],[20332,380.31536902],[20362,382.25968925],[20393,382.64850038],[20423,382.06493475],[20454,381.39423064],[20485,383.09064871],[20513,376.90016195],[20544,377.17506835],[20574,370.77938067],[20605,368.18151185],[20635,367.86974193],[20666,365.69280193],[20697,364.16322589]],"ANG":[[19784,1.78755638],[19814,1.80146851],[19844,1.79228973],[19875,1.78771071],[19905,1.79153295],[19936,1.7912105],[19967,1.78750094],[19997,1.7923572],[20028,1.79884866],[20058,1.78750052],[20089,1.79773838],[20120,1.79160976],[20148,1.78964308],[20179,1.7901572],[20209,1.78999617],[20240,1.79700489],[20270,1.80025027],[20301,1.80137717],[20332,1.79798838],[20362,1.80297072],[20393,1.79859109],[20423,1.79611531],[20454,1.80195026],[20485,1.79702407],[20513,1.79379379],[20544,1.8009072],[20574,1.78988404],[20605,1.80126744],[20635,1.80037568],[20666,1.79702303],[20697,1.80043]],"AOA":[[19784,829.30191342],[19814,837.99984064],[19844,844.35746222],[19875,852.79157239],[19905,865.3403975],[19936,889.28337189],[19967,912.36846525],[19997,955.38646804],[20028,918.15922075],[20058,910.91354973],[20089,920.93156646],[20120,912.9140588],[20148,912.25378302],[20179,918.96277543],[20209,918.61489885],[20240,911.95383491],[20270,917.9573568],[20301,917.35725049],[20332,918.59886865],[20362,917.75281627],[20393,914.29862049],[20423,914.15606586],[20454,915.49604403],[20485,912.19708546],[20513,927.29537859],[20544,917.96813828],[20574,918.00004431],[20605,918.07917234],[20635,916.42738631],[20666,915.96126889],[20697,918.58625625]],"ARS":[[19784,842.05528044],[19814,857.03766706],[19844,877.91064091],[19875,894.09519899],[19905,906.88402679],[19936,930.71979829],[19967,950.5000192],[19997,967.06710798],[20028,990.23159217],[20058,1010.00133899],[20089,1030.94531014],[20120,1050.92675593],[20148,1063.4891204],[20179,1073.13128523],[20209,1171.20476079],[20240,1187.9991092],[20270,1204.07544337],[20301,1371.41528639],[20332,1333.11987356],[20362,1379.74898099],[20393,1441.82875097],[20423,1446.46056225],[20454,1451.42740866],[20485,1448.57501442],[20513,1396.92610203],[20544,1382.52711213],[20574,1392.15011935],[20605,1412.0590183],[20635,1483.95520061],[20666,1486.03054636],[20697,1508.67108367]],"ATOM":[[19784,0.084788289],[19814,0.081596651],[19844,0.11984176],[19875,0.12067715],[19905,0.14730815],[19936,0.17201073],[19967,0.22044052],[19997,0.20946574],[20028,0.23775094],[20058,0.11807494],[20089,0.16149512],[20120,0.15946948],[20148,0.21653033],[20179,0.22981084],[20209,0.23077932],[20240,0.23137955],[20270,0.24601759],[20301,0.23700184],[20332,0.22522516],[20362,0.24224557],[20393,0.33941241],[20423,0.44002252],[20454,0.52005994],[20485,0.50549208],[20513,0.5335377],[20544,0.58830194],[20574,0.52717274],[20605,0.51134136],[20635,0.65438423],[20666,0.81383818],[20697,0.66952655]],"AUD":[[19784,1.53246602],[19814,1.53077062],[19844,1.54534765],[19875,1.50122325],[19905,1.49847318],[19936,1.52760564],[19967,1.47841031],[19997,1.44291733],[20028,1.52121741],[20058,1.53600671],[20089,1.61599044],[20120,1.60936521],[20148,1.61181278],[20179,1.60199807],[20209,1.56031867],[20240,1.55456906],[20270,1.52417016],[20301,1.55503395],[20332,1.52821836],[20362,1.51481188],[20393,1.52850077],[20423,1.52798518],[20454,1.49850264],[20485,1.43656718],[20513,1.4059119],[20544,1.44712942],[20574,1.38885987],[20605,1.3919467],[20635,1.45222042],[20666,1.42199701],[20697,1.39534506]],"AVAX":[[19784,0.023347763],[19814,0.018703963],[19844,0.030996492],[19875,0.027799587],[19905,0.033622388],[19936,0.038810535],[19967,0.044097928],[19997,0.035597915],[20028,0.040148047],[20058,0.022461026],[20089,0.028049033],[20120,0.028681832],[20148,0.044818083],[20179,0.053274627],[20209,0.047293882],[20240,0.048596511],[20270,0.056023148],[20301,0.044582242],[20332,0.042744383],[20362,0.033118744],[20393,0.055056772],[20423,0.075952894],[20454,0.081128601],[20485,0.097988646],[20513,0.10664835],[20544,0.11184523],[20574,0.10914523],[20605,0.11223469],[20635,0.14977646],[20666,0.15638769],[20697,0.13733714]],"AWG":[[19784,1.79],[19814,1.8],[19844,1.79],[19875,1.79],[19905,1.79],[19936,1.79],[19967,1.79],[19997,1.79],[20028,1.79],[20058,1.79],[20089,1.79],[20120,1.79],[20148,1.79],[20179,1.79],[20209,1.79],[20240,1.79],[20270,1.79],[20301,1.79],[20332,1.79],[20362,1.79],[20393,1.79],[20423,1.79],[20454,1.79],[20485,1.79],[20513,1.79],[20544,1.79],[20574,1.79999966],[20605,1.79],[20635,1.79],[20666,1.79],[20697,1.79]],"AZN":[[19784,1.70082509],[19814,1.70424015],[19844,1.70098502],[19875,1.70004298],[19905,1.70105321],[19936,1.69994576],[19967,1.70211275],[19997,1.69995161],[20028,1.69998413],[20058,1.70000005],[20089,1.69999311],[20120,1.70000004],[20148,1.70000004],[20179,1.70000236],[20209,1.69999336],[20240,1.7],[20270,1.69996628],[20301,1.69999746],[20332,1.70001962],[20362,1.69999984],[20393,1.69999957],[20423,1.70000431],[20454,1.69999996],[20485,1.70045191],[20513,1.70153016],[20544,1.6999997],[20574,1.69569886],[20605,1.70000649],[20635,1.70000788],[20666,1.69999927],[20697,1.69999656]],"BAM":[[19784,1.80488254],[19814,1.81231047],[19844,1.83428206],[19875,1.80271896],[19905,1.81775221],[19936,1.80544766],[19967,1.76778745],[19997,1.75546845],[20028,1.79767953],[20058,1.84891984],[20089,1.88887101],[20120,1.88738233],[20148,1.88335707],[20179,1.80747536],[20209,1.72964377],[20240,1.72360309],[20270,1.65993684],[20301,1.71379076],[20332,1.67145431],[20362,1.66601933],[20393,1.69475578],[20423,1.68651145],[20454,1.66513331],[20485,1.64978401],[20513,1.65517345],[20544,1.69090953],[20574,1.66969738],[20605,1.67921625],[20635,1.71498558],[20666,1.69642381],[20697,1.68557413]],"BBD":[[19784,2.0],[19814,2.01830125],[19844,2.0],[19875,2.0],[19905,2.0],[19936,2.0],[19967,2.0],[19997,2.0],[20028,2.0],[20058,2.0],[20089,2.0],[20120,2.0],[20148,2.0],[20179,2.0],[20209,2.0],[20240,2.0],[20270,2.0],[20301,2.0],[20332,2.0],[20362,2.0],[20393,2.0],[20423,2.0],[20454,2.0],[20485,2.0],[20513,2.0],[20544,2.0],[20574,2.01453939],[20605,2.0],[20635,2.0],[20666,2.0],[20697,2.0]],"BCH":[[19784,0.0030028733],[19814,0.0014621102],[19844,0.0023432599],[19875,0.0021955117],[19905,0.0025200615],[19936,0.0024164972],[19967,0.0030977055],[19997,0.0029235208],[20028,0.0028287139],[20058,0.0019280322],[20089,0.0022895553],[20120,0.0023388625],[20148,0.0031318114],[20179,0.0033015559],[20209,0.0027075785],[20240,0.0024285521],[20270,0.0019185979],[20301,0.0017770703],[20332,0.0018500549],[20362,0.001777131],[20393,0.0018552263],[20423,0.0018869072],[20454,0.0016663571],[20485,0.0019368098],[20513,0.0021460197],[20544,0.0021687469],[20574,0.0022521107],[20605,0.0033522309],[20635,0.0048515045],[20666,0.0047301105],[20697,0.0040268763]],"BDT":[[19784,109.71179656],[19814,109.70417159],[19844,110.0861613],[19875,117.38336346],[19905,117.14723718],[19936,117.52457048],[19967,119.95907099],[19997,119.44725057],[20028,119.43335434],[20058,119.99289248],[20089,119.51161994],[20120,121.91884993],[20148,121.45783467],[20179,121.08177388],[20209,121.57689823],[20240,122.69483443],[20270,122.63023255],[20301,122.31537585],[20332,121.40291649],[20362,121.77389688],[20393,122.37617817],[20423,121.81749296],[20454,122.33330698],[20485,122.34985217],[20513,122.32583899],[20544,122.66416952],[20574,122.72513301],[20605,122.73932897],[20635,123.13466634],[20666,123.36114197],[20697,122.82459022]],"BHD":[[19784,0.376],[19814,0.37686517],[19844,0.376],[19875,0.376],[19905,0.376],[19936,0.376],[19967,0.376],[19997,0.376],[20028,0.376],[20058,0.376],[20089,0.376],[20120,0.376],[20148,0.376],[20179,0.376],[20209,0.376],[20240,0.376],[20270,0.376],[20301,0.376],[20332,0.376],[20362,0.376],[20393,0.376],[20423,0.376],[20454,0.376],[20485,0.376],[20513,0.376],[20544,0.376],[20574,0.37762531],[20605,0.376],[20635,0.376],[20666,0.376],[20697,0.376]],"BIF":[[19784,2865.0968736],[19814,2861.84223195],[19844,2870.96958398],[19875,2876.31263059],[19905,2876.27444328],[19936,2875.29507149],[19967,2881.31231153],[19997,2894.61320479],[20028,2905.24455669],[20058,2900.53561134],[20089,2955.73264753],[20120,2958.17664262],[20148,2959.41715393],[20179,2956.57324725],[20209,2975.91340765],[20240,2930.86979744],[20270,2961.01552551],[20301,2974.23021289],[20332,2979.8856695],[20362,2943.63055363],[20393,2955.65517918],[20423,2943.96329113],[20454,2962.66257037],[20485,2963.88905689],[20513,2959.8704614],[20544,2969.01365836],[20574,2974.9994009],[20605,2971.09971136],[20635,2974.92142013],[20666,2957.58901196],[20697,2995.46545584]],"BMD":[[19784,1.0],[19814,1.0],[19844,1.0],[19875,1.0],[19905,1.0],[19936,1.0],[19967,1.0],[19997,1.0],[20028,1.0],[20058,1.0],[20089,1.0],[20120,1.0],[20148,1.0],[20179,1.0],[20209,1.0],[20240,1.0],[20270,1.0],[20301,1.0],[20332,1.0],[20362,1.0],[20393,1.0],[20423,1.0],[20454,1.0],[20485,1.0],[20513,1.0],[20544,1.0],[20574,1.0],[20605,1.0],[20635,1.0],[20666,1.0],[20697,1.0]],"BND":[[19784,1.34438059],[19814,1.34835754],[19844,1.36516636],[19875,1.3527777],[19905,1.35459224],[19936,1.33464106],[19967,1.3066498],[19997,1.28435172],[20028,1.32148096],[20058,1.33931333],[20089,1.36578209],[20120,1.35718755],[20148,1.35123775],[20179,1.34352299],[20209,1.30789452],[20240,1.29135794],[20270,1.27215806],[20301,1.2986822],[20332,1.28358174],[20362,1.29045016],[20393,1.30089978],[20423,1.29572264],[20454,1.2855248],[20485,1.27047846],[20513,1.26467337],[20544,1.28534802],[20574,1.27589645],[20605,1.27766616],[20635,1.29659402],[20666,1.28208222],[20697,1.27192132]],"BOB":[[19784,6.90904509],[19814,6.93214741],[19844,6.93443344],[19875,6.91836164],[19905,6.90862413],[19936,6.91150774],[19967,6.93488495],[19997,6.90689565],[20028,6.90248154],[20058,6.92509546],[20089,6.92251955],[20120,6.90884283],[20148,6.90582167],[20179,6.89072621],[20209,6.90675396],[20240,6.93497243],[20270,6.92123346],[20301,6.90894423],[20332,6.89400643],[20362,6.91578442],[20393,6.91312393],[20423,6.88984508],[20454,6.92712409],[20485,6.92225068],[20513,6.92099438],[20544,6.90449083],[20574,6.91132941],[20605,6.90932495],[20635,6.91803212],[20666,12.01449688],[20697,11.84328154]],"BRL":[[19784,4.95493842],[19814,5.014699],[19844,5.19525751],[19875,5.24590897],[19905,5.59353819],[19936,5.65812286],[19967,5.61191666],[19997,5.44416982],[20028,5.79066408],[20058,5.97411577],[20089,6.18440563],[20120,5.84198982],[20148,5.885143],[20179,5.69815525],[20209,5.67329812],[20240,5.73028096],[20270,5.43158758],[20301,5.60119559],[20332,5.43319692],[20362,5.32015343],[20393,5.37709743],[20423,5.33994124],[20454,5.51379765],[20485,5.24953289],[20513,5.13267882],[20544,5.19174166],[20574,4.97109848],[20605,5.03842643],[20635,5.17113191],[20666,5.07691593],[20697,5.18360658]],"BSD":[[19784,1.0],[19814,0.99955991],[19844,1.0],[19875,1.0],[19905,1.0],[19936,1.0],[19967,1.0],[19997,1.0],[20028,1.0],[20058,1.0],[20089,1.0],[20120,1.0],[20148,1.0],[20179,1.0],[20209,1.0],[20240,1.0],[20270,1.0],[20301,1.0],[20332,1.0],[20362,1.0],[20393,1.0],[20423,1.0],[20454,1.0],[20485,1.0],[20513,1.0],[20544,1.0],[20574,1.00022583],[20605,1.0],[20635,1.0],[20666,1.0],[20697,1.0]],"BTC":[[19784,1.60357e-05],[19814,1.4068718e-05],[19844,1.66872e-05],[19875,1.48115e-05],[19905,1.57725e-05],[19936,1.54946e-05],[19967,1.69609e-05],[19997,1.57335e-05],[20028,1.43022e-05],[20058,1.03988e-05],[20089,1.06526e-05],[20120,9.7833e-06],[20148,1.19197e-05],[20179,1.2097e-05],[20209,1.05167e-05],[20240,9.5734e-06],[20270,9.3178e-06],[20301,8.63e-06],[20332,9.234e-06],[20362,8.7298e-06],[20393,9.1604774e-06],[20423,1.1468259e-05],[20454,1.1365691e-05],[20485,1.2684786e-05],[20513,1.4681221e-05],[20544,1.472765e-05],[20574,1.2963224e-05],[20605,1.3635283e-05],[20635,1.6918209e-05],[20666,1.5892338e-05],[20697,1.2692964e-05]],"BTN":[[19784,82.84831702],[19814,83.3225377],[19844,83.42927157],[19875,83.44494768],[19905,83.36704112],[19936,83.69261922],[19967,83.91218037],[19997,83.79294778],[20028,84.09376548],[20058,84.56467808],[20089,85.57660032],[20120,86.69381164],[20148,87.47168],[20179,85.48083927],[20209,84.57683471],[20240,85.53006922],[20270,85.66993634],[20301,87.61870702],[20332,88.17110418],[20362,88.8277725],[20393,88.81459681],[20423,89.34558452],[20454,89.87900494],[20485,91.67291519],[20513,91.08069631],[20544,93.59562608],[20574,94.88179129],[20605,94.83532563],[20635,94.71663814],[20666,95.4199614],[20697,94.9086577]],"BWP":[[19784,13.75919254],[19814,13.74012554],[19844,13.78981949],[19875,13.61282579],[19905,13.45532781],[19936,13.56916043],[19967,13.25234678],[19997,13.07168448],[20028,13.3816521],[20058,13.64214831],[20089,14.00449735],[20120,13.90832902],[20148,13.77472722],[20179,13.6537734],[20209,13.6769127],[20240,13.46172118],[20270,13.29068277],[20301,13.85304561],[20332,13.95028554],[20362,14.17075311],[20393,14.29750667],[20423,14.14570965],[20454,13.1389692],[20485,12.98965481],[20513,13.05220706],[20544,13.7868576],[20574,13.59299253],[20605,13.43642448],[20635,13.54268503],[20666,13.59371388],[20697,13.44913316]],"BYN":[[19784,3.27380485],[19814,3.27120284],[19844,3.270082],[19875,3.27457621],[19905,3.27027054],[19936,3.26997294],[19967,3.27670338],[19997,3.26998255],[20028,3.26993178],[20058,3.26944567],[20089,3.27004071],[20120,3.27435922],[20148,3.2745678],[20179,3.27004357],[20209,3.27749654],[20240,3.275],[20270,3.27469747],[20301,3.27498872],[20332,3.37534726],[20362,3.38500156],[20393,3.4055366],[20423,3.10415723],[20454,2.93510144],[20485,2.84500945],[20513,2.89356908],[20544,2.970153],[20574,2.82252758],[20605,2.74048816],[20635,2.9296911],[20666,2.90215862],[20697,3.06495727]],"BZD":[[19784,2.02241818],[19814,2.01487226],[19844,2.02410136],[19875,2.02284894],[19905,2.01443257],[19936,2.02438523],[19967,2.01531013],[19997,2.02437432],[20028,2.01449043],[20058,2.01530823],[20089,2.01485554],[20120,2.01472208],[20148,2.0146658],[20179,2.00447667],[20209,2.01142633],[20240,2.01779826],[20270,2.0128464],[20301,2.01140754],[20332,2.01004387],[20362,2.01355779],[20393,2.01459281],[20423,2.00732039],[20454,2.01468588],[20485,2.01352915],[20513,2.01177502],[20544,2.01360942],[20574,2.01162827],[20605,2.01272046],[20635,2.01249722],[20666,2.01469717],[20697,2.01165574]],"CAD":[[19784,1.35590692],[19814,1.35254997],[19844,1.37747458],[19875,1.36304253],[19905,1.36666937],[19936,1.38067365],[19967,1.34921556],[19997,1.35216822],[20028,1.39319985],[20058,1.40060011],[20089,1.43865454],[20120,1.45420712],[20148,1.44457829],[20179,1.43916806],[20209,1.37915191],[20240,1.37405623],[20270,1.36127779],[20301,1.38569503],[20332,1.37365905],[20362,1.39238635],[20393,1.40289285],[20423,1.39797596],[20454,1.37223414],[20485,1.36197503],[20513,1.3645306],[20544,1.39018402],[20574,1.35734976],[20605,1.38095196],[20635,1.42184624],[20666,1.40208627],[20697,1.38703051]],"CDF":[[19784,2772.46099222],[19814,2807.50297084],[19844,2783.85704731],[19875,2793.6128611],[19905,2861.1457344],[19936,2861.72634785],[19967,2795.35009769],[19997,2860.1275534],[20028,2845.17978213],[20058,2846.27001953],[20089,2856.49564607],[20120,2844.24977489],[20148,2860.61935263],[20179,2906.49997736],[20209,2901.49783041],[20240,2856.69007259],[20270,2905.64492243],[20301,2909.68041906],[20332,2901.87957937],[20362,2605.51124542],[20393,2278.6732],[20423,2280.36923428],[20454,2280.21902759],[20485,2136.52755442],[20513,2209.4713284],[20544,2314.83572932],[20574,2319.99978269],[20605,2306.64239987],[20635,2313.46983802],[20666,2272.77463372],[20697,2294.99925798]],"CHF":[[19784,0.88400523],[19814,0.9016691],[19844,0.91990304],[19875,0.9026473],[19905,0.89911186],[19936,0.87632424],[19967,0.85007706],[19997,0.84498253],[20028,0.8637697],[20058,0.88114703],[20089,0.90779087],[20120,0.91093042],[20148,0.90266611],[20179,0.88355301],[20209,0.8269291],[20240,0.82226872],[20270,0.79270992],[20301,0.81397438],[20332,0.80042319],[20362,0.796266],[20393,0.8052902],[20423,0.80390173],[20454,0.79222041],[20485,0.77258991],[20513,0.76929253],[20544,0.7976248],[20574,0.78140465],[20605,0.78308352],[20635,0.80949025],[20666,0.80780519],[20697,0.80997697]],"CLP":[[19784,967.4313939],[19814,979.77008501],[19844,957.90506174],[19875,918.24522519],[19905,951.93109596],[19936,943.35162813],[19967,913.19853758],[19997,897.99480401],[20028,961.43556875],[20058,973.80275548],[20089,994.40727967],[20120,983.30114431],[20148,960.36274824],[20179,946.12554753],[20209,949.74804963],[20240,945.2054564],[20270,931.92315123],[20301,973.6213953],[20332,966.54171124],[20362,961.81416229],[20393,942.48419418],[20423,925.85453955],[20454,899.88648123],[20485,873.71478845],[20513,873.66089017],[20544,926.34586033],[20574,900.96949634],[20605,889.42936963],[20635,922.3467301],[20666,931.00863827],[20697,934.64139364]],"CNH":[[19784,7.21068934],[19814,7.25090997],[19844,7.25196222],[19875,7.26364957],[19905,7.29797729],[19936,7.21201492],[19967,7.09100026],[19997,7.00615368],[20028,7.12897048],[20058,7.24880026],[20089,7.33550682],[20120,7.32114984],[20148,7.29475807],[20179,7.27288103],[20209,7.27022304],[20240,7.20490042],[20270,7.15692633],[20301,7.21661963],[20332,7.12526786],[20362,7.13006733],[20393,7.11540221],[20423,7.07187691],[20454,6.97635186],[20485,6.95680048],[20513,6.87270037],[20544,6.88518662],[20574,6.83025788],[20605,6.76742041],[20635,6.80192146],[20666,6.76437097],[20697,6.72073752]],"CNY":[[19784,7.19596069],[19814,7.2212985],[19844,7.24009875],[19875,7.2415356],[19905,7.26733074],[19936,7.21245675],[19967,7.09012742],[19997,7.01756184],[20028,7.12075134],[20058,7.24223922],[20089,7.29965053],[20120,7.25061235],[20148,7.2827921],[20179,7.26454016],[20209,7.27167545],[20240,7.19905802],[20270,7.1606262],[20301,7.20845079],[20332,7.12998411],[20362,7.11937214],[20393,7.11799314],[20423,7.07510733],[20454,6.99423286],[20485,6.95217763],[20513,6.85770311],[20544,6.88245124],[20574,6.82814869],[20605,6.76844581],[20635,6.79407503],[20666,6.75416055],[20697,6.7204688]],"COP":[[19784,3942.46312093],[19814,3861.19996479],[19844,3915.25544898],[19875,3859.03675147],[19905,4150.51264799],[19936,4048.87588878],[19967,4176.6832799],[19997,4209.70284114],[20028,4424.33334542],[20058,4428.15665588],[20089,4407.52219314],[20120,4203.89655971],[20148,4159.06003303],[20179,4178.99325194],[20209,4245.14003231],[20240,4149.26445414],[20270,4088.08335222],[20301,4185.99700159],[20332,4014.30952932],[20362,3923.75962463],[20393,3861.16363693],[20423,3742.89834304],[20454,3766.84945306],[20485,3675.09713051],[20513,3756.71416241],[20544,3679.75219221],[20574,3656.62926388],[20605,3686.83392447],[20635,3427.94976085],[20666,3162.14731414],[20697,3222.6562831]],"CRC":[[19784,512.92823052],[19814,502.79613833],[19844,508.30522037],[19875,518.1561436],[19905,523.21548355],[19936,525.51593007],[19967,519.92852265],[19997,519.5408203],[20028,512.81711343],[20058,509.25608652],[20089,507.60066157],[20120,505.04707381],[20148,505.36655959],[20179,498.35203513],[20209,505.71506277],[20240,507.93005713],[20270,505.12835649],[20301,505.29738594],[20332,504.93863514],[20362,503.94606657],[20393,501.84077852],[20423,496.75264422],[20454,497.45190124],[20485,496.09230794],[20513,470.88604384],[20544,465.01135764],[20574,454.73552815],[20605,453.07137966],[20635,455.3108796],[20666,453.81398614],[20697,451.95656459]],"CUP":[[19784,23.99463157],[19814,26.49999954],[19844,24.03029087],[19875,23.99509599],[19905,23.98253762],[19936,24.01108095],[19967,23.82348001],[19997,23.99617846],[20028,23.96331852],[20058,23.89418008],[20089,24.00936958],[20120,23.99403551],[20148,23.99510545],[20179,23.90404379],[20209,23.97566721],[20240,23.99105043],[20270,23.9699011],[20301,23.99604583],[20332,23.94491335],[20362,23.99959039],[20393,24.02054693],[20423,23.91661553],[20454,24.01411076],[20485,23.96259376],[20513,23.94827021],[20544,23.98573998],[20574,26.49999446],[20605,26.50183895],[20635,26.50024633],[20666,26.51723107],[20697,26.5052354]],"CVE":[[19784,101.75955858],[19814,102.17527158],[19844,103.41710848],[19875,101.63757568],[19905,102.48515292],[19936,101.79142006],[19967,99.66813155],[19997,98.97358486],[20028,101.35345209],[20058,104.24238846],[20089,106.49484169],[20120,106.41090967],[20148,106.18396507],[20179,101.90574224],[20209,97.5175852],[20240,97.17701085],[20270,93.58749758],[20301,96.62379015],[20332,94.23685436],[20362,93.93042948],[20393,95.55059462],[20423,95.08577806],[20454,93.88047553],[20485,93.01507915],[20513,93.3189371],[20544,95.33374242],[20574,94.50552865],[20605,94.67447358],[20635,96.69115383],[20666,95.64463879],[20697,95.03293188]],"CZK":[[19784,23.3846648],[19814,23.42249461],[19844,23.58611815],[19875,22.74838218],[19905,23.28127539],[19936,23.47548256],[19967,22.65496729],[19997,22.62856564],[20028,23.26963491],[20058,23.86762075],[20089,24.33938458],[20120,24.31667442],[20148,24.17146481],[20179,23.04512399],[20209,22.06750956],[20240,21.95782277],[20270,20.98828062],[20301,21.53835675],[20332,20.89439853],[20362,20.71418335],[20393,21.09645034],[20423,20.85217552],[20454,20.58053678],[20485,20.52505131],[20513,20.51862163],[20544,21.22037251],[20574,20.7807459],[20605,20.8472565],[20635,21.28226075],[20666,21.01277125],[20697,20.82283489]],"DJF":[[19784,178.36889685],[19814,177.99707966],[19844,178.17154121],[19875,177.51127396],[19905,177.92451184],[19936,177.65884827],[19967,178.28557013],[19997,177.67021017],[20028,177.7517322],[20058,178.2859493],[20089,177.94835398],[20120,178.36792616],[20148,178.26439573],[20179,177.53370948],[20209,177.91572711],[20240,178.40027222],[20270,178.11216269],[20301,178.01626004],[20332,177.92264569],[20362,177.81680368],[20393,178.15452],[20423,178.08008259],[20454,177.92065349],[20485,179.26717974],[20513,178.43223753],[20544,177.9010925],[20574,177.71985448],[20605,177.93479479],[20635,178.00676767],[20666,178.33716165],[20697,178.07910003]],"DKK":[[19784,6.8777556],[19814,6.91081],[19844,6.99501389],[19875,6.8763023],[19905,6.93191216],[19936,6.88928834],[19967,6.75127212],[19997,6.69054562],[20028,6.85595381],[20058,7.05155366],[20089,7.20331009],[20120,7.20156644],[20148,7.18732435],[20179,6.89471259],[20209,6.60136522],[20240,6.57386499],[20270,6.33228918],[20301,6.54081521],[20332,6.37967567],[20362,6.35874875],[20393,6.4726492],[20423,6.44020505],[20454,6.36039133],[20485,6.29846281],[20513,6.32424857],[20544,6.46045381],[20574,6.36825878],[20605,6.41720393],[20635,6.55421663],[20666,6.4831493],[20697,6.44199555]],"DOGE":[[19784,6.81372462],[19814,4.58556726],[19844,7.5114656],[19875,6.23465686],[19905,7.90353917],[19936,8.03261511],[19967,9.74784215],[19997,8.5048377],[20028,6.13770245],[20058,2.32838682],[20089,3.12525762],[20120,3.01279556],[20148,4.89853749],[20179,6.00367403],[20209,5.73506585],[20240,5.29145723],[20270,6.0462354],[20301,4.78016278],[20332,4.70513718],[20362,4.274313],[20393,5.41052233],[20423,7.18876257],[20454,8.44798423],[20485,9.505116],[20513,10.26844267],[20544,10.84922265],[20574,9.14683339],[20605,9.97859889],[20635,13.82052925],[20666,14.28517286],[20697,11.9991098]],"DOP":[[19784,58.73655014],[19814,59.21244574],[19844,58.84231002],[19875,59.17400794],[19905,58.9643304],[19936,58.5018569],[19967,59.53060612],[19997,60.34003865],[20028,60.39884222],[20058,60.35001349],[20089,61.10234666],[20120,61.75641087],[20148,62.39159691],[20179,62.89372962],[20209,59.02197426],[20240,58.99925167],[20270,59.4017838],[20301,60.98032776],[20332,62.82527948],[20362,62.282963],[20393,64.1929482],[20423,62.43884444],[20454,63.08892988],[20485,62.90135452],[20513,59.6750677],[20544,60.09606495],[20574,59.40410914],[20605,58.73761636],[20635,59.57026962],[20666,58.16331828],[20697,58.92209701]],"DOT":[[19784,0.11450607],[19814,0.10380331],[19844,0.15692435],[19875,0.14360516],[19905,0.15903668],[19936,0.18586539],[19967,0.2365674],[19997,0.22235015],[20028,0.25370779],[20058,0.11318125],[20089,0.14948156],[20120,0.15671327],[20148,0.21005234],[20179,0.24812794],[20209,0.24285286],[20240,0.24640906],[20270,0.29441823],[20301,0.27015385],[20332,0.26819393],[20362,0.25417275],[20393,0.34894747],[20423,0.4824225],[20454,0.56154162],[20485,0.64288504],[20513,0.60671573],[20544,0.79433928],[20574,0.82242353],[20605,0.84372451],[20635,1.19583659],[20666,1.31035328],[20697,1.17053371]],"DZD":[[19784,134.8831927],[19814,134.67296042],[19844,134.57485776],[19875,134.72266978],[19905,134.29909652],[19936,134.60578003],[19967,134.22716505],[19997,132.45021285],[20028,133.33156155],[20058,134.0851019],[20089,135.81029081],[20120,135.37520738],[20148,135.08184999],[20179,133.89531329],[20209,132.63726817],[20240,132.88604419],[20270,129.65819489],[20301,130.96709259],[20332,129.63984984],[20362,129.6058226],[20393,130.12882918],[20423,130.38994533],[20454,129.55345113],[20485,129.7990166],[20513,129.9275],[20544,133.25503204],[20574,132.41898344],[20605,132.72972053],[20635,133.23823932],[20666,132.91296614],[20697,133.34753369]],"EGP":[[19784,30.86479349],[19814,47.38816946],[19844,47.88338056],[19875,47.20225765],[19905,47.82026483],[19936,48.55891389],[19967,48.61625287],[19997,48.29402534],[20028,48.95151512],[20058,49.58429178],[20089,50.83845991],[20120,50.22731762],[20148,50.63433556],[20179,50.57005309],[20209,50.87833264],[20240,49.75805955],[20270,49.58961768],[20301,48.58591859],[20332,48.47750008],[20362,47.85327033],[20393,47.09677232],[20423,47.46676482],[20454,47.70320713],[20485,46.96388342],[20513,47.73065728],[20544,54.53289544],[20574,53.61028708],[20605,52.37768934],[20635,49.22598776],[20666,51.15295476],[20697,50.92040112]],"ERN":[[19784,15.0],[19814,15.0],[19844,15.0],[19875,15.0],[19905,15.0],[19936,15.0],[19967,15.0],[19997,15.0],[20028,15.0],[20058,15.0],[20089,15.0],[20120,15.0],[20148,15.0],[20179,15.0],[20209,15.0],[20240,15.0],[20270,15.0],[20301,15.0],[20332,15.0],[20362,15.0],[20393,15.0],[20423,15.0],[20454,15.0],[20485,15.0],[20513,15.0],[20544,15.0],[20574,14.99999659],[20605,15.0],[20635,15.0],[20666,15.0],[20697,15.0]],"ETB":[[19784,56.79819456],[19814,56.80515137],[19844,57.24615811],[19875,57.39826901],[19905,57.72199642],[19936,79.14918802],[19967,110.89003003],[19997,119.5907674],[20028,120.80108982],[20058,124.1957348],[20089,127.98526326],[20120,128.02702745],[20148,127.19820295],[20179,130.2027137],[20209,134.67297652],[20240,135.77153967],[20270,135.19738507],[20301,138.19088761],[20332,142.09723873],[20362,144.71908087],[20393,153.59805446],[20423,153.81796551],[20454,155.30451159],[20485,155.4156838],[20513,156.10951722],[20544,156.99771001],[20574,156.99994887],[20605,159.69573671],[20635,159.67523089],[20666,159.31960032],[20697,162.03567413]],"ETC":[[19784,0.032133977],[19814,0.029125385],[19844,0.040022834],[19875,0.03389073],[19905,0.041710367],[19936,0.045408957],[19967,0.054611351],[19997,0.050886537],[20028,0.053950766],[20058,0.030816268],[20089,0.039912451],[20120,0.036852033],[20148,0.050931733],[20179,0.059651984],[20209,0.060009067],[20240,0.05964412],[20270,0.060517966],[20301,0.048709286],[20332,0.048326239],[20362,0.05414363],[20393,0.063096798],[20423,0.07675256],[20454,0.086695122],[20485,0.10215661],[20513,0.11263389],[20544,0.12230072],[20574,0.11863853],[20605,0.12324448],[20635,0.14407081],[20666,0.14961794],[20697,0.13673373]],"ETH":[[19784,0.0002898512],[19814,0.00027605775],[19844,0.0003350113],[19875,0.0002657676],[19905,0.0002860493],[19936,0.0003115239],[19967,0.0003996676],[19997,0.0003808958],[20028,0.0003968625],[20058,0.0002694975],[20089,0.000298976],[20120,0.0003006675],[20148,0.0004481818],[20179,0.0005478292],[20209,0.0005527918],[20240,0.0003946946],[20270,0.0004010462],[20301,0.000269056],[20332,0.0002265373],[20362,0.0002397179],[20393,0.00026154453],[20423,0.00034981916],[20454,0.000334999],[20485,0.00040763007],[20513,0.0004888128],[20544,0.00047678874],[20574,0.00043783756],[20605,0.00050163765],[20635,0.00062857353],[20666,0.00053616508],[20697,0.00040414544]],"EUR":[[19784,0.92282179],[19814,0.92650499],[19844,0.93785353],[19875,0.92171557],[19905,0.92940195],[19936,0.92311073],[19967,0.90385537],[19997,0.89755677],[20028,0.91913895],[20058,0.9453377],[20089,0.96576441],[20120,0.96500326],[20148,0.96294518],[20179,0.92414748],[20209,0.88435282],[20240,0.88126427],[20270,0.84871223],[20301,0.8762473],[20332,0.85460102],[20362,0.85182216],[20393,0.86651487],[20423,0.86229961],[20454,0.85136914],[20485,0.84352117],[20513,0.84627675],[20544,0.86454831],[20574,0.85220034],[20605,0.85856963],[20635,0.8768582],[20666,0.86736772],[20697,0.86182037]],"FIL":[[19784,0.11903809],[19814,0.097588601],[19844,0.18037671],[19875,0.17516521],[19905,0.22045747],[19936,0.2378301],[19967,0.28149273],[19997,0.25834222],[20028,0.2828808],[20058,0.14756376],[20089,0.20111352],[20120,0.20663679],[20148,0.30772691],[20179,0.35887649],[20209,0.35748982],[20240,0.39068653],[20270,0.43500256],[20301,0.41900746],[20332,0.44156735],[20362,0.45619],[20393,0.65628143],[20423,0.6742027],[20454,0.77454334],[20485,0.94527231],[20513,0.98001413],[20544,1.20538737],[20574,1.07124066],[20605,1.06331228],[20635,1.3634668],[20666,1.39136443],[20697,1.4437644]],"FJD":[[19784,2.24851943],[19814,2.25689667],[19844,2.29077872],[19875,2.2598671],[19905,2.25914052],[19936,2.25462075],[19967,2.20673065],[19997,2.19357643],[20028,2.27004712],[20058,2.29601055],[20089,2.32872515],[20120,2.32155477],[20148,2.33589121],[20179,2.33577265],[20209,2.26948127],[20240,2.26024896],[20270,2.23815246],[20301,2.2738092],[20332,2.25451399],[20362,2.25706399],[20393,2.28884427],[20423,2.27190344],[20454,2.27277494],[20485,2.20476944],[20513,2.20514983],[20544,2.2573156],[20574,2.1921047],[20605,2.21944509],[20635,2.25047432],[20666,2.21391091],[20697,2.19830056]],"FKP":[[19784,0.79023841],[19814,0.79205615],[19844,0.80075643],[19875,0.78494269],[19905,0.789604],[19936,0.77761732],[19967,0.76156108],[19997,0.7469564],[20028,0.77568246],[20058,0.7859034],[20089,0.79908761],[20120,0.80702838],[20148,0.79492402],[20179,0.77389163],[20209,0.75150096],[20240,0.74256646],[20270,0.7283443],[20301,0.75788905],[20332,0.73970156],[20362,0.74394029],[20393,0.76017498],[20423,0.75601488],[20454,0.74206457],[20485,0.73045243],[20513,0.74158609],[20544,0.75540895],[20574,0.74129584],[20605,0.74315661],[20635,0.75564998],[20666,0.74188341],[20697,0.73861768]],"GBP":[[19784,0.79023841],[19814,0.79141037],[19844,0.80075643],[19875,0.78494269],[19905,0.789604],[19936,0.77761732],[19967,0.76156108],[19997,0.7469564],[20028,0.77568246],[20058,0.7859034],[20089,0.79908761],[20120,0.80702838],[20148,0.79492402],[20179,0.77389163],[20209,0.75150096],[20240,0.74256646],[20270,0.7283443],[20301,0.75788905],[20332,0.73970156],[20362,0.74394029],[20393,0.76017498],[20423,0.75601488],[20454,0.74206457],[20485,0.73045243],[20513,0.74158609],[20544,0.75540895],[20574,0.73481486],[20605,0.74315661],[20635,0.75564998],[20666,0.74188341],[20697,0.73861768]],"GEL":[[19784,2.65909987],[19814,2.6949751],[19844,2.67793112],[19875,2.79645744],[19905,2.80490721],[19936,2.71477825],[19967,2.69110012],[19997,2.72987817],[20028,2.73989774],[20058,2.77939987],[20089,2.81341716],[20120,2.8730665],[20148,2.81528904],[20179,2.75895109],[20209,2.74580772],[20240,2.73340001],[20270,2.72085585],[20301,2.7038326],[20332,2.69347744],[20362,2.71953136],[20393,2.70890675],[20423,2.69794793],[20454,2.69156264],[20485,2.69086951],[20513,2.67073693],[20544,2.68606942],[20574,2.6850242],[20605,2.65846917],[20635,2.63664213],[20666,2.61590793],[20697,2.60665951]],"GGP":[[19784,0.79023841],[19814,0.79205615],[19844,0.80075643],[19875,0.78494269],[19905,0.789604],[19936,0.77761732],[19967,0.76156108],[19997,0.7469564],[20028,0.77568246],[20058,0.7859034],[20089,0.79908761],[20120,0.80702838],[20148,0.79492402],[20179,0.77389163],[20209,0.75150096],[20240,0.74256646],[20270,0.7283443],[20301,0.75788905],[20332,0.73970156],[20362,0.74394029],[20393,0.76017498],[20423,0.75601488],[20454,0.74206457],[20485,0.73045243],[20513,0.74158609],[20544,0.75540895],[20574,0.74129584],[20605,0.74315661],[20635,0.75564998],[20666,0.74188341],[20697,0.73861768]],"GHS":[[19784,12.68893167],[19814,13.24437681],[19844,13.6709973],[19875,14.6624812],[19905,15.21569594],[19936,15.55564529],[19967,15.58265612],[19997,15.78510369],[20028,16.30003679],[20058,15.2827545],[20089,14.69911901],[20120,15.29977205],[20148,15.49490284],[20179,15.45635346],[20209,14.15055264],[20240,10.23270629],[20270,10.33928541],[20301,10.49685148],[20332,11.67243659],[20362,12.44586053],[20393,10.90497871],[20423,11.29921692],[20454,10.50110481],[20485,10.93789948],[20513,10.66530995],[20544,10.98538861],[20574,11.19496298],[20605,11.73087469],[20635,11.32183137],[20666,11.65786056],[20697,11.25631219]],"GIP":[[19784,0.79023841],[19814,0.79205615],[19844,0.80075643],[19875,0.78494269],[19905,0.789604],[19936,0.77761732],[19967,0.76156108],[19997,0.7469564],[20028,0.77568246],[20058,0.7859034],[20089,0.79908761],[20120,0.80702838],[20148,0.79492402],[20179,0.77389163],[20209,0.75150096],[20240,0.74256646],[20270,0.7283443],[20301,0.75788905],[20332,0.73970156],[20362,0.74394029],[20393,0.76017498],[20423,0.75601488],[20454,0.74206457],[20485,0.73045243],[20513,0.74158609],[20544,0.75540895],[20574,0.74129584],[20605,0.74315661],[20635,0.75564998],[20666,0.74188341],[20697,0.73861768]],"GMD":[[19784,67.90952133],[19814,67.89363908],[19844,67.94028689],[19875,67.75956034],[19905,67.91568474],[19936,68.15395002],[19967,70.99830725],[19997,68.89081745],[20028,70.34901772],[20058,70.99870596],[20089,72.36307323],[20120,72.49504975],[20148,71.48420899],[20179,72.06056299],[20209,72.10053857],[20240,71.96750518],[20270,72.22908594],[20301,72.5763746],[20332,72.36417179],[20362,73.39926558],[20393,72.43489237],[20423,73.62040098],[20454,73.89526202],[20485,74.24652065],[20513,73.39407918],[20544,73.95042096],[20574,73.4976475],[20605,73.66788781],[20635,73.88884204],[20666,73.83310955],[20697,74.48392903]],"GNF":[[19784,8582.69853495],[19814,8591.82786278],[19844,8600.69644639],[19875,8593.44338849],[19905,8618.96657059],[19936,8614.38002361],[19967,8624.62918715],[19997,8628.14040885],[20028,8626.18691268],[20058,8611.84499009],[20089,8647.20047983],[20120,8641.94943879],[20148,8663.84028064],[20179,8644.87835588],[20209,8678.131616],[20240,8684.99201575],[20270,8670.67314457],[20301,8676.06051787],[20332,8672.06673199],[20362,8681.56513593],[20393,8682.49885101],[20423,8695.63544112],[20454,8754.00351266],[20485,8772.78590969],[20513,8775.19756249],[20544,8775.16318095],[20574,8777.50114664],[20605,8765.52849215],[20635,8768.68944425],[20666,8755.39107757],[20697,8798.81652984]],"GTQ":[[19784,7.80651355],[19814,7.79391657],[19844,7.78073183],[19875,7.77057989],[19905,7.76687465],[19936,7.75249443],[19967,7.72972867],[19997,7.72642807],[20028,7.72025082],[20058,7.7182198],[20089,7.71519124],[20120,7.73484413],[20148,7.71142799],[20179,7.71359364],[20209,7.69795123],[20240,7.67705105],[20270,7.68749511],[20301,7.6747728],[20332,7.65764134],[20362,7.66431881],[20393,7.66842496],[20423,7.66396315],[20454,7.66973296],[20485,7.66690341],[20513,7.66524148],[20544,7.64866211],[20574,7.64150488],[20605,7.6251507],[20635,7.62529781],[20666,7.62254491],[20697,7.63262677]],"GYD":[[19784,208.92547312],[19814,209.28950131],[19844,209.11102585],[19875,209.11184825],[19905,208.79281951],[19936,208.99363877],[19967,208.98756809],[19997,208.77039116],[20028,208.61057878],[20058,208.98320841],[20089,209.26838248],[20120,209.29854123],[20148,209.09467867],[20179,209.57394153],[20209,210.20857894],[20240,208.98231509],[20270,209.00973171],[20301,209.11637127],[20332,208.62994811],[20362,209.21825389],[20393,209.41594741],[20423,208.74492562],[20454,209.33987564],[20485,209.11106264],[20513,209.26785787],[20544,209.27152673],[20574,209.2523476],[20605,209.20186392],[20635,209.03215109],[20666,208.19197728],[20697,209.01720013]],"HKD":[[19784,7.82892032],[19814,7.82637482],[19844,7.82287028],[19875,7.82003531],[19905,7.80899293],[19936,7.81371746],[19967,7.79747356],[19997,7.77300144],[20028,7.77428018],[20058,7.78189551],[20089,7.76895758],[20120,7.79376979],[20148,7.77861235],[20179,7.78095661],[20209,7.75655034],[20240,7.84083033],[20270,7.8499384],[20301,7.8499774],[20332,7.79530382],[20362,7.78205529],[20393,7.77055366],[20423,7.78733498],[20454,7.78098865],[20485,7.81106446],[20513,7.82324663],[20544,7.83853966],[20574,7.8332278],[20605,7.83741362],[20635,7.84389935],[20666,7.84188485],[20697,7.83919263]],"HNL":[[19784,24.74047616],[19814,24.67440391],[19844,24.72097206],[19875,24.71888622],[19905,24.74641309],[19936,24.77787561],[19967,24.77883301],[19997,24.89009244],[20028,25.17039866],[20058,25.19656691],[20089,25.41623926],[20120,25.46596672],[20148,25.71762214],[20179,25.57674354],[20209,25.94023135],[20240,25.99341066],[20270,26.15040312],[20301,26.29169206],[20332,26.19630098],[20362,26.29246269],[20393,26.32295904],[20423,26.30253362],[20454,26.44012192],[20485,26.46046354],[20513,26.5187319],[20544,26.58191077],[20574,26.61961356],[20605,26.61393401],[20635,26.74682357],[20666,26.77021609],[20697,26.86504203]],"HTG":[[19784,133.10492254],[19814,132.52838858],[19844,132.78046468],[19875,133.41617974],[19905,132.75151458],[19936,132.13675659],[19967,133.97756404],[19997,132.01851515],[20028,131.65107419],[20058,133.08245554],[20089,130.80656355],[20120,130.79061593],[20148,131.22339252],[20179,130.84891129],[20209,130.65477474],[20240,132.92193851],[20270,131.67951273],[20301,131.28417493],[20332,130.66519699],[20362,130.92545668],[20393,130.95635344],[20423,130.57768255],[20454,131.06855268],[20485,132.10614476],[20513,131.62357885],[20544,131.26871518],[20574,131.02462262],[20605,130.9473417],[20635,130.66860767],[20666,130.3547822],[20697,130.65229392]],"HUF":[[19784,363.57684047],[19814,365.03023742],[19844,366.63301279],[19875,359.02212009],[19905,367.04497047],[19936,363.75133276],[19967,355.57925904],[19997,356.5420421],[20028,375.3993749],[20058,390.12392209],[20089,397.52081852],[20120,393.52399758],[20148,388.84353074],[20179,372.23620209],[20209,357.85118946],[20240,355.75681132],[20270,339.14309175],[20301,350.48926728],[20332,339.03163133],[20362,332.30585396],[20393,336.46265801],[20423,328.99679677],[20454,327.48523858],[20485,321.61131333],[20513,318.94563659],[20544,332.26493168],[20574,310.4439674],[20605,304.02579379],[20635,312.41238931],[20666,316.5538242],[20697,314.7632912]],"ICP":[[19784,0.076415206],[19814,0.051984491],[19844,0.078339472],[19875,0.084300044],[19905,0.12084581],[19936,0.11192371],[19967,0.13363924],[19997,0.10977778],[20028,0.12713947],[20058,0.082289073],[20089,0.10081165],[20120,0.10683745],[20148,0.15299136],[20179,0.18893872],[20209,0.20044971],[20240,0.20524841],[20270,0.20463212],[20301,0.18994355],[20332,0.21075258],[20362,0.23370898],[20393,0.33990301],[20423,0.27706128],[20454,0.33901611],[20485,0.36149974],[20513,0.39580061],[20544,0.44089609],[20574,0.41739423],[20605,0.35911077],[20635,0.46891694],[20666,0.48353151],[20697,0.41256463]],"IDR":[[19784,15705.32945652],[19814,15873.04985523],[19844,16269.42044015],[19875,16250.49678869],[19905,16355.37462455],[19936,16243.98543461],[19967,15445.7678243],[19997,15138.41207572],[20028,15705.79827075],[20058,15847.90587001],[20089,16267.55870369],[20120,16359.3130205],[20148,16536.49341503],[20179,16620.82755551],[20209,16559.46584548],[20240,16289.8131249],[20270,16184.93295487],[20301,16500.60620433],[20332,16469.35089629],[20362,16670.01449276],[20393,16639.52520018],[20423,16662.78084253],[20454,16700.24782056],[20485,16786.44268879],[20513,16771.79149376],[20544,17011.27255114],[20574,17299.89651646],[20605,17817.15103883],[20635,17946.24110835],[20666,18024.17529787],[20697,17730.42601401]],"ILS":[[19784,3.56490418],[19814,3.69424965],[19844,3.73570338],[19875,3.71684122],[19905,3.76714538],[19936,3.76712181],[19967,3.63689293],[19997,3.71987428],[20028,3.73977441],[20058,3.63958401],[20089,3.64125189],[20120,3.57396872],[20148,3.59917391],[20179,3.72319999],[20209,3.6374505],[20240,3.51696167],[20270,3.37050728],[20301,3.39438997],[20332,3.33620192],[20362,3.3068825],[20393,3.25330012],[20423,3.25831981],[20454,3.18566824],[20485,3.10225873],[20513,3.12827514],[20544,3.14331971],[20574,2.95246938],[20605,2.80691424],[20635,2.98292885],[20666,3.06124059],[20697,2.9893357]],"IMP":[[19784,0.79023841],[19814,0.79205615],[19844,0.80075643],[19875,0.78494269],[19905,0.789604],[19936,0.77761732],[19967,0.76156108],[19997,0.7469564],[20028,0.77568246],[20058,0.7859034],[20089,0.79908761],[20120,0.80702838],[20148,0.79492402],[20179,0.77389163],[20209,0.75150096],[20240,0.74256646],[20270,0.7283443],[20301,0.75788905],[20332,0.73970156],[20362,0.74394029],[20393,0.76017498],[20423,0.75601488],[20454,0.74206457],[20485,0.73045243],[20513,0.74158609],[20544,0.75540895],[20574,0.74129584],[20605,0.74315661],[20635,0.75564998],[20666,0.74188341],[20697,0.73861768]],"INR":[[19784,82.84831702],[19814,83.33844949],[19844,83.42927157],[19875,83.44494768],[19905,83.36704112],[19936,83.69261922],[19967,83.91218037],[19997,83.79294778],[20028,84.09376548],[20058,84.56467808],[20089,85.57660032],[20120,86.69381164],[20148,87.47168],[20179,85.48083927],[20209,84.57683471],[20240,85.53006922],[20270,85.66993634],[20301,87.61870702],[20332,88.17110418],[20362,88.8277725],[20393,88.81459681],[20423,89.34558452],[20454,89.87900494],[20485,91.67291519],[20513,91.08069631],[20544,93.59562608],[20574,94.89488364],[20605,94.83532563],[20635,94.71663814],[20666,95.4199614],[20697,94.9086577]],"IQD":[[19784,1309.34790668],[19814,1309.42617006],[19844,1310.15582504],[19875,1308.86865555],[19905,1309.64036746],[19936,1309.00149919],[19967,1310.41888561],[19997,1309.15836482],[20028,1309.53940868],[20058,1310.30800411],[20089,1309.82626164],[20120,1309.20727454],[20148,1309.08758181],[20179,1307.25267763],[20209,1312.30227441],[20240,1310.22165928],[20270,1309.93107003],[20301,1309.94626227],[20332,1310.15976576],[20362,1310.4639265],[20393,1310.47613382],[20423,1306.3069358],[20454,1310.71842758],[20485,1310.4597116],[20513,1310.41715499],[20544,1310.23830361],[20574,1309.99973582],[20605,1310.29023452],[20635,1309.34146144],[20666,1306.34599815],[20697,1310.18804699]],"IRR":[[19784,42022.15950393],[19814,42047.50004401],[19844,41964.03053604],[19875,42166.81590642],[19905,42374.53044971],[19936,41964.90999295],[19967,42002.63071212],[19997,42164.94063167],[20028,42090.06881918],[20058,42002.59611175],[20089,42048.32146819],[20120,42098.57044702],[20148,42079.75662603],[20179,42097.32903682],[20209,41945.18062351],[20240,42002.57430125],[20270,42059.73472012],[20301,42298.19916709],[20332,42079.63445841],[20362,42046.243853],[20393,42068.53224441],[20423,42017.76560956],[20454,41963.40129044],[20485,1111903.25543632],[20513,1314250.3344506],[20544,1313556.38693394],[20574,1314999.73491968],[20605,1351143.76797984],[20635,1376012.78614439],[20666,1376019.14488032],[20697,1374871.56843626]],"ISK":[[19784,138.13525558],[19814,139.25990503],[19844,140.58838267],[19875,137.43225235],[19905,138.20726679],[19936,138.19757448],[19967,137.91145637],[19997,135.0836199],[20028,136.86561609],[20058,137.40021877],[20089,139.02840137],[20120,141.56527167],[20148,140.17815644],[20179,132.0702187],[20209,128.85388272],[20240,127.24746378],[20270,120.6872001],[20301,124.6108585],[20332,122.38720361],[20362,121.25212121],[20393,125.48799686],[20423,127.93449372],[20454,125.37807383],[20485,122.29887258],[20513,121.45819013],[20544,123.97663612],[20574,122.56003027],[20605,123.11161027],[20635,126.0893548],[20666,123.23418971],[20697,121.33978517]],"JEP":[[19784,0.79023841],[19814,0.79205615],[19844,0.80075643],[19875,0.78494269],[19905,0.789604],[19936,0.77761732],[19967,0.76156108],[19997,0.7469564],[20028,0.77568246],[20058,0.7859034],[20089,0.79908761],[20120,0.80702838],[20148,0.79492402],[20179,0.77389163],[20209,0.75150096],[20240,0.74256646],[20270,0.7283443],[20301,0.75788905],[20332,0.73970156],[20362,0.74394029],[20393,0.76017498],[20423,0.75601488],[20454,0.74206457],[20485,0.73045243],[20513,0.74158609],[20544,0.75540895],[20574,0.74129584],[20605,0.74315661],[20635,0.75564998],[20666,0.74188341],[20697,0.73861768]],"JMD":[[19784,155.90196723],[19814,153.8468654],[19844,156.34577251],[19875,155.39382591],[19905,155.99897599],[19936,156.52673188],[19967,157.33432484],[19997,157.10518955],[20028,158.17714598],[20058,156.49271714],[20089,156.20306387],[20120,157.66717169],[20148,157.50960242],[20179,156.44181263],[20209,158.22258947],[20240,159.05302405],[20270,160.3015326],[20301,160.026745],[20332,159.65627501],[20362,160.33606887],[20393,160.35074688],[20423,159.57955931],[20454,158.94289387],[20485,156.93537964],[20513,156.10024196],[20544,158.1069079],[20574,156.72511426],[20605,157.39613628],[20635,157.44753593],[20666,158.26510306],[20697,158.17170022]],"JOD":[[19784,0.709],[19814,0.70890232],[19844,0.709],[19875,0.709],[19905,0.709],[19936,0.709],[19967,0.709],[19997,0.709],[20028,0.709],[20058,0.709],[20089,0.709],[20120,0.709],[20148,0.709],[20179,0.709],[20209,0.709],[20240,0.709],[20270,0.709],[20301,0.709],[20332,0.709],[20362,0.709],[20393,0.709],[20423,0.709],[20454,0.709],[20485,0.709],[20513,0.709],[20544,0.709],[20574,0.70901875],[20605,0.709],[20635,0.709],[20666,0.709],[20697,0.709]],"JPY":[[19784,150.12018549],[19814,151.32149909],[19844,157.79776463],[19875,157.32951389],[19905,160.92191134],[19936,148.66990024],[19967,146.19573258],[19997,143.8314291],[20028,152.23975307],[20058,149.77423938],[20089,157.29432149],[20120,155.2019506],[20148,150.64746647],[20179,149.83135337],[20209,143.18986867],[20240,144.04059088],[20270,143.68446719],[20301,150.8224511],[20332,147.19643574],[20362,147.96460543],[20393,153.9914981],[20423,155.57725258],[20454,156.70086294],[20485,154.7433216],[20513,156.05179854],[20544,158.61456864],[20574,157.27698642],[20605,159.47035442],[20635,162.78888732],[20666,157.65410642],[20697,159.84829647]],"KES":[[19784,146.67353883],[19814,132.44012415],[19844,134.96789976],[19875,130.32004194],[19905,129.04594784],[19936,128.99343907],[19967,128.3842085],[19997,128.98794483],[20028,128.99962643],[20058,129.83820146],[20089,129.37311968],[20120,129.19787233],[20148,128.93662791],[20179,129.29199078],[20209,129.5610074],[20240,129.36859216],[20270,129.34313503],[20301,129.29204253],[20332,129.01252318],[20362,129.44258368],[20393,129.19388433],[20423,129.23340253],[20454,129.01539893],[20485,129.03599908],[20513,129.00109134],[20544,129.98546569],[20574,129.18042956],[20605,129.44905615],[20635,129.48102398],[20666,129.03711147],[20697,129.51486363]],"KGS":[[19784,89.40622287],[19814,89.47079703],[19844,88.68276091],[19875,87.70001595],[19905,86.44712445],[19936,84.02922636],[19967,84.91520045],[19997,84.20281462],[20028,85.80080368],[20058,86.80000305],[20089,86.80268032],[20120,87.45090939],[20148,87.46991664],[20179,86.42715732],[20209,87.47773033],[20240,87.45002198],[20270,87.3148816],[20301,87.46672705],[20332,87.37633768],[20362,87.44468404],[20393,87.46319239],[20423,87.47853181],[20454,87.45247919],[20485,87.46456942],[20513,87.44830073],[20544,87.45779339],[20574,87.42048246],[20605,87.45394561],[20635,87.46656117],[20666,87.48127413],[20697,87.54502574]],"KHR":[[19784,4070.20712009],[19814,4041.08728511],[19844,4086.3228522],[19875,4093.98369578],[19905,4105.01019933],[19936,4100.43903334],[19967,4050.31655952],[19997,4069.11980569],[20028,4065.56976373],[20058,4020.03209437],[20089,4028.00254131],[20120,4021.81100015],[20148,4027.50638215],[20179,3989.56621421],[20209,4018.6024765],[20240,4000.64008474],[20270,4005.7310878],[20301,4014.25868961],[20332,4024.30662528],[20362,4009.39984334],[20393,4018.63644009],[20423,3988.57516668],[20454,4010.16571842],[20485,4020.45419979],[20513,4006.95513719],[20544,4003.05542274],[20574,4012.49439465],[20605,4015.27290448],[20635,4024.0757255],[20666,4034.5071634],[20697,4052.68521691]],"KMF":[[19784,453.99855875],[19814,456.34968337],[19844,461.39368977],[19875,453.45433411],[19905,457.23578569],[19936,454.14070821],[19967,444.66769226],[19997,441.56898387],[20028,452.18672152],[20058,465.07566252],[20089,475.12494472],[20120,474.75048342],[20148,473.7379739],[20179,454.65075471],[20209,435.0730659],[20240,433.55359915],[20270,417.53904608],[20301,431.08541433],[20332,420.43614042],[20362,419.06903098],[20393,426.29737053],[20423,424.22359927],[20454,418.8461623],[20485,414.98521092],[20513,416.34086802],[20544,425.32988807],[20574,419.99982445],[20605,422.38857123],[20635,431.38595622],[20666,426.716947],[20697,423.9878269]],"KPW":[[19784,900.107292],[19814,899.99999166],[19844,900.00147084],[19875,899.99209912],[19905,900.02681368],[19936,899.98709379],[19967,900.0],[19997,900.01820169],[20028,900.02876014],[20058,900.0],[20089,900.00051406],[20120,899.96863344],[20148,899.99487091],[20179,899.99401253],[20209,899.91945929],[20240,900.0],[20270,899.96201554],[20301,899.99452112],[20332,900.02401312],[20362,899.97934908],[20393,899.99619776],[20423,899.97699234],[20454,900.03657762],[20485,900.00000063],[20513,900.00004029],[20544,900.04577765],[20574,899.85050616],[20605,899.91770235],[20635,900.00871309],[20697,900.17809994]],"KRW":[[19784,1331.7429266],[19814,1344.32016584],[19844,1385.99935387],[19875,1382.72304163],[19905,1378.34244997],[19936,1365.96682909],[19967,1337.06898707],[19997,1315.48758201],[20028,1378.19934207],[20058,1394.94005059],[20089,1478.21278542],[20120,1457.76026],[20148,1461.5967258],[20179,1472.01213381],[20209,1426.06800937],[20240,1382.76083867],[20270,1354.14278365],[20301,1398.92224127],[20332,1392.48940128],[20362,1406.29980829],[20393,1428.25511432],[20423,1468.65411009],[20454,1442.29570512],[20485,1450.84241373],[20513,1437.51692233],[20544,1509.04660771],[20574,1474.36467272],[20605,1514.37268301],[20635,1556.46204874],[20666,1443.03434425],[20697,1372.93688041]],"KWD":[[19784,0.30781688],[19814,0.30765061],[19844,0.30821308],[19875,0.30683165],[19905,0.30683231],[19936,0.3053898],[19967,0.30629827],[19997,0.30536438],[20028,0.30659604],[20058,0.30720054],[20089,0.30831146],[20120,0.30848545],[20148,0.30869495],[20179,0.30834979],[20209,0.30643361],[20240,0.30770061],[20270,0.30558892],[20301,0.30615209],[20332,0.30543522],[20362,0.30556296],[20393,0.30676499],[20423,0.30684322],[20454,0.30790587],[20485,0.30608914],[20513,0.30644212],[20544,0.30855325],[20574,0.30728981],[20605,0.30940444],[20635,0.30954287],[20666,0.30926359],[20697,0.30901635]],"KYD":[[19784,0.82007738],[19814,0.8330438],[19844,0.82187234],[19875,0.8200037],[19905,0.82091251],[19936,0.82092953],[19967,0.82000447],[19997,0.82205059],[20028,0.82533737],[20058,0.82000575],[20089,0.8270889],[20120,0.82022747],[20148,0.82016761],[20179,0.82824162],[20209,0.83158555],[20240,0.82000387],[20270,0.82921856],[20301,0.83145892],[20332,0.83114396],[20362,0.83131933],[20393,0.83269972],[20423,0.83122237],[20454,0.83167701],[20485,0.82084987],[20513,0.82605531],[20544,0.83167083],[20574,0.83354312],[20605,0.83164308],[20635,0.83168968],[20666,0.83109929],[20697,0.83151437]],"KZT":[[19784,451.29185439],[19814,447.0081273],[19844,441.99435573],[19875,447.16916966],[19905,473.58003227],[19936,475.08492791],[19967,481.45768671],[19997,480.7936949],[20028,488.95131916],[20058,514.44166678],[20089,524.93202587],[20120,518.7237031],[20148,498.50596568],[20179,503.95543687],[20209,514.7854238],[20240,510.64604259],[20270,519.52425717],[20301,540.6902882],[20332,538.08170619],[20362,548.88203573],[20393,529.54541923],[20423,512.73342844],[20454,507.33752717],[20485,502.33189186],[20513,497.99284356],[20544,478.2114781],[20574,463.28803093],[20605,485.49112657],[20635,478.39817276],[20666,473.8843649],[20697,461.61105507]],"LAK":[[19784,20864.6187643],[19814,21105.01057976],[19844,21260.22450945],[19875,21491.09783553],[19905,22139.43940056],[19936,22204.05041302],[19967,22183.59088192],[19997,22112.91942856],[20028,21922.29137092],[20058,21811.06497992],[20089,21874.93663283],[20120,21753.32536239],[20148,21719.00550755],[20179,21574.03989142],[20209,21718.77180116],[20240,21417.66613201],[20270,21541.88802739],[20301,21549.7908066],[20332,21632.72570144],[20362,21636.14856338],[20393,21726.75007727],[20423,21634.18903225],[20454,21615.92324372],[20485,21428.5505622],[20513,21383.19777094],[20544,21924.2696639],[20574,21977.48429778],[20605,21981.61322612],[20635,22432.73894136],[20666,22602.09753595],[20697,22429.62623994]],"LBP":[[19784,89910.13653378],[19814,89512.6361513],[19844,92333.24392599],[19875,89725.99170815],[19905,90875.77044108],[19936,90373.92294996],[19967,89550.00060295],[19997,91525.21800732],[20028,89308.30065362],[20058,90229.5450644],[20089,89748.52902458],[20120,89574.19921991],[20148,89483.06718741],[20179,89322.76734763],[20209,90181.46593199],[20240,90033.80904104],[20270,89844.78765984],[20301,89921.79528791],[20332,89427.85396485],[20362,89338.68344251],[20393,89928.86485687],[20423,89322.34694886],[20454,89993.28564116],[20485,90107.18229058],[20513,90471.68496434],[20544,89614.910896],[20574,89549.98172882],[20605,89640.36341118],[20635,89769.30394644],[20666,89136.98947314],[20697,89548.83252476]],"LINK":[[19784,0.049587075],[19814,0.052201383],[19844,0.07670799],[19875,0.054513972],[19905,0.06967332],[19936,0.077990359],[19967,0.091225796],[19997,0.083555391],[20028,0.087606937],[20058,0.053464658],[20089,0.049882365],[20120,0.038948034],[20148,0.067489198],[20179,0.073720302],[20209,0.068734481],[20240,0.072414671],[20270,0.075255806],[20301,0.058969795],[20332,0.042998419],[20362,0.046457572],[20393,0.05866244],[20423,0.080973506],[20454,0.081600824],[20485,0.09948975],[20513,0.10930742],[20544,0.11380816],[20574,0.10902725],[20605,0.1101498],[20635,0.13730142],[20666,0.1218788],[20697,0.087246166]],"LKR":[[19784,308.41522207],[19814,300.32385611],[19844,296.66377146],[19875,301.01675666],[19905,304.98860627],[19936,302.6626466],[19967,299.19009125],[19997,296.19311905],[20028,292.98732402],[20058,290.84015304],[20089,293.16525245],[20120,297.893817],[20148,295.09332604],[20179,294.85577392],[20209,299.24942799],[20240,299.31738269],[20270,299.81098847],[20301,302.09415674],[20332,301.25793493],[20362,302.73662723],[20393,304.77881065],[20423,307.12074947],[20454,309.84864292],[20485,309.27946296],[20513,309.18828573],[20544,315.33937105],[20574,319.67105237],[20605,329.8292012],[20635,335.76187003],[20666,334.68422716],[20697,327.91696845]],"LRD":[[19784,191.92508099],[19814,192.7030255],[19844,193.4712512],[19875,193.82853467],[19905,193.96337084],[19936,196.16044257],[19967,195.30786468],[19997,193.91021559],[20028,191.99758841],[20058,179.44408692],[20089,183.92216858],[20120,198.80708377],[20148,198.79730816],[20179,199.83873946],[20209,200.29606639],[20240,198.70081969],[20270,200.0264715],[20301,200.70072221],[20332,200.34172341],[20362,181.40210651],[20393,183.49970737],[20423,177.76128288],[20454,178.77541178],[20485,185.44240387],[20513,183.56777985],[20544,183.6914986],[20574,183.84996672],[20605,181.84419952],[20635,181.32890931],[20666,179.95090926],[20697,180.61308767]],"LSL":[[19784,19.11786164],[19814,18.88028536],[19844,18.79676962],[19875,18.79855068],[19905,18.07466649],[19936,18.18753629],[19967,17.82219656],[19997,17.26884917],[20028,17.6159842],[20058,18.06301279],[20089,18.89291344],[20120,18.67081622],[20148,18.68908039],[20179,18.31765661],[20209,18.60919794],[20240,17.97832883],[20270,17.70523005],[20301,18.22630205],[20332,17.64317262],[20362,17.26625527],[20393,17.34998912],[20423,17.11785542],[20454,16.52612523],[20485,16.16344066],[20513,15.89527656],[20544,16.87826503],[20574,16.66017574],[20605,16.23173682],[20635,16.42712626],[20666,16.54463648],[20697,16.11424738]],"LTC":[[19784,0.0114616],[19814,0.0093270994],[19844,0.012642495],[19875,0.012045804],[19905,0.013203884],[19936,0.014106675],[19967,0.015541032],[19997,0.014772723],[20028,0.014432481],[20058,0.0098378626],[20089,0.0095081936],[20120,0.007848318],[20148,0.0078558742],[20179,0.012038806],[20209,0.011802244],[20240,0.011551905],[20270,0.011540992],[20301,0.0093754145],[20332,0.0091931339],[20362,0.009337781],[20393,0.010518509],[20423,0.012701958],[20454,0.012952366],[20485,0.016728777],[20513,0.018029789],[20544,0.018518323],[20574,0.018017732],[20605,0.01938072],[20635,0.02340694],[20666,0.022497959],[20697,0.020449047]],"LYD":[[19784,4.83832948],[19814,4.83610127],[19844,4.89889986],[19875,4.85835394],[19905,4.870105],[19936,4.83385888],[19967,4.75929978],[19997,4.73880631],[20028,4.82010584],[20058,4.87650015],[20089,4.92026719],[20120,4.90741972],[20148,4.87998129],[20179,4.80991802],[20209,5.46934701],[20240,5.47802541],[20270,5.40716125],[20301,5.42109075],[20332,5.4087304],[20362,5.41006543],[20393,5.43997713],[20423,5.43440435],[20454,5.41943697],[20485,6.27599022],[20513,6.31421365],[20544,6.40017835],[20574,6.35499939],[20605,6.35021636],[20635,6.42016558],[20666,6.37885337],[20697,6.3480922]],"MAD":[[19784,10.07933044],[19814,10.08937345],[19844,10.12324008],[19875,9.95715799],[19905,9.96628905],[19936,9.92237646],[19967,9.77036527],[19997,9.72132731],[20028,9.83177081],[20058,9.98985537],[20089,10.11807676],[20120,10.0344412],[20148,9.96129522],[20179,9.62948425],[20209,9.27657926],[20240,9.21993237],[20270,8.99704513],[20301,9.15731505],[20332,9.01486746],[20362,9.09266487],[20393,9.2994369],[20423,9.26360061],[20454,9.11813986],[20485,9.10761999],[20513,9.14858231],[20544,9.33123515],[20574,9.2340483],[20605,9.17990833],[20635,9.39964636],[20666,9.32745733],[20697,9.30073826]],"MDL":[[19784,17.76314825],[19814,17.63291409],[19844,17.75563749],[19875,17.69796301],[19905,17.91435652],[19936,17.73221266],[19967,17.26000412],[19997,17.42451236],[20028,17.91797146],[20058,18.210999],[20089,18.42392061],[20120,18.6482402],[20148,18.63900476],[20179,17.97770586],[20209,17.16679204],[20240,17.27006582],[20270,16.85786908],[20301,16.95745762],[20332,16.62048826],[20362,16.70366152],[20393,17.03075519],[20423,16.91744265],[20454,16.81309877],[20485,16.80202971],[20513,17.09202194],[20544,17.69475824],[20574,17.23350034],[20605,17.30885421],[20635,17.65625434],[20666,17.41987768],[20697,17.13262622]],"MGA":[[19784,4529.85309477],[19814,4366.29829014],[19844,4464.52370496],[19875,4441.29529469],[19905,4476.25850101],[19936,4556.00279192],[19967,4563.32348959],[19997,4544.14285942],[20028,4613.57823994],[20058,4692.80782021],[20089,4714.26971947],[20120,4648.79490411],[20148,4722.69982317],[20179,4648.20575148],[20209,4520.54904427],[20240,4547.30831795],[20270,4378.23044966],[20301,4518.72540112],[20332,4396.50136785],[20362,4446.62512492],[20393,4504.04009261],[20423,4476.50173303],[20454,4590.95904345],[20485,4448.30506788],[20513,4206.49127042],[20544,4180.6705342],[20574,4154.99896969],[20605,4189.72114615],[20635,4238.65568227],[20666,4266.54131931],[20697,4319.91993623]],"MKD":[[19784,56.95041343],[19814,57.09453964],[19844,57.43510891],[19875,56.74325085],[19905,57.42261521],[19936,56.93580914],[19967,55.55000846],[19997,55.07522328],[20028,56.65295675],[20058,58.16000037],[20089,59.14456645],[20120,59.34029884],[20148,59.3037983],[20179,56.87524533],[20209,54.27284635],[20240,54.20997918],[20270,52.23891355],[20301,53.23157964],[20332,52.62688912],[20362,52.50619127],[20393,53.36153537],[20423,53.01448172],[20454,52.44010279],[20485,51.80102379],[20513,52.15570967],[20544,53.29219123],[20574,52.53379017],[20605,52.94367656],[20635,54.05004079],[20666,53.32944585],[20697,53.00082986]],"MMK":[[19784,2098.87052923],[19814,2099.10346281],[19844,2102.11680662],[19875,2099.92451732],[19905,2097.89529563],[19936,2098.18140494],[19967,2096.03411558],[19997,2097.40797729],[20028,2098.26244663],[20058,2099.76009935],[20089,2098.76214457],[20120,2098.82418231],[20148,2098.68461215],[20179,2099.45677561],[20209,2099.33999144],[20240,2099.45755146],[20270,2099.28679966],[20301,2098.69816882],[20332,2099.23864838],[20362,2099.36322913],[20393,2099.70067255],[20423,2100.0653245],[20454,2099.08727521],[20485,2099.91600141],[20513,2100.12810296],[20544,2099.52693738],[20574,2099.68207899],[20605,2099.3912736],[20635,2099.38626398],[20666,2100.42661484],[20697,2099.92228476]],"MNT":[[19784,3380.13233183],[19814,0.7659098],[19844,3397.22939515],[19875,3386.80177694],[19905,3397.96263307],[19936,3401.51210098],[19967,3384.41664694],[19997,3400.3845368],[20028,3414.48432038],[20058,3421.7639366],[20089,3434.68896807],[20120,3450.10736712],[20148,3470.39494633],[20179,3480.2802228],[20209,3572.31652474],[20240,3574.66205291],[20270,3583.43519362],[20301,3590.6392746],[20332,3595.59126278],[20362,3594.84670311],[20393,0.69372624],[20423,3559.33364324],[20454,3560.25425839],[20485,3567.60907861],[20513,3566.03324906],[20544,3573.15652343],[20574,3578.09903676],[20605,3577.48649829],[20635,3582.61347118],[20666,3598.1034161],[20697,3597.80404101]],"MOP":[[19784,8.06378793],[19814,8.057359],[19844,8.05755639],[19875,8.05463637],[19905,8.04326272],[19936,8.04812898],[19967,8.03139777],[19997,8.00619149],[20028,8.00750859],[20058,8.01535237],[20089,8.00202631],[20120,8.02758289],[20148,8.01197072],[20179,8.01438531],[20209,7.98924686],[20240,8.07605524],[20270,8.08543655],[20301,8.08547672],[20332,8.02916293],[20362,8.01551695],[20393,8.00367027],[20423,8.02095503],[20454,8.01441831],[20485,8.04539639],[20513,8.05794403],[20544,8.07369585],[20574,8.07084512],[20605,8.07253602],[20635,8.07921633],[20666,8.0771414],[20697,8.07436841]],"MRO":[[19784,399.23448304],[19814,377.7335],[19844,396.81810751],[19875,395.36344895],[19905,395.33494854],[19936,396.56647362],[19967,398.5047798],[19997,396.35933194],[20028,397.90050085],[20058,399.15398994],[20089,398.13321632],[20120,399.62135387],[20148,400.37006755],[20179,396.49864631],[20209,396.80114163],[20240,397.21857962],[20270,398.08157957],[20301,398.43425479],[20332,400.7246046],[20362,398.98170891],[20393,399.9986881],[20423,399.10109715],[20454,397.71319088],[20485,399.52576867],[20513,399.22806168],[20544,399.70765005],[20605,400.1187462],[20635,399.22300097],[20666,400.6788548],[20697,399.47306677]],"MRU":[[19784,39.9234483],[19814,40.06991129],[19844,39.68181075],[19875,39.5363449],[19905,39.53349485],[19936,39.65664736],[19967,39.85047798],[19997,39.63593319],[20028,39.79005008],[20058,39.91539899],[20089,39.81332163],[20120,39.96213539],[20148,40.03700676],[20179,39.64986463],[20209,39.68011416],[20240,39.72185796],[20270,39.80815796],[20301,39.84342548],[20332,40.07246046],[20362,39.89817089],[20393,39.99986881],[20423,39.91010972],[20454,39.77131909],[20485,39.95257687],[20513,39.92280617],[20544,39.970765],[20574,39.98966366],[20605,40.01187462],[20635,39.9223001],[20666,40.06788548],[20697,39.94730668]],"MUR":[[19784,45.77312922],[19814,46.36989044],[19844,46.36083774],[19875,46.06241175],[19905,47.16213634],[19936,46.62441505],[19967,46.5292785],[19997,45.87426856],[20028,46.09638574],[20058,46.42978273],[20089,46.77312685],[20120,46.64938593],[20148,46.73274728],[20179,45.62438494],[20209,45.12520747],[20240,45.66000858],[20270,45.02818578],[20301,46.67371329],[20332,45.89740182],[20362,45.63448216],[20393,45.7930372],[20423,46.15911055],[20454,46.23295976],[20485,45.41224819],[20513,46.36316528],[20544,47.09404641],[20574,47.04017102],[20605,47.34647252],[20635,47.18907685],[20666,46.98168962],[20697,47.09126963]],"MVR":[[19784,15.40508106],[19814,15.45011929],[19844,15.42055197],[19875,15.40779127],[19905,15.42641796],[19936,15.41432065],[19967,15.45999497],[19997,15.39849311],[20028,15.43401531],[20058,15.46000003],[20089,15.43687575],[20120,15.41019935],[20148,15.38890445],[20179,15.44360822],[20209,15.43787632],[20240,15.45999654],[20270,15.4432165],[20301,15.43765091],[20332,15.44186763],[20362,15.41572156],[20393,15.29981974],[20423,15.45999817],[20454,15.45901959],[20485,15.4596289],[20513,15.45897848],[20544,15.45959509],[20574,15.45497953],[20605,15.44455897],[20635,15.45938347],[20666,15.45852543],[20697,15.46235534]],"MWK":[[19784,1682.17359841],[19814,1732.6198096],[19844,1739.19843678],[19875,1732.87215099],[19905,1731.81475502],[19936,1736.12550288],[19967,1733.28494535],[19997,1737.25481633],[20028,1734.19233751],[20058,1733.38765873],[20089,1735.56270967],[20120,1733.63838995],[20148,1734.24741394],[20179,1730.5995788],[20209,1737.04365686],[20240,1733.46773703],[20270,1734.10770705],[20301,1735.36556995],[20332,1730.80383955],[20362,1736.20791441],[20393,1736.22525629],[20423,1728.54920911],[20454,1735.29183159],[20485,1734.12782933],[20513,1735.08123999],[20544,1735.80867115],[20574,1741.50152842],[20605,1735.51424258],[20635,1733.0731034],[20666,1728.1181799],[20697,1732.72124718]],"MXN":[[19784,17.02103046],[19814,16.55849628],[19844,17.13979861],[19875,16.99092283],[19905,18.27566741],[19936,18.67703363],[19967,19.72518691],[19997,19.66756058],[20028,20.05276162],[20058,20.37477464],[20089,20.86505564],[20120,20.67537254],[20148,20.52799496],[20179,20.47674379],[20209,19.61760371],[20240,19.42866978],[20270,18.75841158],[20301,18.88200399],[20332,18.65368813],[20362,18.31420644],[20393,18.5775133],[20423,18.29620559],[20454,18.01180009],[20485,17.4655279],[20513,17.23368425],[20544,17.90099226],[20574,17.46598996],[20605,17.34137621],[20635,17.53185557],[20666,17.34480518],[20697,17.00821939]],"MYR":[[19784,4.74518651],[19814,4.72403864],[19844,4.76779109],[19875,4.70646066],[19905,4.71719561],[19936,4.55779757],[19967,4.31992498],[19997,4.14381734],[20028,4.37261705],[20058,4.44472213],[20089,4.4715081],[20120,4.4549961],[20148,4.45529518],[20179,4.43673016],[20209,4.31521608],[20240,4.25672702],[20270,4.18811117],[20301,4.27993454],[20332,4.22491742],[20362,4.20711313],[20393,4.1934007],[20423,4.1311617],[20454,4.05549869],[20485,3.94211105],[20513,3.89224185],[20544,4.03239167],[20574,3.96992755],[20605,3.96547883],[20635,4.09301511],[20666,4.08543314],[20697,4.03563711]],"MZN":[[19784,63.75783949],[19814,63.51150117],[19844,63.84898668],[19875,63.6434049],[19905,63.62716284],[19936,63.90449153],[19967,63.77095289],[19997,63.9052685],[20028,63.90486249],[20058,63.68519222],[20089,63.89212286],[20120,63.58445483],[20148,63.58211162],[20179,63.88702871],[20209,63.84107164],[20240,63.71667253],[20270,63.82484549],[20301,63.88511479],[20332,63.88247129],[20362,63.90036566],[20393,63.90980122],[20423,63.90675246],[20454,63.84830649],[20485,63.73203426],[20513,63.9111603],[20544,63.80851657],[20574,63.90499074],[20605,63.80144581],[20635,63.86750374],[20666,63.82093626],[20697,63.82361409]],"NAD":[[19784,19.11786164],[19814,18.87990828],[19844,18.79676962],[19875,18.79855068],[19905,18.07466649],[19936,18.18753629],[19967,17.82219656],[19997,17.26884917],[20028,17.6159842],[20058,18.06301279],[20089,18.89291344],[20120,18.67081622],[20148,18.68908039],[20179,18.31765661],[20209,18.60919794],[20240,17.97832883],[20270,17.70523005],[20301,18.22630205],[20332,17.64317262],[20362,17.26625527],[20393,17.34998912],[20423,17.11785542],[20454,16.52612523],[20485,16.16344066],[20513,15.89527656],[20544,16.87826503],[20574,16.65957494],[20605,16.23173682],[20635,16.42712626],[20666,16.54463648],[20697,16.11424738]],"NGN":[[19784,1571.19506675],[19814,1304.34015704],[19844,1390.22154372],[19875,1485.82354857],[19905,1527.56924376],[19936,1660.64215271],[19967,1589.17745067],[19997,1670.69452442],[20028,1643.40073884],[20058,1673.9448032],[20089,1543.93590456],[20120,1502.74841713],[20148,1499.51700553],[20179,1534.7576852],[20209,1606.13347087],[20240,1585.13891191],[20270,1536.15052245],[20301,1530.34109732],[20332,1531.40182721],[20362,1484.76300699],[20393,1425.35183495],[20423,1442.53340632],[20454,1445.79286662],[20485,1384.36751788],[20513,1363.01646683],[20544,1383.07653552],[20574,1374.68941388],[20605,1372.75895425],[20635,1379.79676656],[20666,1363.19985525],[20697,1338.15261871]],"NIO":[[19784,36.73409628],[19814,36.78770157],[19844,36.97692503],[19875,36.76647726],[19905,36.79521311],[19936,36.834458],[19967,36.62449669],[19997,36.79756418],[20028,36.79037791],[20058,36.62440359],[20089,36.81166306],[20120,36.78074206],[20148,36.79678267],[20179,36.72296899],[20209,36.81960202],[20240,36.62445039],[20270,36.73019841],[20301,36.79767193],[20332,36.76204249],[20362,36.8476979],[20393,36.74847816],[20423,36.75751239],[20454,36.8090423],[20485,36.69875024],[20513,36.80703504],[20544,36.76701904],[20574,36.7196704],[20605,36.80145418],[20635,36.78857936],[20666,36.69009884],[20697,36.7992356]],"NOK":[[19784,10.54887219],[19814,10.82768953],[19844,11.12161014],[19875,10.49651569],[19905,10.65165989],[19936,10.89796588],[19967,10.54818543],[19997,10.53267263],[20028,11.00788299],[20058,11.04361232],[20089,11.38544521],[20120,11.3253056],[20148,11.25190901],[20179,10.50716678],[20209,10.41491588],[20240,10.21235816],[20270,10.09211775],[20301,10.33825918],[20332,10.05616881],[20362,9.99508019],[20393,10.12318324],[20423,10.12271695],[20454,10.09120304],[20485,9.63878912],[20513,9.50981862],[20544,9.67404447],[20574,9.26669354],[20605,9.24828468],[20635,9.93314339],[20666,9.48624435],[20697,9.35289727]],"NPR":[[19784,132.61944348],[19814,133.31634401],[19844,133.54940647],[19875,133.57450001],[19905,133.44979107],[19936,133.97096022],[19967,134.32242273],[19997,134.13156116],[20028,134.61309509],[20058,135.36690844],[20089,136.98674296],[20120,138.77511898],[20148,140.02029176],[20179,136.83345346],[20209,135.38636815],[20240,136.9122583],[20270,137.13615059],[20301,140.25564527],[20332,141.13989502],[20362,142.19105683],[20393,142.16996585],[20423,143.01994441],[20454,143.87381715],[20485,146.74541899],[20513,145.79742462],[20544,149.82319845],[20574,151.80356697],[20605,151.80764751],[20635,151.6176585],[20666,152.74350322],[20697,151.92503381]],"NZD":[[19784,1.6377514],[19814,1.66926459],[19844,1.69859764],[19875,1.62753477],[19905,1.63869833],[19936,1.67723666],[19967,1.60015082],[19997,1.57641322],[20028,1.6757353],[20058,1.69435918],[20089,1.78721654],[20120,1.77398138],[20148,1.78563472],[20179,1.76336521],[20209,1.68478464],[20240,1.67839373],[20270,1.64428759],[20301,1.70074804],[20332,1.6924377],[20362,1.72661613],[20393,1.74685287],[20423,1.74468825],[20454,1.73973367],[20485,1.66073494],[20513,1.66757708],[20544,1.7436121],[20574,1.69309454],[20605,1.67521069],[20635,1.76443375],[20666,1.70117751],[20697,1.69318579]],"OMR":[[19784,0.38498232],[19814,0.38490538],[19844,0.38504952],[19875,0.38465893],[19905,0.38500487],[19936,0.38497258],[19967,0.38504652],[19997,0.3849729],[20028,0.38502519],[20058,0.38504744],[20089,0.38499497],[20120,0.38504788],[20148,0.38499979],[20179,0.38495594],[20209,0.38503682],[20240,0.38489843],[20270,0.38491683],[20301,0.38497613],[20332,0.38493512],[20362,0.38479765],[20393,0.3845254],[20423,0.3847261],[20454,0.38477257],[20485,0.38505669],[20513,0.38514864],[20544,0.38486311],[20574,0.38449916],[20605,0.38499855],[20635,0.3849083],[20666,0.38463639],[20697,0.38486059]],"PAB":[[19784,1.0],[19814,0.99955991],[19844,1.0],[19875,1.0],[19905,1.0],[19936,1.0],[19967,1.0],[19997,1.0],[20028,1.0],[20058,1.0],[20089,1.0],[20120,1.0],[20148,1.0],[20179,1.0],[20209,1.0],[20240,1.0],[20270,1.0],[20301,1.0],[20332,1.0],[20362,1.0],[20393,1.0],[20423,1.0],[20454,1.0],[20485,1.0],[20513,1.0],[20544,1.0],[20574,1.00020027],[20605,1.0],[20635,1.0],[20666,1.0],[20697,1.0]],"PEN":[[19784,3.77927877],[19814,3.71737706],[19844,3.76859323],[19875,3.7496889],[19905,3.84279582],[19936,3.73755798],[19967,3.74894698],[19997,3.70671481],[20028,3.77280135],[20058,3.74489878],[20089,3.76019287],[20120,3.7187742],[20148,3.68133896],[20179,3.6660098],[20209,3.67084912],[20240,3.61939358],[20270,3.54239702],[20301,3.58155375],[20332,3.53160226],[20362,3.47010636],[20393,3.38794828],[20423,3.35681389],[20454,3.36101695],[20485,3.36768872],[20513,3.35472193],[20544,3.48736554],[20574,3.50770006],[20605,3.39985543],[20635,3.41549791],[20666,3.38954467],[20697,3.36352253]],"PGK":[[19784,3.77322698],[19814,3.82519723],[19844,3.80910024],[19875,3.83437947],[19905,3.84615432],[19936,3.87898758],[19967,3.91008988],[19997,3.91849415],[20028,3.9718459],[20058,4.03348013],[20089,3.97607242],[20120,4.01071554],[20148,4.01637809],[20179,4.08667488],[20209,4.13060197],[20240,4.10001393],[20270,4.1272868],[20301,4.20828309],[20332,4.19722627],[20362,4.24737119],[20393,4.24579399],[20423,4.28333392],[20454,4.26241006],[20485,4.23496815],[20513,4.28409986],[20544,4.33631932],[20574,4.34016514],[20605,4.36700618],[20635,4.38903821],[20666,4.41776632],[20697,4.42514048]],"PHP":[[19784,56.05443945],[19814,56.14397702],[19844,57.81951984],[19875,58.5367079],[19905,58.47516147],[19936,58.30033501],[19967,56.23914351],[19997,56.16593917],[20028,58.30205738],[20058,58.62332142],[20089,58.08534191],[20120,58.42138513],[20148,57.98566661],[20179,57.25999429],[20209,55.81626875],[20240,55.77963639],[20270,56.20521353],[20301,58.4618544],[20332,57.29256712],[20362,58.25961054],[20393,58.69100821],[20423,58.61962774],[20454,58.90768264],[20485,58.90590241],[20513,57.69583923],[20544,60.32015637],[20574,61.26448804],[20605,61.72368401],[20635,61.56224613],[20666,61.24461311],[20697,62.4123964]],"PKR":[[19784,279.4454091],[19814,277.83408334],[19844,278.39252244],[19875,278.41899788],[19905,278.06606112],[19936,278.89329402],[19967,278.46948649],[19997,279.11410661],[20028,277.76946795],[20058,277.94609098],[20089,278.37813794],[20120,278.82897442],[20148,279.59761185],[20179,279.45877897],[20209,281.69805288],[20240,281.86541057],[20270,283.72545195],[20301,283.51003567],[20332,283.4628271],[20362,282.66046764],[20393,280.98430698],[20423,281.99483731],[20454,280.20999084],[20485,279.6603334],[20513,279.54390987],[20544,279.15894522],[20574,278.75020389],[20605,278.90130467],[20635,277.96706591],[20666,276.89015263],[20697,277.55251427]],"PLN":[[19784,3.98595583],[19814,3.98006161],[19844,4.06201893],[19875,3.93826349],[19905,4.01607483],[19936,3.95978671],[19967,3.87612194],[19997,3.84446481],[20028,4.00172722],[20058,4.0620927],[20089,4.13224024],[20120,4.06582564],[20148,4.04270652],[20179,3.86971948],[20209,3.7877192],[20240,3.74503617],[20270,3.60178395],[20301,3.74754857],[20332,3.64413376],[20362,3.63408692],[20393,3.68754983],[20423,3.65176714],[20454,3.5897913],[20485,3.55512209],[20513,3.57437502],[20544,3.7069285],[20574,3.62654451],[20605,3.63304754],[20635,3.77144721],[20666,3.73729467],[20697,3.73013317]],"PYG":[[19784,7302.00414178],[19814,7390.83089153],[19844,7458.89981234],[19875,7548.82330625],[19905,7551.95809044],[19936,7564.93772773],[19967,7685.96609133],[19997,7799.16679365],[20028,8005.40856684],[20058,7819.11500044],[20089,7812.46550314],[20120,7887.15169632],[20148,7893.53388596],[20179,7934.76276721],[20209,8002.18249508],[20240,7996.32888607],[20270,7961.55290167],[20301,7606.4523303],[20332,7212.33914617],[20362,7087.17801312],[20393,7086.3382673],[20423,6970.81186752],[20454,6749.06209896],[20485,6699.73180333],[20513,6440.98753592],[20544,6512.27529456],[20574,6151.62503611],[20605,6133.13705752],[20635,6073.68466492],[20666,5942.44231053],[20697,5912.74789233]],"QAR":[[19784,3.64],[19814,3.64103676],[19844,3.64],[19875,3.64],[19905,3.64],[19936,3.64],[19967,3.64],[19997,3.64],[20028,3.64],[20058,3.64],[20089,3.64],[20120,3.64],[20148,3.64],[20179,3.64],[20209,3.64],[20240,3.64],[20270,3.64],[20301,3.64],[20332,3.64],[20362,3.64],[20393,3.64],[20423,3.64],[20454,3.64],[20485,3.64],[20513,3.64],[20544,3.64],[20574,3.64374873],[20605,3.64],[20635,3.64],[20666,3.64],[20697,3.64]],"RON":[[19784,4.58511279],[19814,4.60609733],[19844,4.66648405],[19875,4.58832757],[19905,4.62619672],[19936,4.59333926],[19967,4.50388787],[19997,4.46628584],[20028,4.57251765],[20058,4.70520941],[20089,4.80683177],[20120,4.79956656],[20148,4.79626162],[20179,4.60019183],[20209,4.4024646],[20240,4.45971077],[20270,4.31035789],[20301,4.44707221],[20332,4.33748625],[20362,4.32874125],[20393,4.40919597],[20423,4.38969262],[20454,4.33899569],[20485,4.2961484],[20513,4.31374157],[20544,4.40801036],[20574,4.4325019],[20605,4.50907076],[20635,4.59458671],[20666,4.54889397],[20697,4.53210593]],"RSD":[[19784,108.18206644],[19814,108.5532921],[19844,109.82458314],[19875,107.99121387],[19905,108.91657607],[19936,108.0265623],[19967,105.87884679],[19997,105.04588041],[20028,107.57825035],[20058,110.66307468],[20089,113.07922148],[20120,113.04240792],[20148,112.91537023],[20179,108.29439485],[20209,103.65497607],[20240,103.34225893],[20270,99.43418808],[20301,102.66297111],[20332,100.18914115],[20362,99.82056204],[20393,101.62984958],[20423,101.15131704],[20454,99.87753121],[20485,98.98776578],[20513,99.4210984],[20544,101.6033218],[20574,100.07699545],[20605,100.70054466],[20635,102.90818467],[20666,102.0471191],[20697,101.13940494]],"RUB":[[19784,92.08863075],[19814,92.58496838],[19844,91.07547715],[19875,90.39277051],[19905,88.14164673],[19936,86.32769887],[19967,90.68420459],[19997,92.89851937],[20028,97.37418908],[20058,107.65429094],[20089,113.64737908],[20120,98.5697192],[20148,89.3404429],[20179,83.76306017],[20209,82.07031891],[20240,77.51795711],[20270,78.24384065],[20301,80.79084798],[20332,80.43668384],[20362,82.59315531],[20393,80.56925085],[20423,77.63827009],[20454,78.98726486],[20485,75.98876521],[20513,77.27011296],[20544,81.3027102],[20574,74.9291242],[20605,71.24340767],[20635,78.69972464],[20666,79.31474744],[20697,86.31931694]],"RWF":[[19784,1285.68699915],[19814,1285.42635536],[19844,1298.87286211],[19875,1299.4357451],[19905,1323.90000013],[19936,1312.56036681],[19967,1329.88728519],[19997,1359.04657122],[20028,1359.83864754],[20058,1368.23954352],[20089,1391.9335242],[20120,1418.7056304],[20148,1407.4743079],[20179,1417.65865709],[20209,1420.68438247],[20240,1414.32789398],[20270,1435.12630889],[20301,1441.01777584],[20332,1444.97949298],[20362,1450.68356846],[20393,1452.33302619],[20423,1452.33903841],[20454,1457.93307878],[20485,1455.40360958],[20513,1458.37547239],[20544,1460.61237163],[20574,1461.99970599],[20605,1465.81456957],[20635,1465.84248004],[20666,1465.02999426],[20697,1472.6287631]],"SAR":[[19784,3.75],[19814,3.75049962],[19844,3.75],[19875,3.75],[19905,3.75],[19936,3.75],[19967,3.75],[19997,3.75],[20028,3.75],[20058,3.75],[20089,3.75],[20120,3.75],[20148,3.75],[20179,3.75],[20209,3.75],[20240,3.75],[20270,3.75],[20301,3.75],[20332,3.75],[20362,3.75],[20393,3.75],[20423,3.75],[20454,3.75],[20485,3.75],[20513,3.75],[20544,3.75],[20574,3.75022945],[20605,3.75],[20635,3.75],[20666,3.75],[20697,3.75]],"SBD":[[19784,8.46923473],[19814,8.47594561],[19844,8.30143466],[19875,8.23044443],[19905,8.27228558],[19936,8.33645572],[19967,8.23045243],[19997,8.30892116],[20028,8.37171879],[20058,8.37520387],[20089,8.40641777],[20120,8.45376663],[20148,8.43217303],[20179,8.42002113],[20209,8.45099649],[20240,8.49979583],[20270,8.4525422],[20301,8.40130251],[20332,8.3923281],[20362,8.40504108],[20393,8.49978251],[20423,8.14374043],[20454,8.13497756],[20485,8.05151289],[20513,8.04507801],[20544,8.02325508],[20574,8.04858138],[20605,8.00326351],[20635,8.07582511],[20666,8.08066304],[20697,8.0025338]],"SCR":[[19784,13.89411243],[19814,13.51302712],[19844,13.85557829],[19875,14.02432284],[19905,13.98810752],[19936,13.35613058],[19967,14.70770861],[19997,13.68960312],[20028,13.60939843],[20058,14.98646658],[20089,14.24407547],[20120,14.34635265],[20148,14.5965846],[20179,14.4770327],[20209,14.33014962],[20240,14.80520595],[20270,14.72956984],[20301,14.72283427],[20332,14.83526645],[20362,14.95504589],[20393,14.64986569],[20423,14.08009024],[20454,15.12843602],[20485,14.9074198],[20513,14.69428451],[20544,14.85659732],[20574,14.62498413],[20605,14.25342601],[20635,14.05238156],[20666,13.50362445],[20697,14.18299065]],"SDG":[[19784,600.63084911],[19814,600.99999815],[19844,586.08210898],[19875,600.55940314],[19905,600.85982511],[19936,601.48891034],[19967,598.96264655],[19997,601.58267135],[20028,601.48064941],[20058,598.86250635],[20089,601.40524282],[20120,600.94273567],[20148,600.69954717],[20179,600.48053714],[20209,600.49605496],[20240,598.91558459],[20270,600.21267814],[20301,600.36434942],[20332,600.43487518],[20362,600.46731309],[20393,600.29322878],[20423,598.53874054],[20454,599.81663184],[20485,599.5381708],[20513,600.21280609],[20544,565.31893943],[20574,600.5022954],[20605,600.22703888],[20635,600.17720636],[20666,600.19468966],[20697,600.12256851]],"SEK":[[19784,10.34792958],[19814,10.67363491],[19844,11.02085261],[19875,10.53869117],[19905,10.55763557],[19936,10.69270647],[19967,10.30241301],[19997,10.15371627],[20028,10.65266224],[20058,10.90237976],[20089,11.06789363],[20120,11.0965305],[20148,10.75993329],[20179,10.03728909],[20209,9.67372015],[20240,9.59062283],[20270,9.46474309],[20301,9.80220467],[20332,9.45304286],[20362,9.41958683],[20393,9.49292322],[20423,9.45208832],[20454,9.21146653],[20485,8.92102539],[20513,9.03058102],[20544,9.45486042],[20574,9.23624783],[20605,9.24921515],[20635,9.73026869],[20666,9.5220761],[20697,9.59628145]],"SGD":[[19784,1.34438059],[19814,1.34793042],[19844,1.36516636],[19875,1.3527777],[19905,1.35459224],[19936,1.33464106],[19967,1.3066498],[19997,1.28435172],[20028,1.32148096],[20058,1.33931333],[20089,1.36578209],[20120,1.35718755],[20148,1.35123775],[20179,1.34352299],[20209,1.30789452],[20240,1.29135794],[20270,1.27215806],[20301,1.2986822],[20332,1.28358174],[20362,1.29045016],[20393,1.30089978],[20423,1.29572264],[20454,1.2855248],[20485,1.27047846],[20513,1.26467337],[20544,1.28534802],[20574,1.2729146],[20605,1.27766616],[20635,1.29659402],[20666,1.28208222],[20697,1.27192132]],"SHIB":[[19784,49883.91674036],[19814,32896.61162732],[19844,46165.27300344],[19875,39554.31623638],[19905,57178.66383869],[19936,62749.07064331],[19967,72749.36133799],[19997,54649.84704861],[20028,56356.30615855],[20058,34718.22105099],[20089,47162.34137907],[20120,52140.31675387],[20148,71611.76141826],[20179,79737.01529119],[20209,74648.01958807],[20240,78674.26207217],[20270,87523.25923052],[20301,80513.91589463],[20332,82431.22462123],[20362,84205.92908029],[20393,100369.69577046],[20423,124231.17003272],[20454,144081.66541467],[20485,146460.4499787],[20513,168259.59780402],[20544,168899.04830538],[20574,157126.16355912],[20605,182254.32226278],[20635,235773.41866919],[20666,202604.65059592],[20697,194331.947895]],"SHP":[[19784,0.79023841],[19814,1.26214995],[19844,0.80075643],[19875,0.78494269],[19905,0.789604],[19936,0.77761732],[19967,0.76156108],[19997,0.7469564],[20028,0.77568246],[20058,0.7859034],[20089,0.79908761],[20120,0.80702838],[20148,0.79492402],[20179,0.77389163],[20209,0.75150096],[20240,0.74256646],[20270,0.7283443],[20301,0.75788905],[20332,0.73970156],[20362,0.74394029],[20393,0.76017498],[20423,0.75601488],[20454,0.74206457],[20485,0.73045243],[20513,0.74158609],[20544,0.75540895],[20574,0.74660079],[20605,0.74315661],[20635,0.75564998],[20666,0.74188341],[20697,0.73861768]],"SLE":[[19784,22.7438052],[19814,22.69006648],[19844,22.74921087],[19875,22.50001588],[19905,22.47681325],[19936,22.49828831],[19967,22.5550239],[19997,22.58444401],[20028,22.85639047],[20058,22.75993108],[20089,22.75117528],[20120,22.72287632],[20148,22.84989623],[20179,22.78290884],[20209,22.78997221],[20240,22.79506964],[20270,22.76168319],[20301,22.69488109],[20332,22.74492421],[20362,22.78003463],[20393,22.69737822],[20423,22.81458066],[20454,22.82518638],[20485,22.9321145],[20513,22.92490141],[20544,22.89566762],[20574,24.62500884],[20605,22.86501531],[20635,22.82002223],[20666,22.90003078],[20697,22.91997036]],"SOL":[[19784,0.007733472],[19814,0.004932486],[19844,0.0080222531],[19875,0.0060096814],[19905,0.006765805],[19936,0.0058348564],[19967,0.0074569356],[19997,0.0064723717],[20028,0.0059608814],[20058,0.0042159118],[20089,0.0052675276],[20120,0.0042768542],[20148,0.0067127295],[20179,0.0080312003],[20209,0.0067182151],[20240,0.0064610752],[20270,0.0064857499],[20301,0.0058272892],[20332,0.0049780104],[20362,0.0047483149],[20393,0.0053964975],[20423,0.0078005876],[20454,0.008000472],[20485,0.0094783735],[20513,0.011303669],[20544,0.012026327],[20574,0.011909006],[20605,0.012218652],[20635,0.013294856],[20666,0.013727575],[20697,0.0096269645]],"SOS":[[19784,571.23952793],[19814,571.49446923],[19844,574.25374522],[19875,570.48593092],[19905,570.27359077],[19936,569.82775709],[19967,570.14049216],[19997,571.48996382],[20028,571.3213453],[20058,571.45323677],[20089,571.22694669],[20120,571.09946262],[20148,571.90185974],[20179,569.27760188],[20209,573.29520456],[20240,571.45639445],[20270,570.27652622],[20301,571.42712524],[20332,569.46774661],[20362,571.47193935],[20393,577.0951451],[20423,569.55730201],[20454,571.48745456],[20485,572.63061968],[20513,571.48478157],[20544,571.40624421],[20574,571.49751711],[20605,571.14594164],[20635,570.33156767],[20666,570.98105819],[20697,570.72727802]],"SRD":[[19784,35.33742942],[19814,35.20498923],[19844,33.59635917],[19875,32.14578846],[19905,30.77877688],[19936,28.82952116],[19967,28.71000837],[19997,30.80227425],[20028,34.70153031],[20058,35.19999838],[20089,35.5157803],[20120,35.0973198],[20148,35.39754299],[20179,37.10559541],[20209,36.73309541],[20240,37.14931293],[20270,37.58613592],[20301,36.85180336],[20332,37.6293892],[20362,38.20571029],[20393,39.64638015],[20423,38.45202615],[20454,38.09213027],[20485,38.0641721],[20513,37.83570434],[20544,37.41801865],[20574,37.4580253],[20605,37.17518061],[20635,37.5453608],[20666,37.77783517],[20697,37.7941139]],"SSP":[[19784,1470.00202498],[19814,1582.2905],[19905,1549.39242272],[19936,1634.44556537],[20423,4565.87752169],[20454,4551.19033835],[20485,4541.39814724],[20513,4542.36866086],[20544,4575.80051908],[20605,4680.34741309],[20635,4703.09620654],[20666,5000.13860719],[20697,5628.0005657]],"STN":[[19784,22.64757907],[19814,22.8665],[19844,23.17174812],[19875,22.7814026],[19905,22.90442215],[19936,22.68918853],[19967,22.1508866],[19997,22.25183293],[20028,22.67334923],[20058,23.18513587],[20089,23.63488611],[20120,23.69773039],[20148,23.73776365],[20179,22.81128852],[20209,21.79478896],[20240,21.60030087],[20270,20.98821111],[20301,21.65794775],[20332,21.12492896],[20362,20.95650479],[20393,21.32824406],[20423,21.34380332],[20454,20.98076941],[20485,20.77946162],[20513,20.87324401],[20544,21.38561505],[20574,21.19999608],[20605,21.19208364],[20635,21.62008197],[20666,21.24189217],[20697,21.25335709]],"SVC":[[19784,8.75],[19814,8.74649619],[19844,8.75],[19875,8.75],[19905,8.75],[19936,8.75],[19967,8.75],[19997,8.75],[20028,8.75],[20058,8.75],[20089,8.75],[20120,8.75],[20148,8.75],[20179,8.75],[20209,8.75],[20240,8.75],[20270,8.75],[20301,8.75],[20332,8.75],[20362,8.75],[20393,8.75],[20423,8.75],[20454,8.75],[20485,8.75],[20513,8.75],[20544,8.75],[20574,8.75229775],[20605,8.75],[20635,8.75],[20666,8.75],[20697,8.75]],"SYP":[[19784,13001.86066508],[19814,13001.83697496],[19844,13001.81566602],[19875,13001.86204779],[19905,13001.49999924],[19936,13001.85513464],[19967,13001.98298152],[19997,13002.84455695],[20028,13002.85225243],[20058,13001.85513345],[20089,13001.9494916],[20120,13001.83866435],[20148,13001.83142555],[20179,13001.85885059],[20209,13001.85857986],[20240,13001.85316919],[20270,13001.70376138],[20301,13001.85061845],[20332,13001.8552249],[20362,13001.8520056],[20393,11057.26245084],[20423,11058.38751965],[20454,11057.6256617],[20485,110.57497679],[20513,110.52498271],[20544,110.54582047],[20574,110.64405637],[20605,110.53976893],[20635,110.53312515],[20697,13004.56860905]],"SZL":[[19784,19.11786164],[19814,18.88850254],[19844,18.79676962],[19875,18.79855068],[19905,18.07466649],[19936,18.18753629],[19967,17.82219656],[19997,17.26884917],[20028,17.6159842],[20058,18.06301279],[20089,18.89291344],[20120,18.67081622],[20148,18.68908039],[20179,18.31765661],[20209,18.60919794],[20240,17.97832883],[20270,17.70523005],[20301,18.22630205],[20332,17.64317262],[20362,17.26625527],[20393,17.34998912],[20423,17.11785542],[20454,16.52612523],[20485,16.16344066],[20513,15.89527656],[20544,16.87826503],[20574,16.65984338],[20605,16.23173682],[20635,16.42712626],[20666,16.54463648],[20697,16.11424738]],"THB":[[19784,35.81357699],[19814,36.33749519],[19844,37.20962138],[19875,36.7337132],[19905,36.73222455],[19936,35.43795017],[19967,33.95078219],[19997,32.45677783],[20028,33.82354342],[20058,34.2844542],[20089,34.33832258],[20120,33.79078796],[20148,34.2409932],[20179,34.04120258],[20209,33.49330305],[20240,32.73045587],[20270,32.4542253],[20301,32.81296189],[20332,32.31523144],[20362,32.44663657],[20393,32.33457561],[20423,32.1155683],[20454,31.49963809],[20485,31.48765852],[20513,31.08814905],[20544,32.74671388],[20574,32.50749382],[20605,32.58399734],[20635,33.3143066],[20666,33.44254537],[20697,33.24005535]],"TJS":[[19784,10.94571792],[19814,10.93548282],[19844,10.9235902],[19875,10.72636646],[19905,10.67434536],[19936,10.59513621],[19967,10.67193906],[19997,10.6468345],[20028,10.64328369],[20058,10.90237021],[20089,10.90055973],[20120,10.93083048],[20148,10.90362526],[20179,10.87209612],[20209,10.54401649],[20240,9.94729048],[20270,9.89195991],[20301,9.44660791],[20332,9.51636113],[20362,9.37622217],[20393,9.2350021],[20423,9.26902319],[20454,9.2362219],[20485,9.38975597],[20513,9.51064358],[20544,9.58870051],[20574,9.38182069],[20605,9.23820108],[20635,9.24577784],[20666,9.21829568],[20697,9.22606469]],"TMT":[[19784,3.49569012],[19814,3.51000023],[19844,3.50356851],[19875,3.50295034],[19905,3.50347017],[19936,3.4958604],[19967,3.49735973],[19997,3.49369092],[20028,3.50651169],[20058,3.5000524],[20089,3.50942834],[20120,3.50783208],[20148,3.50900412],[20179,3.49754535],[20209,3.49646255],[20240,3.50018752],[20270,3.50757081],[20301,3.50872669],[20332,3.50672054],[20362,3.49808558],[20393,3.49979398],[20423,3.49980606],[20454,3.4959828],[20485,3.49998284],[20513,3.49019834],[20544,3.49840087],[20574,3.50499943],[20605,3.50823622],[20635,3.50926218],[20666,3.42947277],[20697,3.49628963]],"TND":[[19784,3.12463306],[19814,3.13028421],[19844,3.14638158],[19875,3.11709232],[19905,3.14697613],[19936,3.09428419],[19967,3.05046903],[19997,3.02742072],[20028,3.09956725],[20058,3.13951016],[20089,3.19051103],[20120,3.19381836],[20148,3.16379339],[20179,3.10220311],[20209,2.98297827],[20240,2.97633055],[20270,2.8865213],[20301,2.89622889],[20332,2.88358159],[20362,2.9039783],[20393,2.93799099],[20423,2.93962335],[20454,2.89207345],[20485,2.84633927],[20513,2.85640703],[20544,2.92590776],[20574,2.88201116],[20605,2.86187264],[20635,2.94876286],[20666,2.94856518],[20697,2.91153924]],"TOP":[[19784,2.34591801],[19814,2.37805017],[19844,2.38795017],[19875,2.33451775],[19905,2.3606817],[19936,2.38032977],[19967,2.3116013],[19997,2.32341208],[20028,2.38375731],[20058,2.36070884],[20089,2.39491698],[20120,2.40079861],[20148,2.40156379],[20179,2.40690569],[20209,2.39332842],[20240,2.36070883],[20270,2.37544504],[20301,2.39817901],[20332,2.39336039],[20362,2.39597495],[20393,2.35584729],[20423,2.40437695],[20454,2.40590765],[20485,2.35349847],[20513,2.35742041],[20544,2.39369259],[20574,2.40775997],[20605,2.3739899],[20635,2.39661953],[20666,2.39232836],[20697,2.40833042]],"TTD":[[19784,6.7753953],[19814,6.79037176],[19844,6.80156711],[19875,6.78071523],[19905,6.7912869],[19936,6.78594352],[19967,6.75493727],[19997,6.79520233],[20028,6.78084997],[20058,6.69926004],[20089,6.79771638],[20120,6.78109722],[20148,6.78679676],[20179,6.77216429],[20209,6.77816931],[20240,6.77362055],[20270,6.76311252],[20301,6.78228137],[20332,6.78401522],[20362,6.79128686],[20393,6.74862787],[20423,6.76893289],[20454,6.79667038],[20485,6.74625417],[20513,6.74018965],[20544,6.7881335],[20574,6.78938465],[20605,6.78984728],[20635,6.78501055],[20666,6.77940982],[20697,6.75466739]],"TWD":[[19784,31.57604119],[19814,31.95949969],[19844,32.62871613],[19875,32.48827379],[19905,32.46883788],[19936,32.69727813],[19967,32.03663498],[19997,31.75627388],[20028,31.99270905],[20058,32.48764618],[20089,32.75705587],[20120,32.945382],[20148,32.91956405],[20179,33.22856376],[20209,32.07818887],[20240,29.94263387],[20270,29.26065081],[20301,30.02528876],[20332,30.6048954],[20362,30.45868279],[20393,30.78745369],[20423,31.41864826],[20454,31.32806723],[20485,31.63281045],[20513,31.3957953],[20544,31.91570955],[20574,31.54697712],[20605,31.35773706],[20635,31.88387274],[20666,32.30078332],[20697,31.64448108]],"TZS":[[19784,2549.26502304],[19814,2564.99971464],[19844,2583.87843908],[19875,2602.67553229],[19905,2637.19574158],[19936,2699.43164057],[19967,2714.45970496],[19997,2711.77894285],[20028,2712.107659],[20058,2629.96491128],[20089,2401.81908829],[20120,2549.84537618],[20148,2607.79105181],[20179,2647.79349131],[20209,2691.19247936],[20240,2677.16855591],[20270,2629.95186701],[20301,2568.8860626],[20332,2502.149613],[20362,2452.61339866],[20393,2462.53132734],[20423,2461.14123941],[20454,2469.64711498],[20485,2548.19130151],[20513,2545.11434089],[20544,2590.20552118],[20574,2604.99943073],[20605,2625.11634198],[20635,2625.29316967],[20666,2642.70029505],[20697,2645.51820874]],"UAH":[[19784,38.21794833],[19814,39.20902509],[19844,39.68575723],[19875,40.54583449],[19905,40.56025918],[19936,41.04979299],[19967,41.25035224],[19997,41.14771017],[20028,41.27772771],[20058,41.69170727],[20089,41.56504723],[20120,41.71780513],[20148,41.68345525],[20179,41.318129],[20209,41.55200828],[20240,41.64173498],[20270,41.76403126],[20301,41.76265429],[20332,41.34036613],[20362,41.17308304],[20393,42.0039536],[20423,42.23804117],[20454,42.24353717],[20485,42.90625305],[20513,43.17674965],[20544,43.86996917],[20574,43.94932646],[20605,44.27944106],[20635,44.63330299],[20666,44.52303243],[20697,44.48981113]],"UGX":[[19784,3912.94285119],[19814,3886.30200079],[19844,3806.54646254],[19875,3813.91867025],[19905,3706.94848209],[19936,3720.7070127],[19967,3716.09370296],[19997,3683.20542523],[20028,3661.42704054],[20058,3688.68788164],[20089,3679.1725061],[20120,3681.52971102],[20148,3675.2824077],[20179,3654.22158353],[20209,3662.95749986],[20240,3631.77072427],[20270,3594.91075736],[20301,3585.00338365],[20332,3545.14936557],[20362,3486.10426541],[20393,3483.51482143],[20423,3629.76048426],[20454,3620.28923037],[20485,3562.57456129],[20513,3601.79038753],[20544,3761.84281932],[20574,3760.98657614],[20605,3771.6401394],[20635,3664.4352383],[20666,3749.33263973],[20697,3784.82646753]],"UNI":[[19784,0.085823861],[19814,0.07780321],[19844,0.14341904],[19875,0.10039411],[19905,0.1057758],[19936,0.13845213],[19967,0.17031823],[19997,0.13258123],[20028,0.13023137],[20058,0.077304997],[20089,0.075281255],[20120,0.084088448],[20148,0.13354023],[20179,0.16724078],[20209,0.18724801],[20240,0.16846063],[20270,0.14028693],[20301,0.10634941],[20332,0.10501345],[20362,0.13048727],[20393,0.17461194],[20423,0.17729575],[20454,0.17586609],[20485,0.24961697],[20513,0.25376317],[20544,0.28064625],[20574,0.30938773],[20605,0.33338531],[20635,0.35169038],[20666,0.23273483],[20697,0.18567572]],"USD":[[19784,1.0],[19814,1.0],[19844,1.0],[19875,1.0],[19905,1.0],[19936,1.0],[19967,1.0],[19997,1.0],[20028,1.0],[20058,1.0],[20089,1.0],[20120,1.0],[20148,1.0],[20179,1.0],[20209,1.0],[20240,1.0],[20270,1.0],[20301,1.0],[20332,1.0],[20362,1.0],[20393,1.0],[20423,1.0],[20454,1.0],[20485,1.0],[20513,1.0],[20544,1.0],[20574,1.0],[20605,1.0],[20635,1.0],[20666,1.0],[20697,1.0]],"USDC":[[19784,1.00129628],[19814,0.99983038],[19844,0.99989269],[19875,1.00043305],[19905,0.99986976],[19936,1.00000262],[19967,0.99978898],[19997,1.00017621],[20028,1.00023658],[20058,1.00001998],[20089,1.00482287],[20120,0.9997088],[20148,0.99924398],[20179,1.00027252],[20209,1.00052516],[20240,0.99189366],[20270,0.99058699],[20301,0.99383675],[20332,0.99174225],[20362,0.99586611],[20393,1.00530304],[20423,1.0009487],[20454,0.9992449],[20485,1.0002077],[20513,1.00034115],[20544,1.00030366],[20574,1.00016331],[20605,1.00041292],[20635,1.00040191],[20666,1.00083182],[20697,1.00022658]],"USDT":[[19784,1.00074005],[19814,0.99962505],[19844,1.00082709],[19875,1.00145354],[19905,1.00125039],[19936,1.00028439],[19967,0.99987522],[19997,1.00048438],[20028,1.0016248],[20058,0.99958419],[20089,1.00681251],[20120,0.99995923],[20148,0.99989648],[20179,1.00032949],[20209,1.00038678],[20240,0.99970576],[20270,1.00087583],[20301,1.00010881],[20332,0.99965986],[20362,0.99977825],[20393,1.00546769],[20423,1.00055496],[20454,1.00053637],[20485,1.0009361],[20513,0.99999404],[20544,1.00085468],[20574,1.00030669],[20605,1.00151518],[20635,1.00157216],[20666,1.00169395],[20697,1.00054935]],"UYU":[[19784,39.09004739],[19814,37.54720033],[19844,38.54235694],[19875,38.62619604],[19905,39.44233033],[19936,40.31200564],[19967,40.36499232],[19997,41.92103236],[20028,41.41314138],[20058,43.19616935],[20089,43.69480736],[20120,43.29143109],[20148,42.96464102],[20179,42.14355255],[20209,42.21349182],[20240,41.61890779],[20270,39.96225691],[20301,40.17823783],[20332,39.92913288],[20362,39.86555292],[20393,39.92030005],[20423,39.75986077],[20454,39.08421883],[20485,38.5801088],[20513,38.38741795],[20544,40.64446298],[20574,39.88951052],[20605,40.11742146],[20635,40.11550189],[20666,40.10944682],[20697,40.24392443]],"UZS":[[19784,12523.27715329],[19814,12622.60511523],[19844,12640.07711648],[19875,12599.45135771],[19905,12519.60940071],[19936,12499.53819117],[19967,12625.02373345],[19997,12713.38442552],[20028,12782.33121666],[20058,12866.31856749],[20089,12848.60986089],[20120,12972.65870543],[20148,12900.12259411],[20179,12909.49104069],[20209,12968.91140011],[20240,12768.79599122],[20270,12664.69078824],[20301,12648.07906681],[20332,12479.06805816],[20362,12097.49777559],[20393,12024.69982438],[20423,11864.44193846],[20454,12016.12263346],[20485,12225.36436559],[20513,12172.05318547],[20544,12212.74822169],[20574,11937.49617149],[20605,12016.35357461],[20635,12037.21341094],[20666,11945.2236868],[20697,11811.68338586]],"VES":[[19784,36.03917032],[19814,36.20387557],[19844,36.40242649],[19875,36.48470959],[19905,36.39972236],[19936,36.59076944],[19967,36.5803637],[19997,36.87693196],[20028,42.65612064],[20058,47.54856386],[20089,51.96240464],[20120,58.37065645],[20148,64.40385948],[20179,69.47967926],[20209,87.09809414],[20240,96.61877752],[20270,107.93010604],[20301,124.47362328],[20332,148.25280522],[20362,177.45816987],[20393,223.14989735],[20423,245.06518665],[20454,299.48295243],[20485,366.39573764],[20513,416.71169305],[20544,472.63458815],[20574,485.5876569],[20605,550.86180506],[20635,626.65316542],[20666,746.53439356],[20697,796.11717286]],"VND":[[19784,24645.90395159],[19814,24812.49977347],[19844,25454.7890212],[19875,25454.84657403],[19905,25432.04031596],[19936,25007.33038766],[19967,24865.07251761],[19997,24498.65714163],[20028,25299.47960555],[20058,25347.94437064],[20089,25481.40620925],[20120,25074.94223245],[20148,25538.32745813],[20179,25542.52415637],[20209,26004.0104282],[20240,26034.94327739],[20270,26139.4199175],[20301,26219.89498683],[20332,26415.45271413],[20362,26428.64716929],[20393,26306.84379606],[20423,26350.33456151],[20454,26418.82435321],[20485,25995.38868776],[20513,25936.59535561],[20544,26341.58714156],[20574,26355.9946925],[20605,26315.65228935],[20635,26311.61163074],[20666,26251.9096619],[20697,26100.49437023]],"VUV":[[19784,119.92779362],[19814,119.80186135],[19844,120.62504614],[19875,118.76788689],[19905,119.41515951],[19936,119.87491952],[19967,118.49826092],[19997,117.54762327],[20028,120.36454882],[20058,121.63983128],[20089,120.95162956],[20120,124.28778176],[20148,124.08737725],[20179,123.30643539],[20209,121.06612875],[20240,120.29175827],[20270,119.30442577],[20301,120.15229458],[20332,119.48050411],[20362,120.40848941],[20393,121.58526289],[20423,121.65360392],[20454,120.53866817],[20485,119.02556035],[20513,119.16912515],[20544,120.15807825],[20574,118.50629563],[20605,117.85653451],[20635,120.16768271],[20666,118.1502008],[20697,118.31500153]],"WST":[[19784,2.68448092],[19814,2.6831112],[19844,2.77820084],[19875,2.68274166],[19905,2.73621838],[19936,2.76448797],[19967,2.68240111],[19997,2.69155969],[20028,2.77023358],[20058,2.68240193],[20089,2.78659468],[20120,2.8321907],[20148,2.83215878],[20179,2.82905334],[20209,2.76713783],[20240,2.76700858],[20270,2.72993115],[20301,2.77136688],[20332,2.6613619],[20362,2.76554438],[20393,2.79957633],[20423,2.80498341],[20454,2.77550298],[20485,2.71078086],[20513,2.71590836],[20544,2.74795803],[20574,2.71218808],[20605,2.69335836],[20635,2.7481221],[20666,2.73449054],[20697,2.70708942]],"XAF":[[19784,605.33141167],[19814,607.83190327],[19844,615.19158636],[19875,604.60577882],[19905,609.64771426],[19936,605.52094428],[19967,592.89025635],[19997,588.75864516],[20028,602.91562869],[20058,620.10088336],[20089,633.49992629],[20120,633.00064456],[20148,631.65063187],[20179,606.20100628],[20209,580.09742119],[20240,578.07146553],[20270,556.71872811],[20301,574.78055244],[20332,560.58152056],[20362,558.75870797],[20393,568.39649404],[20423,565.63146569],[20454,558.46154974],[20485,553.31361456],[20513,555.12115736],[20544,567.10651742],[20574,560.04138114],[20605,563.18476164],[20635,575.18127496],[20666,568.95592933],[20697,565.31710254]],"XAG":[[19784,0.043205871],[19814,0.039606235],[19844,0.037993433],[19875,0.032889323],[19905,0.03429017],[19936,0.034355069],[19967,0.03465079],[19997,0.031913337],[20028,0.030566165],[20058,0.032659516],[20089,0.034608063],[20120,0.031943776],[20148,0.032097601],[20179,0.029436655],[20209,0.030888588],[20240,0.030320834],[20270,0.027902448],[20301,0.027348148],[20332,0.024868573],[20362,0.021184351],[20393,0.020542326],[20423,0.017383269],[20454,0.014024263],[20485,0.011726929],[20513,0.01066743],[20544,0.01348003],[20574,0.01347414],[20605,0.01325532],[20635,0.017341314],[20666,0.017343095],[20697,0.015046869]],"XAU":[[19784,0.000479927],[19814,0.00044286939],[19844,0.0004371117],[19875,0.0004295864],[19905,0.0004295081],[19936,0.0004076837],[19967,0.0003994642],[19997,0.0003789825],[20028,0.0003639548],[20058,0.0003773514],[20089,0.0003810064],[20120,0.0003569924],[20148,0.000349819],[20179,0.0003190738],[20209,0.0003086938],[20240,0.0003037572],[20270,0.0003015996],[20301,0.0003040821],[20332,0.0002886016],[20362,0.0002584663],[20393,0.0002498587],[20423,0.0002355978],[20454,0.0002317446],[20485,0.0002055348],[20513,0.0001894696],[20544,0.0002132165],[20574,0.00021645889],[20605,0.0002216725],[20635,0.000251484],[20666,0.0002473603],[20697,0.0002257634]],"XCD":[[19784,2.70197905],[19814,2.70254928],[19844,2.70188923],[19875,2.70205619],[19905,2.70108431],[19936,2.70096302],[19967,2.70000005],[19997,2.701871],[20028,2.70520665],[20058,2.70000007],[20089,2.70629157],[20120,2.70249665],[20148,2.70251128],[20179,2.7058353],[20209,2.70663245],[20240,2.70000016],[20270,2.70653936],[20301,2.70728499],[20332,2.70639945],[20362,2.70778785],[20393,2.70247105],[20423,2.70882173],[20454,2.70715393],[20485,2.70076981],[20513,2.69998932],[20544,2.70701376],[20574,2.7025497],[20605,2.70731111],[20635,2.70622912],[20666,2.70246723],[20697,2.70731968]],"XCG":[[20179,1.7901572],[20209,1.78999617],[20240,1.79700489],[20270,1.80025027],[20301,1.80137717],[20332,1.79798838],[20362,1.80297072],[20393,1.79859109],[20423,1.79611531],[20454,1.80195026],[20485,1.79702407],[20513,1.79379379],[20544,1.8009072],[20574,1.80265],[20605,1.80126744],[20635,1.80037568],[20666,1.79702303],[20697,1.80043]],"XDR":[[19784,0.75368985],[19814,0.75502096],[19844,0.76064515],[19875,0.75554956],[19905,0.75978788],[19936,0.75239097],[19967,0.74260547],[19997,0.73923889],[20028,0.75065045],[20058,0.75875003],[20089,0.76679471],[20120,0.76697111],[20148,0.76393453],[20179,0.75272378],[20209,0.73773705],[20240,0.73707719],[20270,0.72779112],[20301,0.73804992],[20332,0.7306613],[20362,0.72941687],[20393,0.73616559],[20423,0.73606728],[20454,0.73019351],[20485,0.72834994],[20513,0.72835889],[20544,0.73721459],[20574,0.69771772],[20605,0.73059487],[20635,0.73736622],[20666,0.72856308],[20697,0.73051156]],"XLM":[[19784,7.74973336],[19814,7.07578721],[19844,9.37412797],[19875,9.4012974],[19905,10.8555832],[19936,10.00254984],[19967,10.86239858],[19997,10.03336517],[20028,10.85576092],[20058,1.99262209],[20089,2.94543279],[20120,2.40107656],[20148,3.46938914],[20179,3.79591254],[20209,3.662249],[20240,3.79160837],[20270,4.1973969],[20301,2.50984228],[20332,2.85438868],[20362,2.72854907],[20393,3.31921753],[20423,4.21332155],[20454,4.91843862],[20485,5.5268718],[20513,6.09796479],[20544,5.93012684],[20574,6.25811715],[20605,3.82710233],[20635,4.94468655],[20666,5.80705864],[20697,5.6057953]],"XOF":[[19784,605.33141167],[19814,607.83190327],[19844,615.19158636],[19875,604.60577882],[19905,609.64771426],[19936,605.52094428],[19967,592.89025635],[19997,588.75864516],[20028,602.91562869],[20058,620.10088336],[20089,633.49992629],[20120,633.00064456],[20148,631.65063187],[20179,606.20100628],[20209,580.09742119],[20240,578.07146553],[20270,556.71872811],[20301,574.78055244],[20332,560.58152056],[20362,558.75870797],[20393,568.39649404],[20423,565.63146569],[20454,558.46154974],[20485,553.31361456],[20513,555.12115736],[20544,567.10651742],[20574,560.50124038],[20605,563.18476164],[20635,575.18127496],[20666,568.95592933],[20697,565.31710254]],"XPD":[[19784,0.0010416666],[19814,0.00098092877],[19844,0.0010595318],[19875,0.0010416666],[19905,0.0010124398],[19936,0.00106935],[19967,0.0010416666],[19997,0.0009926845],[20028,0.0008978039],[20058,0.0010416676],[20089,0.0010952902],[20120,0.0010416667],[20148,0.0010793423],[20179,0.0010094309],[20209,0.0010628594],[20240,0.0010267032],[20270,0.0009006336],[20301,0.0008350765],[20332,0.0008876247],[20362,0.0007878297],[20393,0.0006942077],[20423,0.0006683042],[20454,0.0006199729],[20485,0.0005819202],[20513,0.0005585078],[20544,0.0006749088],[20605,0.000727701],[20635,0.0008334476],[20666,0.0007815705],[20697,0.0007357017]],"XPF":[[19784,110.12193176],[19814,110.56145461],[19844,111.91569541],[19875,109.98992458],[19905,110.90715382],[19936,110.15641154],[19967,107.85863593],[19997,107.10701294],[20028,109.68245235],[20058,112.80879505],[20089,115.24634986],[20120,115.15552049],[20148,114.90992609],[20179,110.28012847],[20209,105.53136249],[20240,105.16280049],[20270,101.27830904],[20301,104.56411736],[20332,101.98102851],[20362,101.64942232],[20393,103.40272902],[20423,102.89971487],[20454,101.59536327],[20485,100.65885055],[20513,100.98767886],[20544,103.16805638],[20574,101.69455095],[20605,102.4546103],[20635,104.63701683],[20666,103.50450152],[20697,102.84252591]],"XPT":[[19784,0.0011144663],[19814,0.0010980549],[19844,0.0010678251],[19875,0.0011144661],[19905,0.0010050125],[19936,0.0010208706],[19967,0.0011144664],[19997,0.0010127404],[20028,0.0010053474],[20058,0.0010544377],[20089,0.0011025937],[20120,0.0010167537],[20148,0.0010520339],[20179,0.001002485],[20209,0.001034568],[20240,0.0009438635],[20270,0.0007419666],[20301,0.0007715306],[20332,0.000723249],[20362,0.0006277881],[20393,0.0006361384],[20423,0.0005890607],[20454,0.0004867392],[20485,0.0004620106],[20513,0.0004230137],[20544,0.0005095725],[20605,0.0005161711],[20635,0.0006478246],[20666,0.0006055157],[20697,0.0005549662]],"XRP":[[19784,1.63038994],[19814,1.58707878],[19844,2.00607251],[19875,1.92752619],[19905,2.09022335],[19936,1.60204516],[19967,1.77125449],[19997,1.60900924],[20028,1.96036422],[20058,0.52705587],[20089,0.47664671],[20120,0.32725475],[20148,0.46316208],[20179,0.47971322],[20209,0.45371591],[20240,0.46301158],[20270,0.4479278],[20301,0.33156617],[20332,0.361921],[20362,0.34985852],[20393,0.40204245],[20423,0.48125259],[20454,0.54315256],[20485,0.59877832],[20513,0.70033661],[20544,0.74603455],[20574,0.72621829],[20605,0.75766327],[20635,0.95338174],[20666,0.94027513],[20697,0.72142591]],"YER":[[19784,250.33140596],[19814,250.34997985],[19844,250.21576415],[19875,250.36610699],[19905,250.21852496],[19936,250.39423627],[19967,250.24156174],[19997,250.40656236],[20028,250.26051719],[20058,249.81436541],[20089,249.68942341],[20120,248.91181201],[20148,247.29368204],[20179,246.00787592],[20209,245.05906189],[20240,243.44077748],[20270,242.18853934],[20301,240.5814474],[20332,239.99806034],[20362,239.29345214],[20393,238.48164165],[20423,238.43771643],[20454,238.40369528],[20485,238.32227089],[20513,238.38049203],[20544,238.62163907],[20574,238.65011722],[20605,238.64257858],[20635,238.5950368],[20666,238.29262572],[20697,237.008958]],"ZAR":[[19784,19.11786164],[19814,18.84239872],[19844,18.79676962],[19875,18.79855068],[19905,18.07466649],[19936,18.18753629],[19967,17.82219656],[19997,17.26884917],[20028,17.6159842],[20058,18.06301279],[20089,18.89291344],[20120,18.67081622],[20148,18.68908039],[20179,18.31765661],[20209,18.60919794],[20240,17.97832883],[20270,17.70523005],[20301,18.22630205],[20332,17.64317262],[20362,17.26625527],[20393,17.34998912],[20423,17.11785542],[20454,16.52612523],[20485,16.16344066],[20513,15.89527656],[20544,16.87826503],[20574,16.67149211],[20605,16.23173682],[20635,16.42712626],[20666,16.54463648],[20697,16.11424738]],"ZMW":[[19784,23.69359228],[19814,25.02281796],[19844,26.93791903],[19875,26.71127613],[19905,23.95991754],[19936,26.08799946],[19967,26.07643118],[19997,26.61095201],[20028,26.66231435],[20058,26.94355187],[20089,28.00383948],[20120,27.96392509],[20148,28.23290676],[20179,28.14920905],[20209,27.96681731],[20240,26.75113772],[20270,23.96984587],[20301,22.89952134],[20332,23.64921249],[20362,23.9002905],[20393,22.19882845],[20423,22.9664346],[20454,22.25132398],[20485,19.75057565],[20513,18.90594804],[20544,19.20051953],[20574,18.67894631],[20605,18.42986752],[20635,18.06009208],[20666,18.75055941],[20697,19.1602983]],"ZWG":[[19936,13.76946578],[19967,14.09010907],[19997,14.09014906],[20028,28.63609576],[20058,25.47783656],[20089,25.47499895],[20120,26.74925573],[20148,26.53881911],[20179,26.75355815],[20209,26.84101304],[20240,26.91223778],[20270,26.97464231],[20301,26.79280509],[20332,26.78780523],[20362,26.69620589],[20393,26.29685056],[20423,26.17177631],[20454,25.98620723],[20485,25.58326223],[20513,25.77625691],[20544,25.36755057],[20605,26.90822928],[20635,26.82832821],[20666,26.70485912],[20697,26.68501717]]},"fiat":{"AED":[[14791,3.6764],[14822,3.6752],[14853,3.6731],[14883,3.6725],[14914,3.675],[14944,3.6742],[14975,3.6736],[15006,3.6751],[15034,3.6741],[15065,3.6741],[15095,3.6748],[15126,3.6739],[15156,3.6743],[15187,3.6755],[15218,3.6771],[15248,3.6781],[15279,3.6761],[15309,3.6762],[15340,3.6739],[15371,3.675],[15400,3.6737],[15431,3.6747],[15461,3.6741],[15492,3.6747],[15522,3.6746],[15553,3.6746],[15584,3.6722],[15614,3.6731],[15645,3.6727],[15675,3.6723],[15706,3.6737],[15737,3.673],[15765,3.6732],[15796,3.6734],[15826,3.6723],[15857,3.6751],[15887,3.6738],[15918,3.6739],[15949,3.6741],[15979,3.6733],[16010,3.6736],[16040,3.6732],[16071,3.6733],[16102,3.6743],[16130,3.6731],[16161,3.6731],[16191,3.6737],[16222,3.6737],[16252,3.6733],[16283,3.6732],[16314,3.674],[16344,3.6737],[16375,3.6758],[16405,3.6732],[16436,3.6695],[16467,3.6719],[16495,3.6733],[16526,3.6767],[16556,3.6698],[16587,3.6721],[16617,3.6732],[16648,3.6764],[16679,3.672],[16709,3.6699],[16740,3.6724],[16770,3.6751],[16801,3.6745],[16832,3.6725],[16861,3.6776],[16892,3.6726],[16922,3.6725],[16953,3.6729],[16983,3.6729],[17014,3.6715],[17045,3.6737],[17075,3.6721],[17106,3.6716],[17136,3.6734],[17167,3.6741],[17198,3.6737],[17226,3.6746],[17257,3.679],[17287,3.6763],[17318,3.6757],[17348,3.6776],[17379,3.677],[17410,3.6734],[17440,3.6714],[17471,3.6749],[17501,3.6756],[17532,3.6768],[17563,3.6742],[17591,3.6756],[17622,3.6737],[17652,3.6712],[17683,3.6722],[17713,3.6739],[17744,3.6754],[17775,3.6737],[17805,3.672],[17836,3.6736],[17866,3.6732],[17897,3.6729],[17928,3.674],[17956,3.6739],[17987,3.6748],[18017,3.6741],[18048,3.6755],[18078,3.6729],[18109,3.6735],[18140,3.6736],[18170,3.6747],[18201,3.6735],[18231,3.6748],[18262,3.6726],[18293,3.6744],[18322,3.6706],[18353,3.678],[18383,3.6809],[18414,3.6771],[18444,3.6785],[18475,3.6769],[18506,3.6728],[18536,3.6724],[18567,3.676],[18597,3.6766],[18628,3.6735],[18659,3.6756],[18687,3.6715],[18718,3.6759],[18748,3.6742],[18779,3.6757],[18809,3.6784],[18840,3.6756],[18871,3.6742],[18901,3.6761],[18932,3.6772],[18962,3.6793],[18993,3.6749],[19024,3.6726],[19052,3.6742],[19083,3.6715],[19113,3.6784],[19144,3.6741],[19174,3.6734],[19205,3.6744],[19236,3.6715],[19266,3.6769],[19297,3.6799],[19327,3.676],[19358,3.676],[19389,3.6735],[19417,3.6761],[19448,3.6739],[19478,3.6714],[19509,3.6741],[19539,3.6739],[19570,3.6751],[19601,3.672],[19631,3.674],[19662,3.6763],[19692,3.6749],[19723,3.6725],[19754,3.673]],"AFN":[[14791,45.275],[14822,45.471],[14853,44.993],[14883,44.471],[14914,44.353],[14944,44.651],[14975,44.915],[15006,44.628],[15034,44.549],[15065,45.678],[15095,45.147],[15126,45.611],[15156,47.478],[15187,47.235],[15218,46.49],[15248,47.598],[15279,47.866],[15309,48.588],[15340,47.69],[15371,49.183],[15400,48.932],[15431,49.388],[15461,49.524],[15492,49.928],[15522,51.385],[15553,51.239],[15584,50.797],[15614,52.055],[15645,52.762],[15675,51.703],[15706,51.669],[15737,51.979],[15765,53.194],[15796,54.017],[15826,54.418],[15857,55.368],[15887,56.497],[15918,56.121],[15949,56.497],[15979,56.809],[16010,57.373],[16040,56.852],[16071,56.19],[16102,56.882],[16130,56.994],[16161,56.963],[16191,57.0],[16222,57.491],[16252,56.792],[16283,56.421],[16314,56.903],[16344,57.35],[16375,57.771],[16405,57.807],[16436,57.422],[16467,57.229],[16495,57.645],[16526,58.065],[16556,58.63],[16587,59.5],[16617,60.627],[16648,63.219],[16679,64.211],[16709,64.195],[16740,66.233],[16770,66.873],[16801,68.683],[16832,68.799],[16861,68.636],[16892,68.327],[16922,68.393],[16953,68.811],[16983,68.701],[17014,67.324],[17045,66.535],[17075,65.932],[17106,66.245],[17136,66.637],[17167,66.873],[17198,66.813],[17226,67.016],[17257,67.658],[17287,68.051],[17318,68.241],[17348,68.302],[17379,68.543],[17410,67.934],[17440,68.302],[17471,68.892],[17501,69.382],[17532,69.783],[17563,69.2],[17591,69.294],[17622,69.799],[17652,71.034],[17683,71.786],[17713,73.079],[17744,73.126],[17775,74.948],[17805,75.678],[17836,75.905],[17866,75.616],[17897,75.631],[17928,75.23],[17956,75.389],[17987,77.181],[18017,79.099],[18048,80.44],[18078,80.12],[18109,78.36],[18140,78.204],[18170,78.321],[18201,78.344],[18231,78.418],[18262,77.171],[18293,77.049],[18322,75.991],[18353,76.105],[18383,76.481],[18414,76.887],[18444,77.132],[18475,76.879],[18506,76.584],[18536,76.723],[18567,76.97],[18597,77.266],[18628,77.318],[18659,77.693],[18687,77.545],[18718,78.167],[18748,77.963],[18779,78.824],[18809,80.03],[18840,83.47],[18871,86.32],[18901,89.84],[18932,92.64],[18962,101.9],[18993,103.51],[19024,93.63],[19052,89.01],[19083,86.89],[19113,88.98],[19144,88.64],[19174,88.93],[19205,89.29],[19236,88.06],[19266,87.66],[19297,88.54],[19327,88.24],[19358,89.37],[19389,89.24],[19417,87.82],[19448,86.37],[19478,87.23],[19509,86.33],[19539,85.85],[19570,84.29],[19601,78.213],[19631,75.406],[19662,71.205],[19692,70.147],[19723,72.002],[19754,73.714]],"ALL":[[14791,107.5],[14822,105.71],[14853,105.34],[14883,100.12],[14914,102.28],[14944,105.12],[14975,103.66],[15006,101.9],[15034,100.2],[15065,97.89],[15095,98.21],[15126,98.35],[15156,97.98],[15187,97.51],[15218,101.88],[15248,102.61],[15279,103.19],[15309,105.57],[15340,106.63],[15371,105.13],[15400,105.24],[15431,106.11],[15461,108.92],[15492,110.97],[15522,111.81],[15553,110.87],[15584,108.4],[15614,107.83],[15645,108.44],[15675,106.5],[15706,104.89],[15737,104.67],[15765,108.06],[15796,108.06],[15826,108.19],[15857,107.61],[15887,107.12],[15918,105.38],[15949,105.12],[15979,103.32],[16010,103.36],[16040,102.35],[16071,102.87],[16102,102.73],[16130,101.74],[16161,101.52],[16191,101.92],[16222,102.83],[16252,103.35],[16283,104.71],[16314,108.7],[16344,110.21],[16375,111.72],[16405,113.96],[16436,119.87],[16467,123.82],[16495,128.89],[16526,129.11],[16556,126.95],[16587,126.07],[16617,126.96],[16648,125.6],[16679,124.11],[16709,124.53],[16740,128.69],[16770,126.71],[16801,126.98],[16832,125.37],[16861,123.76],[16892,122.0],[16922,122.49],[16953,123.14],[16983,123.33],[17014,122.41],[17045,122.7],[17075,123.79],[17106,126.07],[17136,128.26],[17167,128.22],[17198,127.57],[17226,126.97],[17257,125.66],[17287,121.77],[17318,117.89],[17348,114.99],[17379,112.56],[17410,112.38],[17440,113.86],[17471,113.75],[17501,112.05],[17532,109.38],[17563,107.47],[17591,106.25],[17622,105.32],[17652,107.19],[17683,108.04],[17713,107.82],[17744,108.63],[17775,108.41],[17805,108.95],[17836,109.58],[17866,108.27],[17897,108.58],[17928,109.95],[17956,110.74],[17987,110.52],[18017,110.08],[18048,108.15],[18078,108.38],[18109,109.47],[18140,110.7],[18170,111.12],[18201,111.12],[18231,109.65],[18262,109.87],[18293,111.63],[18322,112.79],[18353,114.47],[18383,113.48],[18414,110.83],[18444,108.16],[18475,104.85],[18506,105.27],[18536,105.55],[18567,104.69],[18597,101.68],[18628,101.41],[18659,102.14],[18687,103.36],[18718,103.17],[18748,101.43],[18779,101.99],[18809,103.09],[18840,103.28],[18871,103.65],[18901,104.79],[18932,106.36],[18962,106.88],[18993,107.16],[19024,107.52],[19052,109.99],[19083,111.8],[19113,113.53],[19144,113.2],[19174,114.16],[19205,115.57],[19236,117.92],[19266,119.27],[19297,115.58],[19327,108.99],[19358,107.82],[19389,107.81],[19417,106.47],[19448,102.79],[19478,102.04],[19509,99.5],[19539,95.24],[19570,96.26],[19601,99.87],[19631,100.18],[19662,96.54],[19692,94.09],[19723,94.9],[19754,95.98]],"AMD":[[14791,367.8],[14822,366.26],[14853,364.12],[14883,359.53],[14914,361.6],[14944,360.83],[14975,363.78],[15006,364.76],[15034,367.27],[15065,371.9],[15095,374.64],[15126,373.19],[15156,368.9],[15187,367.98],[15218,371.72],[15248,375.07],[15279,381.12],[15309,383.42],[15340,387.02],[15371,387.97],[15400,388.17],[15431,391.5],[15461,397.49],[15492,412.87],[15522,414.3],[15553,409.76],[15584,407.91],[15614,406.73],[15645,406.63],[15675,405.55],[15706,406.4],[15737,406.85],[15765,414.44],[15796,416.67],[15826,414.81],[15857,412.92],[15887,410.92],[15918,407.45],[15949,408.05],[15979,405.88],[16010,405.35],[16040,404.78],[16071,405.92],[16102,411.71],[16130,413.83],[16161,414.33],[16191,413.59],[16222,410.78],[16252,407.35],[16283,409.38],[16314,408.91],[16344,410.04],[16375,419.25],[16405,460.18],[16436,472.54],[16467,477.09],[16495,475.75],[16526,475.35],[16556,476.78],[16587,474.74],[16617,476.51],[16648,480.56],[16679,479.62],[16709,470.87],[16740,478.6],[16770,483.3],[16801,485.73],[16832,491.76],[16861,486.4],[16892,480.15],[16922,477.16],[16953,477.14],[16983,476.46],[17014,474.74],[17045,474.75],[17075,474.58],[17106,477.36],[17136,481.9],[17167,485.23],[17198,486.51],[17226,484.55],[17257,485.96],[17287,482.81],[17318,481.98],[17348,478.8],[17379,478.49],[17410,477.29],[17440,480.35],[17471,485.81],[17501,483.96],[17532,482.62],[17563,480.73],[17591,480.24],[17622,480.62],[17652,483.38],[17683,482.56],[17713,481.47],[17744,482.95],[17775,482.81],[17805,484.29],[17836,485.94],[17866,484.55],[17897,485.11],[17928,487.76],[17956,486.66],[17987,483.77],[18017,480.55],[18048,478.75],[18078,476.08],[18109,475.38],[18140,475.96],[18170,476.48],[18201,476.64],[18231,478.08],[18262,478.13],[18293,478.5],[18322,486.86],[18353,487.04],[18383,483.7],[18414,481.6],[18444,485.29],[18475,485.5],[18506,485.69],[18536,490.26],[18567,499.36],[18597,517.67],[18628,520.46],[18659,523.78],[18687,525.6],[18718,526.03],[18748,520.92],[18779,511.7],[18809,493.66],[18840,490.53],[18871,489.28],[18901,479.88],[18932,478.07],[18962,485.82],[18993,480.88],[19024,480.45],[19052,493.14],[19083,472.56],[19113,458.44],[19144,426.22],[19174,409.19],[19205,406.03],[19236,408.4],[19266,404.33],[19297,396.6],[19327,394.98],[19358,395.46],[19389,393.25],[19417,389.81],[19448,388.27],[19478,386.02],[19509,386.53],[19539,386.84],[19570,386.62],[19601,386.61],[19631,400.92],[19662,401.35],[19692,402.67],[19723,403.99],[19754,404.64]],"ANG":[[14791,1.7769],[14822,1.7766],[14853,1.7763],[14883,1.7754],[14914,1.7777],[14944,1.7753],[14975,1.7793],[15006,1.7814],[15034,1.7792],[15065,1.7803],[15095,1.7811],[15126,1.7804],[15156,1.7789],[15187,1.7803],[15218,1.779],[15248,1.7792],[15279,1.7787],[15309,1.78],[15340,1.7742],[15371,1.7735],[15400,1.7704],[15431,1.7655],[15461,1.7676],[15492,1.7696],[15522,1.7686],[15553,1.7773],[15584,1.7772],[15614,1.7766],[15645,1.7778],[15675,1.7789],[15706,1.7782],[15737,1.7773],[15765,1.7775],[15796,1.7775],[15826,1.7759],[15857,1.7786],[15887,1.7762],[15918,1.7818],[15949,1.7814],[15979,1.7821],[16010,1.7812],[16040,1.7824],[16071,1.7829],[16102,1.7836],[16130,1.7804],[16161,1.7868],[16191,1.7845],[16222,1.7769],[16252,1.7761],[16283,1.7755],[16314,1.7777],[16344,1.7757],[16375,1.7764],[16405,1.7754],[16436,1.7759],[16467,1.7764],[16495,1.7759],[16526,1.7764],[16556,1.7749],[16587,1.7783],[16617,1.777],[16648,1.7769],[16679,1.7765],[16709,1.7848],[16740,1.7843],[16770,1.7854],[16801,1.7855],[16832,1.7837],[16861,1.7837],[16892,1.7844],[16922,1.7843],[16953,1.7833],[16983,1.7828],[17014,1.7839],[17045,1.7833],[17075,1.7847],[17106,1.7853],[17136,1.784],[17167,1.7831],[17198,1.7846],[17226,1.7836],[17257,1.7862],[17287,1.7853],[17318,1.7843],[17348,1.7832],[17379,1.7842],[17410,1.784],[17440,1.7864],[17471,1.7879],[17501,1.7854],[17532,1.7868],[17563,1.7858],[17591,1.7864],[17622,1.7877],[17652,1.7885],[17683,1.7918],[17713,1.8065],[17744,1.8134],[17775,1.8056],[17805,1.7828],[17836,1.7831],[17866,1.7826],[17897,1.7848],[17928,1.7925],[17956,1.7989],[17987,1.8178],[18017,1.8228],[18048,1.816],[18078,1.797],[18109,1.7868],[18140,1.7816],[18170,1.7745],[18201,1.766],[18231,1.7599],[18262,1.79],[18293,1.7773],[18322,1.788],[18353,1.7876],[18383,1.7891],[18414,1.7877],[18444,1.7878],[18475,1.787],[18506,1.786],[18536,1.7873],[18567,1.7865],[18597,1.7861],[18628,1.7876],[18659,1.7872],[18687,1.7852],[18718,1.7871],[18748,1.7863],[18779,1.7869],[18809,1.7852],[18840,1.7872],[18871,1.7856],[18901,1.7869],[18932,1.7932],[18962,1.789],[18993,1.7904],[19024,1.7944],[19052,1.7908],[19083,1.7895],[19113,1.791],[19144,1.7898],[19174,1.7891],[19205,1.7906],[19236,1.7874],[19266,1.7929],[19297,1.7918],[19327,1.7897],[19358,1.791],[19389,1.7928],[19417,1.7915],[19448,1.7935],[19478,1.7914],[19509,1.7913],[19539,1.7937],[19570,1.7935],[19601,1.7914],[19631,1.7949],[19662,1.7959],[19692,1.7912],[19723,1.7933],[19754,1.7929]],"AOA":[[14791,93.01],[14822,91.68],[14853,90.84],[14883,92.02],[14914,92.62],[14944,92.63],[14975,93.36],[15006,93.13],[15034,92.58],[15065,93.25],[15095,93.11],[15126,93.69],[15156,93.04],[15187,93.24],[15218,94.23],[15248,94.88],[15279,94.6],[15309,95.25],[15340,95.4],[15371,95.02],[15400,94.99],[15431,95.2],[15461,95.26],[15492,95.67],[15522,95.57],[15553,95.18],[15584,95.26],[15614,95.67],[15645,95.68],[15675,95.58],[15706,95.92],[15737,95.81],[15765,96.0],[15796,96.27],[15826,95.96],[15857,96.04],[15887,96.49],[15918,96.13],[15949,96.47],[15979,97.37],[16010,97.54],[16040,97.07],[16071,97.47],[16102,97.7],[16130,97.27],[16161,97.72],[16191,97.67],[16222,97.72],[16252,97.86],[16283,97.66],[16314,98.03],[16344,98.92],[16375,100.56],[16405,102.21],[16436,103.58],[16467,105.19],[16495,106.46],[16526,109.05],[16556,109.37],[16587,117.4],[16617,124.14],[16648,125.83],[16679,133.88],[16709,135.78],[16740,135.59],[16770,135.4],[16801,155.98],[16832,157.27],[16861,160.22],[16892,164.35],[16922,165.59],[16953,168.21],[16983,166.21],[17014,166.29],[17045,166.71],[17075,165.89],[17106,166.79],[17136,166.78],[17167,165.77],[17198,165.87],[17226,166.62],[17257,167.3],[17287,166.78],[17318,166.55],[17348,165.09],[17379,165.13],[17410,165.89],[17440,165.95],[17471,164.74],[17501,166.62],[17532,193.97],[17563,209.36],[17591,214.19],[17622,218.76],[17652,231.92],[17683,242.87],[17713,252.23],[17744,265.94],[17775,285.51],[17805,302.07],[17836,309.47],[17866,308.94],[17897,311.52],[17928,312.46],[17956,314.81],[17987,319.99],[18017,326.82],[18048,337.53],[18078,345.52],[18109,356.93],[18140,367.51],[18170,427.87],[18201,476.3],[18231,475.67],[18262,487.34],[18293,495.35],[18322,512.15],[18353,556.2],[18383,565.99],[18414,586.24],[18444,572.54],[18475,580.98],[18506,620.26],[18536,645.45],[18567,656.45],[18597,651.58],[18628,653.63],[18659,649.16],[18687,630.56],[18718,637.62],[18748,650.64],[18779,650.47],[18809,646.96],[18840,641.85],[18871,621.0],[18901,604.5],[18932,595.28],[18962,572.72],[18993,548.23],[19024,518.34],[19052,474.44],[19083,430.15],[19113,413.48],[19144,422.27],[19174,429.15],[19205,430.2],[19236,427.9],[19266,450.81],[19297,487.68],[19327,508.37],[19358,506.78],[19389,507.86],[19417,507.66],[19448,509.53],[19478,539.55],[19509,698.17],[19539,828.53],[19570,829.57],[19601,830.85],[19631,831.59],[19662,833.52],[19692,836.21],[19723,839.07],[19754,840.06]],"ARS":[[14791,3.9393],[14822,3.939],[14853,3.9504],[14883,3.9572],[14914,3.969],[14944,3.9784],[14975,3.9846],[15006,4.0196],[15034,4.036],[15065,4.0635],[15095,4.0879],[15126,4.0957],[15156,4.1214],[15187,4.164],[15218,4.2043],[15248,4.2185],[15279,4.2603],[15309,4.2878],[15340,4.3167],[15371,4.346],[15400,4.3581],[15431,4.3951],[15461,4.4481],[15492,4.4848],[15522,4.5494],[15553,4.6041],[15584,4.6659],[15614,4.7252],[15645,4.7943],[15675,4.8783],[15706,4.9472],[15737,5.009],[15765,5.0863],[15796,5.1506],[15826,5.2322],[15857,5.3309],[15887,5.4336],[15918,5.573],[15949,5.7287],[15979,5.8408],[16010,6.0023],[16040,6.3249],[16071,6.9297],[16102,7.8695],[16130,7.9186],[16161,8.0034],[16191,8.039],[16222,8.1206],[16252,8.1591],[16283,8.3019],[16314,8.4298],[16344,8.4751],[16375,8.5171],[16405,8.5263],[16436,8.5826],[16467,8.6794],[16495,8.7803],[16526,8.8836],[16556,8.9329],[16587,9.0468],[16617,9.1411],[16648,9.256],[16679,9.3643],[16709,9.4529],[16740,9.6104],[16770,11.2733],[16801,13.5949],[16832,14.7336],[16861,14.9648],[16892,14.44],[16922,14.1124],[16953,14.1544],[16983,14.8998],[17014,14.882],[17045,15.1366],[17075,15.187],[17106,15.3491],[17136,15.821],[17167,15.8731],[17198,15.6228],[17226,15.5159],[17257,15.4111],[17287,15.7367],[17318,16.189],[17348,17.16],[17379,17.4838],[17410,17.3559],[17440,17.4638],[17471,17.5079],[17501,17.6005],[17532,19.0692],[17563,19.8535],[17591,20.189],[17622,20.259],[17652,23.627],[17683,26.618],[17713,27.572],[17744,29.331],[17775,38.63],[17805,37.16],[17836,36.499],[17866,37.897],[17897,37.557],[17928,38.259],[17956,41.401],[17987,43.137],[18017,44.705],[18048,43.737],[18078,42.623],[18109,52.364],[18140,56.483],[18170,58.396],[18201,59.715],[18231,59.892],[18262,59.993],[18293,61.4],[18322,63.173],[18353,65.579],[18383,67.699],[18414,69.478],[18444,71.467],[18475,73.233],[18506,75.096],[18536,77.414],[18567,79.862],[18597,82.65],[18628,85.56],[18659,88.53],[18687,90.81],[18718,92.76],[18748,94.01],[18779,95.09],[18809,96.17],[18840,97.15],[18871,98.09],[18901,99.14],[18932,100.22],[18962,101.78],[18993,103.66],[19024,106.15],[19052,109.75],[19083,112.65],[19113,117.61],[19144,122.62],[19174,127.36],[19205,134.63],[19236,142.39],[19266,151.68],[19297,161.6],[19327,171.8],[19358,182.12],[19389,190.81],[19417,202.52],[19448,215.17],[19478,231.44],[19509,247.34],[19539,264.06],[19570,312.07],[19601,350.63],[19631,350.03],[19662,354.35],[19692,567.83],[19723,815.57],[19754,833.71]],"AUD":[[14791,1.1449],[14822,1.1127],[14853,1.0716],[14883,1.0214],[14914,1.0128],[14944,1.0105],[14975,1.0047],[15006,0.99366],[15034,0.99043],[15065,0.94791],[15095,0.93645],[15126,0.94346],[15156,0.93028],[15187,0.9514],[15218,0.97451],[15248,0.9903],[15279,0.99008],[15309,0.98929],[15340,0.96402],[15371,0.9338],[15400,0.94882],[15431,0.9675],[15461,1.002],[15492,1.0051],[15522,0.97425],[15553,0.95626],[15584,0.96306],[15614,0.97182],[15645,0.96241],[15675,0.95694],[15706,0.95383],[15737,0.96929],[15765,0.96885],[15796,0.96398],[15826,1.0087],[15857,1.0599],[15887,1.092],[15918,1.1083],[15949,1.0807],[15979,1.0532],[16010,1.0739],[16040,1.1136],[16071,1.1302],[16102,1.1178],[16130,1.1036],[16161,1.0751],[16191,1.0753],[16222,1.0693],[16252,1.0664],[16283,1.0753],[16314,1.105],[16344,1.1409],[16375,1.1566],[16405,1.2116],[16436,1.2391],[16467,1.2842],[16495,1.2949],[16526,1.2945],[16556,1.2684],[16587,1.2979],[16617,1.3484],[16648,1.3724],[16679,1.4175],[16709,1.3914],[16740,1.3997],[16770,1.3811],[16801,1.4247],[16832,1.4036],[16861,1.3375],[16892,1.3068],[16922,1.3656],[16953,1.3546],[16983,1.3303],[17014,1.3129],[17045,1.3189],[17075,1.314],[17106,1.3283],[17136,1.3627],[17167,1.3445],[17198,1.3068],[17226,1.3128],[17257,1.3275],[17287,1.3463],[17318,1.325],[17348,1.2852],[17379,1.2644],[17410,1.2567],[17440,1.285],[17471,1.3127],[17501,1.3091],[17532,1.2595],[17563,1.2693],[17591,1.2891],[17622,1.3024],[17652,1.3292],[17683,1.3348],[17713,1.3524],[17744,1.3657],[17775,1.389],[17805,1.4073],[17836,1.3825],[17866,1.3935],[17897,1.401],[17928,1.401],[17956,1.4127],[17987,1.4075],[18017,1.4405],[18048,1.4412],[18078,1.4331],[18109,1.4772],[18140,1.4711],[18170,1.4733],[18201,1.4654],[18231,1.4547],[18262,1.4574],[18293,1.4999],[18322,1.6031],[18353,1.5906],[18383,1.5361],[18414,1.4539],[18444,1.4257],[18475,1.391],[18506,1.3839],[18536,1.4039],[18567,1.3782],[18597,1.3303],[18628,1.2969],[18659,1.2916],[18687,1.2971],[18718,1.3006],[18748,1.2897],[18779,1.309],[18809,1.3464],[18840,1.3701],[18871,1.3682],[18901,1.354],[18932,1.3677],[18962,1.3975],[18993,1.3942],[19024,1.3974],[19052,1.3585],[19083,1.3551],[19113,1.4184],[19144,1.4227],[19174,1.4575],[19205,1.4384],[19236,1.4954],[19266,1.571],[19297,1.5192],[19327,1.4829],[19358,1.4439],[19389,1.4475],[19417,1.4972],[19448,1.4962],[19478,1.506],[19509,1.4937],[19539,1.4873],[19570,1.5396],[19601,1.5563],[19631,1.5749],[19662,1.5432],[19692,1.4969],[19723,1.5046],[19754,1.5325]],"AWG":[[14791,1.7909],[14822,1.7906],[14853,1.7898],[14883,1.7894],[14914,1.7916],[14944,1.7877],[14975,1.791],[15006,1.7918],[15034,1.7899],[15065,1.7907],[15095,1.7917],[15126,1.7913],[15156,1.7892],[15187,1.7906],[15218,1.7893],[15248,1.7892],[15279,1.7896],[15309,1.7907],[15340,1.7879],[15371,1.791],[15400,1.7893],[15431,1.7896],[15461,1.7912],[15492,1.7914],[15522,1.79],[15553,1.7892],[15584,1.789],[15614,1.7896],[15645,1.7901],[15675,1.7914],[15706,1.7906],[15737,1.7904],[15765,1.79],[15796,1.7895],[15826,1.7888],[15857,1.7915],[15887,1.79],[15918,1.7906],[15949,1.7888],[15979,1.7892],[16010,1.7902],[16040,1.7908],[16071,1.7899],[16102,1.7906],[16130,1.7901],[16161,1.7905],[16191,1.7888],[16222,1.7868],[16252,1.7883],[16283,1.7877],[16314,1.7887],[16344,1.7866],[16375,1.7887],[16405,1.7851],[16436,1.7905],[16467,1.7911],[16495,1.7907],[16526,1.7935],[16556,1.791],[16587,1.7946],[16617,1.7928],[16648,1.7924],[16679,1.7914],[16709,1.7922],[16740,1.7923],[16770,1.7915],[16801,1.7927],[16832,1.7918],[16861,1.7924],[16892,1.793],[16922,1.7928],[16953,1.7927],[16983,1.7912],[17014,1.7918],[17045,1.7907],[17075,1.791],[17106,1.7924],[17136,1.7916],[17167,1.788],[17198,1.7885],[17226,1.7884],[17257,1.7892],[17287,1.7872],[17318,1.7875],[17348,1.7853],[17379,1.7864],[17410,1.788],[17440,1.7862],[17471,1.7873],[17501,1.786],[17532,1.7873],[17563,1.785],[17591,1.787],[17622,1.7872],[17652,1.7861],[17683,1.7922],[17713,1.7943],[17744,1.7902],[17775,1.79],[17805,1.7897],[17836,1.7898],[17866,1.79],[17897,1.791],[17928,1.7956],[17956,1.8009],[17987,1.7989],[18017,1.7916],[18048,1.7919],[18078,1.7883],[18109,1.7893],[18140,1.79],[18170,1.7876],[18201,1.7889],[18231,1.7897],[18262,1.7949],[18293,1.7826],[18322,1.7963],[18353,1.796],[18383,1.7921],[18414,1.796],[18444,1.7943],[18475,1.7947],[18506,1.7949],[18536,1.795],[18567,1.7941],[18597,1.795],[18628,1.7941],[18659,1.7941],[18687,1.7933],[18718,1.794],[18748,1.7926],[18779,1.7926],[18809,1.7899],[18840,1.7916],[18871,1.7918],[18901,1.7911],[18932,1.7933],[18962,1.7913],[18993,1.79],[19024,1.7935],[19052,1.7932],[19083,1.791],[19113,1.7923],[19144,1.7912],[19174,1.7907],[19205,1.7927],[19236,1.7912],[19266,1.7946],[19297,1.7937],[19327,1.7935],[19358,1.7931],[19389,1.7946],[19417,1.7966],[19448,1.7965],[19478,1.7967],[19509,1.7964],[19539,1.7966],[19570,1.7965],[19601,1.7965],[19631,1.7975],[19662,1.7984],[19692,1.7963],[19723,1.7963],[19754,1.7959]],"AZN":[[14791,0.80391],[14822,0.80403],[14853,0.80287],[14883,0.80148],[14914,0.80096],[14944,0.79871],[14975,0.79808],[15006,0.79588],[15034,0.79369],[15065,0.79291],[15095,0.7921],[15126,0.78821],[15156,0.78646],[15187,0.78561],[15218,0.78669],[15248,0.7862],[15279,0.78639],[15309,0.78658],[15340,0.78559],[15371,0.78604],[15400,0.78585],[15431,0.78598],[15461,0.78609],[15492,0.78567],[15522,0.78593],[15553,0.78493],[15584,0.78429],[15614,0.78481],[15645,0.78474],[15675,0.78467],[15706,0.78452],[15737,0.78474],[15765,0.78499],[15796,0.78422],[15826,0.78416],[15857,0.78496],[15887,0.78413],[15918,0.78434],[15949,0.78373],[15979,0.78398],[16010,0.78426],[16040,0.78414],[16071,0.78425],[16102,0.78448],[16130,0.78385],[16161,0.78419],[16191,0.78407],[16222,0.78383],[16252,0.78406],[16283,0.78386],[16314,0.78515],[16344,0.78413],[16375,0.78424],[16405,0.7836],[16436,0.78381],[16467,0.82492],[16495,1.0477],[16526,1.049],[16556,1.048],[16587,1.0481],[16617,1.0486],[16648,1.0483],[16679,1.0467],[16709,1.048],[16740,1.0484],[16770,1.2182],[16801,1.5819],[16832,1.5742],[16861,1.5908],[16892,1.5198],[16922,1.4974],[16953,1.5126],[16983,1.557],[17014,1.6106],[17045,1.6389],[17075,1.6154],[17106,1.6946],[17136,1.7563],[17167,1.8255],[17198,1.8013],[17226,1.7333],[17257,1.7025],[17287,1.7013],[17318,1.7004],[17348,1.703],[17379,1.6999],[17410,1.6966],[17440,1.697],[17471,1.6999],[17501,1.6998],[17532,1.7035],[17563,1.6986],[17591,1.6995],[17622,1.6962],[17652,1.6968],[17683,1.6972],[17713,1.6984],[17744,1.7007],[17775,1.6996],[17805,1.6961],[17836,1.698],[17866,1.6985],[17897,1.698],[17928,1.6983],[17956,1.6968],[17987,1.697],[18017,1.697],[18048,1.699],[18078,1.6964],[18109,1.6972],[18140,1.6973],[18170,1.6996],[18201,1.6971],[18231,1.6992],[18262,1.6972],[18293,1.6986],[18322,1.6945],[18353,1.6982],[18383,1.7014],[18414,1.699],[18444,1.7025],[18475,1.6991],[18506,1.6975],[18536,1.6972],[18567,1.6986],[18597,1.7007],[18628,1.6979],[18659,1.7006],[18687,1.6971],[18718,1.7009],[18748,1.6989],[18779,1.6982],[18809,1.6993],[18840,1.6995],[18871,1.6978],[18901,1.6988],[18932,1.6982],[18962,1.6994],[18993,1.6968],[19024,1.6959],[19052,1.6988],[19083,1.6946],[19113,1.7011],[19144,1.6977],[19174,1.6963],[19205,1.6971],[19236,1.6956],[19266,1.7008],[19297,1.7015],[19327,1.7],[19358,1.7005],[19389,1.6982],[19417,1.7015],[19448,1.7],[19478,1.6981],[19509,1.7003],[19539,1.699],[19570,1.7007],[19601,1.6978],[19631,1.7006],[19662,1.7027],[19692,1.7019],[19723,1.6986],[19754,1.6996]],"BAM":[[14791,1.5418],[14822,1.5164],[14853,1.4986],[14883,1.4152],[14914,1.4373],[14944,1.482],[14975,1.4618],[15006,1.4302],[15034,1.4026],[15065,1.3561],[15095,1.3569],[15126,1.3598],[15156,1.3664],[15187,1.3647],[15218,1.4164],[15248,1.4214],[15279,1.4339],[15309,1.4896],[15340,1.5103],[15371,1.4768],[15400,1.4717],[15431,1.4814],[15461,1.5265],[15492,1.5682],[15522,1.5881],[15553,1.5763],[15584,1.5254],[15614,1.5098],[15645,1.5174],[15675,1.4915],[15706,1.4696],[15737,1.4656],[15765,1.5123],[15796,1.5051],[15826,1.5045],[15857,1.4939],[15887,1.4917],[15918,1.4709],[15949,1.4609],[15979,1.4337],[16010,1.4411],[16040,1.4277],[16071,1.4339],[16102,1.4348],[16130,1.4198],[16161,1.4173],[16191,1.424],[16222,1.4366],[16252,1.4452],[16283,1.4638],[16314,1.5193],[16344,1.5436],[16375,1.5639],[16405,1.5916],[16436,1.6776],[16467,1.7222],[16495,1.7868],[16526,1.8053],[16556,1.7652],[16587,1.7553],[16617,1.7744],[16648,1.7577],[16679,1.743],[16709,1.7432],[16740,1.8187],[16770,1.8026],[16801,1.798],[16832,1.7736],[16861,1.7628],[16892,1.7257],[16922,1.7336],[16953,1.75],[16983,1.7686],[17014,1.7492],[17045,1.746],[17075,1.771],[17106,1.8147],[17136,1.8579],[17167,1.8435],[17198,1.8405],[17226,1.8312],[17257,1.8173],[17287,1.7719],[17318,1.7411],[17348,1.6964],[17379,1.654],[17410,1.6462],[17440,1.6689],[17471,1.6664],[17501,1.6544],[17532,1.606],[17563,1.5835],[17591,1.5877],[17622,1.5957],[17652,1.6558],[17683,1.6814],[17713,1.6777],[17744,1.6892],[17775,1.6731],[17805,1.7008],[17836,1.7227],[17866,1.717],[17897,1.7136],[17928,1.7216],[17956,1.7326],[17987,1.7441],[18017,1.7519],[18048,1.7327],[18078,1.7417],[18109,1.7583],[18140,1.781],[18170,1.7713],[18201,1.77],[18231,1.7604],[18262,1.7632],[18293,1.7922],[18322,1.7798],[18353,1.8013],[18383,1.7963],[18414,1.7478],[18444,1.7074],[18475,1.6575],[18506,1.6642],[18536,1.6714],[18567,1.6578],[18597,1.6111],[18628,1.6079],[18659,1.6173],[18687,1.6397],[18718,1.6419],[18748,1.6134],[18779,1.6269],[18809,1.6494],[18840,1.6598],[18871,1.6694],[18901,1.6825],[18932,1.7094],[18962,1.7304],[18993,1.7354],[19024,1.736],[19052,1.7604],[19083,1.8044],[19113,1.8397],[19144,1.8532],[19174,1.9005],[19205,1.9335],[19236,1.9799],[19266,1.99],[19297,1.9353],[19327,1.8556],[19358,1.8214],[19389,1.8234],[19417,1.8251],[19448,1.789],[19478,1.798],[19509,1.8063],[19539,1.7766],[19570,1.7854],[19601,1.8289],[19631,1.8506],[19662,1.8183],[19692,1.7856],[19723,1.7903],[19754,1.8095]],"BBD":[[14791,2.0148],[14822,2.0146],[14853,2.0145],[14883,2.0134],[14914,2.0144],[14944,2.013],[14975,2.015],[15006,2.0158],[15034,2.0148],[15065,2.0146],[15095,2.0155],[15126,2.0158],[15156,2.0133],[15187,2.0145],[15218,2.0134],[15248,2.0123],[15279,2.0126],[15309,2.0146],[15340,2.0111],[15371,2.0137],[15400,2.0132],[15431,2.0127],[15461,2.0152],[15492,2.016],[15522,2.0141],[15553,2.0134],[15584,2.0138],[15614,2.0134],[15645,2.0142],[15675,2.0157],[15706,2.0146],[15737,2.0119],[15765,2.0121],[15796,2.011],[15826,2.0092],[15857,2.0128],[15887,2.0102],[15918,2.0103],[15949,2.0095],[15979,2.008],[16010,2.0087],[16040,2.011],[16071,2.009],[16102,2.0097],[16130,2.0114],[16161,2.0141],[16191,2.0125],[16222,2.0136],[16252,2.0168],[16283,2.0162],[16314,2.0182],[16344,2.0163],[16375,2.0172],[16405,2.0159],[16436,2.0165],[16467,2.0171],[16495,2.0165],[16526,2.0171],[16556,2.0155],[16587,2.0222],[16617,2.0174],[16648,2.0164],[16679,2.0163],[16709,2.0166],[16740,2.0167],[16770,2.0155],[16801,2.0172],[16832,2.0157],[16861,2.0167],[16892,2.0168],[16922,2.0167],[16953,2.0171],[16983,2.0153],[17014,2.0164],[17045,2.0171],[17075,2.0171],[17106,2.0178],[17136,2.0176],[17167,2.0149],[17198,2.0155],[17226,2.0157],[17257,2.0161],[17287,2.0156],[17318,2.016],[17348,2.015],[17379,2.0155],[17410,2.0162],[17440,2.0159],[17471,2.0166],[17501,2.0159],[17532,2.0156],[17563,2.0146],[17591,2.0167],[17622,2.0162],[17652,2.0145],[17683,2.0162],[17713,2.0149],[17744,2.0168],[17775,2.0159],[17805,2.0154],[17836,2.014],[17866,2.0133],[17897,2.0106],[17928,2.0119],[17956,2.0123],[17987,2.0116],[18017,2.01],[18048,2.013],[18078,2.0171],[18109,2.0183],[18140,2.0192],[18170,2.0172],[18201,2.0176],[18231,2.0186],[18262,2.0171],[18293,2.0185],[18322,2.0196],[18353,2.0189],[18383,2.0189],[18414,2.0196],[18444,2.0182],[18475,2.0185],[18506,2.0189],[18536,2.0188],[18567,2.0169],[18597,2.019],[18628,2.0155],[18659,2.016],[18687,2.015],[18718,2.0159],[18748,2.0152],[18779,2.0172],[18809,2.0145],[18840,2.0151],[18871,2.0154],[18901,2.0142],[18932,2.0173],[18962,2.0159],[18993,2.0148],[19024,2.0183],[19052,2.0176],[19083,2.0135],[19113,2.0147],[19144,2.0147],[19174,2.0132],[19205,2.0144],[19236,2.0139],[19266,2.0162],[19297,2.0153],[19327,2.0158],[19358,2.0148],[19389,2.0183],[19417,2.0174],[19448,2.0169],[19478,2.0171],[19509,2.0173],[19539,2.0171],[19570,2.017],[19601,2.0173],[19631,2.018],[19662,2.0188],[19692,2.0154],[19723,2.0145],[19754,2.0142]],"BDT":[[14791,69.58],[14822,69.349],[14853,69.428],[14883,70.016],[14914,70.74],[14944,70.467],[14975,70.995],[15006,71.102],[15034,71.664],[15065,72.752],[15095,73.175],[15126,73.531],[15156,74.359],[15187,74.431],[15218,74.543],[15248,75.46],[15279,76.253],[15309,78.487],[15340,82.98],[15371,83.22],[15400,81.52],[15431,81.89],[15461,81.95],[15492,81.97],[15522,82.05],[15553,81.38],[15584,81.59],[15614,81.59],[15645,81.49],[15675,80.79],[15706,79.798],[15737,78.955],[15765,78.946],[15796,78.227],[15826,77.788],[15857,78.018],[15887,77.888],[15918,77.732],[15949,77.876],[15979,77.625],[16010,77.758],[16040,77.815],[16071,77.578],[16102,77.78],[16130,77.892],[16161,77.683],[16191,77.621],[16222,77.799],[16252,77.629],[16283,77.582],[16314,77.658],[16344,77.474],[16375,77.541],[16405,77.716],[16436,77.558],[16467,77.664],[16495,77.97],[16526,78.026],[16556,77.496],[16587,77.825],[16617,77.757],[16648,77.921],[16679,77.936],[16709,77.503],[16740,77.983],[16770,78.56],[16801,78.558],[16832,78.42],[16861,78.71],[16892,78.393],[16922,78.324],[16953,78.386],[16983,78.375],[17014,78.368],[17045,78.507],[17075,78.331],[17106,78.561],[17136,78.911],[17167,79.077],[17198,79.449],[17226,79.855],[17257,80.88],[17287,81.03],[17318,80.74],[17348,80.94],[17379,80.96],[17410,80.8],[17440,81.46],[17471,82.14],[17501,82.53],[17532,83.06],[17563,82.98],[17591,82.92],[17622,83.15],[17652,83.72],[17683,83.79],[17713,83.9],[17744,84.0],[17775,83.69],[17805,84.03],[17836,83.87],[17866,83.87],[17897,83.77],[17928,83.97],[17956,83.95],[17987,84.08],[18017,84.27],[18048,84.42],[18078,84.38],[18109,84.37],[18140,84.39],[18170,84.64],[18201,84.84],[18231,84.99],[18262,84.76],[18293,84.9],[18322,84.94],[18353,84.88],[18383,85.04],[18414,84.95],[18444,85.04],[18475,84.85],[18506,84.75],[18536,84.73],[18567,84.75],[18597,84.83],[18628,84.63],[18659,84.85],[18687,84.65],[18718,84.85],[18748,84.73],[18779,84.71],[18809,84.79],[18840,84.95],[18871,85.12],[18901,85.5],[18932,85.66],[18962,85.81],[18993,85.78],[19024,85.97],[19052,86.19],[19083,85.95],[19113,87.54],[19144,91.5],[19174,93.71],[19205,94.65],[19236,98.57],[19266,102.1],[19297,102.12],[19327,103.52],[19358,104.21],[19389,105.55],[19417,105.88],[19448,106.31],[19478,106.85],[19509,107.84],[19539,108.44],[19570,109.09],[19601,109.65],[19631,110.32],[19662,110.72],[19692,110.55],[19723,109.67],[19754,109.78]],"BHD":[[14791,0.37806],[14822,0.37741],[14853,0.37697],[14883,0.37684],[14914,0.37762],[14944,0.37768],[14975,0.37685],[15006,0.37723],[15034,0.37694],[15065,0.37719],[15095,0.37748],[15126,0.37694],[15156,0.37696],[15187,0.37752],[15218,0.37768],[15248,0.37692],[15279,0.37703],[15309,0.37781],[15340,0.37771],[15371,0.37662],[15400,0.37667],[15431,0.37729],[15461,0.37703],[15492,0.37779],[15522,0.37823],[15553,0.37588],[15584,0.37666],[15614,0.37732],[15645,0.377],[15675,0.37681],[15706,0.37706],[15737,0.37675],[15765,0.37761],[15796,0.37728],[15826,0.37607],[15857,0.37745],[15887,0.37755],[15918,0.37675],[15949,0.37764],[15979,0.37701],[16010,0.37701],[16040,0.3767],[16071,0.37641],[16102,0.37718],[16130,0.37723],[16161,0.37699],[16191,0.37678],[16222,0.37703],[16252,0.37686],[16283,0.37684],[16314,0.37761],[16344,0.37713],[16375,0.37741],[16405,0.37658],[16436,0.37654],[16467,0.37609],[16495,0.37702],[16526,0.37785],[16556,0.37593],[16587,0.37678],[16617,0.37656],[16648,0.37763],[16679,0.37705],[16709,0.3758],[16740,0.37638],[16770,0.37719],[16801,0.37717],[16832,0.37661],[16861,0.37818],[16892,0.37704],[16922,0.37667],[16953,0.37672],[16983,0.37697],[17014,0.37665],[17045,0.37691],[17075,0.37657],[17106,0.37626],[17136,0.37684],[17167,0.3772],[17198,0.37683],[17226,0.37733],[17257,0.37833],[17287,0.37792],[17318,0.37754],[17348,0.37828],[17379,0.3782],[17410,0.37669],[17440,0.37668],[17471,0.37721],[17501,0.37713],[17532,0.37817],[17563,0.37741],[17591,0.37738],[17622,0.37681],[17652,0.37688],[17683,0.3774],[17713,0.37799],[17744,0.37769],[17775,0.37681],[17805,0.3766],[17836,0.37676],[17866,0.37686],[17897,0.37679],[17928,0.37689],[17956,0.37696],[17987,0.37688],[18017,0.3765],[18048,0.3769],[18078,0.37639],[18109,0.37644],[18140,0.37676],[18170,0.37699],[18201,0.37664],[18231,0.3769],[18262,0.37651],[18293,0.37687],[18322,0.37704],[18353,0.3772],[18383,0.37757],[18414,0.37735],[18444,0.37759],[18475,0.37694],[18506,0.37654],[18536,0.37655],[18567,0.37688],[18597,0.37734],[18628,0.37672],[18659,0.37721],[18687,0.37649],[18718,0.37729],[18748,0.37687],[18779,0.37694],[18809,0.37702],[18840,0.37685],[18871,0.37646],[18901,0.37675],[18932,0.37675],[18962,0.37715],[18993,0.37639],[19024,0.37669],[19052,0.37737],[19083,0.37596],[19113,0.37785],[19144,0.37691],[19174,0.37656],[19205,0.37653],[19236,0.37626],[19266,0.37794],[19297,0.3782],[19327,0.37754],[19358,0.37751],[19389,0.37684],[19417,0.37769],[19448,0.37718],[19478,0.37647],[19509,0.37713],[19539,0.37666],[19570,0.37705],[19601,0.3763],[19631,0.37695],[19662,0.37746],[19692,0.37724],[19723,0.37652],[19754,0.37668]],"BIF":[[14791,1227.45],[14822,1222.98],[14853,1221.17],[14883,1222.28],[14914,1230.8],[14944,1239.33],[14975,1225.52],[15006,1227.6],[15034,1231.15],[15065,1230.79],[15095,1227.92],[15126,1236.03],[15156,1215.4],[15187,1226.89],[15218,1231.46],[15248,1248.18],[15279,1234.07],[15309,1316.54],[15340,1337.35],[15371,1299.94],[15400,1307.42],[15431,1306.74],[15461,1392.21],[15492,1412.16],[15522,1434.21],[15553,1448.47],[15584,1460.13],[15614,1479.82],[15645,1494.16],[15675,1521.45],[15706,1538.5],[15737,1587.33],[15765,1591.0],[15796,1569.15],[15826,1558.22],[15857,1551.21],[15887,1542.65],[15918,1535.56],[15949,1540.2],[15979,1539.78],[16010,1545.64],[16040,1542.79],[16071,1542.76],[16102,1549.03],[16130,1546.97],[16161,1546.58],[16191,1544.49],[16222,1545.73],[16252,1544.67],[16283,1547.1],[16314,1547.67],[16344,1552.26],[16375,1558.79],[16405,1552.85],[16436,1557.59],[16467,1557.45],[16495,1563.52],[16526,1561.3],[16556,1554.97],[16587,1570.29],[16617,1591.49],[16648,1568.1],[16679,1573.46],[16709,1564.48],[16740,1573.91],[16770,1582.05],[16801,1582.21],[16832,1580.74],[16861,1587.75],[16892,1584.37],[16922,1587.4],[16953,1619.11],[16983,1658.14],[17014,1663.43],[17045,1668.17],[17075,1672.56],[17106,1675.96],[17136,1675.25],[17167,1681.68],[17198,1694.64],[17226,1683.2],[17257,1704.81],[17287,1706.9],[17318,1715.41],[17348,1718.9],[17379,1733.17],[17410,1739.5],[17440,1751.27],[17471,1758.82],[17501,1755.17],[17532,1760.69],[17563,1763.14],[17591,1764.2],[17622,1765.9],[17652,1770.62],[17683,1765.72],[17713,1770.71],[17744,1775.65],[17775,1772.5],[17805,1783.22],[17836,1789.84],[17866,1791.68],[17897,1799.41],[17928,1806.82],[17956,1809.52],[17987,1829.13],[18017,1830.82],[18048,1833.27],[18078,1840.5],[18109,1844.64],[18140,1845.98],[18170,1857.9],[18201,1868.64],[18231,1870.88],[18262,1877.7],[18293,1884.3],[18322,1881.89],[18353,1890.8],[18383,1899.94],[18414,1902.82],[18444,1915.97],[18475,1921.86],[18506,1922.98],[18536,1929.24],[18567,1937.08],[18597,1937.41],[18628,1941.33],[18659,1947.22],[18687,1947.92],[18718,1954.84],[18748,1963.74],[18779,1972.37],[18809,1979.46],[18840,1980.76],[18871,1983.98],[18901,1990.6],[18932,1995.2],[18962,1994.09],[18993,2000.17],[19024,2013.65],[19052,2024.15],[19083,2025.46],[19113,2041.04],[19144,2030.18],[19174,2044.81],[19205,2050.69],[19236,2046.86],[19266,2055.42],[19297,2062.54],[19327,2063.08],[19358,2067.35],[19389,2073.95],[19417,2072.94],[19448,2079.52],[19478,2326.63],[19509,2822.13],[19539,2829.51],[19570,2830.85],[19601,2837.8],[19631,2841.34],[19662,2845.73],[19692,2850.15],[19723,2852.93],[19754,2859.46]],"BMD":[[14791,1.0],[14822,1.0],[14853,1.0],[14883,1.0],[14914,1.0],[14944,1.0],[14975,1.0],[15006,1.0],[15034,1.0],[15065,1.0],[15095,1.0],[15126,1.0],[15156,1.0],[15187,1.0],[15218,1.0],[15248,1.0],[15279,1.0],[15309,1.0],[15340,1.0],[15371,1.0],[15400,1.0],[15431,1.0],[15461,1.0],[15492,1.0],[15522,1.0],[15553,1.0],[15584,1.0],[15614,1.0],[15645,1.0],[15675,1.0],[15706,1.0],[15737,1.0],[15765,1.0],[15796,1.0],[15826,1.0],[15857,1.0],[15887,1.0],[15918,1.0],[15949,1.0],[15979,1.0],[16010,1.0],[16040,1.0],[16071,1.0],[16102,1.0],[16130,1.0],[16161,1.0],[16191,1.0],[16222,1.0],[16252,1.0],[16283,1.0],[16314,1.0],[16344,1.0],[16375,1.0],[16405,1.0],[16436,1.0],[16467,1.0],[16495,1.0],[16526,1.0],[16556,1.0],[16587,1.0],[16617,1.0],[16648,1.0],[16679,1.0],[16709,1.0],[16740,1.0],[16770,1.0],[16801,1.0],[16832,1.0],[16861,1.0],[16892,1.0],[16922,1.0],[16953,1.0],[16983,1.0],[17014,1.0],[17045,1.0],[17075,1.0],[17106,1.0],[17136,1.0],[17167,1.0],[17198,1.0],[17226,1.0],[17257,1.0],[17287,1.0],[17318,1.0],[17348,1.0],[17379,1.0],[17410,1.0],[17440,1.0],[17471,1.0],[17501,1.0],[17532,1.0],[17563,1.0],[17591,1.0],[17622,1.0],[17652,1.0],[17683,1.0],[17713,1.0],[17744,1.0],[17775,1.0],[17805,1.0],[17836,1.0],[17866,1.0],[17897,1.0],[17928,1.0],[17956,1.0],[17987,1.0],[18017,1.0],[18048,1.0],[18078,1.0],[18109,1.0],[18140,1.0],[18170,1.0],[18201,1.0],[18231,1.0],[18262,0.99765],[18293,1.0],[18322,1.0],[18353,1.0],[18383,1.0001],[18414,1.0],[18444,1.0],[18475,1.0],[18506,1.0],[18536,1.0],[18567,1.0],[18597,1.0],[18628,1.0],[18659,1.0],[18687,1.0],[18718,1.0],[18748,1.0],[18779,1.0],[18809,0.99998],[18840,1.0],[18871,1.0],[18901,1.0],[18932,1.0],[18962,1.0],[18993,1.0],[19024,0.99993],[19052,1.0],[19083,1.0],[19113,1.0],[19144,1.0],[19174,1.0],[19205,1.0],[19236,1.0],[19266,1.0001],[19297,1.0],[19327,1.0],[19358,1.0],[19389,1.0],[19417,1.0],[19448,1.0],[19478,1.0001],[19509,1.0003],[19539,1.0],[19570,1.0],[19601,1.0],[19631,1.0],[19662,1.0],[19692,1.0],[19723,1.0],[19754,1.0]],"BND":[[14791,1.3812],[14822,1.3581],[14853,1.3402],[14883,1.3046],[14914,1.3021],[14944,1.3099],[14975,1.2859],[15006,1.2776],[15034,1.2696],[15065,1.2476],[15095,1.2347],[15126,1.2335],[15156,1.2171],[15187,1.209],[15218,1.258],[15248,1.2758],[15279,1.283],[15309,1.2959],[15340,1.2787],[15371,1.2539],[15400,1.2559],[15431,1.2512],[15461,1.2643],[15492,1.2805],[15522,1.2632],[15553,1.2507],[15584,1.2323],[15614,1.2245],[15645,1.2219],[15675,1.2211],[15706,1.2293],[15737,1.2371],[15765,1.2442],[15796,1.2382],[15826,1.2459],[15857,1.2633],[15887,1.2697],[15918,1.2728],[15949,1.2648],[15979,1.2442],[16010,1.2474],[16040,1.2593],[16071,1.2725],[16102,1.269],[16130,1.2644],[16161,1.2567],[16191,1.253],[16222,1.2517],[16252,1.2436],[16283,1.2478],[16314,1.2613],[16344,1.2745],[16375,1.2958],[16405,1.3062],[16436,1.3381],[16467,1.3551],[16495,1.3741],[16526,1.3495],[16556,1.3328],[16587,1.3474],[16617,1.3613],[16648,1.3972],[16679,1.4144],[16709,1.4053],[16740,1.4128],[16770,1.4103],[16801,1.4296],[16832,1.4091],[16861,1.3719],[16892,1.3493],[16922,1.3682],[16953,1.3559],[16983,1.3506],[17014,1.3494],[17045,1.3611],[17075,1.3813],[17106,1.4109],[17136,1.4379],[17167,1.4297],[17198,1.4171],[17226,1.4029],[17257,1.3981],[17287,1.3935],[17318,1.3822],[17348,1.3704],[17379,1.3595],[17410,1.3534],[17440,1.3595],[17471,1.3562],[17501,1.3452],[17532,1.3269],[17563,1.3207],[17591,1.3168],[17622,1.3187],[17652,1.3337],[17683,1.3443],[17713,1.3582],[17744,1.3647],[17775,1.3699],[17805,1.3786],[17836,1.3763],[17866,1.3693],[17897,1.3566],[17928,1.3527],[17956,1.3536],[17987,1.3573],[18017,1.3705],[18048,1.3623],[18078,1.3604],[18109,1.3833],[18140,1.3818],[18170,1.371],[18201,1.362],[18231,1.3559],[18262,1.3519],[18293,1.3861],[18322,1.4154],[18353,1.4239],[18383,1.4178],[18414,1.3972],[18444,1.3873],[18475,1.3706],[18506,1.3652],[18536,1.3607],[18567,1.3499],[18597,1.3324],[18628,1.3264],[18659,1.3269],[18687,1.3374],[18718,1.3365],[18748,1.3279],[18779,1.3352],[18809,1.3518],[18840,1.3551],[18871,1.3516],[18901,1.3511],[18932,1.3562],[18962,1.3625],[18993,1.3534],[19024,1.3491],[19052,1.356],[19083,1.3648],[19113,1.3789],[19144,1.3832],[19174,1.3912],[19205,1.3866],[19236,1.4125],[19266,1.423],[19297,1.3928],[19327,1.3565],[19358,1.3289],[19389,1.3291],[19417,1.3389],[19448,1.3314],[19478,1.3397],[19509,1.3479],[19539,1.3369],[19570,1.3449],[19601,1.3612],[19631,1.3682],[19662,1.3515],[19692,1.3314],[19723,1.3335],[19754,1.3436]],"BOB":[[14791,7.0155],[14822,7.0185],[14853,7.0042],[14883,7.0118],[14914,7.0212],[14944,6.9902],[14975,6.9976],[15006,6.9914],[15034,6.975],[15065,6.9735],[15095,6.9807],[15126,6.9661],[15156,6.9617],[15187,6.9567],[15218,6.9641],[15248,6.9557],[15279,6.9493],[15309,6.9102],[15340,6.9056],[15371,6.9139],[15400,6.9003],[15431,6.939],[15461,6.9495],[15492,6.9404],[15522,6.9602],[15553,6.9473],[15584,6.9488],[15614,6.9531],[15645,6.9502],[15675,6.9497],[15706,6.9482],[15737,6.9511],[15765,6.9558],[15796,6.9474],[15826,6.944],[15857,6.9308],[15887,6.9037],[15918,6.9048],[15949,6.8952],[15979,6.9016],[16010,6.9055],[16040,6.8998],[16071,6.9045],[16102,6.904],[16130,6.8978],[16161,6.9034],[16191,6.9038],[16222,6.8979],[16252,6.9051],[16283,6.9049],[16314,6.9035],[16344,6.9041],[16375,6.9057],[16405,6.8966],[16436,6.9028],[16467,6.9039],[16495,6.8951],[16526,6.9029],[16556,6.8978],[16587,6.8983],[16617,6.8993],[16648,6.8942],[16679,6.8925],[16709,6.9016],[16740,6.9018],[16770,6.8959],[16801,6.9033],[16832,6.8943],[16861,6.8592],[16892,6.8803],[16922,6.8954],[16953,6.8804],[16983,6.8929],[17014,6.9087],[17045,6.9037],[17075,6.9113],[17106,6.9123],[17136,6.9058],[17167,6.906],[17198,6.907],[17226,6.9019],[17257,6.9088],[17287,6.9124],[17318,6.9104],[17348,6.9128],[17379,6.9156],[17410,6.9122],[17440,6.9079],[17471,6.9113],[17501,6.9095],[17532,6.9254],[17563,6.9015],[17591,6.9008],[17622,6.8965],[17652,6.8904],[17683,6.8947],[17713,6.9043],[17744,6.9177],[17775,6.8991],[17805,6.8934],[17836,6.9009],[17866,6.898],[17897,6.8997],[17928,6.9003],[17956,6.8912],[17987,6.8942],[18017,6.8951],[18048,6.9],[18078,6.893],[18109,6.895],[18140,6.8914],[18170,6.9],[18201,6.8934],[18231,6.8946],[18262,6.8894],[18293,6.8986],[18322,6.881],[18353,6.8903],[18383,6.8996],[18414,6.8938],[18444,6.9117],[18475,6.8984],[18506,6.8833],[18536,6.8903],[18567,6.892],[18597,6.8843],[18628,6.888],[18659,6.9056],[18687,6.8859],[18718,6.9045],[18748,6.8934],[18779,6.8868],[18809,6.8936],[18840,6.8939],[18871,6.8809],[18901,6.8916],[18932,6.8852],[18962,6.8855],[18993,6.8759],[19024,6.8794],[19052,6.8798],[19083,6.8566],[19113,6.8903],[19144,6.8719],[19174,6.8716],[19205,6.8845],[19236,6.8827],[19266,6.9133],[19297,6.9129],[19327,6.9018],[19358,6.9065],[19389,6.8967],[19417,6.9065],[19448,6.905],[19478,6.8939],[19509,6.9015],[19539,6.8969],[19570,6.9053],[19601,6.8898],[19631,6.9057],[19662,6.9154],[19692,6.9001],[19723,6.8959],[19754,6.8959]],"BRL":[[14791,1.7723],[14822,1.7606],[14853,1.7242],[14883,1.6859],[14914,1.7124],[14944,1.6978],[14975,1.6752],[15006,1.6689],[15034,1.6585],[15065,1.5909],[15095,1.6096],[15126,1.5868],[15156,1.5653],[15187,1.5935],[15218,1.7334],[15248,1.7784],[15279,1.782],[15309,1.839],[15340,1.7934],[15371,1.7203],[15400,1.7825],[15431,1.8509],[15461,1.9753],[15492,2.0462],[15522,2.0319],[15553,2.0305],[15584,2.0289],[15614,2.0308],[15645,2.0638],[15675,2.0795],[15706,2.0308],[15737,1.9768],[15765,1.9848],[15796,2.0038],[15826,2.0343],[15857,2.1655],[15887,2.2472],[15918,2.3357],[15949,2.278],[15979,2.1947],[16010,2.2886],[16040,2.3457],[16071,2.3808],[16102,2.3857],[16130,2.3282],[16161,2.2373],[16191,2.222],[16222,2.2325],[16252,2.2246],[16283,2.2664],[16314,2.3208],[16344,2.4508],[16375,2.5447],[16405,2.6334],[16436,2.6371],[16467,2.8104],[16495,3.1364],[16526,3.0468],[16556,3.0516],[16587,3.1158],[16617,3.2122],[16648,3.5005],[16679,3.8868],[16709,3.8817],[16740,3.789],[16770,3.87],[16801,4.0434],[16832,3.9721],[16861,3.7047],[16892,3.5619],[16922,3.5347],[16953,3.4402],[16983,3.2782],[17014,3.2137],[17045,3.25],[17075,3.1924],[17106,3.3343],[17136,3.3552],[17167,3.2003],[17198,3.1067],[17226,3.1297],[17257,3.1376],[17287,3.205],[17318,3.2912],[17348,3.2116],[17379,3.1535],[17410,3.1404],[17440,3.1911],[17471,3.2617],[17501,3.2914],[17532,3.2147],[17563,3.2418],[17591,3.2765],[17622,3.3993],[17652,3.6207],[17683,3.7754],[17713,3.8281],[17744,3.9129],[17775,4.1046],[17805,3.775],[17836,3.7786],[17866,3.8833],[17897,3.7504],[17928,3.7201],[17956,3.8335],[17987,3.8992],[18017,3.996],[18048,3.8697],[18078,3.7852],[18109,4.0108],[18140,4.1243],[18170,4.0924],[18201,4.1469],[18231,4.1198],[18262,4.1405],[18293,4.3415],[18322,4.847],[18353,5.3088],[18383,5.6307],[18414,5.1961],[18444,5.293],[18475,5.4325],[18506,5.405],[18536,5.6266],[18567,5.4458],[18597,5.162],[18628,5.3357],[18659,5.4099],[18687,5.6375],[18718,5.5723],[18748,5.3144],[18779,5.0459],[18809,5.1331],[18840,5.2469],[18871,5.2729],[18901,5.521],[18932,5.5596],[18962,5.6463],[18993,5.5408],[19024,5.2041],[19052,4.9816],[19083,4.7498],[19113,4.9531],[19144,5.0355],[19174,5.3526],[19205,5.1521],[19236,5.2229],[19266,5.268],[19297,5.2806],[19327,5.2577],[19358,5.1999],[19389,5.1673],[19417,5.2052],[19448,5.0322],[19478,4.984],[19509,4.8718],[19539,4.8055],[19570,4.8883],[19601,4.9423],[19631,5.0563],[19662,4.9139],[19692,4.8978],[19723,4.9089],[19754,4.9617]],"BSD":[[14791,1.0089],[14822,1.0089],[14853,1.0068],[14883,1.0081],[14914,1.0087],[14944,1.0059],[14975,1.0091],[15006,1.0093],[15034,1.007],[15065,1.009],[15095,1.0095],[15126,1.0074],[15156,1.008],[15187,1.0089],[15218,1.0066],[15248,1.0083],[15279,1.0078],[15309,1.0072],[15340,1.0072],[15371,1.0092],[15400,1.0066],[15431,1.0081],[15461,1.0089],[15492,1.0077],[15522,1.0086],[15553,1.0081],[15584,1.0064],[15614,1.0081],[15645,1.0085],[15675,1.0077],[15706,1.0088],[15737,1.0086],[15765,1.0069],[15796,1.0086],[15826,1.0079],[15857,1.0075],[15887,1.0085],[15918,1.0087],[15949,1.0066],[15979,1.008],[16010,1.0085],[16040,1.0073],[16071,1.0083],[16102,1.0087],[16130,1.007],[16161,1.0076],[16191,1.0082],[16222,1.0067],[16252,1.0085],[16283,1.0082],[16314,1.0077],[16344,1.0083],[16375,1.0085],[16405,1.0063],[16436,1.0082],[16467,1.0086],[16495,1.0064],[16526,1.0086],[16556,1.0077],[16587,1.0077],[16617,1.0087],[16648,1.0082],[16679,1.0066],[16709,1.0085],[16740,1.0083],[16770,1.007],[16801,1.0086],[16832,1.0079],[16861,1.0069],[16892,1.0085],[16922,1.0085],[16953,1.007],[16983,1.0077],[17014,1.0083],[17045,1.0071],[17075,1.0088],[17106,1.0089],[17136,1.0073],[17167,1.0079],[17198,1.0081],[17226,1.0066],[17257,1.0085],[17287,1.0083],[17318,1.0068],[17348,1.0079],[17379,1.0083],[17410,1.0069],[17440,1.0084],[17471,1.0088],[17501,1.0068],[17532,1.0083],[17563,1.0077],[17591,1.0073],[17622,1.0088],[17652,1.0078],[17683,1.0068],[17713,1.0079],[17744,1.0089],[17775,1.0068],[17805,1.0081],[17836,1.0084],[17866,1.0067],[17897,1.0084],[17928,1.0085],[17956,1.0071],[17987,1.0086],[18017,1.0081],[18048,1.0069],[18078,1.0078],[18109,1.0084],[18140,1.0073],[18170,1.0078],[18201,1.0082],[18231,1.0071],[18262,1.0061],[18293,1.007],[18322,1.0068],[18353,1.0074],[18383,1.0072],[18414,1.0065],[18444,1.0068],[18475,1.0071],[18506,1.006],[18536,1.007],[18567,1.0067],[18597,1.0061],[18628,1.0061],[18659,1.0059],[18687,1.0051],[18718,1.0061],[18748,1.0057],[18779,1.0056],[18809,1.0054],[18840,1.006],[18871,1.0052],[18901,1.0056],[18932,1.0067],[18962,1.0051],[18993,1.0059],[19024,1.0071],[19052,1.0058],[19083,1.0053],[19113,1.0058],[19144,1.0054],[19174,1.0051],[19205,1.0057],[19236,1.0042],[19266,1.0066],[19297,1.0062],[19327,1.0053],[19358,1.006],[19389,1.0065],[19417,1.0051],[19448,1.006],[19478,1.006],[19509,1.0054],[19539,1.006],[19570,1.0059],[19601,1.0052],[19631,1.0065],[19662,1.0067],[19692,1.0051],[19723,1.0059],[19754,1.0056]],"BTN":[[14791,46.574],[14822,46.695],[14853,46.098],[14883,44.661],[14914,45.04],[14944,45.307],[14975,45.395],[15006,45.457],[15034,45.009],[15065,44.501],[15095,44.756],[15126,44.888],[15156,44.526],[15187,45.076],[15218,47.247],[15248,49.044],[15279,50.362],[15309,52.529],[15340,51.535],[15371,49.338],[15400,50.06],[15431,51.715],[15461,54.16],[15492,56.041],[15522,55.968],[15553,55.592],[15584,54.472],[15614,53.294],[15645,54.378],[15675,54.784],[15706,54.236],[15737,53.623],[15765,54.248],[15796,54.305],[15826,55.013],[15857,57.878],[15887,60.202],[15918,62.652],[15949,64.517],[15979,61.883],[16010,62.114],[16040,62.073],[16071,62.074],[16102,62.309],[16130,61.162],[16161,60.302],[16191,59.731],[16222,59.489],[16252,60.097],[16283,60.521],[16314,60.959],[16344,61.45],[16375,61.658],[16405,62.554],[16436,62.589],[16467,61.898],[16495,62.288],[16526,62.804],[16556,63.656],[16587,63.83],[16617,63.765],[16648,64.901],[16679,66.099],[16709,65.458],[16740,65.983],[16770,66.553],[16801,67.104],[16832,68.289],[16861,67.386],[16892,66.452],[16922,66.819],[16953,67.302],[16983,67.309],[17014,66.979],[17045,66.834],[17075,66.772],[17106,67.522],[17136,68.136],[17167,68.012],[17198,67.26],[17226,65.965],[17257,64.623],[17287,64.366],[17318,64.579],[17348,64.413],[17379,64.054],[17410,64.485],[17440,65.074],[17471,64.785],[17501,64.237],[17532,63.791],[17563,64.28],[17591,64.98],[17622,65.7],[17652,67.13],[17683,67.835],[17713,68.658],[17744,69.502],[17775,71.724],[17805,73.359],[17836,72.006],[17866,70.315],[17897,70.547],[17928,71.167],[17956,70.043],[17987,69.422],[18017,69.826],[18048,69.389],[18078,68.897],[18109,70.416],[18140,71.3],[18170,70.836],[18201,71.304],[18231,71.376],[18262,71.335],[18293,71.666],[18322,73.659],[18353,75.749],[18383,75.696],[18414,75.685],[18444,75.112],[18475,74.478],[18506,73.522],[18536,73.66],[18567,74.152],[18597,73.639],[18628,73.243],[18659,72.954],[18687,72.863],[18718,73.674],[18748,73.726],[18779,73.32],[18809,74.39],[18840,74.077],[18871,73.808],[18901,74.474],[18932,74.756],[18962,74.861],[18993,74.904],[19024,74.954],[19052,75.667],[19083,76.131],[19113,76.935],[19144,77.985],[19174,79.104],[19205,79.686],[19236,80.15],[19266,81.63],[19297,82.16],[19327,82.17],[19358,82.22],[19389,82.04],[19417,82.5],[19448,82.13],[19478,82.2],[19509,82.43],[19539,82.12],[19570,82.47],[19601,82.87],[19631,83.19],[19662,83.26],[19692,83.29],[19723,83.14],[19754,83.04]],"BWP":[[14791,6.9619],[14822,6.8297],[14853,6.754],[14883,6.5517],[14914,6.6328],[14944,6.6171],[14975,6.6188],[15006,6.7222],[15034,6.6053],[15065,6.4776],[15095,6.5315],[15126,6.5455],[15156,6.539],[15187,6.6934],[15218,7.0712],[15248,7.2984],[15279,7.4101],[15309,7.5312],[15340,7.4428],[15371,7.2363],[15400,7.2322],[15431,7.3427],[15461,7.5635],[15492,7.7951],[15522,7.7382],[15553,7.7412],[15584,7.6693],[15614,7.8263],[15645,7.942],[15675,7.8504],[15706,7.929],[15737,8.0106],[15765,8.2062],[15796,8.1837],[15826,8.3433],[15857,8.5685],[15887,8.5759],[15918,8.6058],[15949,8.5797],[15979,8.4783],[16010,8.6],[16040,8.6885],[16071,8.9221],[16102,8.9693],[16130,8.8534],[16161,8.7955],[16191,8.7197],[16222,8.7982],[16252,8.8283],[16283,8.8627],[16314,9.111],[16344,9.171],[16375,9.2233],[16405,9.415],[16436,9.5834],[16467,9.588],[16495,9.8869],[16526,9.8747],[16556,9.8178],[16587,9.92],[16617,10.0262],[16648,10.2005],[16679,10.4872],[16709,10.4514],[16740,10.7334],[16770,11.0445],[16801,11.4587],[16832,11.3664],[16861,11.0744],[16892,10.8084],[16922,11.0222],[16953,10.9876],[16983,10.8137],[17014,10.5854],[17045,10.585],[17075,10.5983],[17106,10.6781],[17136,10.7191],[17167,10.6181],[17198,10.454],[17226,10.3915],[17257,10.449],[17287,10.3476],[17318,10.2178],[17348,10.1903],[17379,10.1859],[17410,10.1807],[17440,10.3867],[17471,10.4828],[17501,10.1271],[17532,9.7346],[17563,9.5501],[17591,9.5426],[17622,9.6417],[17652,9.916],[17683,10.2323],[17713,10.2946],[17744,10.5394],[17775,10.7082],[17805,10.7336],[17836,10.6414],[17866,10.6549],[17897,10.5284],[17928,10.49],[17956,10.6813],[17987,10.6702],[18017,10.775],[18048,10.7221],[18078,10.63],[18109,10.9714],[18140,10.9823],[18170,10.9489],[18201,10.8715],[18231,10.717],[18262,10.7298],[18293,10.9967],[18322,11.5315],[18353,12.059],[18383,12.0553],[18414,11.7344],[18444,11.5784],[18475,11.5673],[18506,11.5258],[18536,11.4716],[18567,11.1915],[18597,10.8796],[18628,10.9303],[18659,10.9333],[18687,10.9879],[18718,10.9181],[18748,10.7293],[18779,10.771],[18809,10.9779],[18840,11.1059],[18871,11.1653],[18901,11.2573],[18932,11.4764],[18962,11.7046],[18993,11.6488],[19024,11.5824],[19052,11.5349],[19083,11.6582],[19113,12.0601],[19144,12.1706],[19174,12.4853],[19205,12.6834],[19236,13.0552],[19266,13.3341],[19297,13.1398],[19327,12.8448],[19358,12.7726],[19389,12.9943],[19417,13.1826],[19448,13.1491],[19478,13.4267],[19509,13.5196],[19539,13.2581],[19570,13.3665],[19601,13.5961],[19631,13.7199],[19662,13.5486],[19692,13.4828],[19723,13.5348],[19754,13.6884]],"BYN":[[14791,0.2975],[14822,0.3012],[14853,0.301],[14883,0.3011],[14914,0.3032],[14944,0.3],[14975,0.3011],[15006,0.302],[15034,0.3045],[15065,0.3013],[15095,0.4977],[15126,0.4964],[15156,0.495],[15187,0.5096],[15218,0.5599],[15248,0.85],[15279,0.86],[15309,0.835],[15340,0.837],[15371,0.807],[15400,0.802],[15431,0.805],[15461,0.835],[15492,0.832],[15522,0.825],[15553,0.841],[15584,0.85],[15614,0.853],[15645,0.858],[15675,0.857],[15706,0.863],[15737,0.863],[15765,0.868],[15796,0.865],[15826,0.87],[15857,0.879],[15887,0.888],[15918,0.899],[15949,0.908],[15979,0.921],[16010,0.933],[16040,0.951],[16071,0.964],[16102,0.976],[16130,0.987],[16161,0.998],[16191,1.008],[16222,1.02],[16252,1.031],[16283,1.042],[16314,1.058],[16344,1.071],[16375,1.08],[16405,1.185],[16436,1.536],[16467,1.489],[16495,1.474],[16526,1.447],[16556,1.489],[16587,1.5346],[16617,1.5221],[16648,1.7539],[16679,1.7703],[16709,1.7438],[16740,1.8153],[16770,1.8569],[16801,2.1234],[16832,2.1482],[16861,2.0133],[16892,1.9382],[16922,1.9712],[16953,2.0053],[16983,1.9954],[17014,1.9603],[17045,1.9451],[17075,1.9133],[17106,1.9365],[17136,1.9611],[17167,1.9471],[17198,1.8963],[17226,1.8877],[17257,1.8837],[17287,1.8666],[17318,1.9021],[17348,1.9487],[17379,1.9407],[17410,1.9397],[17440,1.9623],[17471,1.9959],[17501,2.0044],[17532,1.9888],[17563,1.9702],[17591,1.9564],[17622,1.9944],[17652,2.008],[17683,2.0006],[17713,1.9863],[17744,2.0449],[17775,2.1097],[17805,2.1176],[17836,2.1233],[17866,2.1367],[17897,2.1548],[17928,2.1502],[17956,2.1271],[17987,2.1139],[18017,2.0937],[18048,2.0629],[18078,2.0344],[18109,2.0574],[18140,2.0752],[18170,2.0577],[18201,2.0622],[18231,2.1039],[18262,2.1169],[18293,2.1944],[18322,2.3959],[18353,2.4898],[18383,2.4277],[18414,2.3854],[18444,2.4188],[18475,2.4903],[18506,2.6141],[18536,2.5888],[18567,2.5791],[18597,2.5677],[18628,2.5731],[18659,2.5984],[18687,2.6053],[18718,2.6091],[18748,2.5358],[18779,2.5195],[18809,2.5338],[18840,2.5127],[18871,2.5017],[18901,2.4605],[18932,2.4811],[18962,2.5336],[18993,2.5782],[19024,2.6115],[19052,2.9641],[19083,3.0033],[19113,2.9978],[19144,3.1331],[19174,2.6104],[19205,2.5456],[19236,2.5206],[19266,2.5179],[19297,2.5581],[19327,2.553],[19358,2.6858],[19389,2.7253],[19417,2.6483],[19448,2.775],[19478,2.7716],[19509,2.7973],[19539,2.7996],[19570,2.8793],[19601,3.2767],[19631,3.2721],[19662,3.2298],[19692,3.2412],[19723,3.2252],[19754,3.2445]],"BZD":[[14791,1.9987],[14822,1.9925],[14853,1.9949],[14883,1.9911],[14914,1.9917],[14944,1.9943],[14975,1.9949],[15006,1.9976],[15034,1.9974],[15065,1.9999],[15095,2.0093],[15126,2.0074],[15156,2.0067],[15187,2.0081],[15218,2.0037],[15248,2.0065],[15279,2.0069],[15309,2.0068],[15340,1.998],[15371,1.9808],[15400,1.9715],[15431,1.9364],[15461,1.9383],[15492,1.9733],[15522,1.9009],[15553,1.9638],[15584,1.9785],[15614,1.9836],[15645,2.0139],[15675,2.0127],[15706,2.0141],[15737,2.0223],[15765,2.0225],[15796,2.027],[15826,2.0253],[15857,2.0235],[15887,2.0233],[15918,2.0121],[15949,2.0092],[15979,2.0114],[16010,2.0127],[16040,2.0111],[16071,2.0121],[16102,2.0117],[16130,2.0106],[16161,2.0202],[16191,2.0228],[16222,2.012],[16252,2.0155],[16283,2.0148],[16314,2.014],[16344,2.015],[16375,2.0161],[16405,2.0113],[16436,2.0153],[16467,2.0188],[16495,2.012],[16526,2.016],[16556,2.0138],[16587,2.0188],[16617,2.027],[16648,2.0251],[16679,2.0122],[16709,2.013],[16740,2.013],[16770,2.0105],[16801,2.0135],[16832,2.0119],[16861,2.0104],[16892,2.0131],[16922,2.0174],[16953,2.0191],[16983,2.0185],[17014,2.022],[17045,2.016],[17075,2.0194],[17106,2.0221],[17136,2.0194],[17167,2.0198],[17198,2.0256],[17226,2.0203],[17257,2.0198],[17287,2.0192],[17318,2.0156],[17348,2.0193],[17379,2.0206],[17410,2.0172],[17440,2.0204],[17471,2.02],[17501,2.0147],[17532,2.0181],[17563,2.017],[17591,2.0158],[17622,2.0191],[17652,2.017],[17683,2.0154],[17713,2.0176],[17744,2.0198],[17775,2.0149],[17805,2.0181],[17836,2.0196],[17866,2.0157],[17897,2.0196],[17928,2.0198],[17956,2.0165],[17987,2.0201],[18017,2.0192],[18048,2.0164],[18078,2.0185],[18109,2.0199],[18140,2.0172],[18170,2.0189],[18201,2.0195],[18231,2.0169],[18262,2.02],[18293,2.0202],[18322,2.0183],[18353,2.0203],[18383,2.0205],[18414,2.0178],[18444,2.0196],[18475,2.0199],[18506,2.0168],[18536,2.0202],[18567,2.0193],[18597,2.0169],[18628,2.0172],[18659,2.0168],[18687,2.0137],[18718,2.0166],[18748,2.0159],[18779,2.0152],[18809,2.0149],[18840,2.0168],[18871,2.014],[18901,2.0156],[18932,2.0182],[18962,2.0137],[18993,2.0164],[19024,2.0194],[19052,2.0155],[19083,2.0148],[19113,2.0162],[19144,2.0131],[19174,2.0145],[19205,2.0159],[19236,2.0122],[19266,2.018],[19297,2.017],[19327,2.0144],[19358,2.0163],[19389,2.0206],[19417,2.0164],[19448,2.019],[19478,2.0192],[19509,2.0162],[19539,2.0191],[19570,2.019],[19601,2.0183],[19631,2.0226],[19662,2.0235],[19692,2.0183],[19723,2.019],[19754,2.0193]],"CAD":[[14791,1.0442],[14822,1.0401],[14853,1.0367],[14883,1.02],[14914,1.0142],[14944,1.0103],[14975,0.99471],[15006,0.98879],[15034,0.97704],[15065,0.95895],[15095,0.96694],[15126,0.9775],[15156,0.95716],[15187,0.98006],[15218,1.0015],[15248,1.0227],[15279,1.0254],[15309,1.0246],[15340,1.014],[15371,0.99823],[15400,0.99377],[15431,0.99353],[15461,1.0089],[15492,1.0281],[15522,1.0149],[15553,0.99416],[15584,0.97935],[15614,0.9864],[15645,0.99702],[15675,0.99028],[15706,0.99201],[15737,1.0073],[15765,1.0248],[15796,1.0185],[15826,1.0203],[15857,1.0316],[15887,1.0405],[15918,1.0394],[15949,1.0359],[15979,1.0369],[16010,1.0484],[16040,1.0639],[16071,1.0917],[16102,1.1058],[16130,1.1096],[16161,1.0991],[16191,1.0888],[16222,1.0832],[16252,1.0729],[16283,1.0921],[16314,1.1002],[16344,1.1214],[16375,1.1325],[16405,1.1526],[16436,1.2064],[16467,1.2508],[16495,1.2613],[16526,1.2357],[16556,1.2173],[16587,1.2367],[16617,1.2825],[16648,1.3147],[16679,1.327],[16709,1.3097],[16740,1.3271],[16770,1.3697],[16801,1.4208],[16832,1.3825],[16861,1.3253],[16892,1.2854],[16922,1.2938],[16953,1.2918],[16983,1.3044],[17014,1.3007],[17045,1.3095],[17075,1.324],[17106,1.3452],[17136,1.3361],[17167,1.3235],[17198,1.3109],[17226,1.3384],[17257,1.3436],[17287,1.361],[17318,1.3311],[17348,1.2738],[17379,1.2613],[17410,1.2306],[17440,1.2587],[17471,1.2768],[17501,1.2769],[17532,1.2447],[17563,1.2568],[17591,1.2927],[17622,1.2747],[17652,1.2866],[17683,1.3117],[17713,1.3146],[17744,1.3045],[17775,1.3037],[17805,1.3008],[17836,1.3195],[17866,1.3427],[17897,1.3323],[17928,1.3209],[17956,1.3362],[17987,1.3384],[18017,1.3464],[18048,1.3298],[18078,1.31],[18109,1.3268],[18140,1.3248],[18170,1.3203],[18201,1.3238],[18231,1.318],[18262,1.3084],[18293,1.328],[18322,1.3892],[18353,1.4065],[18383,1.3972],[18414,1.3561],[18444,1.3513],[18475,1.3244],[18506,1.3216],[18536,1.3216],[18567,1.309],[18597,1.2827],[18628,1.2733],[18659,1.27],[18687,1.2582],[18718,1.251],[18748,1.2147],[18779,1.2218],[18809,1.2507],[18840,1.2595],[18871,1.2667],[18901,1.2464],[18932,1.2556],[18962,1.2794],[18993,1.2644],[19024,1.2716],[19052,1.2659],[19083,1.2616],[19113,1.2842],[19144,1.2814],[19174,1.2942],[19205,1.2912],[19236,1.3307],[19266,1.3708],[19297,1.3459],[19327,1.358],[19358,1.3447],[19389,1.3441],[19417,1.3674],[19448,1.3498],[19478,1.3522],[19509,1.3316],[19539,1.3223],[19570,1.3462],[19601,1.3534],[19631,1.3691],[19662,1.3724],[19692,1.3433],[19723,1.3409],[19754,1.3491]],"CDF":[[14791,906.68],[14822,900.92],[14853,901.77],[14883,903.49],[14914,912.11],[14944,913.32],[14975,920.21],[15006,922.38],[15034,920.08],[15065,924.38],[15095,922.21],[15126,919.72],[15156,924.65],[15187,923.14],[15218,923.28],[15248,921.46],[15279,902.51],[15309,906.03],[15340,918.25],[15371,920.77],[15400,916.68],[15431,919.83],[15461,919.07],[15492,925.37],[15522,921.92],[15553,918.37],[15584,919.25],[15614,919.86],[15645,918.68],[15675,917.4],[15706,918.58],[15737,916.21],[15765,919.27],[15796,921.12],[15826,915.9],[15857,920.29],[15887,920.11],[15918,918.22],[15949,921.79],[15979,930.25],[16010,925.53],[16040,922.21],[16071,924.13],[16102,927.88],[16130,923.86],[16161,924.61],[16191,923.26],[16222,924.0],[16252,924.25],[16283,924.14],[16314,925.15],[16344,925.59],[16375,926.77],[16405,921.9],[16436,925.76],[16467,934.24],[16495,924.62],[16526,926.81],[16556,920.66],[16587,925.6],[16617,926.57],[16648,925.79],[16679,929.82],[16709,927.98],[16740,928.2],[16770,926.49],[16801,927.65],[16832,925.73],[16861,929.04],[16892,934.28],[16922,938.56],[16953,953.35],[16983,964.38],[17014,986.82],[17045,1007.27],[17075,1023.98],[17106,1068.36],[17136,1182.47],[17167,1211.72],[17198,1277.3],[17226,1334.16],[17257,1382.95],[17287,1414.34],[17318,1468.52],[17348,1553.33],[17379,1575.32],[17410,1561.58],[17440,1573.6],[17471,1579.44],[17501,1583.15],[17532,1598.92],[17563,1612.23],[17591,1613.55],[17622,1612.19],[17652,1617.66],[17683,1620.64],[17713,1627.38],[17744,1633.42],[17775,1629.52],[17805,1637.55],[17836,1638.62],[17866,1634.88],[17897,1638.98],[17928,1640.89],[17956,1639.05],[17987,1646.99],[18017,1643.86],[18048,1644.9],[18078,1645.71],[18109,1659.46],[18140,1657.31],[18170,1658.59],[18201,1661.44],[18231,1669.07],[18262,1681.21],[18293,1694.22],[18322,1698.34],[18353,1714.59],[18383,1785.22],[18414,1864.28],[18444,1945.67],[18475,1956.93],[18506,1956.27],[18536,1965.94],[18567,1967.99],[18597,1967.24],[18628,1972.3],[18659,1980.72],[18687,1974.12],[18718,1982.89],[18748,1983.17],[18779,1989.46],[18809,1986.75],[18840,1990.4],[18871,1981.78],[18901,1986.34],[18932,1996.17],[18962,1996.74],[18993,1997.45],[19024,2008.99],[19052,2003.45],[19083,2000.46],[19113,2012.4],[19144,1994.88],[19174,2002.94],[19205,2012.23],[19236,2019.47],[19266,2036.68],[19297,2033.61],[19327,2034.97],[19358,2034.94],[19389,2052.82],[19417,2056.22],[19448,2070.29],[19478,2218.87],[19509,2330.53],[19539,2392.12],[19570,2467.07],[19601,2476.48],[19631,2501.5],[19662,2550.68],[19692,2657.86],[19723,2705.17],[19754,2734.91]],"CHF":[[14791,1.0571],[14822,1.0412],[14853,1.0047],[14883,0.97074],[14914,0.98589],[14944,0.97225],[14975,0.95713],[15006,0.95184],[15034,0.92125],[15065,0.9003],[15095,0.87456],[15126,0.84237],[15156,0.8258],[15187,0.782],[15218,0.87093],[15248,0.89921],[15279,0.90937],[15309,0.93344],[15340,0.94009],[15371,0.9139],[15400,0.91386],[15431,0.91457],[15461,0.93906],[15492,0.95988],[15522,0.97734],[15553,0.97128],[15584,0.94179],[15614,0.93403],[15645,0.93995],[15675,0.92303],[15706,0.92506],[15737,0.92168],[15765,0.94554],[15796,0.93859],[15826,0.95584],[15857,0.93732],[15887,0.9472],[15918,0.9287],[15949,0.92537],[15979,0.90455],[16010,0.91287],[16040,0.89531],[16071,0.90453],[16102,0.89621],[16130,0.88275],[16161,0.88406],[16191,0.88949],[16222,0.89682],[16252,0.89746],[16283,0.91045],[16314,0.93657],[16344,0.95371],[16375,0.96512],[16405,0.97658],[16436,0.93873],[16467,0.93416],[16495,0.97906],[16526,0.96254],[16556,0.93319],[16587,0.93422],[16617,0.95403],[16648,0.9685],[16679,0.97311],[16709,0.96959],[16740,1.0085],[16770,0.99728],[16801,1.0071],[16832,0.99552],[16861,0.98415],[16892,0.96573],[16922,0.97876],[16953,0.97233],[16983,0.98257],[17014,0.97209],[17045,0.97477],[17075,0.98679],[17106,0.99762],[17136,1.0203],[17167,1.0101],[17198,1.0022],[17226,1.0031],[17257,1.0026],[17287,0.9878],[17318,0.96964],[17348,0.96183],[17379,0.96663],[17410,0.96346],[17440,0.98244],[17471,0.99275],[17501,0.98804],[17532,0.96344],[17563,0.93652],[17591,0.94896],[17622,0.96858],[17652,0.99766],[17683,0.99065],[17713,0.99545],[17744,0.9897],[17775,0.96928],[17805,0.99412],[17836,1.0022],[17866,0.99299],[17897,0.98996],[17928,1.002],[17956,1.0015],[17987,1.0082],[18017,1.0122],[18048,0.99044],[18078,0.98811],[18109,0.98124],[18140,0.99129],[18170,0.99448],[18201,0.99415],[18231,0.9847],[18262,0.97171],[18293,0.9771],[18322,0.95865],[18353,0.97259],[18383,0.97252],[18414,0.95318],[18444,0.93588],[18475,0.91214],[18506,0.91584],[18536,0.9137],[18567,0.9119],[18597,0.89077],[18628,0.88764],[18659,0.89805],[18687,0.92927],[18718,0.9251],[18748,0.90474],[18779,0.90994],[18809,0.92026],[18840,0.91614],[18871,0.92433],[18901,0.92529],[18932,0.92373],[18962,0.92219],[18993,0.92039],[19024,0.92442],[19052,0.93047],[19083,0.94405],[19113,0.97868],[19144,0.97012],[19174,0.96996],[19205,0.95917],[19236,0.97386],[19266,0.99543],[19297,0.96813],[19327,0.93379],[19358,0.92597],[19389,0.92529],[19417,0.92736],[19448,0.90117],[19478,0.89806],[19509,0.90185],[19539,0.87625],[19570,0.87832],[19601,0.89835],[19631,0.90476],[19662,0.89309],[19692,0.86565],[19723,0.85827],[19754,0.87623]],"CLP":[[14791,531.54],[14822,510.15],[14853,495.82],[14883,484.91],[14914,484.56],[14944,477.85],[14975,485.86],[15006,477.82],[15034,478.35],[15065,472.37],[15095,468.95],[15126,469.57],[15156,463.69],[15187,466.01],[15218,480.53],[15248,510.21],[15279,508.56],[15309,518.05],[15340,502.98],[15371,482.2],[15400,485.87],[15431,486.46],[15461,498.08],[15492,507.34],[15522,493.62],[15553,482.58],[15584,474.7],[15614,475.43],[15645,480.8],[15675,477.79],[15706,473.06],[15737,472.75],[15765,472.89],[15796,472.3],[15826,480.58],[15857,501.23],[15887,507.07],[15918,511.58],[15949,504.98],[15979,501.95],[16010,518.52],[16040,527.86],[16071,537.15],[16102,554.31],[16130,561.92],[16161,556.11],[16191,555.94],[16222,552.84],[16252,558.6],[16283,578.82],[16314,594.47],[16344,589.83],[16375,592.72],[16405,610.17],[16436,619.22],[16467,622.57],[16495,627.94],[16526,615.2],[16556,609.14],[16587,630.13],[16617,651.94],[16648,686.77],[16679,695.46],[16709,686.35],[16740,703.12],[16770,707.15],[16801,718.52],[16832,702.61],[16861,682.39],[16892,670.66],[16922,681.23],[16953,676.96],[16983,661.77],[17014,661.19],[17045,666.49],[17075,661.13],[17106,665.39],[17136,667.84],[17167,660.86],[17198,644.39],[17226,660.41],[17257,659.32],[17287,671.48],[17318,666.77],[17348,658.77],[17379,642.67],[17410,629.24],[17440,630.4],[17471,635.89],[17501,632.1],[17532,608.14],[17563,596.03],[17591,602.67],[17622,601.66],[17652,623.3],[17683,637.71],[17713,649.06],[17744,660.19],[17775,674.33],[17805,678.87],[17836,678.26],[17866,685.56],[17897,676.89],[17928,657.08],[17956,669.43],[17987,670.22],[18017,692.25],[18048,690.18],[18078,687.89],[18109,712.81],[18140,720.19],[18170,724.14],[18201,774.13],[18231,768.41],[18262,774.88],[18293,799.09],[18322,837.53],[18353,849.96],[18383,825.73],[18414,801.83],[18444,786.1],[18475,783.15],[18506,777.12],[18536,785.18],[18567,766.43],[18597,732.99],[18628,726.11],[18659,722.21],[18687,723.9],[18718,712.03],[18748,712.67],[18779,727.62],[18809,749.53],[18840,776.81],[18871,788.99],[18901,808.33],[18932,816.03],[18962,843.54],[18993,825.26],[19024,807.31],[19052,797.58],[19083,814.58],[19113,843.57],[19144,859.14],[19174,935.48],[19205,902.72],[19236,927.62],[19266,954.0],[19297,917.69],[19327,878.89],[19358,833.31],[19389,805.52],[19417,805.85],[19448,804.95],[19478,800.0],[19509,802.68],[19539,813.99],[19570,851.53],[19601,884.64],[19631,918.71],[19662,891.33],[19692,877.44],[19723,907.06],[19754,957.44]],"CNH":[[15187,6.3617],[15218,6.4078],[15248,6.4131],[15279,6.3715],[15309,6.3738],[15340,6.3065],[15371,6.2968],[15400,6.3064],[15431,6.3089],[15461,6.3247],[15492,6.3677],[15522,6.3672],[15553,6.3654],[15584,6.326],[15614,6.2707],[15645,6.2316],[15675,6.2174],[15706,6.2037],[15737,6.218],[15765,6.2067],[15796,6.1689],[15826,6.1329],[15857,6.1267],[15887,6.125],[15918,6.1072],[15949,6.1002],[15979,6.0859],[16010,6.0693],[16040,6.0632],[16071,6.0246],[16102,6.0464],[16130,6.1527],[16161,6.2144],[16191,6.2292],[16222,6.2202],[16252,6.1901],[16283,6.1461],[16314,6.1378],[16344,6.1275],[16375,6.1216],[16405,6.1842],[16436,6.2158],[16467,6.2521],[16495,6.2389],[16526,6.192],[16556,6.1958],[16587,6.1973],[16617,6.2043],[16648,6.3667],[16679,6.4055],[16709,6.3513],[16740,6.3844],[16770,6.5092],[16801,6.6042],[16832,6.5412],[16861,6.4942],[16892,6.4743],[16922,6.5321],[16953,6.5927],[16983,6.6757],[17014,6.6466],[17045,6.6686],[17075,6.7286],[17106,6.8471],[17136,6.9164],[17167,6.8517],[17198,6.834],[17226,6.872],[17257,6.8927],[17287,6.8814],[17318,6.802],[17348,6.776],[17379,6.6829],[17410,6.5712],[17440,6.6284],[17471,6.6288],[17501,6.597],[17532,6.4354],[17563,6.3156],[17591,6.3201],[17622,6.293],[17652,6.3674],[17683,6.4656],[17713,6.7282],[17744,6.8525],[17775,6.8619],[17805,6.929],[17836,6.9389],[17866,6.8923],[17897,6.8014],[17928,6.7501],[17956,6.7208],[17987,6.7229],[18017,6.8763],[18048,6.9138],[18078,6.883],[18109,7.0816],[18140,7.1193],[18170,7.0992],[18201,7.025],[18231,7.0156],[18262,6.9336],[18293,7.0038],[18322,7.0318],[18353,7.0888],[18383,7.1309],[18414,7.0872],[18444,7.0157],[18475,6.9324],[18506,6.8199],[18536,6.7151],[18567,6.6022],[18597,6.5319],[18628,6.474],[18659,6.4533],[18687,6.5149],[18718,6.5233],[18748,6.4364],[18779,6.4262],[18809,6.4812],[18840,6.4801],[18871,6.4593],[18901,6.4234],[18932,6.3941],[18962,6.3765],[18993,6.3643],[19024,6.3491],[19052,6.3599],[19083,6.4465],[19113,6.7229],[19144,6.7006],[19174,6.7387],[19205,6.8116],[19236,7.0215],[19266,7.1984],[19297,7.1891],[19327,6.9873],[19358,6.7975],[19389,6.8432],[19417,6.9026],[19448,6.8976],[19478,7.0002],[19509,7.1709],[19539,7.2001],[19570,7.2654],[19601,7.3058],[19631,7.3184],[19662,7.2394],[19692,7.1533],[19723,7.1831],[19754,7.2122]],"CNY":[[14791,6.7795],[14822,6.789],[14853,6.75],[14883,6.671],[14914,6.6559],[14944,6.6502],[14975,6.6004],[15006,6.5821],[15034,6.5673],[15065,6.5306],[15095,6.4968],[15126,6.4758],[15156,6.4593],[15187,6.4071],[15218,6.3894],[15248,6.3813],[15279,6.357],[15309,6.349],[15340,6.3164],[15371,6.3018],[15400,6.3119],[15431,6.3045],[15461,6.3244],[15492,6.3629],[15522,6.3694],[15553,6.3611],[15584,6.3234],[15614,6.2711],[15645,6.2367],[15675,6.2348],[15706,6.2236],[15737,6.2336],[15765,6.2179],[15796,6.1894],[15826,6.1441],[15857,6.1356],[15887,6.1332],[15918,6.1288],[15949,6.1205],[15979,6.1056],[16010,6.0937],[16040,6.076],[16071,6.0532],[16102,6.0793],[16130,6.1679],[16161,6.223],[16191,6.2375],[16222,6.2289],[16252,6.2003],[16283,6.1562],[16314,6.1397],[16344,6.1288],[16375,6.1263],[16405,6.1866],[16436,6.2176],[16467,6.2487],[16495,6.2372],[16526,6.1994],[16556,6.2014],[16587,6.2024],[16617,6.2067],[16648,6.333],[16679,6.3723],[16709,6.3511],[16740,6.3697],[16770,6.4585],[16801,6.5715],[16832,6.5511],[16861,6.5069],[16892,6.4795],[16922,6.5298],[16953,6.594],[16983,6.6762],[17014,6.6505],[17045,6.6735],[17075,6.7271],[17106,6.8409],[17136,6.9225],[17167,6.8943],[17198,6.872],[17226,6.8966],[17257,6.8946],[17287,6.8866],[17318,6.808],[17348,6.7756],[17379,6.6777],[17410,6.5716],[17440,6.6272],[17471,6.6235],[17501,6.5923],[17532,6.4321],[17563,6.3224],[17591,6.3214],[17622,6.305],[17652,6.37],[17683,6.4661],[17713,6.7116],[17744,6.8471],[17775,6.8543],[17805,6.9185],[17836,6.9391],[17866,6.8893],[17897,6.792],[17928,6.738],[17956,6.7131],[17987,6.7203],[18017,6.8514],[18048,6.8994],[18078,6.8766],[18109,7.0504],[18140,7.113],[18170,7.0932],[18201,7.0179],[18231,7.0104],[18262,6.922],[18293,6.9911],[18322,7.0177],[18353,7.0783],[18383,7.1083],[18414,7.0886],[18444,7.0177],[18475,6.9354],[18506,6.8183],[18536,6.7277],[18567,6.6119],[18597,6.5426],[18628,6.4743],[18659,6.4614],[18687,6.5101],[18718,6.524],[18748,6.437],[18779,6.4287],[18809,6.4798],[18840,6.4811],[18871,6.4576],[18901,6.4236],[18932,6.3899],[18962,6.3704],[18993,6.357],[19024,6.3408],[19052,6.3419],[19083,6.4265],[19113,6.6935],[19144,6.6952],[19174,6.7357],[19205,6.8029],[19236,7.0128],[19266,7.1885],[19297,7.1835],[19327,6.9912],[19358,6.8018],[19389,6.8343],[19417,6.8961],[19448,6.8879],[19478,6.9825],[19509,7.149],[19539,7.1849],[19570,7.2408],[19601,7.2913],[19631,7.2985],[19662,7.2269],[19692,7.1383],[19723,7.1653],[19754,7.1915]],"COP":[[14791,1876.23],[14822,1829.9],[14853,1809.08],[14883,1811.74],[14914,1867.06],[14944,1914.88],[14975,1878.04],[15006,1880.91],[15034,1886.79],[15065,1818.01],[15095,1802.07],[15126,1789.51],[15156,1767.09],[15187,1781.23],[15218,1845.34],[15248,1905.44],[15279,1921.2],[15309,1935.17],[15340,1857.22],[15371,1789.24],[15400,1772.06],[15431,1773.62],[15461,1793.42],[15492,1803.01],[15522,1787.75],[15553,1801.71],[15584,1801.7],[15614,1807.19],[15645,1820.48],[15675,1794.36],[15706,1773.65],[15737,1793.74],[15765,1814.6],[15796,1829.97],[15826,1851.77],[15857,1907.52],[15887,1906.01],[15918,1905.48],[15949,1919.28],[15979,1887.78],[16010,1918.34],[16040,1933.04],[16071,1960.02],[16102,2035.44],[16130,2022.78],[16161,1946.23],[16191,1919.26],[16222,1890.31],[16252,1864.71],[16283,1896.11],[16314,1978.1],[16344,2042.62],[16375,2123.56],[16405,2350.55],[16436,2387.98],[16467,2423.9],[16495,2572.26],[16526,2488.74],[16556,2456.73],[16587,2560.34],[16617,2735.42],[16648,3022.72],[16679,3102.67],[16709,2927.61],[16740,3002.81],[16770,3206.42],[16801,3285.49],[16832,3348.73],[16861,3118.42],[16892,2988.85],[16922,2999.75],[16953,2984.48],[16983,2992.17],[17014,2961.54],[17045,2913.02],[17075,2936.76],[17106,3090.75],[17136,3012.97],[17167,2950.16],[17198,2888.25],[17226,2922.51],[17257,2898.43],[17287,2921.67],[17318,2980.14],[17348,3034.44],[17379,2976.07],[17410,2924.41],[17440,2958.53],[17471,3013.56],[17501,2990.7],[17532,2881.86],[17563,2848.45],[17591,2836.43],[17622,2782.39],[17652,2854.0],[17683,2904.01],[17713,2891.61],[17744,2967.23],[17775,3019.05],[17805,3095.65],[17836,3205.06],[17866,3230.19],[17897,3179.88],[17928,3113.36],[17956,3139.83],[17987,3169.38],[18017,3306.9],[18048,3255.6],[18078,3223.56],[18109,3406.03],[18140,3419.98],[18170,3431.82],[18201,3423.51],[18231,3373.38],[18262,3327.5],[18293,3427.14],[18322,3902.52],[18353,4001.37],[18383,3859.35],[18414,3723.44],[18444,3692.94],[18475,3776.31],[18506,3785.42],[18536,3839.21],[18567,3690.6],[18597,3483.21],[18628,3506.14],[18659,3562.06],[18687,3637.47],[18718,3669.5],[18748,3718.72],[18779,3705.11],[18809,3824.43],[18840,3877.88],[18871,3829.15],[18901,3783.44],[18932,3891.01],[18962,3979.19],[18993,3992.58],[19024,3943.91],[19052,3823.43],[19083,3811.95],[19113,3995.3],[19144,3987.52],[19174,4375.02],[19205,4347.14],[19236,4440.34],[19266,4740.01],[19297,4908.58],[19327,4818.49],[19358,4713.75],[19389,4775.64],[19417,4757.73],[19448,4582.29],[19478,4509.66],[19509,4248.17],[19539,4060.91],[19570,4043.59],[19601,4044.58],[19631,4150.66],[19662,4054.3],[19692,3941.98],[19723,3915.06],[19754,3935.14]],"CRC":[[14791,521.91],[14822,511.28],[14853,506.07],[14883,508.71],[14914,509.99],[14944,505.41],[14975,506.1],[15006,501.64],[15034,498.69],[15065,499.1],[15095,500.32],[15126,502.99],[15156,503.51],[15187,505.87],[15218,508.48],[15248,513.08],[15279,507.83],[15309,503.98],[15340,507.71],[15371,510.67],[15400,508.11],[15431,505.53],[15461,504.09],[15492,501.02],[15522,500.09],[15553,499.43],[15584,498.42],[15614,498.57],[15645,498.85],[15675,503.87],[15706,500.72],[15737,500.6],[15765,499.55],[15796,499.23],[15826,498.57],[15857,499.93],[15887,499.87],[15918,502.06],[15949,501.32],[15979,501.28],[16010,500.04],[16040,498.63],[16071,504.12],[16102,517.76],[16130,543.86],[16161,547.9],[16191,551.68],[16222,547.96],[16252,540.49],[16283,539.55],[16314,539.44],[16344,539.61],[16375,538.11],[16405,535.9],[16436,538.36],[16467,536.92],[16495,532.83],[16526,532.63],[16556,532.63],[16587,534.79],[16617,535.2],[16648,533.99],[16679,533.34],[16709,534.32],[16740,533.06],[16770,533.79],[16801,536.9],[16832,535.72],[16861,533.93],[16892,535.74],[16922,536.55],[16953,543.13],[16983,547.11],[17014,549.91],[17045,550.94],[17075,553.34],[17106,553.07],[17136,551.49],[17167,553.0],[17198,557.1],[17226,557.59],[17257,560.41],[17287,569.21],[17318,570.65],[17348,571.27],[17379,574.46],[17410,573.86],[17440,571.96],[17471,568.15],[17501,566.21],[17532,568.37],[17563,569.72],[17591,567.27],[17622,565.77],[17652,564.93],[17683,566.65],[17713,566.91],[17744,569.58],[17775,577.69],[17805,592.94],[17836,604.91],[17866,603.0],[17897,606.27],[17928,609.44],[17956,603.11],[17987,601.46],[18017,592.94],[18048,585.09],[18078,576.56],[18109,570.08],[18140,576.84],[18170,581.79],[18201,574.75],[18231,569.05],[18262,568.39],[18293,569.97],[18322,574.67],[18353,571.89],[18383,569.98],[18414,576.85],[18444,581.95],[18475,591.32],[18506,599.1],[18536,604.17],[18567,607.02],[18597,607.09],[18628,610.79],[18659,611.78],[18687,611.62],[18718,613.75],[18748,615.72],[18779,618.39],[18809,618.79],[18840,620.64],[18871,623.61],[18901,628.35],[18932,636.54],[18962,637.98],[18993,640.51],[19024,642.14],[19052,650.07],[19083,658.29],[19113,670.18],[19144,683.99],[19174,683.5],[19205,661.81],[19236,637.36],[19266,628.24],[19297,613.38],[19327,595.98],[19358,577.21],[19389,565.86],[19417,550.55],[19448,539.66],[19478,538.74],[19509,542.62],[19539,543.71],[19570,540.24],[19601,537.32],[19631,534.65],[19662,532.99],[19692,526.56],[19723,518.8],[19754,516.13]],"CUP":[[14791,8.1528],[14822,8.1528],[14853,8.1528],[14883,8.1527],[14914,8.1528],[14944,8.1528],[14975,8.1528],[15006,8.1528],[15034,8.1528],[15065,8.8333],[15095,8.8333],[15126,8.8333],[15156,8.8333],[15187,8.8333],[15218,8.8344],[15248,8.8333],[15279,8.8333],[15309,8.8333],[15340,8.8333],[15371,8.8333],[15400,8.8333],[15431,8.8333],[15461,8.8333],[15492,8.8333],[15522,8.8333],[15553,8.8333],[15584,8.8333],[15614,8.8333],[15645,8.8333],[15675,8.8333],[15706,8.8333],[15737,8.8333],[15765,8.8333],[15796,8.8333],[15826,8.8333],[15857,8.8333],[15887,8.8333],[15918,8.8333],[15949,8.8333],[15979,8.8333],[16010,8.8333],[16040,8.8333],[16071,8.8333],[16102,8.8333],[16130,8.8333],[16161,8.8333],[16191,8.8333],[16222,8.8333],[16252,8.8333],[16283,8.8333],[16314,8.8333],[16344,8.8333],[16375,8.8333],[16405,8.8333],[16436,8.8333],[16467,8.8333],[16495,1.0],[16526,8.8333],[16556,8.8333],[16587,1.0],[16617,8.8333],[16648,8.8333],[16679,1.0],[16709,8.8333],[16740,8.8333],[16770,1.0],[16801,8.8333],[16832,8.8333],[16861,1.0],[16892,8.8333],[16922,8.8333],[16953,1.0],[16983,8.8333],[17014,8.8333],[17045,1.0],[17075,8.8333],[17106,8.8333],[17136,1.0],[17167,8.8333],[17198,8.8333],[17226,1.0],[17257,8.8333],[17287,1.0012],[17318,0.99968],[17348,0.99918],[17379,0.99984],[17410,0.99984],[17440,1.0001],[17471,1.0006],[17501,0.99971],[17532,0.9999],[17563,0.99883],[17591,1.0005],[17622,1.0007],[17652,1.0006],[17683,0.99978],[17713,0.99943],[17744,1.0007],[17775,0.99982],[17805,0.9997],[17836,1.0],[17866,0.9997],[17897,0.99995],[17928,1.0002],[17956,1.0001],[17987,1.0002],[18017,0.99959],[18048,0.99988],[18078,0.99922],[18109,1.0],[18140,1.0004],[18170,0.99926],[18201,0.99979],[18231,1.0002],[18262,1.0001],[18293,1.0001],[18322,1.0013],[18353,1.0006],[18383,1.0002],[18414,1.0009],[18444,0.99973],[18475,1.0002],[18506,1.0],[18536,0.99997],[18567,0.99955],[18597,1.0002],[18628,13.009],[18659,24.035],[18687,23.976],[18718,24.085],[18748,23.952],[18779,24.029],[18809,24.106],[18840,23.943],[18871,23.951],[18901,24.034],[18932,24.03],[18962,24.005],[18993,24.038],[19024,24.102],[19052,24.064],[19083,23.939],[19113,24.089],[19144,23.968],[19174,24.011],[19205,24.06],[19236,23.97],[19266,24.308],[19297,23.986],[19327,23.986],[19358,23.977],[19389,23.971],[19417,24.021],[19448,23.996],[19478,23.962],[19509,23.97],[19539,23.99],[19570,23.994],[19601,23.995],[19631,23.979],[19662,23.98],[19692,24.003],[19723,23.99],[19754,23.997]],"CVE":[[14791,86.93],[14822,85.54],[14853,84.57],[14883,79.968],[14914,81.29],[14944,82.99],[14975,82.2],[15006,80.58],[15034,79.333],[15065,76.436],[15095,76.456],[15126,76.554],[15156,77.063],[15187,76.902],[15218,79.706],[15248,79.98],[15279,80.77],[15309,83.67],[15340,85.08],[15371,83.39],[15400,83.05],[15431,83.5],[15461,85.46],[15492,88.23],[15522,89.73],[15553,89.06],[15584,85.85],[15614,85.25],[15645,85.52],[15675,83.81],[15706,82.84],[15737,82.66],[15765,85.04],[15796,84.97],[15826,84.7],[15857,83.83],[15887,84.1],[15918,82.83],[15949,81.92],[15979,80.71],[16010,81.08],[16040,80.41],[16071,80.7],[16102,80.78],[16130,80.01],[16161,79.788],[16191,80.3],[16222,80.69],[16252,81.43],[16283,82.77],[16314,85.28],[16344,86.91],[16375,88.03],[16405,88.95],[16436,94.02],[16467,97.06],[16495,100.18],[16526,101.42],[16556,99.49],[16587,98.71],[16617,100.08],[16648,99.24],[16679,97.98],[16709,98.85],[16740,102.64],[16770,101.74],[16801,101.21],[16832,99.92],[16861,98.44],[16892,97.12],[16922,97.62],[16953,98.78],[16983,99.42],[17014,98.59],[17045,98.2],[17075,99.77],[17106,102.29],[17136,104.82],[17167,104.33],[17198,103.9],[17226,102.93],[17257,102.66],[17287,100.02],[17318,97.76],[17348,95.83],[17379,93.49],[17410,92.77],[17440,94.19],[17471,94.28],[17501,92.93],[17532,90.76],[17563,89.4],[17591,89.43],[17622,90.11],[17652,92.87],[17683,94.38],[17713,94.72],[17744,95.06],[17775,94.26],[17805,95.94],[17836,97.24],[17866,96.46],[17897,96.63],[17928,97.02],[17956,97.72],[17987,98.51],[18017,98.9],[18048,97.78],[18078,98.27],[18109,99.3],[18140,100.31],[18170,100.11],[18201,100.11],[18231,99.57],[18262,99.54],[18293,100.83],[18322,100.48],[18353,101.61],[18383,101.31],[18414,98.97],[18444,96.69],[18475,93.68],[18506,93.88],[18536,94.36],[18567,93.83],[18597,91.17],[18628,91.05],[18659,91.34],[18687,92.57],[18718,92.93],[18748,91.59],[18779,91.99],[18809,93.1],[18840,93.68],[18871,94.16],[18901,94.99],[18932,96.46],[18962,97.67],[18993,97.95],[19024,98.01],[19052,99.49],[19083,101.89],[19113,103.7],[19144,104.33],[19174,106.51],[19205,109.04],[19236,111.66],[19266,111.12],[19297,109.24],[19327,104.76],[19358,102.85],[19389,102.84],[19417,102.97],[19448,100.96],[19478,101.45],[19509,101.9],[19539,100.53],[19570,100.67],[19601,103.01],[19631,104.31],[19662,102.62],[19692,100.8],[19723,100.93],[19754,101.99]],"CZK":[[14791,19.8341],[14822,19.2162],[14853,18.9208],[14883,17.6939],[14914,17.9738],[14944,18.9673],[14975,18.306],[15006,17.7687],[15034,17.4386],[15065,16.8284],[15095,16.9281],[15126,16.8679],[15156,17.0188],[15187,16.9143],[15218,17.7947],[15248,18.1007],[15279,18.755],[15309,19.3434],[15340,19.7564],[15371,18.9317],[15400,18.6423],[15431,18.8062],[15461,19.7154],[15492,20.441],[15522,20.675],[15553,20.198],[15584,19.2709],[15614,19.2316],[15645,19.7217],[15675,19.2031],[15706,19.1921],[15737,19.0527],[15765,19.7532],[15796,19.8584],[15826,19.9007],[15857,19.5591],[15887,19.8054],[15918,19.4019],[15949,19.2723],[15979,18.8281],[16010,19.8701],[16040,20.042],[16071,20.157],[16102,20.105],[16130,19.8197],[16161,19.879],[16191,19.9672],[16222,20.159],[16252,20.272],[16283,20.854],[16314,21.366],[16344,21.764],[16375,22.169],[16405,22.409],[16436,23.977],[16467,24.324],[16495,25.209],[16526,25.377],[16556,24.589],[16587,24.369],[16617,24.609],[16648,24.305],[16679,24.082],[16709,24.151],[16740,25.14],[16770,24.855],[16801,24.859],[16832,24.407],[16861,24.308],[16892,23.84],[16922,23.907],[16953,24.102],[16983,24.421],[17014,24.132],[17045,24.082],[17075,24.48],[17106,25.017],[17136,25.594],[17167,25.45],[17198,25.383],[17226,25.291],[17257,25.038],[17287,24.084],[17318,23.35],[17348,22.68],[17379,22.114],[17410,21.867],[17440,21.937],[17471,21.774],[17501,21.615],[17532,20.891],[17563,20.492],[17591,20.594],[17622,20.661],[17652,21.633],[17683,22.067],[17713,22.136],[17744,22.225],[17775,21.954],[17805,22.457],[17836,22.81],[17866,22.673],[17897,22.458],[17928,22.646],[17956,22.693],[17987,22.855],[18017,23.031],[18048,22.663],[18078,22.747],[18109,23.192],[18140,23.47],[18170,23.265],[18201,23.091],[18231,22.916],[18262,22.714],[18293,22.956],[18322,23.901],[18353,25.063],[18383,25.003],[18414,23.731],[18444,23.168],[18475,22.132],[18506,22.631],[18536,23.121],[18567,22.419],[18597,21.607],[18628,21.476],[18659,21.415],[18687,21.937],[18718,21.73],[18748,21.088],[18779,21.1],[18809,21.647],[18840,21.641],[18871,21.582],[18901,21.965],[18932,22.242],[18962,22.329],[18993,21.687],[19024,21.565],[19052,22.612],[19083,22.567],[19113,23.355],[19144,23.347],[19174,24.048],[19205,24.248],[19236,24.78],[19266,24.981],[19297,23.972],[19327,22.943],[19358,22.274],[19389,22.122],[19417,22.135],[19448,21.424],[19478,21.691],[19509,21.87],[19539,21.633],[19570,22.049],[19601,22.721],[19631,23.245],[19662,22.711],[19692,22.396],[19723,22.642],[19754,23.323]],"DJF":[[14791,177.34],[14822,177.31],[14853,177.15],[14883,177.15],[14914,177.44],[14944,176.95],[14975,177.28],[15006,177.18],[15034,176.95],[15065,177.04],[15095,177.16],[15126,177.12],[15156,176.91],[15187,177.09],[15218,176.91],[15248,176.9],[15279,176.96],[15309,177.07],[15340,176.76],[15371,177.04],[15400,177.14],[15431,177.24],[15461,177.43],[15492,177.36],[15522,177.28],[15553,177.19],[15584,177.12],[15614,177.24],[15645,177.3],[15675,177.27],[15706,177.12],[15737,177.02],[15765,176.98],[15796,176.98],[15826,176.82],[15857,176.95],[15887,176.49],[15918,176.56],[15949,176.42],[15979,176.39],[16010,176.69],[16040,177.06],[16071,177.23],[16102,177.04],[16130,176.85],[16161,177.57],[16191,177.78],[16222,177.65],[16252,177.93],[16283,177.94],[16314,177.87],[16344,177.67],[16375,177.49],[16405,177.39],[16436,176.54],[16467,177.21],[16495,177.4],[16526,178.4],[16556,176.58],[16587,177.26],[16617,177.53],[16648,177.93],[16679,177.4],[16709,176.71],[16740,177.2],[16770,177.71],[16801,177.99],[16832,177.38],[16861,178.26],[16892,177.75],[16922,176.93],[16953,177.11],[16983,177.35],[17014,177.27],[17045,177.54],[17075,177.3],[17106,177.49],[17136,177.79],[17167,178.06],[17198,177.99],[17226,178.1],[17257,178.35],[17287,178.42],[17318,178.22],[17348,178.5],[17379,178.5],[17410,177.42],[17440,177.23],[17471,177.79],[17501,177.55],[17532,178.06],[17563,177.2],[17591,177.34],[17622,176.9],[17652,177.12],[17683,177.47],[17713,177.77],[17744,178.42],[17775,177.8],[17805,177.47],[17836,177.91],[17866,177.84],[17897,177.91],[17928,177.98],[17956,177.45],[17987,177.58],[18017,177.63],[18048,177.93],[18078,177.53],[18109,177.67],[18140,177.57],[18170,177.9],[18201,177.65],[18231,177.93],[18262,177.6],[18293,178.06],[18322,177.85],[18353,177.82],[18383,178.24],[18414,177.89],[18444,178.94],[18475,178.16],[18506,177.47],[18536,177.63],[18567,178.07],[18597,178.07],[18628,177.73],[18659,178.23],[18687,177.26],[18718,178.3],[18748,177.89],[18779,177.61],[18809,177.89],[18840,177.92],[18871,177.46],[18901,177.82],[18932,177.72],[18962,177.78],[18993,177.36],[19024,177.72],[19052,178.15],[19083,176.79],[19113,178.56],[19144,177.61],[19174,177.66],[19205,177.44],[19236,177.41],[19266,178.53],[19297,178.13],[19327,178.11],[19358,178.08],[19389,177.6],[19417,178.3],[19448,178.11],[19478,177.61],[19509,177.98],[19539,177.8],[19570,178.21],[19601,177.42],[19631,178.13],[19662,178.56],[19692,178.25],[19723,177.7],[19754,177.93]],"DKK":[[14791,5.8571],[14822,5.7802],[14853,5.72],[14883,5.3813],[14914,5.4529],[14944,5.6476],[14975,5.5886],[15006,5.4685],[15034,5.3394],[15065,5.1742],[15095,5.1978],[15126,5.1958],[15156,5.2271],[15187,5.2046],[15218,5.4072],[15248,5.4421],[15279,5.4866],[15309,5.6503],[15340,5.7664],[15371,5.6348],[15400,5.634],[15431,5.6561],[15461,5.8076],[15492,5.9395],[15522,6.0502],[15553,6.0157],[15584,5.8088],[15614,5.7593],[15645,5.8183],[15675,5.699],[15706,5.6224],[15737,5.5854],[15765,5.755],[15796,5.74],[15826,5.7468],[15857,5.6682],[15887,5.7089],[15918,5.6116],[15949,5.5955],[15979,5.4795],[16010,5.5255],[16040,5.4534],[16071,5.4834],[16102,5.4727],[16130,5.409],[16161,5.4131],[16191,5.4394],[16222,5.4924],[16252,5.5129],[16283,5.603],[16314,5.7748],[16344,5.8795],[16375,5.9703],[16405,6.0446],[16436,6.3931],[16467,6.5695],[16495,6.8879],[16526,6.9185],[16556,6.7012],[16587,6.6681],[16617,6.7864],[16648,6.7156],[16679,6.65],[16709,6.6542],[16740,6.9486],[16770,6.8754],[16801,6.8769],[16832,6.7433],[16861,6.7191],[16892,6.5749],[16922,6.591],[16953,6.6365],[16983,6.7296],[17014,6.6512],[17045,6.6534],[17075,6.7523],[17106,6.8958],[17136,7.0605],[17167,7.0134],[17198,6.9934],[17226,6.9718],[17257,6.9507],[17287,6.7461],[17318,6.6302],[17348,6.4738],[17379,6.3117],[17410,6.2547],[17440,6.3472],[17471,6.3509],[17501,6.295],[17532,6.1189],[17563,6.0364],[17591,6.0507],[17622,6.0759],[17652,6.306],[17683,6.3862],[17713,6.3882],[17744,6.462],[17775,6.4057],[17805,6.4986],[17836,6.5724],[17866,6.5656],[17897,6.545],[17928,6.5809],[17956,6.6113],[17987,6.6532],[18017,6.6843],[18048,6.6218],[18078,6.6579],[18109,6.7154],[18140,6.788],[18170,6.7695],[18201,6.7667],[18231,6.732],[18262,6.7372],[18293,6.8506],[18322,6.7603],[18353,6.8734],[18383,6.851],[18414,6.6376],[18444,6.513],[18475,6.3042],[18506,6.3174],[18536,6.3329],[18567,6.3017],[18597,6.1287],[18628,6.1165],[18659,6.155],[18687,6.2501],[18718,6.2299],[18748,6.1324],[18779,6.1788],[18809,6.2944],[18840,6.3228],[18871,6.3267],[18901,6.4174],[18932,6.5187],[18962,6.5877],[18993,6.5838],[19024,6.5704],[19052,6.7463],[19083,6.8753],[19113,7.0417],[19144,7.0464],[19174,7.2974],[19205,7.3535],[19236,7.5154],[19266,7.5791],[19297,7.3201],[19327,7.045],[19358,6.9178],[19389,6.9513],[19417,6.9672],[19448,6.811],[19478,6.8573],[19509,6.8856],[19539,6.7546],[19570,6.8318],[19601,6.9799],[19631,7.0678],[19662,6.9197],[19692,6.8359],[19723,6.8396],[19754,6.9113]],"DOP":[[14791,36.905],[14822,36.887],[14853,37.021],[14883,37.144],[14914,37.346],[14944,37.448],[14975,37.523],[15006,37.658],[15034,37.817],[15065,37.986],[15095,38.098],[15126,38.11],[15156,38.275],[15187,38.373],[15218,38.361],[15248,38.434],[15279,38.465],[15309,38.773],[15340,39.097],[15371,39.151],[15400,39.101],[15431,39.26],[15461,39.267],[15492,39.383],[15522,39.417],[15553,39.283],[15584,39.392],[15614,39.673],[15645,40.052],[15675,40.362],[15706,40.794],[15737,40.975],[15765,41.255],[15796,41.43],[15826,41.311],[15857,41.701],[15887,42.164],[15918,42.568],[15949,42.856],[15979,42.753],[16010,42.788],[16040,42.811],[16071,43.056],[16102,43.405],[16130,43.459],[16161,43.401],[16191,43.483],[16222,43.617],[16252,43.81],[16283,43.879],[16314,43.931],[16344,44.105],[16375,44.328],[16405,44.364],[16436,44.786],[16467,44.987],[16495,44.941],[16526,45.145],[16556,44.96],[16587,45.258],[16617,45.32],[16648,45.385],[16679,45.487],[16709,45.5],[16740,45.66],[16770,45.718],[16801,45.883],[16832,45.93],[16861,45.882],[16892,45.972],[16922,46.046],[16953,46.147],[16983,46.191],[17014,46.18],[17045,46.457],[17075,46.588],[17106,46.746],[17136,46.834],[17167,46.913],[17198,47.272],[17226,47.374],[17257,47.683],[17287,47.592],[17318,47.644],[17348,47.551],[17379,47.689],[17410,47.735],[17440,48.09],[17471,48.378],[17501,48.358],[17532,48.664],[17563,49.143],[17591,49.542],[17622,49.731],[17652,49.84],[17683,49.719],[17713,49.931],[17744,50.04],[17775,49.998],[17805,50.273],[17836,50.391],[17866,50.411],[17897,50.595],[17928,50.743],[17956,50.744],[17987,50.927],[18017,50.845],[18048,50.898],[18078,51.149],[18109,51.356],[18140,51.935],[18170,52.875],[18201,53.138],[18231,53.149],[18262,53.314],[18293,53.681],[18322,53.865],[18353,54.326],[18383,55.727],[18414,57.864],[18444,58.714],[18475,58.813],[18506,58.609],[18536,58.769],[18567,58.766],[18597,58.482],[18628,58.4],[18659,58.371],[18687,57.628],[18718,57.283],[18748,57.116],[18779,57.265],[18809,57.331],[18840,57.278],[18871,56.931],[18901,56.677],[18932,56.878],[18962,57.077],[18993,57.698],[19024,57.368],[19052,55.601],[19083,55.215],[19113,55.576],[19144,55.111],[19174,54.903],[19205,54.286],[19236,53.468],[19266,53.957],[19297,54.49],[19327,55.435],[19358,56.52],[19389,56.629],[19417,55.535],[19448,55.034],[19478,54.849],[19509,55.136],[19539,55.939],[19570,56.784],[19601,57.03],[19631,57.131],[19662,57.214],[19692,57.602],[19723,58.658],[19754,59.048]],"DZD":[[14791,74.812],[14822,74.838],[14853,75.094],[14883,73.788],[14914,74.242],[14944,74.584],[14975,73.898],[15006,73.085],[15034,72.393],[15065,71.719],[15095,71.968],[15126,72.021],[15156,72.341],[15187,72.15],[15218,73.58],[15248,73.785],[15279,73.905],[15309,74.999],[15340,76.191],[15371,74.856],[15400,74.43],[15431,74.352],[15461,75.216],[15492,77.825],[15522,80.75],[15553,81.38],[15584,79.79],[15614,79.328],[15645,79.551],[15675,78.449],[15706,77.995],[15737,77.871],[15765,78.922],[15796,78.911],[15826,78.982],[15857,79.152],[15887,79.592],[15918,80.3],[15949,81.62],[15979,81.39],[16010,80.54],[16040,78.775],[16071,78.19],[16102,77.981],[16130,77.722],[16161,78.595],[16191,78.814],[16222,79.274],[16252,79.526],[16283,80.04],[16314,81.45],[16344,83.37],[16375,84.77],[16405,86.93],[16436,89.38],[16467,93.6],[16495,96.48],[16526,97.95],[16556,98.13],[16587,98.76],[16617,99.43],[16648,103.4],[16679,106.0],[16709,105.81],[16740,107.73],[16770,107.47],[16801,107.42],[16832,106.87],[16861,109.19],[16892,108.82],[16922,109.72],[16953,110.15],[16983,110.59],[17014,109.52],[17045,109.47],[17075,109.95],[17106,110.39],[17136,110.7],[17167,110.25],[17198,109.94],[17226,109.99],[17257,110.16],[17287,109.25],[17318,108.58],[17348,108.69],[17379,109.87],[17410,111.25],[17440,113.88],[17471,115.29],[17501,115.22],[17532,114.57],[17563,114.04],[17591,114.2],[17622,114.21],[17652,115.97],[17683,117.0],[17713,117.74],[17744,118.35],[17775,118.01],[17805,118.5],[17836,118.61],[17866,118.46],[17897,118.26],[17928,118.56],[17956,119.02],[17987,119.41],[18017,119.55],[18048,119.11],[18078,119.16],[18109,119.65],[18140,120.21],[18170,120.03],[18201,119.85],[18231,119.56],[18262,119.46],[18293,120.39],[18322,121.81],[18353,127.37],[18383,128.68],[18414,128.78],[18444,128.7],[18475,128.24],[18506,128.57],[18536,128.87],[18567,128.99],[18597,131.29],[18628,132.44],[18659,133.05],[18687,133.19],[18718,133.45],[18748,133.3],[18779,133.96],[18809,134.63],[18840,135.03],[18871,136.09],[18901,137.03],[18932,138.17],[18962,139.0],[18993,139.49],[19024,140.78],[19052,142.47],[19083,143.22],[19113,145.85],[19144,145.81],[19174,146.16],[19205,143.04],[19236,140.46],[19266,140.85],[19297,139.86],[19327,138.06],[19358,136.77],[19389,136.3],[19417,136.46],[19448,135.64],[19478,135.86],[19509,136.16],[19539,135.24],[19570,135.88],[19601,136.82],[19631,137.23],[19662,135.26],[19692,134.58],[19723,134.44],[19754,134.53]],"EGP":[[14791,5.6999],[14822,5.6921],[14853,5.7004],[14883,5.7122],[14914,5.7714],[14944,5.7999],[14975,5.8174],[15006,5.8762],[15034,5.917],[15065,5.9569],[15095,5.9452],[15126,5.9436],[15156,5.9563],[15187,5.9554],[15218,5.9666],[15248,5.9616],[15279,5.9754],[15309,6.019],[15340,6.0374],[15371,6.0301],[15400,6.0263],[15431,6.04],[15461,6.0401],[15492,6.0526],[15522,6.0687],[15553,6.071],[15584,6.0885],[15614,6.0992],[15645,6.103],[15675,6.1735],[15706,6.5233],[15737,6.7149],[15765,6.7773],[15796,6.8735],[15826,6.9564],[15857,7.0001],[15887,7.0109],[15918,6.9904],[15949,6.9164],[15979,6.8897],[16010,6.8918],[16040,6.9042],[16071,6.9543],[16102,6.9662],[16130,6.9641],[16161,6.9842],[16191,7.0786],[16222,7.1493],[16252,7.1518],[16283,7.1526],[16314,7.1543],[16344,7.152],[16375,7.1552],[16405,7.1469],[16436,7.2787],[16467,7.589],[16495,7.6194],[16526,7.6394],[16556,7.6003],[16587,7.6244],[16617,7.7871],[16648,7.8281],[16679,7.8342],[16709,7.8989],[16740,7.9025],[16770,7.8296],[16801,7.831],[16832,7.8159],[16861,8.3835],[16892,8.8679],[16922,8.8543],[16953,8.8663],[16983,8.8692],[17014,8.8551],[17045,8.8812],[17075,8.8741],[17106,15.2122],[17136,18.2936],[17167,18.6929],[17198,16.8911],[17226,17.6404],[17257,18.1256],[17287,18.0996],[17318,18.1132],[17348,17.935],[17379,17.7537],[17410,17.6324],[17440,17.6358],[17471,17.6914],[17501,17.8019],[17532,17.7175],[17563,17.6467],[17591,17.6238],[17622,17.646],[17652,17.7971],[17683,17.8539],[17713,17.891],[17744,17.8995],[17775,17.8944],[17805,17.8975],[17836,17.9146],[17866,17.9213],[17897,17.8361],[17928,17.5855],[17956,17.3654],[17987,17.2523],[18017,17.0003],[18048,16.7384],[18078,16.599],[18109,16.5596],[18140,16.3859],[18170,16.2349],[18201,16.1134],[18231,16.0852],[18262,15.8695],[18293,15.6869],[18322,15.695],[18353,15.735],[18383,15.798],[18414,16.1169],[18444,16.0306],[18475,15.9316],[18506,15.754],[18536,15.6918],[18567,15.6406],[18597,15.6982],[18628,15.6868],[18659,15.6899],[18687,15.6827],[18718,15.7092],[18748,15.6671],[18779,15.665],[18809,15.6896],[18840,15.6949],[18871,15.6899],[18901,15.7068],[18932,15.7035],[18962,15.7066],[18993,15.6971],[19024,15.7256],[19052,16.7217],[19083,18.3556],[19113,18.4971],[19144,18.6543],[19174,18.8509],[19205,19.1138],[19236,19.3366],[19266,19.921],[19297,24.402],[19327,24.676],[19358,28.778],[19389,30.374],[19417,30.843],[19448,30.89],[19478,30.867],[19509,30.904],[19539,30.88],[19570,30.912],[19601,30.878],[19631,30.893],[19662,30.934],[19692,30.925],[19723,30.871],[19754,30.887]],"ERN":[[14791,15.2101],[14822,15.1237],[14853,15.1343],[14883,15.0805],[14914,15.2241],[14944,15.2396],[14975,15.1625],[15006,15.1374],[15034,15.0822],[15065,15.1807],[15095,15.0477],[15126,15.1379],[15156,15.0695],[15187,15.1434],[15218,15.1261],[15248,15.1049],[15279,15.0671],[15309,15.1618],[15340,15.1743],[15371,15.1456],[15400,15.0876],[15431,15.1275],[15461,15.1498],[15492,15.2965],[15522,15.621],[15553,15.5023],[15584,15.4279],[15614,15.5473],[15645,15.5744],[15675,15.4193],[15706,15.5386],[15737,15.4798],[15765,15.2507],[15796,14.7842],[15826,15.1929],[15857,15.1875],[15887,15.263],[15918,15.0842],[15949,15.2895],[15979,15.1487],[16010,15.1246],[16040,15.103],[16071,15.1392],[16102,15.1608],[16130,15.1255],[16161,15.1893],[16191,15.1889],[16222,15.1676],[16252,15.2255],[16283,15.2499],[16314,15.2186],[16344,15.2753],[16375,15.2655],[16405,15.1661],[16436,15.3406],[16467,15.3574],[16495,15.2156],[16526,15.3393],[16556,15.181],[16587,15.3167],[16617,15.3519],[16648,15.2647],[16679,15.2582],[16709,15.4471],[16740,15.5649],[16770,15.473],[16801,15.9253],[16832,15.8911],[16861,15.6847],[16892,15.7915],[16922,15.8438],[16953,15.5327],[16983,15.6499],[17014,15.6115],[17045,15.5719],[17075,15.6411],[17106,15.5669],[17136,15.3094],[17167,15.4436],[17198,15.3983],[17226,15.2944],[17257,15.3853],[17287,15.356],[17318,15.301],[17348,15.2691],[17379,15.3296],[17410,15.2525],[17440,15.2943],[17471,15.2531],[17501,15.1594],[17532,15.1632],[17563,15.1792],[17591,15.1594],[17622,15.182],[17652,15.2356],[17683,15.1922],[17713,15.2173],[17744,15.2246],[17775,15.137],[17805,15.2023],[17836,15.2149],[17866,15.1664],[17897,15.1914],[17928,15.1915],[17956,15.1529],[17987,15.2637],[18017,15.2324],[18048,15.1511],[18078,15.1968],[18109,15.1992],[18140,15.1827],[18170,15.2258],[18201,15.1968],[18231,15.1692],[18262,15.1857],[18293,15.2284],[18322,15.1393],[18353,15.1595],[18383,15.2367],[18414,15.1664],[18444,15.1764],[18475,15.195],[18506,15.1404],[18536,15.2057],[18567,15.2353],[18597,15.1578],[18628,15.1416],[18659,15.1751],[18687,15.1082],[18718,15.208],[18748,15.1423],[18779,15.1578],[18809,15.1455],[18840,15.1405],[18871,15.1189],[18901,15.1655],[18932,15.1826],[18962,15.1301],[18993,15.1444],[19024,15.1306],[19052,15.1191],[19083,15.0903],[19113,15.1027],[19144,15.0791],[19174,15.0872],[19205,15.0995],[19236,15.0707],[19266,15.1191],[19297,15.1094],[19327,15.0903],[19358,15.1032],[19389,15.1187],[19417,15.1002],[19448,15.1194],[19478,15.1217],[19509,15.099],[19539,15.1188],[19570,15.1188],[19601,15.0993],[19631,15.1298],[19662,15.1388],[19692,15.0984],[19723,15.1169],[19754,15.1135]],"ETB":[[14791,13.6409],[14822,13.6325],[14853,15.6447],[14883,16.4335],[14914,16.5773],[14944,16.5882],[14975,16.6084],[15006,16.6751],[15034,16.7129],[15065,16.814],[15095,16.851],[15126,16.9081],[15156,16.966],[15187,17.0733],[15218,17.1464],[15248,17.1323],[15279,17.1748],[15309,17.282],[15340,17.2944],[15371,17.3245],[15400,17.3502],[15431,17.4461],[15461,17.5779],[15492,17.7253],[15522,17.886],[15553,17.9314],[15584,17.9428],[15614,18.0649],[15645,18.097],[15675,18.1989],[15706,18.3056],[15737,18.3275],[15765,18.4307],[15796,18.5116],[15826,18.5322],[15857,18.6636],[15887,18.7229],[15918,18.7725],[15949,18.8422],[15979,18.8872],[16010,18.9897],[16040,19.0327],[16071,19.0981],[16102,19.2276],[16130,19.2754],[16161,19.3925],[16191,19.4546],[16222,19.5681],[16252,19.6524],[16283,19.8134],[16314,19.8816],[16344,19.9512],[16375,20.078],[16405,20.074],[16436,20.122],[16467,20.235],[16495,20.353],[16526,20.56],[16556,20.34],[16587,20.593],[16617,20.714],[16648,20.817],[16679,20.892],[16709,20.807],[16740,21.015],[16770,21.087],[16801,21.192],[16832,21.208],[16861,21.463],[16892,21.418],[16922,21.545],[16953,21.754],[16983,22.008],[17014,21.991],[17045,22.124],[17075,22.157],[17106,22.215],[17136,22.385],[17167,22.502],[17198,22.616],[17226,22.726],[17257,22.938],[17287,22.974],[17318,23.169],[17348,23.224],[17379,23.362],[17410,23.369],[17440,25.631],[17471,27.355],[17501,27.34],[17532,27.409],[17563,27.295],[17591,27.324],[17622,27.279],[17652,27.356],[17683,27.392],[17713,27.464],[17744,27.618],[17775,27.613],[17805,27.749],[17836,27.949],[17866,28.054],[17897,28.187],[17928,28.403],[17956,28.444],[17987,28.642],[18017,28.81],[18048,28.909],[18078,28.824],[18109,29.065],[18140,29.315],[18170,29.503],[18201,30.013],[18231,31.46],[18262,31.833],[18293,32.285],[18322,32.645],[18353,32.927],[18383,33.787],[18414,34.574],[18444,35.167],[18475,35.713],[18506,36.594],[18536,37.232],[18567,38.053],[18597,38.84],[18628,39.209],[18659,39.899],[18687,40.688],[18718,41.62],[18748,42.262],[18779,43.473],[18809,44.049],[18840,44.969],[18871,45.838],[18901,46.769],[18932,47.629],[18962,48.677],[18993,49.409],[19024,50.508],[19052,51.192],[19083,51.141],[19113,51.919],[19144,51.804],[19174,52.214],[19205,52.49],[19236,52.511],[19266,53.059],[19297,53.27],[19327,53.445],[19358,53.62],[19389,53.61],[19417,53.924],[19448,54.221],[19478,54.316],[19509,54.698],[19539,54.824],[19570,55.058],[19601,55.122],[19631,55.56],[19662,55.952],[19692,56.15],[19723,56.243],[19754,56.496]],"EUR":[[14791,0.78422],[14822,0.77422],[14853,0.76688],[14883,0.72124],[14914,0.73057],[14944,0.75618],[14975,0.74832],[15006,0.73269],[15034,0.71574],[15065,0.6937],[15095,0.69636],[15126,0.69592],[15156,0.70014],[15187,0.69785],[15218,0.72477],[15248,0.73036],[15279,0.73625],[15309,0.75824],[15340,0.77494],[15371,0.75653],[15400,0.75647],[15431,0.75875],[15461,0.78026],[15492,0.7977],[15522,0.81191],[15553,0.80677],[15584,0.77896],[15614,0.77164],[15645,0.77912],[15675,0.76327],[15706,0.75331],[15737,0.74831],[15765,0.77133],[15796,0.76916],[15826,0.77033],[15857,0.75947],[15887,0.76495],[15918,0.75157],[15949,0.74973],[15979,0.73399],[16010,0.7404],[16040,0.7302],[16071,0.73397],[16102,0.73267],[16130,0.72426],[16161,0.72442],[16191,0.72748],[16222,0.73528],[16252,0.73808],[16283,0.75054],[16314,0.7742],[16344,0.78852],[16375,0.80113],[16405,0.81122],[16436,0.85736],[16467,0.88055],[16495,0.92165],[16526,0.92579],[16556,0.8975],[16587,0.89357],[16617,0.90922],[16648,0.89981],[16679,0.89121],[16709,0.89169],[16740,0.93069],[16770,0.92073],[16801,0.9209],[16832,0.90281],[16861,0.90052],[16892,0.88299],[16922,0.88496],[16953,0.89121],[16983,0.90358],[17014,0.89288],[17045,0.89218],[17075,0.90563],[17106,0.92452],[17136,0.94808],[17167,0.94121],[17198,0.93839],[17226,0.93578],[17257,0.93231],[17287,0.90576],[17318,0.89091],[17348,0.8705],[17379,0.84784],[17410,0.84028],[17440,0.85096],[17471,0.85263],[17501,0.84515],[17532,0.8219],[17563,0.81036],[17591,0.81153],[17622,0.81504],[17652,0.84515],[17683,0.85631],[17713,0.85629],[17744,0.86616],[17775,0.85824],[17805,0.87038],[17836,0.8801],[17866,0.87905],[17897,0.87611],[17928,0.88134],[17956,0.88495],[17987,0.89063],[18017,0.89454],[18048,0.88612],[18078,0.89129],[18109,0.89933],[18140,0.90848],[18170,0.90586],[18201,0.90502],[18231,0.90059],[18262,0.90104],[18293,0.91646],[18322,0.9051],[18353,0.92045],[18383,0.91814],[18414,0.89003],[18444,0.87489],[18475,0.84701],[18506,0.84858],[18536,0.85032],[18567,0.84605],[18597,0.82349],[18628,0.82179],[18659,0.82718],[18687,0.8396],[18718,0.83708],[18748,0.82419],[18779,0.83002],[18809,0.84567],[18840,0.84974],[18871,0.85012],[18901,0.86193],[18932,0.87572],[18962,0.88538],[18993,0.88422],[19024,0.88269],[19052,0.90595],[19083,0.92234],[19113,0.9446],[19144,0.94593],[19174,0.97913],[19205,0.9866],[19236,1.009],[19266,1.0162],[19297,0.98213],[19327,0.94597],[19358,0.92931],[19389,0.93228],[19417,0.93417],[19448,0.91281],[19478,0.91919],[19509,0.92311],[19539,0.90533],[19570,0.9156],[19601,0.9348],[19631,0.94595],[19662,0.92639],[19692,0.91615],[19723,0.91628],[19754,0.92588]],"FJD":[[14791,1.9641],[14822,1.926],[14853,1.9133],[14883,1.8447],[14914,1.8444],[14944,1.8501],[14975,1.8391],[15006,1.8275],[15034,1.8231],[15065,1.7852],[15095,1.7581],[15126,1.7568],[15156,1.7379],[15187,1.7407],[15218,1.7856],[15248,1.7994],[15279,1.7984],[15309,1.8236],[15340,1.7975],[15371,1.7554],[15400,1.7641],[15431,1.7798],[15461,1.8189],[15492,1.8487],[15522,1.8113],[15553,1.786],[15584,1.7753],[15614,1.7832],[15645,1.7831],[15675,1.7746],[15706,1.7718],[15737,1.7777],[15765,1.7851],[15796,1.7819],[15826,1.8116],[15857,1.8619],[15887,1.8937],[15918,1.8955],[15949,1.8718],[15979,1.8427],[16010,1.8579],[16040,1.8814],[16071,1.8942],[16102,1.8912],[16130,1.8686],[16161,1.8512],[16191,1.8445],[16222,1.8392],[16252,1.8415],[16283,1.858],[16314,1.8949],[16344,1.933],[16375,1.9497],[16405,1.9718],[16436,2.012],[16467,2.043],[16495,2.0551],[16526,2.0513],[16556,2.0445],[16587,2.0779],[16617,2.1209],[16648,2.1477],[16679,2.1718],[16709,2.1447],[16740,2.1539],[16770,2.1415],[16801,2.155],[16832,2.1485],[16861,2.0962],[16892,2.0734],[16922,2.1006],[16953,2.0915],[16983,2.0721],[17014,2.0606],[17045,2.0527],[17075,2.0648],[17106,2.0774],[17136,2.1058],[17167,2.0955],[17198,2.0662],[17226,2.0696],[17257,2.0863],[17287,2.0871],[17318,2.0599],[17348,2.0292],[17379,2.0162],[17410,2.0183],[17440,2.0468],[17471,2.0747],[17501,2.0638],[17532,2.0215],[17563,2.0148],[17591,2.0263],[17622,2.0374],[17652,2.072],[17683,2.0755],[17713,2.0933],[17744,2.1023],[17775,2.116],[17805,2.1344],[17836,2.1188],[17866,2.113],[17897,2.1179],[17928,2.1141],[17956,2.1178],[17987,2.1411],[18017,2.1617],[18048,2.1407],[18078,2.1382],[18109,2.1825],[18140,2.1909],[18170,2.1818],[18201,2.1893],[18231,2.1567],[18262,2.1561],[18293,2.2018],[18322,2.2624],[18353,2.2612],[18383,2.2436],[18414,2.1688],[18444,2.144],[18475,2.1194],[18506,2.11],[18536,2.1264],[18567,2.1003],[18597,2.0506],[18628,2.0348],[18659,2.0304],[18687,2.0341],[18718,2.0466],[18748,2.0273],[18779,2.0451],[18809,2.0716],[18840,2.0856],[18871,2.0895],[18901,2.0834],[18932,2.0902],[18962,2.1167],[18993,2.1275],[19024,2.1386],[19052,2.1098],[19083,2.108],[19113,2.1647],[19144,2.1836],[19174,2.2091],[19205,2.2063],[19236,2.2497],[19266,2.3095],[19297,2.2738],[19327,2.2173],[19358,2.1931],[19389,2.1921],[19417,2.2165],[19448,2.2213],[19478,2.2295],[19509,2.2344],[19539,2.2305],[19570,2.256],[19601,2.2758],[19631,2.2884],[19662,2.2758],[19692,2.2263],[19723,2.2313],[19754,2.2551]],"FKP":[[14791,0.65938],[14822,0.63949],[14853,0.64283],[14883,0.63188],[14914,0.62704],[14944,0.64178],[14975,0.64091],[15006,0.62401],[15034,0.62024],[15065,0.61757],[15095,0.6063],[15126,0.61217],[15156,0.62202],[15187,0.61203],[15218,0.62331],[15248,0.63724],[15279,0.62675],[15309,0.64028],[15340,0.64671],[15371,0.63522],[15400,0.63118],[15431,0.6271],[15461,0.62237],[15492,0.64233],[15522,0.64238],[15553,0.63649],[15584,0.62578],[15614,0.6192],[15645,0.62433],[15675,0.62149],[15706,0.62318],[15737,0.63974],[15765,0.66155],[15796,0.65674],[15826,0.64886],[15857,0.65351],[15887,0.65679],[15918,0.64918],[15949,0.63751],[15979,0.62093],[16010,0.62158],[16040,0.61129],[16071,0.60714],[16102,0.60549],[16130,0.60121],[16161,0.59932],[16191,0.59421],[16222,0.59312],[16252,0.58645],[16283,0.59476],[16314,0.60806],[16344,0.61927],[16375,0.62935],[16405,0.63694],[16436,0.65158],[16467,0.65665],[16495,0.6564],[16526,0.67236],[16556,0.64897],[16587,0.64831],[16617,0.63962],[16648,0.64054],[16679,0.65071],[16709,0.65599],[16740,0.65693],[16770,0.66589],[16801,0.68515],[16832,0.69916],[16861,0.70919],[16892,0.69632],[16922,0.68648],[16953,0.6937],[16983,0.75211],[17014,0.7611],[17045,0.76138],[17075,0.78918],[17106,0.81391],[17136,0.80144],[17167,0.8136],[17198,0.80013],[17226,0.80783],[17257,0.79686],[17287,0.77478],[17318,0.779],[17348,0.7704],[17379,0.76771],[17410,0.76253],[17440,0.75087],[17471,0.75707],[17501,0.74613],[17532,0.73394],[17563,0.7119],[17591,0.71716],[17622,0.70851],[17652,0.73463],[17683,0.7524],[17713,0.76174],[17744,0.76945],[17775,0.76674],[17805,0.76432],[17836,0.77962],[17866,0.78568],[17897,0.78206],[17928,0.76649],[17956,0.75513],[17987,0.76475],[18017,0.77689],[18048,0.78931],[18078,0.79413],[18109,0.82228],[18140,0.81365],[18170,0.80195],[18201,0.7761],[18231,0.76821],[18262,0.76331],[18293,0.76722],[18322,0.79191],[18353,0.80585],[18383,0.8097],[18414,0.80656],[18444,0.80021],[18475,0.76514],[18506,0.76106],[18536,0.77397],[18567,0.76479],[18597,0.74745],[18628,0.73439],[18659,0.72708],[18687,0.71425],[18718,0.72475],[18748,0.71337],[18779,0.70958],[18809,0.72359],[18840,0.72054],[18871,0.72729],[18901,0.73589],[18932,0.73531],[18962,0.75048],[18993,0.73927],[19024,0.74277],[19052,0.75283],[19083,0.76663],[19113,0.80385],[19144,0.80136],[19174,0.82836],[19205,0.83003],[19236,0.86834],[19266,0.90399],[19297,0.85861],[19327,0.82644],[19358,0.82533],[19389,0.81666],[19417,0.82914],[19448,0.80664],[19478,0.80088],[19509,0.79796],[19539,0.78315],[19570,0.78224],[19601,0.79785],[19631,0.82047],[19662,0.81449],[19692,0.78899],[19723,0.7854],[19754,0.79062]],"GBP":[[14791,0.65501],[14822,0.63906],[14853,0.64286],[14883,0.63097],[14914,0.62604],[14944,0.64072],[14975,0.63514],[15006,0.62086],[15034,0.61951],[15065,0.61213],[15095,0.61174],[15126,0.6168],[15156,0.6203],[15187,0.61154],[15218,0.63239],[15248,0.63569],[15279,0.6319],[15309,0.64083],[15340,0.64524],[15371,0.63353],[15400,0.63202],[15431,0.62533],[15461,0.62857],[15492,0.64323],[15522,0.64106],[15553,0.63624],[15584,0.62171],[15614,0.62212],[15645,0.62628],[15675,0.61998],[15706,0.62597],[15737,0.64434],[15765,0.66282],[15796,0.65382],[15826,0.65367],[15857,0.64748],[15887,0.65898],[15918,0.64571],[15949,0.63101],[15979,0.62137],[16010,0.62116],[16040,0.61067],[16071,0.60747],[16102,0.60446],[16130,0.60172],[16161,0.59793],[16191,0.59424],[16222,0.59209],[16252,0.58611],[16283,0.59831],[16314,0.61279],[16344,0.62186],[16375,0.6333],[16405,0.63931],[16436,0.6588],[16467,0.65287],[16495,0.66675],[16526,0.66903],[16556,0.64773],[16587,0.6431],[16617,0.6427],[16648,0.64175],[16679,0.65137],[16709,0.65262],[16740,0.65749],[16770,0.66662],[16801,0.69304],[16832,0.69936],[16861,0.70376],[16892,0.69931],[16922,0.68856],[16953,0.7026],[16983,0.75914],[17014,0.76263],[17045,0.76045],[17075,0.80946],[17106,0.80533],[17136,0.80125],[17167,0.81121],[17198,0.80093],[17226,0.81028],[17257,0.79249],[17287,0.77468],[17318,0.7813],[17348,0.77053],[17379,0.77168],[17410,0.75286],[17440,0.75733],[17471,0.75669],[17501,0.74628],[17532,0.72563],[17563,0.7157],[17591,0.71664],[17622,0.71084],[17652,0.74161],[17683,0.75257],[17713,0.75967],[17744,0.77608],[17775,0.76638],[17805,0.76834],[17836,0.7756],[17866,0.78893],[17897,0.77647],[17928,0.76898],[17956,0.75937],[17987,0.76763],[18017,0.77848],[18048,0.78923],[18078,0.80128],[18109,0.82272],[18140,0.81076],[18170,0.79281],[18201,0.77642],[18231,0.76347],[18262,0.76535],[18293,0.77096],[18322,0.80531],[18353,0.80661],[18383,0.81359],[18414,0.79986],[18444,0.79123],[18475,0.76255],[18506,0.77101],[18536,0.77081],[18567,0.75852],[18597,0.74506],[18628,0.73423],[18659,0.72217],[18687,0.72078],[18718,0.72247],[18748,0.7114],[18779,0.71321],[18809,0.7242],[18840,0.72483],[18871,0.72849],[18901,0.73141],[18932,0.74277],[18962,0.75134],[18993,0.73857],[19024,0.73947],[19052,0.75851],[19083,0.77201],[19113,0.8025],[19144,0.81069],[19174,0.83277],[19205,0.83403],[19236,0.88122],[19266,0.88664],[19297,0.85362],[19327,0.8225],[19358,0.81873],[19389,0.82599],[19417,0.82436],[19448,0.80426],[19478,0.80142],[19509,0.7929],[19539,0.77755],[19570,0.78666],[19601,0.80527],[19631,0.82098],[19662,0.80654],[19692,0.79021],[19723,0.78721],[19754,0.79147]],"GEL":[[14791,1.8429],[14822,1.8376],[14853,1.8274],[14883,1.7903],[14914,1.7686],[14944,1.7645],[14975,1.7937],[15006,1.7763],[15034,1.7175],[15065,1.6662],[15095,1.6726],[15126,1.6562],[15156,1.6612],[15187,1.6505],[15218,1.6616],[15248,1.657],[15279,1.6544],[15309,1.6623],[15340,1.67],[15371,1.6594],[15400,1.6488],[15431,1.6358],[15461,1.626],[15492,1.6378],[15522,1.6548],[15553,1.6479],[15584,1.6526],[15614,1.6604],[15645,1.6607],[15675,1.6591],[15706,1.6588],[15737,1.6555],[15765,1.6604],[15796,1.6564],[15826,1.6413],[15857,1.6545],[15887,1.6555],[15918,1.6593],[15949,1.6624],[15979,1.6652],[16010,1.6793],[16040,1.7142],[16071,1.759],[16102,1.7527],[16130,1.742],[16161,1.7544],[16191,1.7631],[16222,1.7687],[16252,1.7584],[16283,1.7326],[16314,1.7448],[16344,1.7534],[16375,1.779],[16405,1.8671],[16436,1.9555],[16467,2.0975],[16495,2.2012],[16526,2.2705],[16556,2.3033],[16587,2.2586],[16617,2.2598],[16648,2.3255],[16679,2.3906],[16709,2.3809],[16740,2.3957],[16770,2.3983],[16801,2.4407],[16832,2.4781],[16861,2.3891],[16892,2.2595],[16922,2.1758],[16953,2.2171],[16983,2.3345],[17014,2.3153],[17045,2.3166],[17075,2.362],[17106,2.4712],[17136,2.6499],[17167,2.7],[17198,2.6354],[17226,2.4594],[17257,2.4312],[17287,2.4249],[17318,2.4145],[17348,2.3981],[17379,2.4046],[17410,2.4622],[17440,2.5111],[17471,2.6705],[17501,2.6135],[17532,2.5394],[17563,2.4657],[17591,2.4372],[17622,2.425],[17652,2.4553],[17683,2.4532],[17713,2.4469],[17744,2.535],[17775,2.603],[17805,2.6602],[17836,2.6915],[17866,2.6657],[17897,2.6607],[17928,2.6566],[17956,2.6818],[17987,2.693],[18017,2.7489],[18048,2.7939],[18078,2.8678],[18109,2.9237],[18140,2.9602],[18170,2.9656],[18201,2.9621],[18231,2.8965],[18262,2.875],[18293,2.8422],[18322,3.0461],[18353,3.1789],[18383,3.1986],[18414,3.0545],[18444,3.0704],[18475,3.0748],[18506,3.1697],[18536,3.2229],[18567,3.3041],[18597,3.2865],[18628,3.2881],[18659,3.3128],[18687,3.3412],[18718,3.4286],[18748,3.3713],[18779,3.1593],[18809,3.1278],[18840,3.1081],[18871,3.1089],[18901,3.1349],[18932,3.129],[18962,3.0975],[18993,3.0713],[19024,3.0173],[19052,3.206],[19083,3.06],[19113,2.977],[19144,2.9337],[19174,2.8381],[19205,2.7959],[19236,2.8336],[19266,2.787],[19297,2.7304],[19327,2.6902],[19358,2.6667],[19389,2.6371],[19417,2.5846],[19448,2.5233],[19478,2.5343],[19509,2.6081],[19539,2.5966],[19570,2.6118],[19601,2.6412],[19631,2.6892],[19662,2.7058],[19692,2.6888],[19723,2.6734],[19754,2.6546]],"GHS":[[14791,1.4404],[14822,1.4321],[14853,1.4306],[14883,1.421],[14914,1.4394],[14944,1.4495],[14975,1.4666],[15006,1.5082],[15034,1.51],[15065,1.5152],[15095,1.4989],[15126,1.509],[15156,1.5065],[15187,1.5134],[15218,1.5406],[15248,1.5704],[15279,1.571],[15309,1.6116],[15340,1.6615],[15371,1.675],[15400,1.7203],[15431,1.771],[15461,1.8549],[15492,1.8992],[15522,1.9399],[15553,1.9504],[15584,1.9223],[15614,1.9095],[15645,1.8962],[15675,1.8998],[15706,1.9082],[15737,1.9075],[15765,1.9251],[15796,1.9478],[15826,1.9721],[15857,2.0018],[15887,2.0223],[15918,2.041],[15949,2.0924],[15979,2.0955],[16010,2.1355],[16040,2.2268],[16071,2.3297],[16102,2.4572],[16130,2.5765],[16161,2.7326],[16191,2.8587],[16222,3.0512],[16252,3.1783],[16283,3.3471],[16314,3.3729],[16344,3.2112],[16375,3.2075],[16405,3.1972],[16436,3.2235],[16467,3.3549],[16495,3.6204],[16526,3.8101],[16556,3.9058],[16587,4.2149],[16617,3.7339],[16648,3.8339],[16679,3.8616],[16709,3.7858],[16740,3.7974],[16770,3.8034],[16801,3.8449],[16832,3.902],[16861,3.873],[16892,3.8178],[16922,3.8143],[16953,3.8939],[16983,3.9269],[17014,3.9291],[17045,3.9557],[17075,3.9699],[17106,3.9931],[17136,4.1598],[17167,4.2391],[17198,4.3901],[17226,4.4576],[17257,4.253],[17287,4.2389],[17318,4.3503],[17348,4.3672],[17379,4.3921],[17410,4.4036],[17440,4.4052],[17471,4.4765],[17501,4.4849],[17532,4.4878],[17563,4.4615],[17591,4.4285],[17622,4.4249],[17652,4.5074],[17683,4.6427],[17713,4.7553],[17744,4.7611],[17775,4.7891],[17805,4.8272],[17836,4.8335],[17866,4.8533],[17897,4.8919],[17928,5.1083],[17956,5.2624],[17987,5.1541],[18017,5.1677],[18048,5.3114],[18078,5.3271],[18109,5.358],[18140,5.4185],[18170,5.4006],[18201,5.4674],[18231,5.6046],[18262,5.5895],[18293,5.3873],[18322,5.4609],[18353,5.64],[18383,5.6921],[18414,5.7147],[18444,5.731],[18475,5.7589],[18506,5.7338],[18536,5.7675],[18567,5.7985],[18597,5.8162],[18628,5.8274],[18659,5.8072],[18687,5.7498],[18718,5.7635],[18748,5.7684],[18779,5.798],[18809,5.8516],[18840,5.9205],[18871,5.959],[18901,5.9801],[18932,6.0232],[18962,6.0466],[18993,6.1206],[19024,6.3911],[19052,6.9769],[19083,7.324],[19113,7.4011],[19144,7.5925],[19174,7.7558],[19205,8.6064],[19236,9.5573],[19266,11.3811],[19297,13.6178],[19327,11.2466],[19358,10.5099],[19389,11.929],[19417,12.2703],[19448,11.4758],[19478,11.3725],[19509,11.0986],[19539,11.2583],[19570,11.2105],[19601,11.3338],[19631,11.5815],[19662,11.8276],[19692,11.9593],[19723,12.0314],[19754,12.3345]],"GIP":[[14791,0.66447],[14822,0.64602],[14853,0.64938],[14883,0.63802],[14914,0.63371],[14944,0.64752],[14975,0.64482],[15006,0.62915],[15034,0.62576],[15065,0.62145],[15095,0.61644],[15126,0.62049],[15156,0.62719],[15187,0.61833],[15218,0.63461],[15248,0.64308],[15279,0.63607],[15309,0.64761],[15340,0.65163],[15371,0.64116],[15400,0.63808],[15431,0.6327],[15461,0.63184],[15492,0.64979],[15522,0.64867],[15553,0.64276],[15584,0.62959],[15614,0.62697],[15645,0.63209],[15675,0.62749],[15706,0.63114],[15737,0.64978],[15765,0.66929],[15796,0.66207],[15826,0.65825],[15857,0.65676],[15887,0.66526],[15918,0.65409],[15949,0.64101],[15979,0.62735],[16010,0.62799],[16040,0.61748],[16071,0.6132],[16102,0.61168],[16130,0.60781],[16161,0.60388],[16191,0.5998],[16222,0.59874],[16252,0.59221],[16283,0.60275],[16314,0.61756],[16344,0.6272],[16375,0.63805],[16405,0.64428],[16436,0.6624],[16467,0.66142],[16495,0.6683],[16526,0.67789],[16556,0.65419],[16587,0.65308],[16617,0.6478],[16648,0.64715],[16679,0.65805],[16709,0.66093],[16740,0.66396],[16770,0.67379],[16801,0.69709],[16832,0.70582],[16861,0.71401],[16892,0.70492],[16922,0.695],[16953,0.70509],[16983,0.76423],[17014,0.7692],[17045,0.7692],[17075,0.80779],[17106,0.81886],[17136,0.81029],[17167,0.82027],[17198,0.80835],[17226,0.81719],[17257,0.80247],[17287,0.78239],[17318,0.78838],[17348,0.77839],[17379,0.77764],[17410,0.7647],[17440,0.76219],[17471,0.76511],[17501,0.75401],[17532,0.73255],[17563,0.72003],[17591,0.72237],[17622,0.7153],[17652,0.74493],[17683,0.75875],[17713,0.7663],[17744,0.78032],[17775,0.77279],[17805,0.77318],[17836,0.78317],[17866,0.79473],[17897,0.78484],[17928,0.77401],[17956,0.7637],[17987,0.77275],[18017,0.78412],[18048,0.79572],[18078,0.80523],[18109,0.82952],[18140,0.81845],[18170,0.80311],[18201,0.78255],[18231,0.77191],[18262,0.77111],[18293,0.77555],[18322,0.80854],[18353,0.8137],[18383,0.81819],[18414,0.80789],[18444,0.80146],[18475,0.7695],[18506,0.7734],[18536,0.77848],[18567,0.76729],[18597,0.75265],[18628,0.73958],[18659,0.72958],[18687,0.72443],[18718,0.72943],[18748,0.71715],[18779,0.71751],[18809,0.7294],[18840,0.72902],[18871,0.73403],[18901,0.73873],[18932,0.7465],[18962,0.75747],[18993,0.74419],[19024,0.74743],[19052,0.76352],[19083,0.77605],[19113,0.81037],[19144,0.8143],[19174,0.83766],[19205,0.83933],[19236,0.88367],[19266,0.90063],[19297,0.86217],[19327,0.83034],[19358,0.8281],[19389,0.82961],[19417,0.83296],[19448,0.81132],[19478,0.8079],[19509,0.80122],[19539,0.78584],[19570,0.79137],[19601,0.80936],[19631,0.82861],[19662,0.81653],[19692,0.79661],[19723,0.79313],[19754,0.79789]],"GMD":[[14791,27.548],[14822,28.553],[14853,29.489],[14883,28.324],[14914,28.03],[14944,27.969],[14975,28.221],[15006,28.522],[15034,28.177],[15065,28.184],[15095,27.96],[15126,28.6],[15156,28.714],[15187,29.099],[15218,29.451],[15248,29.896],[15279,29.551],[15309,30.007],[15340,30.395],[15371,30.261],[15400,30.222],[15431,30.076],[15461,30.414],[15492,31.395],[15522,32.049],[15553,32.336],[15584,33.397],[15614,33.935],[15645,32.557],[15675,33.808],[15706,33.88],[15737,33.54],[15765,34.6],[15796,35.496],[15826,35.816],[15857,38.036],[15887,35.011],[15918,33.885],[15949,34.081],[15979,34.979],[16010,37.907],[16040,38.729],[16071,39.023],[16102,39.437],[16130,39.403],[16161,39.435],[16191,39.96],[16222,40.76],[16252,41.938],[16283,42.901],[16314,42.517],[16344,42.945],[16375,43.911],[16405,44.643],[16436,45.301],[16467,45.946],[16495,46.915],[16526,48.657],[16556,42.927],[16587,39.653],[16617,39.498],[16648,39.571],[16679,39.461],[16709,39.413],[16740,39.491],[16770,39.851],[16801,40.148],[16832,40.929],[16861,41.287],[16892,42.029],[16922,42.593],[16953,43.139],[16983,43.579],[17014,44.195],[17045,45.168],[17075,46.07],[17106,44.664],[17136,43.812],[17167,44.425],[17198,45.116],[17226,45.574],[17257,45.943],[17287,46.303],[17318,46.354],[17348,46.159],[17379,46.178],[17410,46.33],[17440,46.915],[17471,47.234],[17501,47.509],[17532,47.051],[17563,46.77],[17591,47.031],[17622,46.97],[17652,47.142],[17683,47.149],[17713,47.969],[17744,47.884],[17775,48.235],[17805,49.663],[17836,49.091],[17866,49.162],[17897,49.694],[17928,49.746],[17956,49.281],[17987,49.303],[18017,49.356],[18048,49.967],[18078,49.328],[18109,49.724],[18140,50.043],[18170,50.299],[18201,51.121],[18231,51.246],[18262,51.188],[18293,50.425],[18322,51.051],[18353,50.661],[18383,51.757],[18414,50.909],[18444,51.002],[18475,50.733],[18506,50.883],[18536,51.958],[18567,51.082],[18597,51.12],[18628,50.786],[18659,50.899],[18687,50.745],[18718,50.848],[18748,50.487],[18779,51.163],[18809,50.811],[18840,51.328],[18871,51.033],[18901,52.079],[18932,52.36],[18962,52.717],[18993,52.929],[19024,53.312],[19052,53.356],[19083,53.541],[19113,53.446],[19144,54.028],[19174,54.25],[19205,53.819],[19236,54.703],[19266,57.215],[19297,59.439],[19327,60.412],[19358,60.35],[19389,60.951],[19417,61.273],[19448,61.278],[19478,60.347],[19509,59.656],[19539,60.589],[19570,61.011],[19601,61.89],[19631,62.401],[19662,64.381],[19692,65.159],[19723,66.686],[19754,67.149]],"GNF":[[14791,5985.0],[14822,5667.0],[14853,5934.0],[14883,6327.0],[14914,6091.0],[14944,6413.0],[14975,6105.0],[15006,6691.0],[15034,6517.0],[15065,6607.0],[15095,6596.0],[15126,6724.0],[15156,6751.0],[15187,6812.0],[15218,6856.0],[15248,6873.0],[15279,6921.0],[15309,7109.0],[15340,7100.0],[15371,7042.0],[15400,7024.0],[15431,7034.0],[15461,7021.0],[15492,7071.0],[15522,7062.0],[15553,7022.0],[15584,7012.0],[15614,7048.0],[15645,7014.0],[15675,6967.0],[15706,7037.0],[15737,6995.0],[15765,7000.0],[15796,6988.0],[15826,6918.0],[15857,6907.0],[15887,6939.0],[15918,6912.0],[15949,6925.0],[15979,6943.0],[16010,6931.0],[16040,6978.0],[16071,6987.0],[16102,7025.0],[16130,7017.0],[16161,7013.0],[16191,7023.0],[16222,6998.0],[16252,6985.0],[16283,6973.0],[16314,7015.0],[16344,7027.0],[16375,7080.0],[16405,7095.0],[16436,7221.0],[16467,7246.0],[16495,7300.0],[16526,7302.0],[16556,7351.0],[16587,7515.0],[16617,7432.0],[16648,7544.0],[16679,7572.0],[16709,7528.0],[16740,7787.0],[16770,7859.0],[16801,8262.0],[16832,8515.0],[16861,8939.0],[16892,8899.0],[16922,8941.0],[16953,8997.0],[16983,8976.0],[17014,9029.0],[17045,9093.0],[17075,9080.0],[17106,9126.0],[17136,9218.0],[17167,9293.0],[17198,9328.0],[17226,9262.0],[17257,9244.0],[17287,9090.0],[17318,9050.0],[17348,8997.0],[17379,8974.0],[17410,8944.0],[17440,9008.0],[17471,9054.0],[17501,9013.0],[17532,9032.0],[17563,8998.0],[17591,9004.0],[17622,8975.0],[17652,9003.0],[17683,8995.0],[17713,9026.0],[17744,9057.0],[17775,9010.0],[17805,9020.0],[17836,9066.0],[17866,9088.0],[17897,9099.0],[17928,9102.0],[17956,9097.0],[17987,9122.0],[18017,9131.0],[18048,9158.0],[18078,9147.0],[18109,9167.0],[18140,9207.0],[18170,9269.0],[18201,9377.0],[18231,9458.0],[18262,9401.0],[18293,9452.0],[18322,9416.0],[18353,9453.0],[18383,9486.0],[18414,9571.0],[18444,9659.0],[18475,9641.0],[18506,9688.0],[18536,9747.0],[18567,9832.0],[18597,9958.0],[18628,10085.0],[18659,10169.0],[18687,9991.0],[18718,9974.0],[18748,9824.0],[18779,9798.0],[18809,9790.0],[18840,9753.0],[18871,9724.0],[18901,9695.0],[18932,9553.0],[18962,9371.0],[18993,9131.0],[19024,9027.0],[19052,8953.0],[19083,8776.0],[19113,8859.0],[19144,8651.0],[19174,8657.0],[19205,8621.0],[19236,8570.0],[19266,8675.0],[19297,8611.0],[19327,8592.0],[19358,8606.0],[19389,8575.0],[19417,8597.0],[19448,8582.0],[19478,8548.0],[19509,8584.0],[19539,8582.0],[19570,8600.0],[19601,8531.0],[19631,8576.0],[19662,8602.0],[19692,8593.0],[19723,8560.0],[19754,8574.0]],"GTQ":[[14791,8.006],[14822,8.0125],[14853,8.0572],[14883,8.0686],[14914,8.0104],[14944,7.9826],[14975,7.948],[15006,7.8397],[15034,7.7386],[15065,7.6371],[15095,7.6527],[15126,7.7714],[15156,7.7624],[15187,7.8112],[15218,7.8474],[15248,7.8452],[15279,7.8162],[15309,7.8114],[15340,7.7896],[15371,7.7873],[15400,7.739],[15431,7.7404],[15461,7.791],[15492,7.8245],[15522,7.8321],[15553,7.8675],[15584,7.9546],[15614,7.9091],[15645,7.8599],[15675,7.9015],[15706,7.8903],[15737,7.8389],[15765,7.7949],[15796,7.7872],[15826,7.7834],[15857,7.8169],[15887,7.8256],[15918,7.8817],[15949,7.9353],[15979,7.9401],[16010,7.9126],[16040,7.8684],[16071,7.8374],[16102,7.7813],[16130,7.7336],[16161,7.7383],[16191,7.7403],[16222,7.7813],[16252,7.7816],[16283,7.7951],[16314,7.727],[16344,7.6588],[16375,7.6231],[16405,7.6113],[16436,7.6304],[16467,7.6466],[16495,7.6307],[16526,7.6838],[16556,7.6986],[16587,7.6605],[16617,7.6405],[16648,7.6601],[16679,7.6757],[16709,7.6785],[16740,7.6522],[16770,7.6245],[16801,7.6444],[16832,7.6642],[16861,7.7018],[16892,7.7305],[16922,7.6766],[16953,7.6366],[16983,7.6012],[17014,7.5476],[17045,7.531],[17075,7.5055],[17106,7.5179],[17136,7.5206],[17167,7.5047],[17198,7.4296],[17226,7.3504],[17257,7.3387],[17287,7.3417],[17318,7.3398],[17348,7.3058],[17379,7.2809],[17410,7.3081],[17440,7.3371],[17471,7.3424],[17501,7.338],[17532,7.3455],[17563,7.3528],[17591,7.3892],[17622,7.4077],[17652,7.4291],[17683,7.4858],[17713,7.4853],[17744,7.5155],[17775,7.6344],[17805,7.717],[17836,7.7182],[17866,7.718],[17897,7.7356],[17928,7.7323],[17956,7.6924],[17987,7.6561],[18017,7.6661],[18048,7.6947],[18078,7.6804],[18109,7.6741],[18140,7.705],[18170,7.7346],[18201,7.7137],[18231,7.6942],[18262,7.6893],[18293,7.6616],[18322,7.6831],[18353,7.7304],[18383,7.6991],[18414,7.6974],[18444,7.6981],[18475,7.7053],[18506,7.751],[18536,7.7835],[18567,7.7857],[18597,7.7979],[18628,7.7908],[18659,7.7528],[18687,7.7111],[18718,7.718],[18748,7.7098],[18779,7.7323],[18809,7.7382],[18840,7.7403],[18871,7.7306],[18901,7.7284],[18932,7.7365],[18962,7.7209],[18993,7.7088],[19024,7.7071],[19052,7.698],[19083,7.668],[19113,7.6659],[19144,7.7079],[19174,7.7357],[19205,7.7301],[19236,7.7886],[19266,7.8505],[19297,7.8301],[19327,7.8447],[19358,7.8498],[19389,7.842],[19417,7.8067],[19448,7.7991],[19478,7.8072],[19509,7.8281],[19539,7.8472],[19570,7.8554],[19601,7.8568],[19631,7.8518],[19662,7.8384],[19692,7.8221],[19723,7.814],[19754,7.8079]],"GYD":[[14791,205.08],[14822,204.71],[14853,204.43],[14883,204.24],[14914,204.82],[14944,204.44],[14975,204.33],[15006,204.81],[15034,204.32],[15065,205.41],[15095,204.94],[15126,204.23],[15156,204.55],[15187,205.19],[15218,204.05],[15248,204.3],[15279,204.16],[15309,204.66],[15340,205.28],[15371,204.97],[15400,204.09],[15431,205.15],[15461,205.08],[15492,204.89],[15522,205.45],[15553,204.41],[15584,204.46],[15614,205.48],[15645,205.21],[15675,205.03],[15706,205.65],[15737,205.4],[15765,205.26],[15796,206.04],[15826,205.83],[15857,205.97],[15887,206.01],[15918,205.35],[15949,205.15],[15979,205.5],[16010,205.99],[16040,206.14],[16071,206.92],[16102,207.44],[16130,206.65],[16161,207.27],[16191,207.17],[16222,206.6],[16252,207.28],[16283,207.34],[16314,206.74],[16344,207.35],[16375,207.58],[16405,206.18],[16436,207.34],[16467,207.02],[16495,206.47],[16526,207.71],[16556,206.61],[16587,207.1],[16617,207.61],[16648,207.72],[16679,207.28],[16709,207.46],[16740,207.76],[16770,206.84],[16801,207.81],[16832,207.42],[16861,206.78],[16892,207.02],[16922,207.32],[16953,207.0],[16983,208.46],[17014,207.37],[17045,207.36],[17075,207.99],[17106,207.81],[17136,207.37],[17167,207.77],[17198,208.26],[17226,208.85],[17257,209.12],[17287,206.57],[17318,208.15],[17348,207.81],[17379,208.29],[17410,209.32],[17440,208.74],[17471,209.14],[17501,209.64],[17532,208.46],[17563,206.55],[17591,208.09],[17622,209.03],[17652,209.85],[17683,210.68],[17713,210.19],[17744,210.25],[17775,210.51],[17805,210.23],[17836,210.14],[17866,210.69],[17897,209.98],[17928,207.99],[17956,210.8],[17987,210.47],[18017,210.08],[18048,210.86],[18078,208.2],[18109,210.01],[18140,211.09],[18170,210.31],[18201,210.16],[18231,210.95],[18262,209.8],[18293,210.13],[18322,210.68],[18353,209.7],[18383,210.37],[18414,210.75],[18444,210.3],[18475,210.33],[18506,210.72],[18536,208.19],[18567,210.65],[18597,209.35],[18628,208.31],[18659,210.37],[18687,210.55],[18718,208.91],[18748,208.29],[18779,211.12],[18809,210.39],[18840,210.08],[18871,210.61],[18901,210.03],[18932,209.89],[18962,210.37],[18993,209.49],[19024,210.34],[19052,210.79],[19083,209.03],[19113,210.05],[19144,209.87],[19174,209.27],[19205,209.73],[19236,209.7],[19266,209.21],[19297,208.85],[19327,210.02],[19358,208.99],[19389,209.76],[19417,210.62],[19448,209.97],[19478,210.22],[19509,211.32],[19539,210.61],[19570,210.4],[19601,211.04],[19631,210.23],[19662,210.41],[19692,210.99],[19723,210.33],[19754,210.35]],"HKD":[[14791,7.7932],[14822,7.7873],[14853,7.7806],[14883,7.7743],[14914,7.772],[14944,7.7888],[14975,7.7966],[15006,7.8037],[15034,7.8066],[15065,7.7894],[15095,7.7914],[15126,7.7979],[15156,7.8042],[15187,7.8098],[15218,7.8112],[15248,7.7911],[15279,7.7967],[15309,7.7925],[15340,7.7784],[15371,7.7684],[15400,7.7755],[15431,7.7771],[15461,7.7793],[15492,7.772],[15522,7.772],[15553,7.7698],[15584,7.7667],[15614,7.7658],[15645,7.7644],[15675,7.763],[15706,7.7651],[15737,7.7682],[15765,7.7714],[15796,7.7742],[15826,7.7741],[15857,7.7722],[15887,7.7681],[15918,7.7673],[15949,7.7651],[15979,7.7654],[16010,7.7647],[16040,7.7646],[16071,7.7697],[16102,7.7707],[16130,7.7721],[16161,7.7647],[16191,7.7642],[16222,7.7631],[16252,7.7618],[16283,7.7621],[16314,7.7642],[16344,7.7692],[16375,7.7655],[16405,7.7654],[16436,7.7645],[16467,7.7659],[16495,7.7687],[16526,7.7621],[16556,7.7633],[16587,7.7635],[16617,7.7628],[16648,7.7638],[16679,7.7615],[16709,7.7612],[16740,7.7616],[16770,7.7616],[16801,7.7904],[16832,7.7938],[16861,7.7726],[16892,7.7676],[16922,7.7747],[16953,7.7733],[16983,7.7681],[17014,7.7674],[17045,7.7667],[17075,7.7678],[17106,7.7666],[17136,7.7688],[17167,7.7667],[17198,7.7701],[17226,7.7762],[17257,7.7846],[17287,7.7971],[17318,7.8085],[17348,7.8192],[17379,7.8315],[17410,7.8243],[17440,7.8161],[17471,7.8156],[17501,7.8305],[17532,7.8301],[17563,7.8325],[17591,7.8505],[17622,7.8589],[17652,7.8593],[17683,7.8575],[17713,7.8584],[17744,7.8603],[17775,7.8484],[17805,7.8472],[17836,7.8409],[17866,7.8309],[17897,7.8518],[17928,7.8584],[17956,7.8595],[17987,7.8556],[18017,7.8582],[18048,7.8363],[18078,7.8223],[18109,7.8504],[18140,7.8466],[18170,7.8522],[18201,7.8393],[18231,7.8155],[18262,7.784],[18293,7.7845],[18322,7.7761],[18353,7.7614],[18383,7.7622],[18414,7.7579],[18444,7.7609],[18475,7.7608],[18506,7.7599],[18536,7.7603],[18567,7.7627],[18597,7.762],[18628,7.7625],[18659,7.7623],[18687,7.7738],[18718,7.7791],[18748,7.7742],[18779,7.7709],[18809,7.779],[18840,7.7923],[18871,7.7902],[18901,7.7894],[18932,7.7987],[18962,7.8085],[18993,7.8021],[19024,7.8085],[19052,7.8308],[19083,7.8496],[19113,7.8583],[19144,7.8571],[19174,7.8581],[19205,7.8557],[19236,7.8582],[19266,7.8589],[19297,7.8403],[19327,7.7971],[19358,7.8267],[19389,7.8545],[19417,7.8574],[19448,7.8585],[19478,7.8464],[19509,7.8412],[19539,7.8275],[19570,7.8336],[19601,7.8353],[19631,7.8339],[19662,7.8176],[19692,7.8179],[19723,7.8252],[19754,7.8304]],"HNL":[[14791,18.9223],[14822,18.9202],[14853,18.9046],[14883,18.8864],[14914,18.9341],[14944,18.8842],[14975,18.9246],[15006,18.9174],[15034,18.8938],[15065,18.9028],[15095,18.9169],[15126,18.9092],[15156,18.8701],[15187,18.8574],[15218,18.898],[15248,18.941],[15279,18.9572],[15309,18.9801],[15340,19.0277],[15371,19.1191],[15400,19.148],[15431,19.1726],[15461,19.2278],[15492,19.303],[15522,19.2958],[15553,19.5134],[15584,19.5882],[15614,19.7192],[15645,19.813],[15675,19.9011],[15706,19.9576],[15737,19.9976],[15765,19.9443],[15796,19.8252],[15826,19.9132],[15857,20.258],[15887,20.369],[15918,20.445],[15949,20.42],[15979,20.483],[16010,20.488],[16040,20.491],[16071,20.322],[16102,20.206],[16130,20.107],[16161,19.9649],[16191,20.464],[16222,20.841],[16252,20.961],[16283,21.027],[16314,21.072],[16344,21.241],[16375,21.314],[16405,21.263],[16436,21.355],[16467,21.467],[16495,21.667],[16526,21.761],[16556,21.914],[16587,21.904],[16617,21.884],[16648,21.904],[16679,21.914],[16709,21.982],[16740,21.989],[16770,22.044],[16801,22.445],[16832,22.544],[16861,22.582],[16892,22.599],[16922,22.609],[16953,22.657],[16983,22.778],[17014,22.869],[17045,22.901],[17075,22.932],[17106,23.118],[17136,23.376],[17167,23.453],[17198,23.567],[17226,23.498],[17257,23.47],[17287,23.469],[17318,23.452],[17348,23.423],[17379,23.393],[17410,23.406],[17440,23.477],[17471,23.598],[17501,23.572],[17532,23.605],[17563,23.59],[17591,23.613],[17622,23.678],[17652,23.762],[17683,23.947],[17713,23.951],[17744,24.006],[17775,24.025],[17805,24.066],[17836,24.217],[17866,24.466],[17897,24.36],[17928,24.4],[17956,24.549],[17987,24.449],[18017,24.457],[18048,24.594],[18078,24.488],[18109,24.537],[18140,24.687],[18170,24.622],[18201,24.637],[18231,24.711],[18262,24.641],[18293,24.665],[18322,24.79],[18353,24.81],[18383,24.844],[18414,25.046],[18444,24.701],[18475,24.629],[18506,24.654],[18536,24.502],[18567,24.369],[18597,24.347],[18628,24.126],[18659,24.1],[18687,24.051],[18718,24.027],[18748,24.013],[18779,23.954],[18809,23.795],[18840,23.777],[18871,23.959],[18901,24.094],[18932,24.134],[18962,24.242],[18993,24.445],[19024,24.601],[19052,24.501],[19083,24.436],[19113,24.47],[19144,24.462],[19174,24.489],[19205,24.543],[19236,24.551],[19266,24.699],[19297,24.709],[19327,24.652],[19358,24.635],[19389,24.65],[19417,24.632],[19448,24.601],[19478,24.591],[19509,24.599],[19539,24.627],[19570,24.627],[19601,24.635],[19631,24.688],[19662,24.713],[19692,24.675],[19723,24.652],[19754,24.678]],"HTG":[[14791,40.121],[14822,39.928],[14853,39.777],[14883,39.999],[14914,40.256],[14944,39.836],[14975,40.463],[15006,40.439],[15034,39.968],[15065,40.309],[15095,40.321],[15126,39.962],[15156,40.291],[15187,40.488],[15218,40.156],[15248,40.648],[15279,40.7],[15309,40.304],[15340,40.745],[15371,40.975],[15400,40.721],[15431,41.49],[15461,41.906],[15492,42.029],[15522,42.072],[15553,42.088],[15584,42.156],[15614,42.263],[15645,42.356],[15675,42.358],[15706,42.455],[15737,42.682],[15765,42.704],[15796,42.876],[15826,42.987],[15857,43.001],[15887,43.561],[15918,43.613],[15949,43.474],[15979,43.575],[16010,43.842],[16040,43.76],[16071,43.63],[16102,44.199],[16130,44.383],[16161,44.881],[16191,45.168],[16222,45.2],[16252,45.244],[16283,45.198],[16314,45.282],[16344,45.453],[16375,46.164],[16405,46.541],[16436,46.728],[16467,46.925],[16495,46.959],[16526,47.417],[16556,47.687],[16587,49.334],[16617,54.907],[16648,53.709],[16679,52.591],[16709,53.359],[16740,54.881],[16770,56.425],[16801,58.183],[16832,60.401],[16861,61.844],[16892,62.214],[16922,62.409],[16953,62.695],[16983,63.373],[17014,64.22],[17045,65.055],[17075,65.613],[17106,66.168],[17136,67.053],[17167,67.428],[17198,67.902],[17226,68.383],[17257,68.613],[17287,66.196],[17318,62.248],[17348,62.647],[17379,63.152],[17410,62.638],[17440,63.217],[17471,63.48],[17501,63.628],[17532,64.144],[17563,64.067],[17591,64.623],[17622,65.313],[17652,65.531],[17683,66.188],[17713,66.957],[17744,68.223],[17775,69.224],[17805,70.932],[17836,73.173],[17866,76.172],[17897,78.56],[17928,81.26],[17956,82.71],[17987,84.7],[18017,88.82],[18048,92.4],[18078,93.3],[18109,93.78],[18140,93.87],[18170,93.32],[18201,91.78],[18231,91.94],[18262,93.96],[18293,94.4],[18322,97.65],[18353,99.29],[18383,106.18],[18414,109.56],[18444,114.43],[18475,116.86],[18506,87.24],[18536,65.468],[18567,64.232],[18597,69.782],[18628,71.814],[18659,73.794],[18687,77.299],[18718,81.55],[18748,86.67],[18779,90.26],[18809,93.72],[18840,95.68],[18871,97.14],[18901,98.15],[18932,99.06],[18962,99.89],[18993,100.91],[19024,102.49],[19052,104.3],[19083,106.33],[19113,108.8],[19144,112.41],[19174,114.62],[19205,119.57],[19236,117.97],[19266,121.11],[19297,131.26],[19327,142.38],[19358,146.64],[19389,149.27],[19417,152.09],[19448,153.78],[19478,147.45],[19509,139.27],[19539,137.74],[19570,136.31],[19601,135.13],[19631,134.25],[19662,133.18],[19692,132.19],[19723,131.86],[19754,132.01]],"HUF":[[14791,222.31],[14822,218.27],[14853,217.7],[14883,197.88],[14914,200.73],[14944,210.3],[14975,205.92],[15006,198.5],[15034,193.7],[15065,183.83],[15095,185.19],[15126,185.39],[15156,187.18],[15187,189.73],[15218,207.35],[15248,216.34],[15279,227.75],[15309,230.92],[15340,237.9],[15371,219.97],[15400,220.41],[15431,223.45],[15461,228.4],[15492,234.84],[15522,232.31],[15553,225.35],[15584,221.17],[15614,217.64],[15645,219.99],[15675,218.05],[15706,220.68],[15737,218.91],[15765,232.95],[15796,230.29],[15826,225.7],[15857,224.6],[15887,225.47],[15918,225.01],[15949,224.27],[15979,216.56],[16010,219.97],[16040,218.9],[16071,221.62],[16102,227.06],[16130,225.37],[16161,222.63],[16191,221.81],[16222,224.95],[16252,228.76],[16283,235.47],[16314,242.63],[16344,243.15],[16375,245.92],[16405,252.7],[16436,271.16],[16467,270.55],[16495,279.1],[16526,277.08],[16556,274.69],[16587,278.75],[16617,282.94],[16648,280.0],[16679,278.76],[16709,277.28],[16740,290.06],[16770,289.51],[16801,289.2],[16832,280.33],[16861,279.85],[16892,274.97],[16922,277.98],[16953,280.19],[16983,283.97],[17014,277.21],[17045,275.61],[17075,278.2],[17106,285.86],[17136,295.65],[17167,291.23],[17198,290.0],[17226,289.83],[17257,290.2],[17287,280.57],[17318,274.34],[17348,266.75],[17379,257.97],[17410,258.84],[17440,263.82],[17471,265.73],[17501,264.0],[17532,253.85],[17563,252.13],[17591,253.28],[17622,253.89],[17652,267.14],[17683,276.46],[17713,278.06],[17744,279.49],[17775,278.75],[17805,281.77],[17836,283.68],[17866,283.31],[17897,279.95],[17928,279.7],[17956,279.84],[17987,285.74],[18017,290.36],[18048,286.1],[18078,289.55],[18109,294.01],[18140,302.01],[18170,300.29],[18201,301.27],[18231,297.86],[18262,300.77],[18293,308.8],[18322,311.16],[18353,327.66],[18383,322.07],[18414,310.06],[18444,307.47],[18475,294.88],[18506,305.51],[18536,308.39],[18567,304.56],[18597,295.64],[18628,295.13],[18659,296.16],[18687,306.16],[18718,302.19],[18748,292.0],[18779,290.15],[18809,301.13],[18840,299.33],[18871,299.7],[18901,310.4],[18932,318.47],[18962,325.02],[18993,318.26],[19024,315.29],[19052,339.63],[19083,345.45],[19113,363.6],[19144,373.88],[19174,395.69],[19205,396.67],[19236,407.11],[19266,424.81],[19297,399.9],[19327,385.19],[19358,368.07],[19389,359.18],[19417,358.76],[19448,343.89],[19478,342.64],[19509,342.46],[19539,342.64],[19570,351.39],[19601,360.26],[19631,364.79],[19662,352.16],[19692,349.24],[19723,350.1],[19754,358.83]],"IDR":[[14791,9052.0],[14822,8983.0],[14853,8955.0],[14883,8935.0],[14914,8948.0],[14944,8980.0],[14975,9032.0],[15006,8936.0],[15034,8782.0],[15065,8664.0],[15095,8588.0],[15126,8553.0],[15156,8523.0],[15187,8523.0],[15218,8737.0],[15248,8872.0],[15279,8990.0],[15309,9081.0],[15340,9088.0],[15371,9004.0],[15400,9121.0],[15431,9173.0],[15461,9303.0],[15492,9453.0],[15522,9449.0],[15553,9501.0],[15584,9543.0],[15614,9596.0],[15645,9616.0],[15675,9645.0],[15706,9684.0],[15737,9703.0],[15765,9716.0],[15796,9720.0],[15826,9749.0],[15857,9871.0],[15887,10055.0],[15918,10503.0],[15949,11296.0],[15979,11291.0],[16010,11535.0],[16040,12047.0],[16071,12154.0],[16102,11930.0],[16130,11429.0],[16161,11411.0],[16191,11522.0],[16222,11866.0],[16252,11683.0],[16283,11700.0],[16314,11895.0],[16344,12141.0],[16375,12158.0],[16405,12405.0],[16436,12565.0],[16467,12760.0],[16495,13043.0],[16526,12953.0],[16556,13106.0],[16587,13293.0],[16617,13362.0],[16648,13754.0],[16679,14369.0],[16709,13784.0],[16740,13658.0],[16770,13829.0],[16801,13868.0],[16832,13519.0],[16861,13189.0],[16892,13187.0],[16922,13399.0],[16953,13319.0],[16983,13108.0],[17014,13151.0],[17045,13099.0],[17075,13008.0],[17106,13304.0],[17136,13392.0],[17167,13352.0],[17198,13311.0],[17226,13347.0],[17257,13304.0],[17287,13313.0],[17318,13289.0],[17348,13331.0],[17379,13340.0],[17410,13303.0],[17440,13515.0],[17471,13516.0],[17501,13534.0],[17532,13379.0],[17563,13558.0],[17591,13755.0],[17622,13818.0],[17652,14023.0],[17683,14028.0],[17713,14391.0],[17744,14547.0],[17775,14837.0],[17805,15141.0],[17836,14673.0],[17866,14481.0],[17897,14153.0],[17928,14018.0],[17956,14190.0],[17987,14127.0],[18017,14360.0],[18048,14210.0],[18078,14026.0],[18109,14194.0],[18140,14096.0],[18170,14108.0],[18201,14057.0],[18231,13995.0],[18262,13715.0],[18293,13773.0],[18322,15169.0],[18353,15791.0],[18383,14855.0],[18414,14182.0],[18444,14502.0],[18475,14677.0],[18506,14803.0],[18536,14713.0],[18567,14236.0],[18597,14119.0],[18628,14036.0],[18659,14050.0],[18687,14365.0],[18718,14503.0],[18748,14312.0],[18779,14336.0],[18809,14478.0],[18840,14381.0],[18871,14259.0],[18901,14186.0],[18932,14257.0],[18962,14306.0],[18993,14326.0],[19024,14347.0],[19052,14342.0],[19083,14378.0],[19113,14572.0],[19144,14692.0],[19174,14937.0],[19205,14847.0],[19236,14980.0],[19266,15397.0],[19297,15628.0],[19327,15589.0],[19358,15271.0],[19389,15125.0],[19417,15247.0],[19448,14865.0],[19478,14816.0],[19509,14939.0],[19539,15018.0],[19570,15201.0],[19601,15341.0],[19631,15715.0],[19662,15615.0],[19692,15499.0],[19723,15605.0],[19754,15646.0]],"ILS":[[14791,3.8559],[14822,3.7963],[14853,3.7412],[14883,3.6173],[14914,3.6478],[14944,3.6147],[14975,3.5852],[15006,3.658],[15034,3.5699],[15065,3.4391],[15095,3.4604],[15126,3.4191],[15156,3.4212],[15187,3.5299],[15218,3.6827],[15248,3.6685],[15279,3.7159],[15309,3.7773],[15340,3.8032],[15371,3.742],[15400,3.7594],[15431,3.7473],[15461,3.8243],[15492,3.8958],[15522,3.9839],[15553,4.0163],[15584,3.9497],[15614,3.8621],[15645,3.8906],[15675,3.78],[15706,3.7374],[15737,3.6991],[15765,3.6849],[15796,3.6215],[15826,3.6327],[15857,3.6347],[15887,3.6044],[15918,3.5768],[15949,3.569],[15979,3.5379],[16010,3.5329],[16040,3.5038],[16071,3.494],[16102,3.5123],[16130,3.4816],[16161,3.4768],[16191,3.4665],[16222,3.455],[16252,3.4234],[16283,3.4921],[16314,3.6332],[16344,3.7254],[16375,3.826],[16405,3.9227],[16436,3.9408],[16467,3.9036],[16495,3.9907],[16526,3.936],[16556,3.8647],[16587,3.8228],[16617,3.7884],[16648,3.8416],[16679,3.9143],[16709,3.8704],[16740,3.8871],[16770,3.881],[16801,3.9447],[16832,3.9112],[16861,3.8597],[16892,3.7758],[16922,3.8094],[16953,3.856],[16983,3.8551],[17014,3.7985],[17045,3.7662],[17075,3.8134],[17106,3.8423],[17136,3.8331],[17167,3.8186],[17198,3.7311],[17226,3.6525],[17257,3.6475],[17287,3.5956],[17318,3.5295],[17348,3.5469],[17379,3.596],[17410,3.5355],[17440,3.5122],[17471,3.5171],[17501,3.5012],[17532,3.426],[17563,3.4852],[17591,3.4717],[17622,3.5337],[17652,3.588],[17683,3.6052],[17713,3.6458],[17744,3.6628],[17775,3.5971],[17805,3.6551],[17836,3.7049],[17866,3.7491],[17897,3.6914],[17928,3.6293],[17956,3.6182],[17987,3.5981],[18017,3.5953],[18048,3.5959],[18078,3.5455],[18109,3.5096],[18140,3.5187],[18170,3.5138],[18201,3.4875],[18231,3.4731],[18262,3.4585],[18293,3.4357],[18322,3.5797],[18353,3.5681],[18383,3.5176],[18414,3.4617],[18444,3.4314],[18475,3.3997],[18506,3.4184],[18536,3.4002],[18567,3.3622],[18597,3.2503],[18628,3.2279],[18659,3.2742],[18687,3.3095],[18718,3.2802],[18748,3.2599],[18779,3.2533],[18809,3.2671],[18840,3.2277],[18871,3.2104],[18901,3.211],[18932,3.1301],[18962,3.1307],[18993,3.1363],[19024,3.2108],[19052,3.234],[19083,3.2404],[19113,3.3674],[19144,3.4037],[19174,3.456],[19205,3.3165],[19236,3.4486],[19266,3.5399],[19297,3.4858],[19327,3.4556],[19358,3.4513],[19389,3.5282],[19417,3.6188],[19448,3.6327],[19478,3.6668],[19509,3.6599],[19539,3.6693],[19570,3.732],[19601,3.8117],[19631,3.9605],[19662,3.809],[19692,3.671],[19723,3.7021],[19754,3.6517]],"INR":[[14791,46.824],[14822,46.609],[14853,46.102],[14883,44.459],[14914,44.962],[14944,45.215],[14975,45.409],[15006,45.552],[15034,44.995],[15065,44.387],[15095,44.909],[15126,44.856],[15156,44.445],[15187,45.259],[15218,47.525],[15248,49.235],[15279,50.652],[15309,52.51],[15340,51.243],[15371,49.229],[15400,50.26],[15431,51.693],[15461,54.361],[15492,56.077],[15522,55.545],[15553,55.546],[15584,54.461],[15614,53.046],[15645,54.734],[15675,54.649],[15706,54.261],[15737,53.782],[15765,54.38],[15796,54.342],[15826,54.909],[15857,58.216],[15887,59.795],[15918,62.594],[15949,63.89],[15979,61.654],[16010,62.504],[16040,61.897],[16071,62.051],[16102,62.248],[16130,61.061],[16161,60.344],[16191,59.395],[16222,59.687],[16252,60.066],[16283,60.814],[16314,60.887],[16344,61.391],[16375,61.681],[16405,62.702],[16436,62.208],[16467,62.024],[16495,62.405],[16526,62.695],[16556,63.741],[16587,63.814],[16617,63.66],[16648,65.041],[16679,66.195],[16709,65.137],[16740,66.091],[16770,66.553],[16801,67.303],[16832,68.243],[16861,67.022],[16892,66.475],[16922,66.913],[16953,67.299],[16983,67.212],[17014,66.938],[17045,66.761],[17075,66.743],[17106,67.598],[17136,67.895],[17167,68.085],[17198,67.068],[17226,65.878],[17257,64.571],[17287,64.433],[17318,64.474],[17348,64.464],[17379,64.026],[17410,64.461],[17440,65.079],[17471,64.858],[17501,64.246],[17532,63.666],[17563,64.364],[17591,65.025],[17622,65.658],[17652,67.468],[17683,67.773],[17713,68.678],[17744,69.551],[17775,72.071],[17805,73.468],[17836,71.831],[17866,70.657],[17897,70.712],[17928,71.206],[17956,69.563],[17987,69.453],[18017,69.804],[18048,69.448],[18078,68.784],[18109,70.932],[18140,71.297],[18170,70.959],[18201,71.414],[18231,71.181],[18262,71.281],[18293,71.514],[18322,74.292],[18353,76.146],[18383,75.668],[18414,75.761],[18444,74.976],[18475,74.566],[18506,73.506],[18536,73.561],[18567,74.212],[18597,73.652],[18628,73.135],[18659,72.826],[18687,72.825],[18718,74.365],[18748,73.305],[18779,73.534],[18809,74.506],[18840,74.155],[18871,73.612],[18901,74.866],[18932,74.586],[18962,75.325],[18993,74.514],[19024,74.931],[19052,76.099],[19083,76.158],[19113,77.259],[19144,78.053],[19174,79.455],[19205,79.559],[19236,80.22],[19266,82.3],[19297,81.8],[19327,82.49],[19358,81.86],[19389,82.6],[19417,82.39],[19448,82.07],[19478,82.37],[19509,82.3],[19539,82.2],[19570,82.73],[19601,83.06],[19631,83.26],[19662,83.28],[19692,83.27],[19723,83.11],[19754,82.97]],"IQD":[[14791,1170.47],[14822,1168.56],[14853,1167.75],[14883,1167.11],[14914,1169.26],[14944,1168.08],[14975,1170.65],[15006,1171.67],[15034,1170.56],[15065,1170.31],[15095,1171.52],[15126,1168.63],[15156,1168.63],[15187,1167.92],[15218,1169.97],[15248,1168.11],[15279,1169.78],[15309,1169.54],[15340,1166.23],[15371,1165.26],[15400,1164.69],[15431,1164.49],[15461,1165.57],[15492,1165.08],[15522,1165.94],[15553,1163.89],[15584,1163.97],[15614,1164.72],[15645,1164.85],[15675,1164.86],[15706,1164.71],[15737,1165.09],[15765,1164.24],[15796,1163.49],[15826,1161.42],[15857,1164.55],[15887,1163.9],[15918,1164.35],[15949,1163.72],[15979,1164.4],[16010,1164.78],[16040,1164.55],[16071,1165.5],[16102,1164.33],[16130,1166.56],[16161,1165.15],[16191,1164.42],[16222,1164.47],[16252,1164.95],[16283,1164.79],[16314,1165.11],[16344,1164.39],[16375,1164.37],[16405,1160.68],[16436,1154.36],[16467,1165.65],[16495,1164.75],[16526,1164.84],[16556,1160.78],[16587,1164.15],[16617,1162.82],[16648,1160.52],[16679,1154.93],[16709,1152.81],[16740,1149.62],[16770,1145.91],[16801,1164.11],[16832,1175.36],[16861,1180.75],[16892,1173.75],[16922,1170.86],[16953,1173.93],[16983,1173.44],[17014,1171.86],[17045,1171.39],[17075,1170.25],[17106,1167.56],[17136,1174.03],[17167,1179.28],[17198,1178.39],[17226,1175.76],[17257,1176.55],[17287,1175.57],[17318,1175.06],[17348,1176.43],[17379,1175.52],[17410,1172.38],[17440,1176.05],[17471,1178.38],[17501,1183.73],[17532,1188.9],[17563,1184.73],[17591,1183.55],[17622,1183.95],[17652,1184.33],[17683,1180.98],[17713,1187.34],[17744,1190.04],[17775,1182.35],[17805,1186.15],[17836,1187.93],[17866,1182.78],[17897,1187.91],[17928,1188.3],[17956,1181.22],[17987,1186.64],[18017,1186.85],[18048,1182.89],[18078,1186.03],[18109,1186.58],[18140,1181.31],[18170,1187.37],[18201,1187.2],[18231,1183.27],[18262,1186.66],[18293,1191.3],[18322,1183.74],[18353,1188.63],[18383,1189.47],[18414,1184.2],[18444,1191.66],[18475,1189.49],[18506,1182.68],[18536,1187.85],[18567,1189.12],[18597,1309.91],[18628,1456.11],[18659,1459.73],[18687,1456.1],[18718,1460.07],[18748,1458.1],[18779,1456.75],[18809,1457.9],[18840,1458.29],[18871,1456.65],[18901,1457.65],[18932,1456.93],[18962,1458.29],[18993,1455.71],[19024,1457.84],[19052,1460.22],[19083,1452.98],[19113,1460.61],[19144,1457.19],[19174,1456.53],[19205,1456.23],[19236,1454.86],[19266,1460.73],[19297,1460.86],[19327,1459.07],[19358,1459.89],[19389,1457.17],[19417,1360.97],[19448,1311.97],[19478,1309.32],[19509,1309.38],[19539,1307.8],[19570,1309.74],[19601,1306.87],[19631,1309.36],[19662,1311.04],[19692,1310.5],[19723,1307.05],[19754,1308.32]],"IRR":[[14791,10053.0],[14822,10082.0],[14853,10103.0],[14883,10384.0],[14914,10396.0],[14944,10374.0],[14975,10352.0],[15006,10331.0],[15034,10317.0],[15065,10412.0],[15095,10526.0],[15126,10842.0],[15156,10602.0],[15187,10563.0],[15218,10668.0],[15248,10638.0],[15279,10828.0],[15309,10978.0],[15340,11246.0],[15371,12037.0],[15400,12260.0],[15431,12242.0],[15461,12266.0],[15492,12278.0],[15522,12278.0],[15553,12233.0],[15584,12229.0],[15614,12266.0],[15645,12282.0],[15675,12268.0],[15706,12249.0],[15737,12271.0],[15765,12256.0],[15796,12239.0],[15826,12259.0],[15857,12252.0],[15887,16102.0],[15918,24580.0],[15949,24737.0],[15979,24769.0],[16010,24837.0],[16040,24752.0],[16071,24716.0],[16102,24785.0],[16130,25025.0],[16161,25277.0],[16191,25473.0],[16222,25503.0],[16252,25774.0],[16283,26260.0],[16314,26562.0],[16344,26595.0],[16375,26667.0],[16405,26923.0],[16436,27219.0],[16467,27546.0],[16495,27885.0],[16526,28189.0],[16556,28384.0],[16587,28965.0],[16617,29409.0],[16648,29743.0],[16679,29928.0],[16709,29884.0],[16740,29906.0],[16770,29936.0],[16801,29988.0],[16832,30116.0],[16861,30249.0],[16892,30215.0],[16922,30224.0],[16953,30264.0],[16983,30394.0],[17014,30574.0],[17045,30411.0],[17075,31948.0],[17106,32041.0],[17136,32256.0],[17167,32388.0],[17198,32346.0],[17226,32413.0],[17257,32461.0],[17287,32476.0],[17318,32521.0],[17348,32676.0],[17379,32951.0],[17410,33478.0],[17440,34219.0],[17471,35192.0],[17501,35687.0],[17532,36438.0],[17563,36955.0],[17591,37449.0],[17622,40414.0],[17652,41982.0],[17683,42293.0],[17713,43309.0],[17744,42630.0],[17775,42118.0],[17805,42023.0],[17836,42014.0],[17866,42041.0],[17897,42068.0],[17928,42061.0],[17956,42021.0],[17987,41995.0],[18017,42040.0],[18048,42102.0],[18078,41981.0],[18109,42040.0],[18140,42084.0],[18170,42038.0],[18201,41973.0],[18231,42027.0],[18262,41994.0],[18293,42040.0],[18322,41926.0],[18353,42035.0],[18383,42065.0],[18414,42053.0],[18444,42135.0],[18475,42104.0],[18506,42038.0],[18536,41984.0],[18567,41988.0],[18597,42051.0],[18628,41984.0],[18659,42076.0],[18687,41997.0],[18718,42071.0],[18748,42001.0],[18779,41990.0],[18809,42039.0],[18840,42036.0],[18871,42003.0],[18901,42010.0],[18932,42030.0],[18962,42036.0],[18993,41996.0],[19024,42012.0],[19052,42042.0],[19083,41949.0],[19113,42084.0],[19144,42013.0],[19174,41997.0],[19205,42000.0],[19236,41959.0],[19266,42088.0],[19297,42096.0],[19327,42072.0],[19358,42012.0],[19389,42005.0],[19417,42099.0],[19448,42072.0],[19478,42007.0],[19509,42060.0],[19539,42052.0],[19570,42047.0],[19601,41987.0],[19631,42074.0],[19662,42113.0],[19692,42127.0],[19723,42025.0],[19754,42055.0]],"ISK":[[14791,123.83],[14822,119.59],[14853,117.28],[14883,111.85],[14914,112.62],[14944,115.9],[14975,116.6],[15006,116.56],[15034,115.38],[15065,113.0],[15095,114.1],[15126,114.95],[15156,115.84],[15187,114.56],[15218,116.47],[15248,116.03],[15279,116.73],[15309,120.93],[15340,123.63],[15371,123.26],[15400,125.86],[15431,126.7],[15461,127.1],[15492,127.95],[15522,125.63],[15553,120.56],[15584,122.54],[15614,124.18],[15645,127.1],[15675,126.51],[15706,128.29],[15737,127.58],[15765,125.27],[15796,119.4],[15826,120.37],[15857,122.12],[15887,122.5],[15918,119.64],[15949,120.94],[15979,120.94],[16010,121.65],[16040,117.39],[16071,115.95],[16102,114.24],[16130,112.91],[16161,112.48],[16191,112.6],[16222,113.44],[16252,114.13],[16283,115.85],[16314,119.0],[16344,120.73],[16375,123.34],[16405,125.08],[16436,131.01],[16467,132.35],[16495,136.32],[16526,135.75],[16556,132.61],[16587,132.53],[16617,133.73],[16648,132.11],[16679,128.2],[16709,126.68],[16740,130.93],[16770,130.18],[16801,130.08],[16832,128.54],[16861,126.63],[16892,123.82],[16922,123.69],[16953,123.54],[16983,122.14],[17014,118.16],[17045,115.14],[17075,114.13],[17106,112.46],[17136,112.65],[17167,114.31],[17198,111.84],[17226,109.68],[17257,110.36],[17287,103.24],[17318,101.13],[17348,104.25],[17379,105.58],[17410,106.14],[17440,105.75],[17471,104.46],[17501,104.62],[17532,102.95],[17563,100.96],[17591,99.55],[17622,99.49],[17652,103.89],[17683,106.57],[17713,106.45],[17744,107.34],[17775,110.22],[17805,116.85],[17836,122.67],[17866,120.92],[17897,119.32],[17928,119.91],[17956,120.03],[17987,120.67],[18017,122.86],[18048,124.57],[18078,124.51],[18109,123.53],[18140,124.89],[18170,124.4],[18201,123.51],[18231,122.05],[18262,123.27],[18293,126.25],[18322,134.08],[18353,144.09],[18383,144.34],[18414,136.0],[18444,138.12],[18475,136.59],[18506,137.88],[18536,138.98],[18567,137.02],[18597,128.02],[18628,128.39],[18659,128.36],[18687,127.11],[18718,125.9],[18748,123.29],[18779,121.95],[18809,124.17],[18840,125.88],[18871,128.15],[18901,129.07],[18932,130.42],[18962,130.31],[18993,129.27],[19024,125.73],[19052,129.18],[19083,129.13],[19113,130.64],[19144,130.93],[19174,135.84],[19205,138.24],[19236,141.84],[19266,143.87],[19297,144.04],[19327,142.18],[19358,142.86],[19389,142.69],[19417,140.29],[19448,136.55],[19478,138.1],[19509,138.01],[19539,133.15],[19570,131.74],[19601,134.79],[19631,138.34],[19662,139.79],[19692,137.98],[19723,136.89],[19754,137.59]],"JMD":[[14791,85.36],[14822,84.75],[14853,85.15],[14883,84.78],[14914,85.07],[14944,85.38],[14975,84.96],[15006,84.93],[15034,85.1],[15065,85.06],[15095,84.83],[15126,85.02],[15156,85.14],[15187,85.4],[15218,85.48],[15248,85.43],[15279,85.55],[15309,86.04],[15340,85.97],[15371,85.92],[15400,86.14],[15431,86.37],[15461,86.85],[15492,87.49],[15522,88.35],[15553,88.58],[15584,88.92],[15614,89.6],[15645,90.47],[15675,91.53],[15706,92.41],[15737,93.8],[15765,96.38],[15796,98.35],[15826,98.1],[15857,99.37],[15887,100.76],[15918,100.91],[15949,101.58],[15979,103.22],[16010,104.08],[16040,104.08],[16071,105.81],[16102,107.08],[16130,107.89],[16161,108.9],[16191,109.75],[16222,110.76],[16252,111.56],[16283,112.01],[16314,112.0],[16344,112.09],[16375,112.35],[16405,113.17],[16436,114.26],[16467,114.81],[16495,114.53],[16526,114.51],[16556,114.47],[16587,115.8],[16617,115.71],[16648,116.47],[16679,117.98],[16709,118.6],[16740,119.04],[16770,119.12],[16801,119.83],[16832,120.43],[16861,121.0],[16892,121.1],[16922,122.83],[16953,124.83],[16983,125.94],[17014,125.98],[17045,126.99],[17075,127.84],[17106,128.09],[17136,128.13],[17167,127.59],[17198,127.32],[17226,127.74],[17257,127.86],[17287,128.24],[17318,128.42],[17348,126.83],[17379,127.05],[17410,128.2],[17440,127.61],[17471,125.83],[17501,124.74],[17532,123.66],[17563,124.55],[17591,126.17],[17622,124.12],[17652,124.69],[17683,128.1],[17713,130.77],[17744,134.28],[17775,134.43],[17805,131.24],[17836,126.72],[17866,126.42],[17897,129.12],[17928,131.65],[17956,126.11],[17987,128.59],[18017,133.09],[18048,130.59],[18078,132.31],[18109,134.07],[18140,134.7],[18170,135.24],[18201,137.06],[18231,133.71],[18262,134.25],[18293,138.32],[18322,135.41],[18353,136.7],[18383,142.98],[18414,141.06],[18444,142.29],[18475,147.57],[18506,144.37],[18536,143.43],[18567,145.86],[18597,144.04],[18628,143.44],[18659,148.05],[18687,146.88],[18718,148.52],[18748,150.07],[18779,148.55],[18809,151.59],[18840,152.83],[18871,149.44],[18901,149.72],[18932,153.97],[18962,154.09],[18993,153.86],[19024,155.65],[19052,153.86],[19083,152.91],[19113,154.26],[19144,151.81],[19174,151.22],[19205,151.04],[19236,150.31],[19266,151.69],[19297,152.59],[19327,152.37],[19358,151.93],[19389,153.17],[19417,152.08],[19448,151.26],[19478,152.97],[19509,153.47],[19539,153.47],[19570,153.61],[19601,153.71],[19631,154.11],[19662,154.46],[19692,154.3],[19723,154.2],[19754,155.06]],"JOD":[[14791,0.70911],[14822,0.70936],[14853,0.70792],[14883,0.70846],[14914,0.70997],[14944,0.70839],[14975,0.70897],[15006,0.70925],[15034,0.70898],[15065,0.70895],[15095,0.71007],[15126,0.70909],[15156,0.709],[15187,0.70842],[15218,0.70925],[15248,0.70823],[15279,0.70941],[15309,0.70949],[15340,0.70842],[15371,0.70926],[15400,0.70883],[15431,0.70911],[15461,0.70984],[15492,0.70921],[15522,0.70912],[15553,0.70794],[15584,0.70783],[15614,0.7084],[15645,0.70852],[15675,0.70914],[15706,0.70928],[15737,0.70912],[15765,0.7091],[15796,0.70843],[15826,0.70824],[15857,0.7094],[15887,0.70851],[15918,0.70856],[15949,0.70827],[15979,0.70816],[16010,0.70837],[16040,0.7084],[16071,0.70808],[16102,0.7081],[16130,0.70822],[16161,0.70847],[16191,0.70864],[16222,0.70846],[16252,0.7089],[16283,0.70849],[16314,0.70946],[16344,0.70927],[16375,0.70839],[16405,0.70828],[16436,0.7063],[16467,0.70839],[16495,0.70855],[16526,0.71091],[16556,0.70606],[16587,0.70837],[16617,0.70837],[16648,0.71008],[16679,0.70871],[16709,0.70619],[16740,0.708],[16770,0.70951],[16801,0.70975],[16832,0.7081],[16861,0.71066],[16892,0.70908],[16922,0.70752],[16953,0.70769],[16983,0.70874],[17014,0.70858],[17045,0.70892],[17075,0.70805],[17106,0.70719],[17136,0.70825],[17167,0.70886],[17198,0.70835],[17226,0.70925],[17257,0.71088],[17287,0.71028],[17318,0.7105],[17348,0.71078],[17379,0.7098],[17410,0.70771],[17440,0.70779],[17471,0.7101],[17501,0.70955],[17532,0.71132],[17563,0.70864],[17591,0.70921],[17622,0.70805],[17652,0.70826],[17683,0.70883],[17713,0.70928],[17744,0.71061],[17775,0.70959],[17805,0.70857],[17836,0.70952],[17866,0.70961],[17897,0.70945],[17928,0.70936],[17956,0.70822],[17987,0.70818],[18017,0.70833],[18048,0.70919],[18078,0.70815],[18109,0.70839],[18140,0.70839],[18170,0.70889],[18201,0.70838],[18231,0.70945],[18262,0.70841],[18293,0.70931],[18322,0.7091],[18353,0.709],[18383,0.70976],[18414,0.70925],[18444,0.71079],[18475,0.70942],[18506,0.70825],[18536,0.70842],[18567,0.70933],[18597,0.70949],[18628,0.70852],[18659,0.7097],[18687,0.70783],[18718,0.70987],[18748,0.70889],[18779,0.70826],[18809,0.70891],[18840,0.70916],[18871,0.70815],[18901,0.70872],[18932,0.7085],[18962,0.70895],[18993,0.70778],[19024,0.70881],[19052,0.70992],[19083,0.70651],[19113,0.71021],[19144,0.70845],[19174,0.70838],[19205,0.70839],[19236,0.70748],[19266,0.71035],[19297,0.71061],[19327,0.70974],[19358,0.71009],[19389,0.70876],[19417,0.7103],[19448,0.70977],[19478,0.70871],[19509,0.70985],[19539,0.70886],[19570,0.70937],[19601,0.70799],[19631,0.7096],[19662,0.7107],[19692,0.71028],[19723,0.70879],[19754,0.70897]],"JPY":[[14791,87.76],[14822,85.58],[14853,84.49],[14883,82.0],[14914,82.5],[14944,83.43],[14975,82.67],[15006,82.68],[15034,81.85],[15065,83.35],[15095,81.27],[15126,80.59],[15156,79.55],[15187,77.187],[15218,76.9],[15248,76.756],[15279,77.549],[15309,77.934],[15340,77.077],[15371,78.437],[15400,82.42],[15431,81.52],[15461,79.845],[15492,79.342],[15522,79.107],[15553,78.734],[15584,78.255],[15614,78.997],[15645,80.98],[15675,83.76],[15706,88.99],[15737,92.99],[15765,94.64],[15796,97.73],[15826,100.82],[15857,97.75],[15887,99.63],[15918,98.01],[15949,99.23],[15979,97.97],[16010,100.04],[16040,103.52],[16071,104.15],[16102,102.19],[16130,102.37],[16161,102.61],[16191,101.91],[16222,102.09],[16252,101.81],[16283,103.03],[16314,107.23],[16344,108.15],[16375,116.14],[16405,119.4],[16436,118.48],[16467,118.7],[16495,120.42],[16526,119.65],[16556,120.83],[16587,123.79],[16617,123.42],[16648,123.29],[16679,120.28],[16709,120.17],[16740,122.58],[16770,121.85],[16801,118.48],[16832,115.07],[16861,113.07],[16892,109.85],[16922,109.05],[16953,105.57],[16983,104.13],[17014,101.48],[17045,102.02],[17075,103.79],[17106,108.21],[17136,116.05],[17167,115.2],[17198,113.16],[17226,113.07],[17257,110.4],[17287,112.32],[17318,111.02],[17348,112.56],[17379,110.03],[17410,110.81],[17440,113.03],[17471,112.96],[17501,112.99],[17532,111.13],[17563,108.11],[17591,106.21],[17622,107.66],[17652,109.76],[17683,110.1],[17713,111.5],[17744,111.15],[17775,112.06],[17805,112.93],[17836,113.39],[17866,112.39],[17897,109.08],[17928,110.42],[17956,111.28],[17987,111.72],[18017,110.14],[18048,108.2],[18078,108.3],[18109,106.45],[18140,107.5],[18170,108.22],[18201,108.96],[18231,109.22],[18262,109.4],[18293,110.05],[18322,107.64],[18353,107.85],[18383,107.26],[18414,107.68],[18444,106.88],[18475,106.08],[18506,105.69],[18536,105.31],[18567,104.47],[18597,103.91],[18628,103.82],[18659,105.39],[18687,108.69],[18718,109.17],[18748,109.22],[18779,110.18],[18809,110.42],[18840,109.94],[18871,110.27],[18901,113.06],[18932,114.13],[18962,114.04],[18993,114.97],[19024,115.25],[19052,118.49],[19083,126.09],[19113,128.95],[19144,133.74],[19174,136.74],[19205,135.35],[19236,142.86],[19266,147.14],[19297,142.56],[19327,135.23],[19358,130.7],[19389,132.77],[19417,133.91],[19448,133.57],[19478,137.02],[19509,141.28],[19539,141.35],[19570,144.64],[19601,147.71],[19631,149.65],[19662,149.95],[19692,144.32],[19723,146.04],[19754,149.49]],"KES":[[14791,81.32],[14822,80.53],[14853,80.83],[14883,80.58],[14914,80.73],[14944,80.7],[14975,80.99],[15006,81.17],[15034,83.48],[15065,83.98],[15095,85.11],[15126,88.55],[15156,89.99],[15187,92.62],[15218,96.34],[15248,100.74],[15279,94.13],[15309,87.41],[15340,85.83],[15371,83.42],[15400,82.79],[15431,83.14],[15461,84.42],[15492,85.02],[15522,84.24],[15553,83.97],[15584,84.58],[15614,85.12],[15645,85.57],[15675,85.96],[15706,86.8],[15737,87.32],[15765,86.07],[15796,84.5],[15826,84.04],[15857,85.45],[15887,86.88],[15918,87.43],[15949,87.32],[15979,85.54],[16010,86.07],[16040,86.37],[16071,86.14],[16102,86.28],[16130,86.47],[16161,86.64],[16191,87.37],[16222,87.62],[16252,87.75],[16283,88.21],[16314,88.91],[16344,89.24],[16375,89.96],[16405,90.35],[16436,91.05],[16467,91.39],[16495,91.82],[16526,93.67],[16556,96.08],[16587,97.98],[16617,101.06],[16648,102.78],[16679,105.2],[16709,102.63],[16740,102.08],[16770,102.32],[16801,102.4],[16832,101.8],[16861,101.66],[16892,101.13],[16922,100.6],[16953,100.97],[16983,101.33],[17014,101.27],[17045,101.39],[17075,101.3],[17106,101.56],[17136,102.11],[17167,103.58],[17198,103.68],[17226,102.98],[17257,103.52],[17287,103.38],[17318,103.63],[17348,103.79],[17379,103.58],[17410,102.96],[17440,103.39],[17471,103.59],[17501,103.29],[17532,102.93],[17563,101.57],[17591,101.14],[17622,100.5],[17652,100.66],[17683,101.0],[17713,100.7],[17744,100.75],[17775,100.84],[17805,101.16],[17836,102.27],[17866,102.22],[17897,101.45],[17928,100.28],[17956,100.33],[17987,101.07],[18017,101.16],[18048,101.81],[18078,103.13],[18109,103.33],[18140,103.75],[18170,103.64],[18201,102.55],[18231,101.57],[18262,100.75],[18293,100.93],[18322,103.66],[18353,106.31],[18383,106.88],[18414,106.45],[18444,107.43],[18475,108.13],[18506,108.26],[18536,108.57],[18567,109.48],[18597,110.22],[18628,109.81],[18659,109.89],[18687,109.4],[18718,108.41],[18748,107.53],[18779,107.76],[18809,108.14],[18840,109.14],[18871,109.97],[18901,110.81],[18932,111.77],[18962,112.8],[18993,113.19],[19024,113.74],[19052,114.55],[19083,114.93],[19113,116.52],[19144,117.05],[19174,118.08],[19205,119.38],[19236,120.11],[19266,121.1],[19297,121.94],[19327,122.98],[19358,123.92],[19389,125.3],[19417,129.82],[19448,134.15],[19478,136.91],[19509,139.67],[19539,141.23],[19570,143.37],[19601,146.28],[19631,149.29],[19662,152.12],[19692,154.64],[19723,159.19],[19754,151.2]],"KGS":[[14791,46.328],[14822,46.646],[14853,46.636],[14883,46.651],[14914,46.827],[14944,46.935],[14975,47.263],[15006,47.437],[15034,47.388],[15065,47.073],[15095,46.372],[15126,45.304],[15156,45.079],[15187,44.68],[15218,45.042],[15248,45.231],[15279,46.173],[15309,46.623],[15340,46.635],[15371,46.737],[15400,46.683],[15431,46.78],[15461,47.057],[15492,47.204],[15522,47.208],[15553,46.816],[15584,46.925],[15614,47.082],[15645,47.253],[15675,47.359],[15706,47.525],[15737,47.793],[15765,47.792],[15796,48.119],[15826,48.174],[15857,48.439],[15887,48.785],[15918,48.729],[15949,48.826],[15979,48.578],[16010,48.65],[16040,49.172],[16071,49.875],[16102,51.416],[16130,54.032],[16161,54.356],[16191,52.704],[16222,52.187],[16252,51.88],[16283,52.139],[16314,53.853],[16344,55.428],[16375,57.502],[16405,58.159],[16436,59.421],[16467,60.771],[16495,62.508],[16526,62.461],[16556,58.801],[16587,60.153],[16617,62.056],[16648,62.818],[16679,67.337],[16709,68.862],[16740,71.41],[16770,75.588],[16801,75.993],[16832,74.94],[16861,72.479],[16892,69.204],[16922,68.307],[16953,67.795],[16983,67.336],[17014,68.414],[17045,68.558],[17075,68.271],[17106,68.827],[17136,69.218],[17167,69.338],[17198,69.228],[17226,69.015],[17257,68.108],[17287,67.852],[17318,68.661],[17348,69.203],[17379,68.951],[17410,68.576],[17440,68.484],[17471,69.525],[17501,69.536],[17532,68.981],[17563,68.253],[17591,68.196],[17622,68.515],[17652,68.447],[17683,68.22],[17713,68.149],[17744,68.754],[17775,69.156],[17805,69.233],[17836,69.777],[17866,69.846],[17897,69.86],[17928,69.867],[17956,69.925],[17987,70.181],[18017,70.387],[18048,70.214],[18078,69.675],[18109,69.792],[18140,69.985],[18170,70.049],[18201,70.456],[18231,70.249],[18262,69.759],[18293,69.938],[18322,74.243],[18353,80.73],[18383,76.709],[18414,74.717],[18444,77.236],[18475,77.708],[18506,78.8],[18536,80.33],[18567,83.27],[18597,83.73],[18628,83.82],[18659,84.59],[18687,84.68],[18718,84.89],[18748,84.27],[18779,84.39],[18809,84.65],[18840,84.69],[18871,84.67],[18901,84.75],[18932,84.73],[18962,84.78],[18993,84.69],[19024,84.88],[19052,94.1],[19083,85.97],[19113,82.11],[19144,79.929],[19174,80.79],[19205,81.51],[19236,80.94],[19266,82.22],[19297,84.03],[19327,85.11],[19358,85.81],[19389,86.69],[19417,87.5],[19448,87.54],[19478,87.46],[19509,87.5],[19539,87.59],[19570,88.02],[19601,88.35],[19631,88.97],[19662,89.27],[19692,89.22],[19723,89.2],[19754,89.4]],"KHR":[[14791,4242.48],[14822,4230.6],[14853,4230.46],[14883,4214.79],[14914,4130.6],[14944,4093.7],[14975,4051.83],[15006,4045.31],[15034,4026.34],[15065,4013.17],[15095,4052.11],[15126,4082.71],[15156,4098.96],[15187,4110.12],[15218,4087.97],[15248,4094.58],[15279,4045.82],[15309,4047.62],[15340,4064.89],[15371,4032.89],[15400,4012.87],[15431,4013.29],[15461,4045.92],[15492,4095.86],[15522,4094.05],[15553,4056.16],[15584,4057.54],[15614,4041.11],[15645,4007.62],[15675,4015.09],[15706,4000.02],[15737,3993.58],[15765,4016.94],[15796,4006.94],[15826,4015.63],[15857,4068.18],[15887,4063.08],[15918,4076.48],[15949,4079.43],[15979,4058.74],[16010,4015.17],[16040,4010.07],[16071,3993.63],[16102,3994.4],[16130,4020.03],[16161,4014.26],[16191,4024.54],[16222,4050.38],[16252,4049.85],[16283,4063.77],[16314,4084.9],[16344,4088.36],[16375,4080.16],[16405,4071.94],[16436,4077.25],[16467,4057.83],[16495,4034.29],[16526,4041.79],[16556,4049.23],[16587,4094.34],[16617,4099.54],[16648,4110.27],[16679,4109.0],[16709,4066.6],[16740,4059.1],[16770,4053.36],[16801,4056.05],[16832,4029.23],[16861,4015.74],[16892,4024.43],[16922,4062.41],[16953,4085.87],[16983,4087.24],[17014,4094.33],[17045,4106.03],[17075,4074.95],[17106,4041.99],[17136,4041.13],[17167,4039.38],[17198,4033.5],[17226,4003.0],[17257,4029.39],[17287,4045.79],[17318,4084.54],[17348,4079.88],[17379,4092.34],[17410,4064.69],[17440,4059.21],[17471,4057.1],[17501,4049.88],[17532,4024.61],[17563,4019.68],[17591,4010.14],[17622,4022.05],[17652,4071.67],[17683,4070.17],[17713,4064.76],[17744,4073.71],[17775,4086.13],[17805,4079.82],[17836,4051.27],[17866,4026.86],[17897,4016.95],[17928,4010.09],[17956,4001.89],[17987,4039.67],[18017,4057.44],[18048,4072.36],[18078,4080.0],[18109,4085.17],[18140,4100.32],[18170,4076.48],[18201,4060.91],[18231,4069.21],[18262,4067.72],[18293,4074.26],[18322,4063.36],[18353,4065.29],[18383,4108.42],[18414,4087.23],[18444,4092.64],[18475,4097.15],[18506,4092.19],[18536,4095.81],[18567,4068.16],[18597,4052.19],[18628,4054.8],[18659,4077.03],[18687,4060.57],[18718,4058.39],[18748,4067.67],[18779,4083.68],[18809,4082.48],[18840,4084.39],[18871,4083.5],[18901,4084.91],[18932,4082.79],[18962,4074.67],[18993,4078.01],[19024,4086.57],[19052,4068.4],[19083,4045.19],[19113,4070.8],[19144,4064.12],[19174,4082.56],[19205,4109.07],[19236,4104.64],[19266,4123.89],[19297,4141.08],[19327,4117.49],[19358,4115.36],[19389,4092.52],[19417,4062.25],[19448,4073.13],[19478,4108.66],[19509,4115.9],[19539,4131.47],[19570,4145.29],[19601,4123.54],[19631,4137.26],[19662,4126.24],[19692,4100.34],[19723,4086.44],[19754,4079.59]],"KMF":[[14791,388.26],[14822,381.71],[14853,372.55],[14883,356.21],[14914,363.75],[14944,369.49],[14975,367.08],[15006,359.36],[15034,353.68],[15065,340.92],[15095,340.63],[15126,342.33],[15156,343.83],[15187,342.74],[15218,356.44],[15248,358.13],[15279,359.28],[15309,371.76],[15340,378.99],[15371,371.62],[15400,368.05],[15431,372.48],[15461,384.36],[15492,393.29],[15522,396.8],[15553,396.17],[15584,380.87],[15614,379.72],[15645,381.53],[15675,372.88],[15706,369.46],[15737,368.91],[15765,376.18],[15796,379.03],[15826,377.74],[15857,372.99],[15887,375.24],[15918,370.33],[15949,366.85],[15979,361.32],[16010,362.18],[16040,359.73],[16071,360.9],[16102,359.95],[16130,358.12],[16161,356.45],[16191,358.5],[16222,361.23],[16252,363.97],[16283,369.32],[16314,377.31],[16344,389.24],[16375,393.57],[16405,391.54],[16436,421.43],[16467,434.34],[16495,449.2],[16526,451.41],[16556,445.24],[16587,442.75],[16617,445.68],[16648,443.62],[16679,437.97],[16709,440.34],[16740,452.73],[16770,448.12],[16801,447.54],[16832,447.52],[16861,440.91],[16892,433.45],[16922,434.14],[16953,435.03],[16983,440.1],[17014,443.01],[17045,439.89],[17075,443.55],[17106,455.97],[17136,465.23],[17167,463.26],[17198,462.67],[17226,461.19],[17257,457.52],[17287,447.07],[17318,441.96],[17348,431.91],[17379,420.31],[17410,413.23],[17440,418.9],[17471,419.96],[17501,412.74],[17532,405.44],[17563,398.78],[17591,398.97],[17622,401.18],[17652,414.64],[17683,421.85],[17713,421.24],[17744,424.11],[17775,421.37],[17805,427.31],[17836,433.27],[17866,430.63],[17897,430.01],[17928,432.31],[17956,435.19],[17987,438.22],[18017,440.38],[18048,435.06],[18078,436.73],[18109,442.8],[18140,447.98],[18170,445.25],[18201,445.4],[18231,442.2],[18262,443.06],[18293,449.59],[18322,445.54],[18353,448.83],[18383,447.61],[18414,439.04],[18444,427.78],[18475,416.54],[18506,417.32],[18536,419.19],[18567,415.63],[18597,404.97],[18628,403.9],[18659,406.75],[18687,412.27],[18718,413.12],[18748,405.48],[18779,409.13],[18809,414.24],[18840,416.92],[18871,420.32],[18901,422.33],[18932,428.8],[18962,435.19],[18993,436.54],[19024,437.39],[19052,442.03],[19083,452.76],[19113,462.25],[19144,465.94],[19174,475.46],[19205,486.41],[19236,497.78],[19266,500.91],[19297,487.55],[19327,466.96],[19358,458.52],[19389,457.74],[19417,458.27],[19448,451.01],[19478,452.12],[19509,454.8],[19539,447.36],[19570,448.38],[19601,459.98],[19631,464.69],[19662,458.44],[19692,448.59],[19723,450.02],[19754,454.6]],"KPW":[[14791,1.6],[14822,1.6],[14853,1.6],[14883,1.6],[14914,1.6],[14944,1.6],[14975,1.811],[15006,44.505],[15034,44.498],[15065,44.502],[15095,44.501],[15126,1.9],[15156,1.9],[15187,1.9],[15218,1.9],[15248,1.9],[15279,1.9],[15309,1.9],[15340,1.9],[15371,1.9],[15400,1.9],[15431,1.9],[15461,1.9],[15492,1.9],[15522,1.9],[15553,1.9],[15584,1.9],[15614,1.9],[15645,1.9],[15675,1.9],[15706,1.9],[15737,1.9],[15765,1.9],[15796,1.9],[15826,1.9],[15857,1.9],[15887,1.9],[15918,1.9],[15949,1.9],[15979,1.9],[16010,1.9],[16040,1.9],[16071,1.9],[16102,1.9],[16130,1.9],[16161,1.9],[16191,1.9],[16222,1.9],[16252,1.9],[16283,1.9],[16314,1.9],[16344,1.9],[16375,1.9],[16405,1.9],[16436,1.9],[16467,44.799],[16495,44.798],[16526,44.798],[16556,44.799],[16587,44.802],[16617,44.8],[16648,44.803],[16679,44.799],[16709,44.8],[16740,44.804],[16770,44.802],[16801,44.802],[16832,44.801],[16861,66.102],[16892,66.1],[16922,66.097],[16953,66.099],[16983,66.1],[17014,66.1],[17045,66.102],[17075,66.102],[17106,66.103],[17136,66.097],[17167,66.099],[17198,66.099],[17226,66.101],[17257,66.1],[17287,66.101],[17318,66.102],[17348,66.103],[17379,66.103],[17410,66.098],[17440,66.101],[17471,66.1],[17501,66.1],[17532,66.099],[17563,66.101],[17591,66.103],[17622,66.101],[17652,66.099],[17683,66.101],[17713,66.095],[17744,66.101],[17775,66.103],[17805,66.102],[17836,66.092],[17866,66.099],[17897,66.099],[17928,66.1],[17956,66.101],[17987,66.101],[18017,66.101],[18048,66.103],[18078,66.097],[18109,66.099],[18140,66.1],[18170,66.1],[18201,66.099],[18231,66.098],[18262,66.097],[18293,66.1],[18322,66.103],[18353,66.103],[18383,66.104],[18414,66.1],[18444,66.099],[18475,66.105],[18506,66.098],[18536,66.099],[18567,66.101],[18597,66.098],[18628,66.105],[18659,66.102],[18687,66.101],[18718,66.105],[18748,66.104],[18779,66.098],[18809,66.104],[18840,66.1],[18871,66.1],[18901,66.104],[18932,66.098],[18962,66.1],[18993,66.098],[19024,66.102],[19052,66.099],[19083,66.1],[19113,66.098],[19144,66.101],[19174,66.102],[19205,66.097],[19236,66.1],[19266,66.102],[19297,66.099],[19327,66.096],[19358,66.097],[19389,66.1],[19417,66.1],[19448,66.1],[19478,66.099],[19509,66.099],[19539,66.101],[19570,66.102],[19601,66.098],[19631,66.099],[19662,66.098],[19692,66.098],[19723,66.1],[19754,66.102]],"KRW":[[14791,1205.15],[14822,1181.78],[14853,1163.75],[14883,1123.97],[14914,1130.02],[14944,1146.59],[14975,1120.44],[15006,1118.17],[15034,1120.47],[15065,1085.72],[15095,1083.63],[15126,1080.21],[15156,1059.79],[15187,1072.94],[15218,1114.33],[15248,1153.65],[15279,1132.87],[15309,1148.46],[15340,1143.34],[15371,1123.3],[15400,1126.42],[15431,1135.64],[15461,1157.72],[15492,1165.54],[15522,1143.28],[15553,1132.23],[15584,1123.57],[15614,1106.92],[15645,1088.13],[15675,1075.92],[15706,1066.51],[15737,1087.27],[15765,1101.57],[15796,1120.87],[15826,1111.54],[15857,1135.13],[15887,1127.19],[15918,1116.25],[15949,1085.2],[15979,1067.25],[16010,1062.53],[16040,1056.21],[16071,1066.75],[16102,1071.65],[16130,1070.17],[16161,1044.62],[16191,1025.39],[16222,1019.04],[16252,1020.31],[16283,1024.66],[16314,1034.73],[16344,1061.43],[16375,1095.46],[16405,1102.34],[16436,1089.26],[16467,1100.03],[16495,1112.3],[16526,1086.73],[16556,1092.24],[16587,1112.98],[16617,1144.61],[16648,1178.81],[16679,1184.88],[16709,1147.6],[16740,1152.93],[16770,1172.82],[16801,1201.6],[16832,1216.81],[16861,1185.27],[16892,1146.9],[16922,1173.41],[16953,1168.64],[16983,1142.02],[17014,1112.01],[17045,1109.36],[17075,1125.59],[17106,1162.68],[17136,1184.19],[17167,1182.09],[17198,1144.17],[17226,1133.92],[17257,1132.7],[17287,1126.4],[17318,1131.13],[17348,1133.96],[17379,1130.93],[17410,1132.78],[17440,1133.43],[17471,1103.11],[17501,1083.53],[17532,1066.53],[17563,1078.5],[17591,1071.38],[17622,1068.79],[17652,1076.57],[17683,1093.25],[17713,1122.84],[17744,1121.45],[17775,1118.97],[17805,1131.35],[17836,1127.78],[17866,1122.39],[17897,1121.28],[17928,1122.52],[17956,1131.56],[17987,1142.83],[18017,1182.43],[18048,1174.01],[18078,1175.86],[18109,1208.84],[18140,1195.91],[18170,1184.69],[18201,1168.57],[18231,1173.83],[18262,1166.26],[18293,1194.64],[18322,1217.74],[18353,1223.35],[18383,1228.69],[18414,1208.72],[18444,1198.8],[18475,1187.2],[18506,1177.69],[18536,1145.3],[18567,1115.99],[18597,1094.91],[18628,1099.1],[18659,1111.95],[18687,1129.41],[18718,1118.96],[18748,1122.14],[18779,1122.31],[18809,1144.16],[18840,1160.43],[18871,1172.22],[18901,1181.99],[18932,1183.25],[18962,1184.58],[18993,1195.59],[19024,1197.98],[19052,1218.57],[19083,1234.04],[19113,1266.01],[19144,1276.53],[19174,1305.26],[19205,1319.5],[19236,1392.24],[19266,1426.71],[19297,1359.7],[19327,1295.72],[19358,1246.95],[19389,1273.07],[19417,1306.09],[19448,1321.48],[19478,1328.24],[19509,1299.51],[19539,1286.57],[19570,1318.27],[19601,1333.22],[19631,1350.41],[19662,1310.92],[19692,1303.66],[19723,1323.8],[19754,1332.28]],"KWD":[[14791,0.28931],[14822,0.28751],[14853,0.28692],[14883,0.28275],[14914,0.2815],[14944,0.28221],[14975,0.2812],[15006,0.27995],[15034,0.2784],[15065,0.2769],[15095,0.27619],[15126,0.27572],[15156,0.27492],[15187,0.27274],[15218,0.27575],[15248,0.27591],[15279,0.27677],[15309,0.27841],[15340,0.27852],[15371,0.27817],[15400,0.27867],[15431,0.27867],[15461,0.27948],[15492,0.28062],[15522,0.28173],[15553,0.28189],[15584,0.28162],[15614,0.28131],[15645,0.28216],[15675,0.2817],[15706,0.28185],[15737,0.28238],[15765,0.28474],[15796,0.28524],[15826,0.2856],[15857,0.28517],[15887,0.28554],[15918,0.28514],[15949,0.28415],[15979,0.28253],[16010,0.28298],[16040,0.28257],[16071,0.2826],[16102,0.28222],[16130,0.28145],[16161,0.28144],[16191,0.28155],[16222,0.28203],[16252,0.28232],[16283,0.28366],[16314,0.28678],[16344,0.28886],[16375,0.29099],[16405,0.29207],[16436,0.29373],[16467,0.29527],[16495,0.29886],[16526,0.30178],[16556,0.30181],[16587,0.30205],[16617,0.30274],[16648,0.30264],[16679,0.30205],[16709,0.30193],[16740,0.30353],[16770,0.30381],[16801,0.30408],[16832,0.30019],[16861,0.30162],[16892,0.30182],[16922,0.30176],[16953,0.30162],[16983,0.30218],[17014,0.30165],[17045,0.30151],[17075,0.30252],[17106,0.3038],[17136,0.3056],[17167,0.3057],[17198,0.30536],[17226,0.30541],[17257,0.30526],[17287,0.30443],[17318,0.30363],[17348,0.30319],[17379,0.30222],[17410,0.30152],[17440,0.30215],[17471,0.3023],[17501,0.30205],[17532,0.30108],[17563,0.30007],[17591,0.30011],[17622,0.30025],[17652,0.3019],[17683,0.3024],[17713,0.30272],[17744,0.30325],[17775,0.30287],[17805,0.30337],[17836,0.30399],[17866,0.30402],[17897,0.30325],[17928,0.30364],[17956,0.30388],[17987,0.30426],[18017,0.30416],[18048,0.30383],[18078,0.30403],[18109,0.30399],[18140,0.30389],[18170,0.3038],[18201,0.30362],[18231,0.30347],[18262,0.30329],[18293,0.30517],[18322,0.30872],[18353,0.31081],[18383,0.30919],[18414,0.30778],[18444,0.3075],[18475,0.30578],[18506,0.30582],[18536,0.30586],[18567,0.30575],[18597,0.30504],[18628,0.30327],[18659,0.30278],[18687,0.30223],[18718,0.30181],[18748,0.301],[18779,0.30104],[18809,0.301],[18840,0.30083],[18871,0.30081],[18901,0.30157],[18932,0.30219],[18962,0.30289],[18993,0.30245],[19024,0.30227],[19052,0.30379],[19083,0.30504],[19113,0.30681],[19144,0.30664],[19174,0.30714],[19205,0.30713],[19236,0.3089],[19266,0.31049],[19297,0.3091],[19327,0.30694],[19358,0.30598],[19389,0.30608],[19417,0.30705],[19448,0.30658],[19478,0.30676],[19509,0.30749],[19539,0.30693],[19570,0.30783],[19601,0.30852],[19631,0.30933],[19662,0.30901],[19692,0.30811],[19723,0.30745],[19754,0.30778]],"KYD":[[14791,0.8375],[14822,0.8346],[14853,0.82387],[14883,0.83452],[14914,0.83367],[14944,0.82445],[14975,0.83537],[15006,0.83543],[15034,0.82354],[15065,0.83381],[15095,0.83414],[15126,0.82326],[15156,0.83548],[15187,0.83567],[15218,0.8242],[15248,0.8363],[15279,0.83628],[15309,0.82422],[15340,0.83626],[15371,0.83627],[15400,0.82416],[15431,0.83625],[15461,0.83622],[15492,0.8333],[15522,0.83622],[15553,0.83624],[15584,0.82418],[15614,0.83633],[15645,0.83629],[15675,0.82428],[15706,0.83629],[15737,0.83644],[15765,0.82427],[15796,0.83641],[15826,0.83637],[15857,0.82432],[15887,0.83639],[15918,0.83639],[15949,0.82428],[15979,0.83646],[16010,0.83662],[16040,0.83332],[16071,0.83666],[16102,0.83666],[16130,0.83332],[16161,0.83584],[16191,0.83666],[16222,0.83332],[16252,0.83666],[16283,0.83665],[16314,0.83332],[16344,0.83666],[16375,0.83666],[16405,0.83332],[16436,0.83665],[16467,0.83659],[16495,0.83332],[16526,0.83778],[16556,0.83702],[16587,0.83155],[16617,0.83443],[16648,0.83443],[16679,0.83443],[16709,0.83443],[16740,0.83443],[16770,0.83443],[16801,0.83443],[16832,0.83443],[16861,0.83443],[16892,0.83443],[16922,0.83443],[16953,0.83155],[16983,0.83443],[17014,0.83443],[17045,0.83155],[17075,0.83443],[17106,0.83443],[17136,0.83155],[17167,0.83443],[17198,0.83443],[17226,0.83154],[17257,0.83443],[17287,0.83443],[17318,0.83155],[17348,0.83443],[17379,0.83443],[17410,0.83155],[17440,0.83387],[17471,0.83193],[17501,0.82955],[17532,0.83193],[17563,0.83193],[17591,0.82955],[17622,0.83193],[17652,0.83193],[17683,0.82955],[17713,0.83193],[17744,0.83193],[17775,0.82954],[17805,0.83193],[17836,0.83193],[17866,0.82955],[17897,0.83194],[17928,0.83193],[17956,0.82955],[17987,0.83193],[18017,0.83193],[18048,0.82954],[18078,0.83193],[18109,0.83193],[18140,0.82954],[18170,0.83193],[18201,0.83193],[18231,0.82955],[18262,0.83193],[18293,0.83193],[18322,0.82955],[18353,0.83193],[18383,0.83193],[18414,0.82955],[18444,0.83193],[18475,0.83193],[18506,0.82955],[18536,0.83193],[18567,0.83193],[18597,0.82954],[18628,0.82954],[18659,0.82954],[18687,0.82796],[18718,0.82954],[18748,0.82954],[18779,0.82795],[18809,0.82955],[18840,0.82955],[18871,0.82796],[18901,0.82954],[18932,0.82954],[18962,0.82795],[18993,0.82956],[19024,0.82955],[19052,0.82796],[19083,0.82955],[19113,0.82954],[19144,0.82731],[19174,0.82954],[19205,0.82955],[19236,0.82795],[19266,0.82956],[19297,0.82954],[19327,0.82796],[19358,0.82955],[19389,0.82954],[19417,0.82963],[19448,0.83154],[19478,0.83155],[19509,0.82962],[19539,0.83154],[19570,0.83155],[19601,0.82997],[19631,0.83199],[19662,0.83166],[19692,0.83043],[19723,0.83207],[19754,0.83199]],"KZT":[[14791,147.64],[14822,147.22],[14853,147.49],[14883,147.4],[14914,147.69],[14944,147.49],[14975,146.99],[15006,146.43],[15034,145.66],[15065,145.63],[15095,145.55],[15126,145.62],[15156,145.77],[15187,146.62],[15218,147.27],[15248,147.86],[15279,147.58],[15309,148.06],[15340,148.48],[15371,148.1],[15400,147.61],[15431,147.79],[15461,147.89],[15492,149.04],[15522,149.88],[15553,149.4],[15584,149.76],[15614,150.47],[15645,150.53],[15675,150.49],[15706,150.82],[15737,150.45],[15765,150.88],[15796,151.06],[15826,150.89],[15857,151.44],[15887,152.65],[15918,152.92],[15949,153.35],[15979,153.82],[16010,153.48],[16040,153.87],[16071,154.71],[16102,172.5],[16130,182.69],[16161,182.14],[16191,182.44],[16222,183.32],[16252,183.41],[16283,182.25],[16314,182.06],[16344,181.56],[16375,181.15],[16405,181.78],[16436,183.33],[16467,184.82],[16495,185.34],[16526,186.36],[16556,185.35],[16587,186.19],[16617,186.95],[16648,202.52],[16679,261.97],[16709,274.46],[16740,302.99],[16770,323.59],[16801,364.38],[16832,357.13],[16861,346.5],[16892,336.15],[16922,332.79],[16953,336.78],[16983,342.55],[17014,343.21],[17045,338.25],[17075,332.77],[17106,338.37],[17136,333.81],[17167,330.3],[17198,319.56],[17226,315.4],[17257,313.55],[17287,313.7],[17318,319.29],[17348,325.71],[17379,333.21],[17410,338.62],[17440,336.83],[17471,332.87],[17501,333.37],[17532,326.96],[17563,321.32],[17591,320.24],[17622,324.55],[17652,328.3],[17683,336.67],[17713,344.34],[17744,357.7],[17775,365.66],[17805,366.49],[17836,370.55],[17866,373.24],[17897,378.17],[17928,377.17],[17956,378.07],[17987,379.71],[18017,380.01],[18048,381.92],[18078,383.52],[18109,386.53],[18140,387.22],[18170,388.99],[18201,387.42],[18231,384.02],[18262,378.27],[18293,378.73],[18322,414.89],[18353,432.66],[18383,419.16],[18414,403.47],[18444,413.18],[18475,419.16],[18506,425.04],[18536,429.1],[18567,428.76],[18597,421.19],[18628,420.39],[18659,418.69],[18687,420.25],[18718,429.82],[18748,427.96],[18779,427.4],[18809,426.76],[18840,425.62],[18871,425.27],[18901,425.89],[18932,430.64],[18962,435.36],[18993,434.35],[19024,436.81],[19052,499.74],[19083,450.98],[19113,431.28],[19144,442.19],[19174,474.55],[19205,474.87],[19236,473.79],[19266,473.09],[19297,465.51],[19327,466.7],[19358,462.76],[19389,452.03],[19417,451.28],[19448,452.73],[19478,447.41],[19509,448.75],[19539,445.9],[19570,451.75],[19601,467.94],[19631,475.84],[19662,464.6],[19692,458.02],[19723,451.24],[19754,450.11]],"LAK":[[14791,8268.0],[14822,8193.0],[14853,8152.0],[14883,8019.0],[14914,8070.0],[14944,8066.0],[14975,8063.0],[15006,8058.0],[15034,8038.0],[15065,8053.0],[15095,8027.0],[15126,8002.0],[15156,7989.0],[15187,8026.0],[15218,8006.0],[15248,7984.0],[15279,7967.0],[15309,8019.0],[15340,8026.0],[15371,7987.0],[15400,7964.0],[15431,7990.0],[15461,8013.0],[15492,8043.0],[15522,8033.0],[15553,7991.0],[15584,7992.0],[15614,8009.0],[15645,8009.0],[15675,7994.0],[15706,7942.0],[15737,7871.0],[15765,7806.0],[15796,7718.0],[15826,7629.0],[15857,7685.0],[15887,7756.0],[15918,7804.0],[15949,7839.0],[15979,7855.0],[16010,7944.0],[16040,7998.0],[16071,8004.0],[16102,8029.0],[16130,8033.0],[16161,8032.0],[16191,8021.0],[16222,8016.0],[16252,8012.0],[16283,8009.0],[16314,8016.0],[16344,8008.0],[16375,8026.0],[16405,8049.0],[16436,8096.0],[16467,8087.0],[16495,8098.0],[16526,8112.0],[16556,8062.0],[16587,8112.0],[16617,8144.0],[16648,8186.0],[16679,8165.0],[16709,8132.0],[16740,8166.0],[16770,8151.0],[16801,8149.0],[16832,8114.0],[16861,8112.0],[16892,8091.0],[16922,8092.0],[16953,8098.0],[16983,8072.0],[17014,8069.0],[17045,8105.0],[17075,8121.0],[17106,8153.0],[17136,8171.0],[17167,8170.0],[17198,8191.0],[17226,8162.0],[17257,8218.0],[17287,8188.0],[17318,8220.0],[17348,8215.0],[17379,8230.0],[17410,8278.0],[17440,8298.0],[17471,8327.0],[17501,8295.0],[17532,8268.0],[17563,8268.0],[17591,8278.0],[17622,8280.0],[17652,8327.0],[17683,8368.0],[17713,8401.0],[17744,8439.0],[17775,8482.0],[17805,8512.0],[17836,8518.0],[17866,8524.0],[17897,8511.0],[17928,8534.0],[17956,8543.0],[17987,8596.0],[18017,8620.0],[18048,8643.0],[18078,8674.0],[18109,8696.0],[18140,8789.0],[18170,8816.0],[18201,8828.0],[18231,8861.0],[18262,8853.0],[18293,8873.0],[18322,8894.0],[18353,8932.0],[18383,8973.0],[18414,8995.0],[18444,9025.0],[18475,9069.0],[18506,9128.0],[18536,9222.0],[18567,9268.0],[18597,9269.0],[18628,9285.0],[18659,9330.0],[18687,9355.0],[18718,9399.0],[18748,9406.0],[18779,9451.0],[18809,9496.0],[18840,9554.0],[18871,9687.0],[18901,10031.0],[18932,10479.0],[18962,10938.0],[18993,11204.0],[19024,11379.0],[19052,11533.0],[19083,11817.0],[19113,12812.0],[19144,14265.0],[19174,14951.0],[19205,15144.0],[19236,15731.0],[19266,16512.0],[19297,17001.0],[19327,17091.0],[19358,16825.0],[19389,16635.0],[19417,16673.0],[19448,16720.0],[19478,17208.0],[19509,18058.0],[19539,18607.0],[19570,18970.0],[19601,19557.0],[19631,20008.0],[19662,20281.0],[19692,20421.0],[19723,20454.0],[19754,20756.0]],"LBP":[[14791,1502.58],[14822,1504.72],[14853,1501.73],[14883,1502.01],[14914,1504.17],[14944,1501.34],[14975,1502.77],[15006,1502.88],[15034,1501.81],[15065,1502.46],[15095,1508.63],[15126,1508.65],[15156,1511.18],[15187,1508.57],[15218,1505.35],[15248,1504.13],[15279,1505.86],[15309,1505.46],[15340,1503.96],[15371,1504.75],[15400,1503.86],[15431,1504.78],[15461,1505.57],[15492,1504.03],[15522,1505.58],[15553,1503.72],[15584,1503.28],[15614,1504.14],[15645,1505.82],[15675,1505.04],[15706,1504.59],[15737,1504.47],[15765,1505.09],[15796,1505.07],[15826,1504.95],[15857,1509.11],[15887,1510.1],[15918,1509.88],[15949,1508.02],[15979,1508.49],[16010,1507.18],[16040,1505.37],[16071,1505.28],[16102,1504.76],[16130,1505.31],[16161,1507.48],[16191,1509.98],[16222,1507.9],[16252,1510.37],[16283,1509.77],[16314,1510.32],[16344,1510.98],[16375,1511.54],[16405,1509.14],[16436,1505.25],[16467,1505.33],[16495,1504.68],[16526,1510.64],[16556,1503.02],[16587,1505.86],[16617,1506.55],[16648,1508.73],[16679,1506.53],[16709,1503.11],[16740,1505.49],[16770,1507.18],[16801,1509.71],[16832,1506.67],[16861,1510.95],[16892,1508.42],[16922,1505.85],[16953,1505.35],[16983,1507.55],[17014,1507.06],[17045,1507.1],[17075,1506.87],[17106,1505.9],[17136,1506.39],[17167,1507.75],[17198,1507.43],[17226,1507.14],[17257,1510.92],[17287,1509.58],[17318,1509.03],[17348,1509.8],[17379,1508.31],[17410,1504.48],[17440,1507.4],[17471,1512.45],[17501,1511.0],[17532,1514.32],[17563,1509.67],[17591,1509.91],[17622,1507.78],[17652,1507.69],[17683,1507.86],[17713,1509.25],[17744,1512.8],[17775,1509.12],[17805,1508.08],[17836,1509.67],[17866,1508.14],[17897,1508.0],[17928,1509.39],[17956,1506.24],[17987,1506.5],[18017,1507.57],[18048,1508.71],[18078,1507.58],[18109,1508.13],[18140,1507.48],[18170,1509.11],[18201,1508.23],[18231,1509.13],[18262,1507.39],[18293,1510.24],[18322,1509.28],[18353,1509.4],[18383,1510.88],[18414,1509.25],[18444,1512.75],[18475,1510.42],[18506,1507.86],[18536,1508.57],[18567,1510.62],[18597,1510.12],[18628,1509.89],[18659,1512.63],[18687,1507.94],[18718,1512.72],[18748,1509.11],[18779,1508.02],[18809,1511.13],[18840,1511.13],[18871,1507.66],[18901,1509.7],[18932,1509.38],[18962,1509.64],[18993,1507.96],[19024,1509.76],[19052,1511.09],[19083,1504.63],[19113,1512.86],[19144,1507.97],[19174,1507.84],[19205,1508.2],[19236,1506.06],[19266,1512.68],[19297,1513.5],[19327,1510.66],[19358,1513.5],[19389,9500.0],[19417,15031.0],[19448,15020.0],[19478,15006.0],[19509,15028.0],[19539,15008.0],[19570,15029.0],[19601,14999.0],[19631,15035.0],[19662,15050.0],[19692,15045.0],[19723,15004.0],[19754,33747.0]],"LKR":[[14791,113.37],[14822,112.47],[14853,112.24],[14883,111.43],[14914,111.98],[14944,111.41],[14975,110.95],[15006,110.94],[15034,110.41],[15065,110.2],[15095,109.89],[15126,109.51],[15156,109.2],[15187,109.85],[15218,110.19],[15248,109.97],[15279,111.06],[15309,113.97],[15340,113.99],[15371,116.94],[15400,124.03],[15431,128.64],[15461,130.24],[15492,132.15],[15522,133.16],[15553,131.5],[15584,131.11],[15614,129.64],[15645,130.24],[15675,128.35],[15706,127.05],[15737,126.77],[15765,127.08],[15796,126.31],[15826,126.25],[15857,128.15],[15887,130.86],[15918,131.94],[15949,132.47],[15979,131.15],[16010,131.1],[16040,130.83],[16071,130.54],[16102,130.89],[16130,130.78],[16161,130.74],[16191,130.45],[16222,130.26],[16252,130.26],[16283,130.21],[16314,130.35],[16344,130.6],[16375,131.2],[16405,131.14],[16436,131.35],[16467,132.68],[16495,133.08],[16526,133.56],[16556,132.93],[16587,133.89],[16617,133.84],[16648,134.41],[16679,139.1],[16709,140.15],[16740,141.91],[16770,143.68],[16801,143.96],[16832,143.77],[16861,145.84],[16892,145.6],[16922,146.15],[16953,145.65],[16983,145.74],[17014,145.34],[17045,146.08],[17075,146.85],[17106,147.71],[17136,149.14],[17167,149.88],[17198,151.04],[17226,151.7],[17257,152.64],[17287,152.61],[17318,153.36],[17348,153.73],[17379,153.37],[17410,152.71],[17440,153.37],[17471,154.31],[17501,153.59],[17532,153.94],[17563,154.42],[17591,155.63],[17622,156.22],[17652,157.78],[17683,158.54],[17713,159.5],[17744,160.85],[17775,165.57],[17805,171.62],[17836,176.58],[17866,181.06],[17897,180.84],[17928,179.61],[17956,177.29],[17987,175.7],[18017,175.97],[18048,176.62],[18078,176.03],[18109,177.89],[18140,180.84],[18170,181.65],[18201,180.8],[18231,181.55],[18262,181.3],[18293,181.8],[18322,184.94],[18353,190.77],[18383,188.3],[18414,185.69],[18444,186.09],[18475,185.35],[18506,184.87],[18536,184.45],[18567,185.14],[18597,186.57],[18628,190.33],[18659,194.94],[18687,196.59],[18718,197.96],[18748,196.46],[18779,198.34],[18809,199.1],[18840,199.31],[18871,199.52],[18901,200.63],[18932,201.37],[18962,202.14],[18993,201.54],[19024,202.32],[19052,250.77],[19083,317.83],[19113,354.18],[19144,358.16],[19174,359.6],[19205,359.48],[19236,360.33],[19266,364.75],[19297,366.0],[19327,364.29],[19358,364.73],[19389,362.18],[19417,335.9],[19448,321.49],[19478,306.96],[19509,303.07],[19539,315.7],[19570,321.18],[19601,321.07],[19631,325.21],[19662,328.22],[19692,327.38],[19723,320.39],[19754,313.28]],"LRD":[[14791,72.038],[14822,72.313],[14853,72.136],[14883,71.697],[14914,69.679],[14944,69.915],[14975,72.172],[15006,72.403],[15034,72.077],[15065,72.267],[15095,72.375],[15126,72.69],[15156,72.686],[15187,72.671],[15218,72.22],[15248,72.09],[15279,71.769],[15309,71.997],[15340,72.686],[15371,73.453],[15400,73.528],[15431,73.928],[15461,74.842],[15492,75.019],[15522,74.602],[15553,74.239],[15584,73.307],[15614,72.658],[15645,72.815],[15675,72.912],[15706,73.875],[15737,74.598],[15765,74.623],[15796,75.008],[15826,75.5],[15857,76.081],[15887,76.639],[15918,78.015],[15949,78.79],[15979,79.893],[16010,80.96],[16040,80.99],[16071,82.09],[16102,81.37],[16130,82.86],[16161,85.22],[16191,85.79],[16222,87.52],[16252,88.13],[16283,83.01],[16314,84.47],[16344,84.49],[16375,83.9],[16405,82.61],[16436,83.79],[16467,84.53],[16495,84.47],[16526,84.54],[16556,84.44],[16587,85.11],[16617,85.99],[16648,87.19],[16679,87.41],[16709,87.01],[16740,86.92],[16770,87.5],[16801,88.34],[16832,88.59],[16861,90.58],[16892,90.11],[16922,91.74],[16953,91.18],[16983,91.89],[17014,93.04],[17045,96.12],[17075,96.92],[17106,95.4],[17136,96.49],[17167,97.33],[17198,97.77],[17226,101.39],[17257,99.16],[17287,101.3],[17318,107.7],[17348,113.6],[17379,115.08],[17410,116.81],[17440,118.45],[17471,123.17],[17501,125.55],[17532,125.71],[17563,128.16],[17591,131.18],[17622,131.84],[17652,134.57],[17683,143.93],[17713,152.83],[17744,153.18],[17775,155.21],[17805,156.72],[17836,157.82],[17866,157.72],[17897,159.53],[17928,161.41],[17956,162.72],[17987,166.89],[18017,181.39],[18048,193.75],[18078,199.49],[18109,204.28],[18140,208.03],[18170,210.66],[18201,198.41],[18231,189.0],[18262,191.94],[18293,196.53],[18322,198.04],[18353,198.48],[18383,198.72],[18414,199.16],[18444,199.37],[18475,199.34],[18506,199.12],[18536,194.85],[18567,162.53],[18597,161.19],[18628,167.89],[18659,172.3],[18687,172.99],[18718,172.59],[18748,171.54],[18779,171.37],[18809,171.34],[18840,171.72],[18871,171.18],[18901,159.65],[18932,146.14],[18962,143.1],[18993,148.85],[19024,153.69],[19052,153.37],[19083,152.04],[19113,151.8],[19144,151.98],[19174,152.66],[19205,153.46],[19236,153.45],[19266,153.82],[19297,153.93],[19327,154.17],[19358,155.5],[19389,157.67],[19417,161.2],[19448,163.95],[19478,167.29],[19509,176.85],[19539,184.35],[19570,186.32],[19601,187.89],[19631,188.82],[19662,189.25],[19692,189.66],[19723,190.28],[19754,191.31]],"LSL":[[14791,7.5156],[14822,7.3395],[14853,7.188],[14883,6.9374],[14914,7.0376],[14944,6.897],[14975,6.9098],[15006,7.1225],[15034,6.9159],[15065,6.7214],[15095,6.8059],[15126,6.8275],[15156,6.7904],[15187,6.9729],[15218,7.6565],[15248,7.9111],[15279,8.0967],[15309,8.1978],[15340,7.9858],[15371,7.6536],[15400,7.6096],[15431,7.7845],[15461,8.158],[15492,8.383],[15522,8.2762],[15553,8.2758],[15584,8.308],[15614,8.6462],[15645,8.7631],[15675,8.6119],[15706,8.7588],[15737,8.9415],[15765,9.1403],[15796,9.1039],[15826,9.4681],[15857,10.0203],[15887,9.9078],[15918,10.1045],[15949,10.0764],[15979,9.9598],[16010,10.114],[16040,10.3962],[16071,10.8736],[16102,10.9691],[16130,10.7016],[16161,10.5644],[16191,10.4635],[16222,10.6089],[16252,10.6541],[16283,10.6436],[16314,11.041],[16344,11.0967],[16375,11.0195],[16405,11.4128],[16436,11.6044],[16467,11.6077],[16495,11.9263],[16526,11.9933],[16556,11.997],[16587,12.2447],[16617,12.4442],[16648,12.9592],[16679,13.5819],[16709,13.6733],[16740,14.1342],[16770,14.937],[16801,16.0685],[16832,15.9897],[16861,15.3818],[16892,14.588],[16922,15.1944],[16953,15.2415],[16983,14.4887],[17014,14.0562],[17045,14.0432],[17075,13.8804],[17106,13.9186],[17136,13.8396],[17167,13.5633],[17198,13.2622],[17226,13.0282],[17257,13.2602],[17287,13.2403],[17318,13.0133],[17348,13.0787],[17379,13.1451],[17410,13.2134],[17440,13.7587],[17471,13.9552],[17501,13.0508],[17532,12.149],[17563,11.8151],[17591,11.8062],[17622,12.0907],[17652,12.5182],[17683,13.2475],[17713,13.4392],[17744,13.9797],[17775,14.6068],[17805,14.4497],[17836,14.1441],[17866,14.1002],[17897,13.848],[17928,13.7976],[17956,14.2766],[17987,14.3298],[18017,14.4712],[18048,14.4804],[18078,14.114],[18109,15.1794],[18140,15.0356],[18170,15.0217],[18201,14.79],[18231,14.6413],[18262,14.4489],[18293,15.0533],[18322,16.7999],[18353,18.3211],[18383,18.0607],[18414,17.2441],[18444,16.8531],[18475,17.0474],[18506,16.7637],[18536,16.5176],[18567,15.4633],[18597,14.8905],[18628,14.9762],[18659,14.8918],[18687,14.8597],[18718,14.5788],[18748,14.0685],[18779,14.0352],[18809,14.4501],[18840,14.6713],[18871,14.7951],[18901,14.9166],[18932,15.3531],[18962,15.8617],[18993,15.6874],[19024,15.333],[19052,14.9116],[19083,15.0106],[19113,15.9009],[19144,15.8741],[19174,16.2765],[19205,16.8356],[19236,17.4669],[19266,18.0775],[19297,17.6238],[19327,17.1076],[19358,17.1621],[19389,17.7212],[19417,18.1261],[19448,18.215],[19478,19.0279],[19509,18.9796],[19539,18.3083],[19570,18.5759],[19601,18.8253],[19631,18.9771],[19662,18.6885],[19692,18.5915],[19723,18.6645],[19754,19.0301]],"LYD":[[14791,1.2934],[14822,1.2749],[14853,1.2679],[14883,1.2345],[14914,1.2384],[14944,1.2557],[14975,1.2492],[15006,1.2349],[15034,1.2269],[15065,1.2125],[15095,1.2072],[15126,1.2014],[15156,1.2143],[15187,1.2085],[15218,1.2143],[15248,1.2285],[15279,1.2289],[15309,1.2303],[15340,1.237],[15371,1.2385],[15400,1.2503],[15431,1.2447],[15461,1.2554],[15492,1.2689],[15522,1.2678],[15553,1.2706],[15584,1.2636],[15614,1.2527],[15645,1.2584],[15675,1.2635],[15706,1.2573],[15737,1.2628],[15765,1.281],[15796,1.2828],[15826,1.2803],[15857,1.2769],[15887,1.2807],[15918,1.2704],[15949,1.2635],[15979,1.2505],[16010,1.2538],[16040,1.2455],[16071,1.2534],[16102,1.2537],[16130,1.2466],[16161,1.2465],[16191,1.2415],[16222,1.2341],[16252,1.2422],[16283,1.2498],[16314,1.2533],[16344,1.2597],[16375,1.2635],[16405,1.256],[16436,1.2904],[16467,1.3704],[16495,1.3653],[16526,1.37],[16556,1.3764],[16587,1.3783],[16617,1.384],[16648,1.3818],[16679,1.3759],[16709,1.3761],[16740,1.3979],[16770,1.3996],[16801,1.396],[16832,1.3907],[16861,1.3837],[16892,1.3689],[16922,1.3709],[16953,1.3781],[16983,1.3878],[17014,1.3829],[17045,1.3829],[17075,1.3958],[17106,1.4166],[17136,1.4358],[17167,1.4351],[17198,1.4293],[17226,1.4272],[17257,1.4262],[17287,1.4089],[17318,1.3991],[17348,1.386],[17379,1.3734],[17410,1.3655],[17440,1.3735],[17471,1.3753],[17501,1.3653],[17532,1.3385],[17563,1.3339],[17591,1.3319],[17622,1.3228],[17652,1.3557],[17683,1.3665],[17713,1.3743],[17744,1.3867],[17775,1.379],[17805,1.3871],[17836,1.3969],[17866,1.394],[17897,1.3853],[17928,1.3872],[17956,1.3885],[17987,1.3945],[18017,1.397],[18048,1.3947],[18078,1.3953],[18109,1.4048],[18140,1.4121],[18170,1.4091],[18201,1.4042],[18231,1.4017],[18262,1.3975],[18293,1.4103],[18322,1.4068],[18353,1.4127],[18383,1.4172],[18414,1.4027],[18444,1.3919],[18475,1.3701],[18506,1.3662],[18536,1.3659],[18567,1.3621],[18597,1.345],[18628,3.3007],[18659,4.4681],[18687,4.4924],[18718,4.5107],[18748,4.4671],[18779,4.4834],[18809,4.513],[18840,4.5183],[18871,4.5231],[18901,4.5517],[18932,4.5771],[18962,4.6008],[18993,4.5911],[19024,4.5984],[19052,4.6531],[19083,4.6879],[19113,4.8009],[19144,4.8036],[19174,4.8549],[19205,4.8964],[19236,4.9761],[19266,5.0001],[19297,4.9679],[19327,4.868],[19358,4.8143],[19389,4.8104],[19417,4.8249],[19448,4.7914],[19478,4.7963],[19509,4.824],[19539,4.7937],[19570,4.8077],[19601,4.8597],[19631,4.897],[19662,4.8664],[19692,4.8056],[19723,4.8024],[19754,4.8316]],"MAD":[[14791,8.7271],[14822,8.6103],[14853,8.5421],[14883,8.1242],[14914,8.2305],[14944,8.4384],[14975,8.3658],[15006,8.2187],[15034,8.0729],[15065,7.8569],[15095,7.8704],[15126,7.8773],[15156,7.9217],[15187,7.9009],[15218,8.1541],[15248,8.1985],[15279,8.244],[15309,8.4718],[15340,8.5997],[15371,8.44],[15400,8.4286],[15431,8.4623],[15461,8.6521],[15492,8.8215],[15522,8.9382],[15553,8.8884],[15584,8.651],[15614,8.5743],[15645,8.6295],[15675,8.4936],[15706,8.4039],[15737,8.3618],[15765,8.5719],[15796,8.5584],[15826,8.5527],[15857,8.4848],[15887,8.5134],[15918,8.4036],[15949,8.3698],[15979,8.2402],[16010,8.2785],[16040,8.2031],[16071,8.2289],[16102,8.2167],[16130,8.1465],[16161,8.1399],[16191,8.1738],[16222,8.2435],[16252,8.2762],[16283,8.3871],[16314,8.6069],[16344,8.7416],[16375,8.8376],[16405,8.9389],[16436,9.3275],[16467,9.5415],[16495,9.8631],[16526,9.9208],[16556,9.7424],[16587,9.7378],[16617,9.8152],[16648,9.7772],[16679,9.7159],[16709,9.7093],[16740,9.9713],[16770,9.9308],[16801,9.9168],[16832,9.8069],[16861,9.7806],[16892,9.6558],[16922,9.6778],[16953,9.7416],[16983,9.8071],[17014,9.7409],[17045,9.7382],[17075,9.8209],[17106,9.9513],[17136,10.1134],[17167,10.0821],[17198,10.0563],[17226,10.0457],[17257,10.027],[17287,9.8545],[17318,9.7331],[17348,9.6108],[17379,9.4551],[17410,9.3738],[17440,9.4487],[17471,9.4801],[17501,9.4254],[17532,9.28],[17563,9.2029],[17591,9.2062],[17622,9.214],[17652,9.4193],[17683,9.4987],[17713,9.4747],[17744,9.4788],[17775,9.4088],[17805,9.4748],[17836,9.5135],[17866,9.5315],[17897,9.5296],[17928,9.5417],[17956,9.6183],[17987,9.6462],[18017,9.6744],[18048,9.6139],[18078,9.5759],[18109,9.5897],[18140,9.6841],[18170,9.6704],[18201,9.6468],[18231,9.623],[18262,9.5976],[18293,9.6663],[18322,9.7193],[18353,10.0632],[18383,9.8672],[18414,9.7003],[18444,9.5484],[18475,9.2443],[18506,9.2121],[18536,9.2058],[18567,9.1446],[18597,8.9834],[18628,8.9088],[18659,8.9521],[18687,8.9864],[18718,8.9801],[18748,8.8631],[18779,8.8733],[18809,8.9408],[18840,8.9583],[18871,8.9757],[18901,9.0496],[18932,9.1484],[18962,9.2598],[18993,9.2945],[19024,9.3963],[19052,9.6849],[19083,9.7865],[19113,9.9936],[19144,10.0028],[19174,10.1794],[19205,10.3925],[19236,10.7623],[19266,10.9531],[19297,10.8504],[19327,10.5598],[19358,10.2877],[19389,10.28],[19417,10.3407],[19448,10.1599],[19478,10.1071],[19509,10.0519],[19539,9.7944],[19570,9.8885],[19601,10.1815],[19631,10.2853],[19662,10.1814],[19692,10.0302],[19723,9.9538],[19754,10.0541]],"MDL":[[14791,12.5594],[14822,12.1197],[14853,12.1657],[14883,11.7719],[14914,11.8487],[14944,12.1359],[14975,12.1719],[15006,11.9966],[15034,11.9405],[15065,11.6786],[15095,11.5878],[15126,11.5912],[15156,11.5078],[15187,11.3616],[15218,11.6207],[15248,11.7457],[15279,11.7067],[15309,11.778],[15340,11.812],[15371,11.8464],[15400,11.8172],[15431,11.7937],[15461,11.8664],[15492,12.1037],[15522,12.371],[15553,12.4741],[15584,12.3984],[15614,12.2608],[15645,12.3243],[15675,12.1853],[15706,12.1336],[15737,12.1046],[15765,12.3185],[15796,12.3521],[15826,12.3507],[15857,12.4465],[15887,12.5748],[15918,12.7182],[15949,12.8575],[15979,12.9586],[16010,12.9561],[16040,12.9967],[16071,13.1666],[16102,13.4989],[16130,13.4783],[16161,13.4175],[16191,13.6262],[16222,13.9212],[16252,13.9891],[16283,13.8427],[16314,14.2835],[16344,14.6604],[16375,14.9395],[16405,15.3984],[16436,16.7643],[16467,18.5287],[16495,18.3526],[16526,18.0764],[16556,17.8844],[16587,18.4449],[16617,18.9041],[16648,19.002],[16679,19.6965],[16709,19.875],[16740,19.968],[16770,19.7841],[16801,20.337],[16832,20.093],[16861,19.8035],[16892,19.6648],[16922,19.8118],[16953,19.8008],[16983,19.7887],[17014,19.733],[17045,19.7733],[17075,19.8936],[17106,20.064],[17136,20.093],[17167,20.096],[17198,19.9893],[17226,19.6867],[17257,19.3721],[17287,18.6008],[17318,18.2237],[17348,18.132],[17379,17.903],[17410,17.6505],[17440,17.4156],[17471,17.4389],[17501,17.1827],[17532,16.9487],[17563,16.6709],[17591,16.5524],[17622,16.4367],[17652,16.7053],[17683,16.8364],[17713,16.6884],[17744,16.6365],[17775,16.7797],[17805,16.9956],[17836,17.128],[17866,17.1844],[17897,17.125],[17928,17.1091],[17956,17.1853],[17987,17.6437],[18017,17.963],[18048,18.1424],[18078,17.7699],[18109,17.6989],[18140,17.7301],[18170,17.55],[18201,17.4452],[18231,17.3039],[18262,17.4061],[18293,17.6364],[18322,17.7621],[18353,18.0802],[18383,17.7934],[18414,17.2529],[18444,17.0284],[18475,16.627],[18506,16.6917],[18536,16.9364],[18567,17.1581],[18597,17.2293],[18628,17.2581],[18659,17.4502],[18687,17.7292],[18718,17.9322],[18748,17.7338],[18779,17.8019],[18809,18.0103],[18840,17.7485],[18871,17.6133],[18901,17.4814],[18932,17.5991],[18962,17.714],[18993,17.8972],[19024,17.9889],[19052,18.3221],[19083,18.3844],[19113,18.9308],[19144,19.0896],[19174,19.2354],[19205,19.2914],[19236,19.3669],[19266,19.4038],[19297,19.2848],[19327,19.3065],[19358,19.0257],[19389,18.7936],[19417,18.6382],[19448,18.1249],[19478,17.8094],[19509,17.9622],[19539,17.9487],[19570,17.749],[19601,17.9754],[19631,18.154],[19662,17.943],[19692,17.6224],[19723,17.6498],[19754,17.8042]],"MGA":[[14791,2210.57],[14822,2135.72],[14853,2056.62],[14883,1986.53],[14914,2004.35],[14944,2098.88],[14975,2097.02],[15006,2025.67],[15034,1998.8],[15065,1977.91],[15095,1960.61],[15126,1944.27],[15156,1954.48],[15187,1965.32],[15218,2007.17],[15248,2081.03],[15279,2102.71],[15309,2149.43],[15340,2214.44],[15371,2173.37],[15400,2141.26],[15431,2101.9],[15461,2099.45],[15492,2156.44],[15522,2245.47],[15553,2249.39],[15584,2219.9],[15614,2203.15],[15645,2232.72],[15675,2256.65],[15706,2236.24],[15737,2198.64],[15765,2226.11],[15796,2208.62],[15826,2186.36],[15857,2188.46],[15887,2196.25],[15918,2180.96],[15949,2201.91],[15979,2199.22],[16010,2222.21],[16040,2244.63],[16071,2263.44],[16102,2310.76],[16130,2335.16],[16161,2335.28],[16191,2348.14],[16222,2374.43],[16252,2424.85],[16283,2479.99],[16314,2540.12],[16344,2604.61],[16375,2645.38],[16405,2602.26],[16436,2642.27],[16467,2703.66],[16495,2817.85],[16526,2962.05],[16556,2939.2],[16587,2981.01],[16617,3079.0],[16648,3103.18],[16679,3173.73],[16709,3204.24],[16740,3254.08],[16770,3215.27],[16801,3209.3],[16832,3185.77],[16861,3209.85],[16892,3166.9],[16922,3178.34],[16953,3241.75],[16983,3093.65],[17014,3013.21],[17045,3100.91],[17075,3157.51],[17106,3252.6],[17136,3326.68],[17167,3266.64],[17198,3119.15],[17226,3159.05],[17257,3207.11],[17287,3131.27],[17318,3082.63],[17348,2987.65],[17379,2947.85],[17410,2978.46],[17440,3069.81],[17471,3183.05],[17501,3222.95],[17532,3226.05],[17563,3175.91],[17591,3170.84],[17622,3206.88],[17652,3259.96],[17683,3331.16],[17713,3317.17],[17744,3311.95],[17775,3379.4],[17805,3484.4],[17836,3591.75],[17866,3538.98],[17897,3504.0],[17928,3529.71],[17956,3538.56],[17987,3591.0],[18017,3627.61],[18048,3635.67],[18078,3627.14],[18109,3671.88],[18140,3726.39],[18170,3694.28],[18201,3662.66],[18231,3643.63],[18262,3640.42],[18293,3690.77],[18322,3717.86],[18353,3763.04],[18383,3804.86],[18414,3824.95],[18444,3844.81],[18475,3812.96],[18506,3843.07],[18536,3902.71],[18567,3889.59],[18597,3836.47],[18628,3779.71],[18659,3765.04],[18687,3754.86],[18718,3792.43],[18748,3756.73],[18779,3799.64],[18809,3807.43],[18840,3838.44],[18871,3894.81],[18901,3950.71],[18932,3960.1],[18962,3954.56],[18993,3960.21],[19024,3983.13],[19052,4002.34],[19083,3981.29],[19113,4022.19],[19144,4017.34],[19174,4097.86],[19205,4124.96],[19236,4135.23],[19266,4215.01],[19297,4301.03],[19327,4392.63],[19358,4384.99],[19389,4292.8],[19417,4306.26],[19448,4356.8],[19478,4383.88],[19509,4447.95],[19539,4480.67],[19570,4474.92],[19601,4487.64],[19631,4512.72],[19662,4508.14],[19692,4557.84],[19723,4540.43],[19754,4526.43]],"MKD":[[14791,48.486],[14822,47.717],[14853,47.301],[14883,44.531],[14914,45.186],[14944,46.408],[14975,46.069],[15006,45.01],[15034,44.07],[15065,42.691],[15095,42.738],[15126,42.689],[15156,43.052],[15187,42.885],[15218,44.345],[15248,44.785],[15279,45.145],[15309,46.657],[15340,47.55],[15371,46.522],[15400,46.281],[15431,46.626],[15461,47.958],[15492,49.18],[15522,49.879],[15553,49.713],[15584,48.018],[15614,47.548],[15645,47.69],[15675,46.85],[15706,46.601],[15737,46.597],[15765,47.368],[15796,47.486],[15826,47.411],[15857,46.924],[15887,47.005],[15918,46.32],[15949,46.031],[15979,45.104],[16010,45.371],[16040,44.764],[16071,45.155],[16102,45.237],[16130,44.688],[16161,44.672],[16191,44.85],[16222,45.124],[16252,45.479],[16283,46.185],[16314,47.444],[16344,48.643],[16375,49.364],[16405,49.873],[16436,52.686],[16467,54.23],[16495,56.392],[16526,56.851],[16556,55.578],[16587,55.337],[16617,55.921],[16648,55.304],[16679,54.847],[16709,55.086],[16740,57.318],[16770,56.87],[16801,56.67],[16832,55.79],[16861,55.451],[16892,54.424],[16922,54.61],[16953,55.119],[16983,55.603],[17014,55.015],[17045,54.935],[17075,55.728],[17106,57.047],[17136,58.415],[17167,58.125],[17198,57.914],[17226,57.678],[17257,57.318],[17287,55.799],[17318,54.793],[17348,53.421],[17379,52.125],[17410,51.772],[17440,52.428],[17471,52.465],[17501,51.928],[17532,50.499],[17563,49.842],[17591,49.949],[17622,50.178],[17652,51.991],[17683,52.778],[17713,52.728],[17744,53.074],[17775,52.703],[17805,53.466],[17836,54.187],[17866,53.982],[17897,53.839],[17928,54.127],[17956,54.42],[17987,54.852],[18017,55.059],[18048,54.511],[18078,54.636],[18109,55.102],[18140,55.932],[18170,55.725],[18201,55.654],[18231,55.307],[18262,55.452],[18293,56.343],[18322,55.95],[18353,56.632],[18383,56.558],[18414,54.976],[18444,53.772],[18475,52.334],[18506,52.308],[18536,52.481],[18567,52.144],[18597,50.688],[18628,50.613],[18659,50.879],[18687,51.651],[18718,51.644],[18748,50.753],[18779,51.157],[18809,51.954],[18840,52.214],[18871,52.46],[18901,53.024],[18932,53.874],[18962,54.485],[18993,54.585],[19024,54.603],[19052,55.54],[19083,56.791],[19113,58.033],[19144,58.311],[19174,59.95],[19205,60.75],[19236,62.219],[19266,62.648],[19297,60.834],[19327,58.357],[19358,57.302],[19389,57.42],[19417,57.481],[19448,56.294],[19478,56.539],[19509,56.788],[19539,55.853],[19570,56.149],[19601,57.534],[19631,58.116],[19662,57.191],[19692,56.184],[19723,56.347],[19754,57.009]],"MMK":[[14791,6.4293],[14822,6.4265],[14853,6.4256],[14883,6.4222],[14914,6.4324],[14944,6.4154],[14975,6.4303],[15006,6.4336],[15034,6.4255],[15065,6.4303],[15095,6.433],[15126,6.4324],[15156,6.4245],[15187,6.4278],[15218,6.4234],[15248,6.4246],[15279,6.4234],[15309,6.4283],[15340,6.421],[15371,6.4313],[15400,6.4271],[15431,822.14],[15461,826.28],[15492,853.64],[15522,872.94],[15553,868.93],[15584,865.4],[15614,857.91],[15645,857.2],[15675,849.19],[15706,859.47],[15737,856.47],[15765,868.16],[15796,877.63],[15826,911.21],[15857,941.75],[15887,972.82],[15918,971.0],[15949,970.99],[15979,968.91],[16010,973.82],[16040,979.65],[16071,977.83],[16102,983.72],[16130,971.07],[16161,958.1],[16191,956.92],[16222,964.72],[16252,974.62],[16283,965.55],[16314,976.99],[16344,988.32],[16375,1019.44],[16405,1020.23],[16436,1023.28],[16467,1023.12],[16495,1029.05],[16526,1053.76],[16556,1084.22],[16587,1105.57],[16617,1173.27],[16648,1256.27],[16679,1284.3],[16709,1285.11],[16740,1279.97],[16770,1292.92],[16801,1296.67],[16832,1254.61],[16861,1227.88],[16892,1190.02],[16922,1174.5],[16953,1184.68],[16983,1170.71],[17014,1187.48],[17045,1221.1],[17075,1274.42],[17106,1292.51],[17136,1328.75],[17167,1339.77],[17198,1365.98],[17226,1365.07],[17257,1355.68],[17287,1363.52],[17318,1357.75],[17348,1365.27],[17379,1360.02],[17410,1356.93],[17440,1364.58],[17471,1368.64],[17501,1366.32],[17532,1351.42],[17563,1334.78],[17591,1331.38],[17622,1329.07],[17652,1347.29],[17683,1363.42],[17713,1406.75],[17744,1461.33],[17775,1561.93],[17805,1567.39],[17836,1586.84],[17866,1559.78],[17897,1535.32],[17928,1533.26],[17956,1525.72],[17987,1524.43],[18017,1533.24],[18048,1521.6],[18078,1514.49],[18109,1517.38],[18140,1525.32],[18170,1527.84],[18201,1519.7],[18231,1502.03],[18262,1472.73],[18293,1448.47],[18322,1397.37],[18353,1413.95],[18383,1394.29],[18414,1387.03],[18444,1367.77],[18475,1354.39],[18506,1323.51],[18536,1296.23],[18567,1304.94],[18597,1326.9],[18628,1325.25],[18659,1379.59],[18687,1406.28],[18718,1447.51],[18748,1529.03],[18779,1617.34],[18809,1635.65],[18840,1634.19],[18871,1730.96],[18901,1846.26],[18932,1792.0],[18962,1777.82],[18993,1783.67],[19024,1776.73],[19052,1776.14],[19083,1811.69],[19113,1848.95],[19144,1850.07],[19174,1843.16],[19205,2003.91],[19236,2088.52],[19266,2097.52],[19297,2096.07],[19327,2098.43],[19358,2107.66],[19389,2107.22],[19417,2096.53],[19448,2097.88],[19478,2097.04],[19509,2097.88],[19539,2090.8],[19570,2092.11],[19601,2097.09],[19631,2101.5],[19662,2104.1],[19692,2105.87],[19723,2096.56],[19754,2097.08]],"MNT":[[14791,1365.89],[14822,1327.57],[14853,1316.33],[14883,1304.05],[14914,1274.92],[14944,1239.45],[14975,1250.71],[15006,1249.68],[15034,1230.63],[15065,1217.97],[15095,1237.63],[15126,1250.71],[15156,1248.67],[15187,1237.49],[15218,1257.8],[15248,1280.42],[15279,1311.84],[15309,1365.44],[15340,1390.33],[15371,1344.82],[15400,1326.0],[15431,1314.4],[15461,1317.01],[15492,1324.41],[15522,1341.24],[15553,1358.43],[15584,1387.59],[15614,1388.53],[15645,1394.08],[15675,1394.33],[15706,1389.27],[15737,1391.89],[15765,1400.68],[15796,1413.21],[15826,1431.6],[15857,1437.54],[15887,1465.47],[15918,1554.51],[15949,1644.78],[15979,1674.1],[16010,1721.8],[16040,1682.38],[16071,1692.57],[16102,1739.04],[16130,1770.44],[16161,1780.56],[16191,1805.13],[16222,1821.15],[16252,1839.2],[16283,1854.5],[16314,1833.54],[16344,1847.16],[16375,1875.15],[16405,1882.41],[16436,1915.82],[16467,1957.37],[16495,1980.61],[16526,1978.72],[16556,1929.5],[16587,1915.07],[16617,1968.39],[16648,1989.75],[16679,1988.11],[16709,1985.08],[16740,1985.39],[16770,1991.34],[16801,2000.57],[16832,2019.51],[16861,2049.31],[16892,2019.12],[16922,1997.7],[16953,1968.16],[16983,2021.49],[17014,2172.87],[17045,2253.96],[17075,2312.53],[17106,2422.31],[17136,2477.53],[17167,2480.65],[17198,2471.88],[17226,2459.76],[17257,2431.79],[17287,2414.22],[17318,2372.48],[17348,2406.64],[17379,2440.27],[17410,2449.42],[17440,2460.77],[17471,2462.3],[17501,2434.92],[17532,2429.98],[17563,2400.5],[17591,2393.94],[17622,2391.38],[17652,2400.5],[17683,2432.63],[17713,2460.17],[17744,2467.38],[17775,2505.47],[17805,2550.5],[17836,2578.54],[17866,2629.23],[17897,2639.83],[17928,2630.33],[17956,2627.14],[17987,2629.93],[18017,2639.15],[18048,2653.76],[18078,2658.55],[18109,2669.76],[18140,2667.23],[18170,2667.63],[18201,2688.8],[18231,2717.84],[18262,2732.16],[18293,2746.83],[18322,2753.53],[18353,2775.21],[18383,2797.3],[18414,2810.63],[18444,2834.86],[18475,2852.02],[18506,2848.57],[18536,2841.72],[18567,2836.41],[18597,2849.01],[18628,2843.97],[18659,2850.51],[18687,2844.19],[18718,2852.41],[18748,2839.66],[18779,2845.82],[18809,2848.99],[18840,2848.75],[18871,2847.43],[18901,2847.12],[18932,2849.25],[18962,2854.21],[18993,2848.94],[19024,2856.7],[19052,2906.52],[19083,3009.55],[19113,3110.65],[19144,3125.96],[19174,3142.61],[19205,3177.84],[19236,3251.02],[19266,3354.54],[19297,3411.83],[19327,3431.72],[19358,3460.45],[19389,3495.85],[19417,3529.49],[19448,3504.84],[19478,3473.73],[19509,3456.91],[19539,3444.2],[19570,3462.6],[19601,3464.26],[19631,3462.33],[19662,3457.15],[19692,3440.39],[19723,3416.19],[19754,3403.47]],"MOP":[[14791,8.0272],[14822,7.9988],[14853,8.0],[14883,7.981],[14914,7.9874],[14944,8.0008],[14975,8.0177],[15006,8.0286],[15034,8.0235],[15065,8.0124],[15095,8.0108],[15126,8.0183],[15156,8.0189],[15187,8.0301],[15218,8.0248],[15248,8.0104],[15279,8.0121],[15309,8.0139],[15340,7.9908],[15371,7.9904],[15400,7.9888],[15431,7.9929],[15461,7.9979],[15492,7.9961],[15522,7.9875],[15553,7.9853],[15584,7.9825],[15614,7.9819],[15645,7.9821],[15675,7.9872],[15706,7.9864],[15737,7.9888],[15765,7.99],[15796,7.9945],[15826,7.9899],[15857,7.9967],[15887,7.9884],[15918,7.9894],[15949,7.9826],[15979,7.9827],[16010,7.985],[16040,7.9881],[16071,7.9896],[16102,7.9939],[16130,7.9926],[16161,7.9868],[16191,7.9829],[16222,7.982],[16252,7.9835],[16283,7.981],[16314,7.9892],[16344,7.9873],[16375,7.9892],[16405,7.9831],[16436,7.9858],[16467,7.9889],[16495,7.9891],[16526,7.9861],[16556,7.9815],[16587,7.9931],[16617,7.9856],[16648,7.9843],[16679,7.9825],[16709,7.9836],[16740,7.9837],[16770,7.9847],[16801,8.0101],[16832,8.0146],[16861,7.9949],[16892,7.9897],[16922,7.9959],[16953,7.9979],[16983,7.9846],[17014,7.9883],[17045,7.9895],[17075,7.9903],[17106,7.9925],[17136,7.9925],[17167,7.9868],[17198,7.9903],[17226,7.9959],[17257,8.0074],[17287,8.0186],[17318,8.026],[17348,8.038],[17379,8.0517],[17410,8.0431],[17440,8.0415],[17471,8.042],[17501,8.0364],[17532,8.0486],[17563,8.0497],[17591,8.0613],[17622,8.0863],[17652,8.0793],[17683,8.0712],[17713,8.0785],[17744,8.0834],[17775,8.0647],[17805,8.0688],[17836,8.0673],[17866,8.0545],[17897,8.0732],[17928,8.082],[17956,8.0844],[17987,8.0812],[18017,8.08],[18048,8.0641],[18078,8.0434],[18109,8.0717],[18140,8.0759],[18170,8.0727],[18201,8.0659],[18231,8.0432],[18262,8.0114],[18293,8.0113],[18322,8.0083],[18353,7.9877],[18383,7.9853],[18414,7.9878],[18444,7.982],[18475,7.9832],[18506,7.9835],[18536,7.9839],[18567,7.9841],[18597,7.9863],[18628,7.9875],[18659,7.9865],[18687,7.9943],[18718,8.002],[18748,7.9948],[18779,7.9988],[18809,7.9969],[18840,8.015],[18871,8.016],[18901,8.0123],[18932,8.0255],[18962,8.0294],[18993,8.029],[19024,8.0406],[19052,8.0562],[19083,8.0667],[19113,8.0823],[19144,8.0807],[19174,8.0787],[19205,8.0815],[19236,8.0773],[19266,8.0898],[19297,8.0713],[19327,8.0322],[19358,8.0458],[19389,8.0806],[19417,8.0847],[19448,8.0849],[19478,8.077],[19509,8.0675],[19539,8.0552],[19570,8.0563],[19601,8.0698],[19631,8.0641],[19662,8.0535],[19692,8.0402],[19723,8.0485],[19754,8.054]],"MRO":[[14791,285.13],[14822,286.72],[14853,286.51],[14883,283.51],[14914,282.49],[14944,284.1],[14975,281.45],[15006,283.5],[15034,283.86],[15065,276.05],[15095,273.03],[15126,276.41],[15156,275.55],[15187,280.28],[15218,284.49],[15248,288.24],[15279,287.35],[15309,289.0],[15340,290.95],[15371,292.55],[15400,292.13],[15431,292.0],[15461,292.72],[15492,294.74],[15522,297.16],[15553,300.04],[15584,300.15],[15614,299.88],[15645,297.67],[15675,299.08],[15706,299.5],[15737,297.75],[15765,295.55],[15796,298.31],[15826,299.21],[15857,300.44],[15887,300.05],[15918,300.69],[15949,300.23],[15979,299.11],[16010,297.04],[16040,294.63],[16071,296.06],[16102,297.29],[16130,297.4],[16161,297.82],[16191,297.2],[16222,297.26],[16252,296.98],[16283,297.81],[16314,298.26],[16344,299.25],[16375,300.34],[16405,302.46],[16436,303.8],[16467,307.45],[16495,315.68],[16526,318.08],[16556,319.17],[16587,323.24],[16617,327.28],[16648,327.42],[16679,321.91],[16709,328.56],[16740,333.95],[16770,336.76],[16801,340.08],[16832,342.84],[16861,346.79],[16892,349.8],[16922,353.6],[16953,354.87],[16983,356.67],[17014,355.27],[17045,356.27],[17075,357.39],[17106,357.71],[17136,357.61],[17167,358.42],[17198,359.17],[17226,358.78],[17257,361.63],[17287,360.44],[17318,359.18],[17348,360.09],[17379,359.9],[17410,357.45],[17440,357.02],[17471,355.48],[17501,354.79],[17532,354.75],[17563,355.42],[17591,356.37],[17622,355.87],[17652,357.27],[17683,357.89],[17713,359.31],[17744,357.9],[17775,357.2],[17805,358.4],[17836,363.0],[17866,363.5],[17897,363.9],[17928,363.7],[17956,364.5],[17987,365.6],[18017,366.3],[18048,367.0],[18078,366.7],[18109,367.3],[18140,369.1],[18170,370.6],[18201,372.0],[18231,373.2],[18262,373.4],[18293,372.1],[18322,371.2],[18353,376.3],[18383,377.5],[18414,375.4],[18444,374.1],[18475,369.4],[18506,368.3],[18536,369.3],[18567,369.4],[18597,366.7],[18628,359.7],[18659,357.5],[18687,359.2],[18718,359.4],[18748,360.9],[18779,361.2],[18809,361.2],[18840,361.8],[18871,361.2],[18901,360.9],[18932,361.7],[18962,362.2],[18993,362.3],[19024,363.0],[19052,363.0],[19083,363.6],[19113,364.0],[19144,363.8],[19174,375.8],[19205,376.0],[19236,377.9],[19266,378.8],[19297,379.1],[19327,366.4],[19358,357.1],[19389,347.3],[19417,341.8],[19448,342.3],[19478,343.3],[19509,344.7],[19539,379.9],[19570,378.8],[19601,379.5],[19631,391.3],[19662,395.5],[19692,392.8],[19723,406.0],[19754,397.1]],"MRU":[[17532,35.363],[17563,35.252],[17591,35.309],[17622,35.436],[17652,35.659],[17683,35.656],[17713,35.725],[17744,36.071],[17775,35.904],[17805,36.109],[17836,36.451],[17866,36.422],[17897,36.378],[17928,36.473],[17956,36.557],[17987,36.658],[18017,36.736],[18048,36.676],[18078,36.85],[18109,36.815],[18140,36.723],[18170,37.051],[18201,37.134],[18231,37.223],[18262,37.24],[18293,37.445],[18322,37.323],[18353,37.388],[18383,37.913],[18414,37.856],[18444,37.942],[18475,37.774],[18506,37.388],[18536,37.548],[18567,37.62],[18597,37.068],[18628,36.196],[18659,36.054],[18687,36.091],[18718,36.002],[18748,36.026],[18779,36.377],[18809,36.163],[18840,36.073],[18871,36.263],[18901,36.201],[18932,36.314],[18962,36.331],[18993,36.233],[19024,36.356],[19052,36.495],[19083,36.249],[19113,36.554],[19144,36.33],[19174,36.608],[19205,37.708],[19236,37.355],[19266,37.659],[19297,37.73],[19327,37.465],[19358,36.408],[19389,36.032],[19417,35.41],[19448,34.531],[19478,34.548],[19509,34.631],[19539,36.118],[19570,38.131],[19601,38.169],[19631,38.331],[19662,39.518],[19692,39.599],[19723,39.571],[19754,39.704]],"MUR":[[14791,31.283],[14822,30.43],[14853,30.542],[14883,29.785],[14914,29.92],[14944,30.501],[14975,30.213],[15006,29.535],[15034,28.826],[15065,27.953],[15095,27.777],[15126,27.977],[15156,28.011],[15187,28.006],[15218,28.443],[15248,28.867],[15279,28.908],[15309,29.201],[15340,29.366],[15371,28.989],[15400,28.881],[15431,29.001],[15461,29.32],[15492,30.215],[15522,30.854],[15553,30.601],[15584,30.342],[15614,30.663],[15645,30.897],[15675,30.637],[15706,30.53],[15737,30.481],[15765,30.903],[15796,31.006],[15826,31.004],[15857,30.994],[15887,30.952],[15918,30.808],[15949,30.713],[15979,30.377],[16010,30.432],[16040,30.199],[16071,30.124],[16102,30.166],[16130,30.051],[16161,30.026],[16191,30.067],[16222,30.187],[16252,30.287],[16283,30.56],[16314,31.052],[16344,31.305],[16375,31.515],[16405,31.557],[16436,32.152],[16467,32.953],[16495,35.283],[16526,36.078],[16556,35.121],[16587,35.164],[16617,35.381],[16648,35.421],[16679,35.422],[16709,35.549],[16740,36.063],[16770,36.055],[16801,36.06],[16832,35.78],[16861,35.531],[16892,35.101],[16922,35.165],[16953,35.434],[16983,35.424],[17014,35.236],[17045,35.379],[17075,35.626],[17106,35.852],[17136,35.997],[17167,35.861],[17198,35.646],[17226,35.384],[17257,35.292],[17287,34.874],[17318,34.645],[17348,34.12],[17379,33.178],[17410,33.396],[17440,33.98],[17471,34.097],[17501,33.748],[17532,33.034],[17563,32.641],[17591,33.122],[17622,33.606],[17652,34.468],[17683,34.522],[17713,34.498],[17744,34.384],[17775,34.245],[17805,34.425],[17836,34.545],[17866,34.277],[17897,34.198],[17928,34.228],[17956,34.564],[17987,34.867],[18017,35.197],[18048,35.513],[18078,35.795],[18109,36.0],[18140,36.226],[18170,36.375],[18201,36.444],[18231,36.506],[18262,36.533],[18293,37.091],[18322,38.404],[18353,39.641],[18383,40.035],[18414,40.011],[18444,39.945],[18475,39.777],[18506,39.735],[18536,39.886],[18567,39.947],[18597,39.64],[18628,39.432],[18659,39.711],[18687,40.01],[18718,40.479],[18748,40.499],[18779,41.222],[18809,42.174],[18840,42.601],[18871,42.594],[18901,42.678],[18932,43.118],[18962,43.489],[18993,43.574],[19024,43.862],[19052,44.182],[19083,43.742],[19113,43.203],[19144,44.108],[19174,44.985],[19205,45.055],[19236,44.678],[19266,44.453],[19297,44.081],[19327,43.785],[19358,44.111],[19389,45.301],[19417,46.2],[19448,45.526],[19478,45.27],[19509,45.574],[19539,45.635],[19570,45.453],[19601,45.037],[19631,44.478],[19662,44.291],[19692,44.144],[19723,44.572],[19754,45.471]],"MVR":[[14791,12.8442],[14822,12.7926],[14853,12.8021],[14883,12.7786],[14914,12.7975],[14944,12.7964],[14975,12.8091],[15006,12.8137],[15034,12.7997],[15065,12.8066],[15095,14.2327],[15126,15.3598],[15156,15.3521],[15187,15.2913],[15218,15.386],[15248,15.3912],[15279,15.3885],[15309,15.4087],[15340,15.3835],[15371,15.3836],[15400,15.3965],[15431,15.3885],[15461,15.4124],[15492,15.4387],[15522,15.3766],[15553,15.4],[15584,15.391],[15614,15.3894],[15645,15.3992],[15675,15.3779],[15706,15.3919],[15737,15.4],[15765,15.4113],[15796,15.3742],[15826,15.3482],[15857,15.3629],[15887,15.3705],[15918,15.3724],[15949,15.3743],[15979,15.3851],[16010,15.3801],[16040,15.3946],[16071,15.3875],[16102,15.4005],[16130,15.3986],[16161,15.4289],[16191,15.3954],[16222,15.4252],[16252,15.4044],[16283,15.3889],[16314,15.3994],[16344,15.3913],[16375,15.409],[16405,15.3919],[16436,15.3844],[16467,15.3938],[16495,15.391],[16526,15.333],[16556,15.3084],[16587,15.3433],[16617,15.327],[16648,15.3338],[16679,15.3726],[16709,15.4033],[16740,15.3971],[16770,15.358],[16801,15.3454],[16832,15.3318],[16861,15.3329],[16892,15.3373],[16922,15.3411],[16953,15.3503],[16983,15.3365],[17014,15.3508],[17045,15.3654],[17075,15.3748],[17106,15.4131],[17136,15.3875],[17167,15.409],[17198,15.4002],[17226,15.4299],[17257,15.4213],[17287,15.4457],[17318,15.4397],[17348,15.4416],[17379,15.4398],[17410,15.4171],[17440,15.4285],[17471,15.4221],[17501,15.4159],[17532,15.4193],[17563,15.4116],[17591,15.4254],[17622,15.4177],[17652,15.4098],[17683,15.4216],[17713,15.4215],[17744,15.432],[17775,15.4187],[17805,15.4118],[17836,15.417],[17866,15.4139],[17897,15.4129],[17928,15.4102],[17956,15.414],[17987,15.4225],[18017,15.415],[18048,15.4195],[18078,15.4005],[18109,15.3808],[18140,15.4145],[18170,15.4098],[18201,15.407],[18231,15.417],[18262,15.3979],[18293,15.4041],[18322,15.4103],[18353,15.4285],[18383,15.438],[18414,15.4314],[18444,15.41],[18475,15.4119],[18506,15.4128],[18536,15.3972],[18567,15.4093],[18597,15.423],[18628,15.4131],[18659,15.4046],[18687,15.3995],[18718,15.3875],[18748,15.3959],[18779,15.4174],[18809,15.408],[18840,15.4128],[18871,15.4023],[18901,15.4133],[18932,15.4349],[18962,15.4174],[18993,15.42],[19024,15.4318],[19052,15.4239],[19083,15.4101],[19113,15.4155],[19144,15.4132],[19174,15.4055],[19205,15.3923],[19236,15.3919],[19266,15.4195],[19297,15.4194],[19327,15.4044],[19358,15.4115],[19389,15.4295],[19417,15.4144],[19448,15.4078],[19478,15.414],[19509,15.4127],[19539,15.4132],[19570,15.4219],[19601,15.4219],[19631,15.4316],[19662,15.4351],[19692,15.4239],[19723,15.4112],[19754,15.4207]],"MWK":[[14791,151.04],[14822,150.61],[14853,151.0],[14883,150.62],[14914,151.51],[14944,151.72],[14975,150.88],[15006,151.19],[15034,150.95],[15065,150.99],[15095,150.37],[15126,150.7],[15156,150.92],[15187,159.53],[15218,165.71],[15248,165.68],[15279,164.7],[15309,165.03],[15340,164.84],[15371,166.2],[15400,166.41],[15431,166.93],[15461,227.27],[15492,269.16],[15522,271.56],[15553,273.32],[15584,287.52],[15614,306.14],[15645,318.42],[15675,327.89],[15706,339.3],[15737,363.25],[15765,385.51],[15796,399.51],[15826,367.32],[15857,330.28],[15887,331.25],[15918,330.93],[15949,346.52],[15979,375.14],[16010,395.77],[16040,425.22],[16071,433.55],[16102,427.21],[16130,419.59],[16161,403.52],[16191,395.35],[16222,396.48],[16252,397.19],[16283,398.16],[16314,399.26],[16344,432.17],[16375,478.47],[16405,487.0],[16436,463.16],[16467,445.32],[16495,441.03],[16526,442.07],[16556,440.74],[16587,441.99],[16617,463.77],[16648,531.62],[16679,562.19],[16709,555.92],[16740,581.52],[16770,634.47],[16801,693.39],[16832,737.99],[16861,709.59],[16892,688.03],[16922,698.23],[16953,713.22],[16983,716.14],[17014,723.08],[17045,728.05],[17075,723.49],[17106,723.11],[17136,729.77],[17167,729.32],[17198,728.67],[17226,729.85],[17257,729.28],[17287,728.36],[17318,730.16],[17348,724.72],[17379,729.6],[17410,732.29],[17440,727.43],[17471,729.4],[17501,727.54],[17532,727.42],[17563,729.46],[17591,728.2],[17622,728.31],[17652,729.7],[17683,730.31],[17713,729.22],[17744,725.41],[17775,727.48],[17805,730.04],[17836,731.88],[17866,731.81],[17897,732.8],[17928,734.68],[17956,735.48],[17987,738.97],[18017,745.21],[18048,767.42],[18078,769.11],[18109,738.19],[18140,737.61],[18170,736.34],[18201,737.76],[18231,741.5],[18262,738.86],[18293,739.73],[18322,742.97],[18353,737.92],[18383,737.86],[18414,742.02],[18444,738.91],[18475,749.01],[18506,754.35],[18536,757.84],[18567,762.29],[18597,772.2],[18628,770.94],[18659,777.05],[18687,787.55],[18718,790.63],[18748,795.94],[18779,803.83],[18809,810.61],[18840,812.88],[18871,814.68],[18901,818.34],[18932,820.87],[18962,819.33],[18993,821.43],[19024,819.93],[19052,818.41],[19083,817.85],[19113,856.11],[19144,1022.14],[19174,1029.69],[19205,1033.69],[19236,1031.34],[19266,1035.14],[19297,1034.99],[19327,1034.93],[19358,1032.62],[19389,1030.52],[19417,1033.8],[19448,1029.46],[19478,1031.14],[19509,1034.66],[19539,1057.59],[19570,1074.26],[19601,1095.64],[19631,1143.19],[19662,1452.82],[19692,1692.11],[19723,1689.68],[19754,1690.3]],"MXN":[[14791,12.8143],[14822,12.7835],[14853,12.854],[14883,12.4512],[14914,12.3564],[14944,12.4146],[14975,12.1588],[15006,12.0706],[15034,12.0237],[15065,11.7339],[15095,11.6479],[15126,11.7869],[15156,11.6922],[15187,12.1838],[15218,12.9502],[15248,13.4512],[15279,13.6459],[15309,13.7864],[15340,13.4455],[15371,12.8109],[15400,12.7562],[15431,13.0274],[15461,13.6052],[15492,13.9353],[15522,13.394],[15553,13.2136],[15584,12.9714],[15614,12.8953],[15645,13.069],[15675,12.8866],[15706,12.7265],[15737,12.7263],[15765,12.5368],[15796,12.223],[15826,12.3142],[15857,12.9302],[15887,12.7927],[15918,12.9102],[15949,13.107],[15979,13.0196],[16010,13.061],[16040,13.0287],[16071,13.2185],[16102,13.3027],[16130,13.2151],[16161,13.0753],[16191,12.9533],[16222,12.9817],[16252,13.0015],[16283,13.1544],[16314,13.2593],[16344,13.4666],[16375,13.6096],[16405,14.5071],[16436,14.7119],[16467,14.913],[16495,15.217],[16526,15.2239],[16556,15.2848],[16587,15.4788],[16617,15.9224],[16648,16.5415],[16679,16.8458],[16709,16.6166],[16740,16.6355],[16770,17.0287],[16801,18.0101],[16832,18.4404],[16861,17.6774],[16892,17.4689],[16922,18.0831],[16953,18.6418],[16983,18.6015],[17014,18.5334],[17045,19.1299],[17075,18.9589],[17106,20.073],[17136,20.54],[17167,21.3],[17198,20.346],[17226,19.3453],[17257,18.7833],[17287,18.8038],[17318,18.1844],[17348,17.8486],[17379,17.8157],[17410,17.8517],[17440,18.7635],[17471,18.9402],[17501,19.1266],[17532,18.9757],[17563,18.6523],[17591,18.6334],[17622,18.3875],[17652,19.5039],[17683,20.235],[17713,19.0533],[17744,18.853],[17775,19.0245],[17805,19.1186],[17836,20.219],[17866,20.106],[17897,19.226],[17928,19.1824],[17956,19.2443],[17987,19.0145],[18017,19.1333],[18048,19.2728],[18078,19.0616],[18109,19.6493],[18140,19.6305],[18170,19.3564],[18201,19.3028],[18231,19.1608],[18262,18.8081],[18293,18.8667],[18322,22.147],[18353,24.181],[18383,23.562],[18414,22.36],[18444,22.458],[18475,22.189],[18506,21.691],[18536,21.367],[18567,20.479],[18597,19.9721],[18628,19.9335],[18659,20.291],[18687,20.757],[18718,20.133],[18748,19.9765],[18779,19.9933],[18809,19.9827],[18840,20.067],[18871,20.077],[18901,20.44],[18932,20.826],[18962,20.94],[18993,20.548],[19024,20.48],[19052,20.496],[19083,20.08],[19113,20.05],[19144,19.9536],[19174,20.423],[19205,20.178],[19236,20.067],[19266,20.014],[19297,19.5163],[19327,19.5632],[19358,19.0543],[19389,18.6383],[19417,18.3886],[19448,18.114],[19478,17.7802],[19509,17.2958],[19539,16.9509],[19570,16.956],[19601,17.2533],[19631,18.0318],[19662,17.4716],[19692,17.1886],[19723,17.072],[19754,17.1125]],"MYR":[[14791,3.2085],[14822,3.1567],[14853,3.1094],[14883,3.0987],[14914,3.1174],[14944,3.1292],[14975,3.063],[15006,3.0454],[15034,3.037],[15065,3.0111],[15095,3.0104],[15126,3.0269],[15156,2.9962],[15187,2.9823],[15218,3.0803],[15248,3.1412],[15279,3.1479],[15309,3.1608],[15340,3.1159],[15371,3.0246],[15400,3.0406],[15431,3.0603],[15461,3.0994],[15492,3.1801],[15522,3.1688],[15553,3.1201],[15584,3.0798],[15614,3.0564],[15645,3.0577],[15675,3.0539],[15706,3.0409],[15737,3.097],[15765,3.1068],[15796,3.0507],[15826,3.0194],[15857,3.1384],[15887,3.1922],[15918,3.2717],[15949,3.2565],[15979,3.1765],[16010,3.1952],[16040,3.2489],[16071,3.3017],[16102,3.3111],[16130,3.2791],[16161,3.2565],[16191,3.2307],[16222,3.2167],[16252,3.1851],[16283,3.1774],[16314,3.2147],[16344,3.2678],[16375,3.3407],[16405,3.4718],[16436,3.5754],[16467,3.5968],[16495,3.6792],[16526,3.6322],[16556,3.5991],[16587,3.7343],[16617,3.799],[16648,4.0501],[16679,4.3009],[16709,4.2674],[16740,4.3096],[16770,4.2805],[16801,4.3292],[16832,4.1818],[16861,4.0767],[16892,3.9032],[16922,4.0346],[16953,4.0789],[16983,4.0191],[17014,4.0291],[17045,4.1041],[17075,4.1716],[17106,4.327],[17136,4.4601],[17167,4.4578],[17198,4.4413],[17226,4.4367],[17257,4.4036],[17287,4.3161],[17318,4.2775],[17348,4.2889],[17379,4.282],[17410,4.2155],[17440,4.2276],[17471,4.1707],[17501,4.0757],[17532,3.9589],[17563,3.9101],[17591,3.9003],[17622,3.8866],[17652,3.9593],[17683,3.9987],[17713,4.048],[17744,4.0909],[17775,4.1361],[17805,4.1565],[17836,4.1833],[17866,4.1695],[17897,4.1178],[17928,4.0787],[17956,4.0776],[17987,4.1115],[18017,4.1671],[18048,4.1604],[18078,4.1239],[18109,4.1824],[18140,4.1852],[18170,4.1861],[18201,4.1586],[18231,4.1444],[18262,4.0791],[18293,4.1544],[18322,4.2853],[18353,4.35],[18383,4.34],[18414,4.2791],[18444,4.2649],[18475,4.1892],[18506,4.1503],[18536,4.1513],[18567,4.1126],[18597,4.0554],[18628,4.0372],[18659,4.0454],[18687,4.1048],[18718,4.1229],[18748,4.1248],[18779,4.1345],[18809,4.1955],[18840,4.2187],[18871,4.1685],[18901,4.1647],[18932,4.1776],[18962,4.2097],[18993,4.1899],[19024,4.1852],[19052,4.1973],[19083,4.2651],[19113,4.3768],[19144,4.3989],[19174,4.4347],[19205,4.465],[19236,4.5406],[19266,4.6851],[19297,4.615],[19327,4.4223],[19358,4.333],[19389,4.3579],[19417,4.4625],[19448,4.4275],[19478,4.5144],[19509,4.6294],[19539,4.6],[19570,4.6062],[19601,4.6786],[19631,4.7419],[19662,4.6981],[19692,4.6564],[19723,4.6809],[19754,4.7635]],"MZN":[[14791,35.344],[14822,36.8],[14853,36.658],[14883,35.922],[14914,35.827],[14944,34.473],[14975,32.788],[15006,31.71],[15034,30.966],[15065,30.882],[15095,30.154],[15126,28.991],[15156,27.946],[15187,27.104],[15218,27.06],[15248,26.721],[15279,26.685],[15309,26.877],[15340,27.033],[15371,27.076],[15400,27.542],[15431,27.517],[15461,27.558],[15492,27.808],[15522,27.937],[15553,28.211],[15584,28.584],[15614,28.991],[15645,29.933],[15675,29.51],[15706,29.79],[15737,30.168],[15765,30.22],[15796,30.308],[15826,29.984],[15857,29.788],[15887,29.915],[15918,29.794],[15949,29.81],[15979,29.708],[16010,29.954],[16040,29.963],[16071,30.321],[16102,31.301],[16130,31.567],[16161,31.21],[16191,31.337],[16222,31.293],[16252,31.112],[16283,30.466],[16314,30.644],[16344,31.095],[16375,31.511],[16405,32.7],[16436,33.095],[16467,33.485],[16495,35.174],[16526,35.919],[16556,35.357],[16587,37.837],[16617,38.821],[16648,39.912],[16679,42.514],[16709,42.849],[16740,48.997],[16770,49.497],[16801,45.979],[16832,47.395],[16861,50.08],[16892,52.225],[16922,55.392],[16953,60.741],[16983,66.219],[17014,70.703],[17045,76.786],[17075,76.956],[17106,74.994],[17136,72.159],[17167,70.621],[17198,69.949],[17226,68.174],[17257,66.056],[17287,61.49],[17318,60.414],[17348,60.686],[17379,61.183],[17410,61.094],[17440,60.961],[17471,61.087],[17501,59.634],[17532,59.494],[17563,61.305],[17591,61.964],[17622,60.496],[17652,60.223],[17683,59.221],[17713,58.833],[17744,59.347],[17775,60.532],[17805,60.788],[17836,61.163],[17866,61.477],[17897,61.784],[17928,62.39],[17956,62.978],[17987,64.101],[18017,62.952],[18048,62.009],[18078,61.588],[18109,61.087],[18140,61.505],[18170,62.29],[18201,63.228],[18231,62.543],[18262,62.816],[18293,64.67],[18322,66.099],[18353,67.326],[18383,68.552],[18414,69.406],[18444,70.507],[18475,70.954],[18506,71.469],[18536,72.207],[18567,73.474],[18597,74.365],[18628,74.683],[18659,74.88],[18687,71.3],[18718,57.835],[18748,58.756],[18779,62.597],[18809,63.602],[18840,63.706],[18871,63.654],[18901,63.852],[18932,63.826],[18962,63.798],[18993,63.736],[19024,63.943],[19052,63.975],[19083,63.207],[19113,64.11],[19144,63.711],[19174,63.851],[19205,63.875],[19236,63.606],[19266,63.981],[19297,64.092],[19327,63.874],[19358,64.007],[19389,63.867],[19417,63.961],[19448,63.991],[19478,63.838],[19509,63.971],[19539,63.95],[19570,63.915],[19601,63.593],[19631,63.809],[19662,63.872],[19692,63.819],[19723,63.738],[19754,63.731]],"NAD":[[14791,7.5154],[14822,7.3388],[14853,7.1875],[14883,6.9376],[14914,7.0378],[14944,6.897],[14975,6.9105],[15006,7.1221],[15034,6.9164],[15065,6.7218],[15095,6.8059],[15126,6.8275],[15156,6.7907],[15187,7.0626],[15218,7.6564],[15248,7.9112],[15279,8.0967],[15309,8.1982],[15340,7.9862],[15371,7.654],[15400,7.6092],[15431,7.7846],[15461,8.1578],[15492,8.3834],[15522,8.2759],[15553,8.2756],[15584,8.3083],[15614,8.649],[15645,8.7625],[15675,8.6118],[15706,8.7587],[15737,8.9418],[15765,9.1505],[15796,9.1043],[15826,9.4677],[15857,10.0172],[15887,9.9072],[15918,10.1043],[15949,10.0665],[15979,9.9598],[16010,10.1055],[16040,10.3872],[16071,10.8729],[16102,10.9693],[16130,10.7018],[16161,10.5698],[16191,10.4518],[16222,10.6194],[16252,10.6704],[16283,10.648],[16314,11.0215],[16344,11.0933],[16375,11.0299],[16405,11.4206],[16436,11.5949],[16467,11.6029],[16495,11.9916],[16526,11.9948],[16556,11.9873],[16587,12.2527],[16617,12.444],[16648,12.9594],[16679,13.6327],[16709,13.6733],[16740,14.1348],[16770,14.1674],[16801,16.0739],[16832,15.9872],[16861,14.6117],[16892,14.0337],[16922,14.5481],[16953,15.1492],[16983,14.4924],[17014,13.6441],[17045,14.0058],[17075,13.852],[17106,13.9161],[17136,13.8039],[17167,13.5934],[17198,12.985],[17226,12.9248],[17257,13.0066],[17287,13.2314],[17318,12.824],[17348,13.1183],[17379,12.9164],[17410,13.0803],[17440,13.3743],[17471,13.9668],[17501,12.7509],[17532,12.1429],[17563,11.8077],[17591,11.797],[17622,12.069],[17652,12.5112],[17683,13.2935],[17713,13.4484],[17744,13.9861],[17775,14.5553],[17805,14.4266],[17836,14.1222],[17866,14.1679],[17897,13.864],[17928,13.8],[17956,14.3519],[17987,14.2671],[18017,14.485],[18048,14.4406],[18078,14.0899],[18109,15.1912],[18140,15.0254],[18170,14.9562],[18201,14.807],[18231,14.6208],[18262,14.4286],[18293,15.0712],[18322,16.813],[18353,18.3338],[18383,18.0732],[18414,17.2364],[18444,16.86],[18475,17.0051],[18506,16.7872],[18536,16.5389],[18567,15.4618],[18597,14.8819],[18628,15.0483],[18659,14.911],[18687,14.9028],[18718,14.5058],[18748,14.0378],[18779,14.0367],[18809,14.5068],[18840,14.6928],[18871,14.7679],[18901,14.9458],[18932,15.5786],[18962,15.9294],[18993,15.5935],[19024,15.3324],[19052,14.8693],[19083,15.0798],[19113,15.8877],[19144,15.9198],[19174,16.6174],[19205,16.7577],[19236,17.5695],[19266,18.1028],[19297,17.4987],[19327,17.1189],[19358,17.1467],[19389,17.8757],[19417,18.1494],[19448,18.193],[19478,19.1672],[19509,18.9035],[19539,18.1755],[19570,18.7338],[19601,18.8963],[19631,19.0454],[19662,18.6302],[19692,18.5815],[19723,18.7464],[19754,19.0244]],"NGN":[[14791,150.01],[14822,149.89],[14853,151.32],[14883,150.67],[14914,150.51],[14944,151.71],[14975,151.73],[15006,151.95],[15034,153.44],[15065,154.22],[15095,155.41],[15126,154.56],[15156,152.43],[15187,153.29],[15218,156.07],[15248,157.46],[15279,158.36],[15309,161.06],[15340,160.37],[15371,158.47],[15400,156.99],[15431,157.08],[15461,157.3],[15492,160.66],[15522,160.29],[15553,157.75],[15584,157.24],[15614,157.06],[15645,157.03],[15675,156.98],[15706,156.84],[15737,156.92],[15765,157.66],[15796,157.73],[15826,157.38],[15857,159.16],[15887,159.61],[15918,159.36],[15949,160.6],[15979,158.77],[16010,158.15],[16040,158.37],[16071,158.95],[16102,161.88],[16130,163.72],[16161,160.9],[16191,160.33],[16222,161.12],[16252,160.55],[16283,160.41],[16314,161.5],[16344,163.29],[16375,170.42],[16405,181.81],[16436,185.57],[16467,197.17],[16495,198.83],[16526,198.85],[16556,197.74],[16587,198.99],[16617,198.54],[16648,198.64],[16679,198.72],[16709,198.24],[16740,198.54],[16770,198.43],[16801,198.36],[16832,197.96],[16861,198.15],[16892,197.74],[16922,198.05],[16953,233.57],[16983,288.32],[17014,316.1],[17045,313.12],[17075,310.3],[17106,308.88],[17136,307.59],[17167,308.1],[17198,309.6],[17226,309.45],[17257,309.83],[17287,308.69],[17318,316.55],[17348,308.93],[17379,320.3],[17410,307.88],[17440,308.19],[17471,307.89],[17501,307.89],[17532,306.61],[17563,306.5],[17591,306.99],[17622,307.38],[17652,307.9],[17683,308.13],[17713,307.56],[17744,307.18],[17775,306.07],[17805,306.69],[17836,306.53],[17866,306.31],[17897,306.85],[17928,306.92],[17956,307.19],[17987,307.55],[18017,306.7],[18048,306.73],[18078,306.84],[18109,306.46],[18140,306.74],[18170,306.82],[18201,307.03],[18231,306.86],[18262,306.27],[18293,307.53],[18322,328.92],[18353,360.57],[18383,360.47],[18414,360.85],[18444,368.03],[18475,379.64],[18506,381.34],[18536,381.92],[18567,382.86],[18597,384.14],[18628,385.73],[18659,386.48],[18687,391.57],[18718,390.42],[18748,399.99],[18779,410.64],[18809,412.35],[18840,411.36],[18871,409.72],[18901,411.8],[18932,410.44],[18962,412.53],[18993,412.88],[19024,416.51],[19052,414.26],[19083,414.51],[19113,414.82],[19144,414.9],[19174,415.54],[19205,421.88],[19236,428.56],[19266,435.07],[19297,443.68],[19327,447.57],[19358,454.13],[19389,461.41],[19417,459.74],[19448,461.46],[19478,462.18],[19509,580.12],[19539,761.35],[19570,765.33],[19601,768.24],[19631,785.37],[19662,832.21],[19692,869.07],[19723,898.98],[19754,1460.8]],"NIO":[[14791,21.424],[14822,21.493],[14853,21.568],[14883,21.648],[14914,21.786],[14944,21.796],[14975,21.935],[15006,22.04],[15034,22.092],[15065,22.202],[15095,22.306],[15126,22.397],[15156,22.452],[15187,22.568],[15218,22.653],[15248,22.73],[15279,22.829],[15309,22.947],[15340,22.99],[15371,23.127],[15400,23.205],[15431,23.291],[15461,23.396],[15492,23.467],[15522,23.582],[15553,23.662],[15584,23.783],[15614,23.868],[15645,23.977],[15675,24.097],[15706,24.21],[15737,24.323],[15765,24.364],[15796,24.451],[15826,24.503],[15857,24.609],[15887,24.758],[15918,24.898],[15949,24.854],[15979,24.951],[16010,25.083],[16040,25.299],[16071,25.336],[16102,25.465],[16130,25.553],[16161,25.693],[16191,25.793],[16222,25.872],[16252,26.01],[16283,26.106],[16314,26.219],[16344,26.367],[16375,26.482],[16405,26.519],[16436,26.631],[16467,26.735],[16495,26.804],[16526,26.92],[16556,26.965],[16587,27.106],[16617,27.209],[16648,27.34],[16679,27.466],[16709,27.559],[16740,27.602],[16770,27.875],[16801,27.892],[16832,28.044],[16861,28.228],[16892,28.264],[16922,28.463],[16953,28.541],[16983,28.62],[17014,28.816],[17045,28.908],[17075,29.025],[17106,29.116],[17136,29.22],[17167,29.38],[17198,29.504],[17226,29.598],[17257,29.709],[17287,29.827],[17318,29.917],[17348,30.055],[17379,30.139],[17410,30.313],[17440,30.47],[17471,30.615],[17501,30.683],[17532,30.879],[17563,30.992],[17591,31.105],[17622,31.224],[17652,31.337],[17683,31.508],[17713,31.63],[17744,31.823],[17775,31.921],[17805,32.039],[17836,32.191],[17866,32.303],[17897,32.466],[17928,32.661],[17956,32.781],[17987,32.856],[18017,32.943],[18048,33.043],[18078,33.102],[18109,33.404],[18140,33.497],[18170,33.585],[18201,33.705],[18231,33.773],[18262,33.885],[18293,33.931],[18322,34.028],[18353,34.058],[18383,34.113],[18414,34.275],[18444,34.46],[18475,34.656],[18506,34.715],[18536,34.747],[18567,34.788],[18597,34.848],[18628,34.877],[18659,34.908],[18687,34.941],[18718,34.984],[18748,35.028],[18779,35.114],[18809,35.09],[18840,35.205],[18871,35.226],[18901,35.253],[18932,35.35],[18962,35.389],[18993,35.489],[19024,35.584],[19052,35.718],[19083,35.751],[19113,35.812],[19144,35.838],[19174,35.855],[19205,35.932],[19236,35.944],[19266,36.039],[19297,36.085],[19327,36.301],[19358,36.471],[19389,36.513],[19417,36.479],[19448,36.559],[19478,36.571],[19509,36.547],[19539,36.566],[19570,36.569],[19601,36.582],[19631,36.636],[19662,36.663],[19692,36.639],[19723,36.656],[19754,36.695]],"NOK":[[14791,6.3047],[14822,6.1568],[14853,6.083],[14883,5.8411],[14914,5.9653],[14944,5.9942],[14975,5.8683],[15006,5.7453],[15034,5.6051],[15065,5.4202],[15095,5.4643],[15126,5.4487],[15156,5.4551],[15187,5.4443],[15218,5.621],[15248,5.67],[15279,5.7408],[15309,5.8894],[15340,5.959],[15371,5.7286],[15400,5.6989],[15431,5.7583],[15461,5.9149],[15492,6.0262],[15522,6.0772],[15553,5.9275],[15584,5.7623],[15614,5.7153],[15645,5.727],[15675,5.626],[15706,5.5635],[15737,5.561],[15765,5.7772],[15796,5.8053],[15826,5.834],[15857,5.881],[15887,6.0355],[15918,5.9741],[15949,5.9885],[15979,5.9656],[16010,6.0819],[16040,6.1425],[16071,6.1685],[16102,6.1371],[16130,6.0089],[16161,5.9856],[16191,5.9503],[16222,6.0496],[16252,6.2048],[16283,6.2085],[16314,6.345],[16344,6.5572],[16375,6.8156],[16405,7.2922],[16436,7.68],[16467,7.6113],[16495,7.9884],[16526,7.9116],[16556,7.5601],[16587,7.8199],[16617,8.1207],[16648,8.2562],[16679,8.2995],[16709,8.2939],[16740,8.6343],[16770,8.7099],[16801,8.8335],[16832,8.6286],[16861,8.5],[16892,8.2405],[16922,8.2433],[16953,8.3272],[16983,8.4767],[17014,8.3235],[17045,8.2165],[17075,8.1706],[17106,8.4163],[17136,8.5745],[17167,8.4921],[17198,8.3376],[17226,8.5133],[17257,8.5959],[17287,8.5206],[17318,8.468],[17348,8.1911],[17379,7.9102],[17410,7.8471],[17440,8.0042],[17471,8.1943],[17501,8.3213],[17532,7.9346],[17563,7.8389],[17591,7.7901],[17622,7.8563],[17652,8.105],[17683,8.1285],[17713,8.1433],[17744,8.3353],[17775,8.2644],[17805,8.2616],[17836,8.4781],[17866,8.627],[17897,8.5695],[17928,8.5894],[17956,8.6088],[17987,8.5806],[18017,8.7493],[18048,8.6418],[18078,8.6181],[18109,8.9675],[18140,9.0294],[18170,9.1601],[18201,9.164],[18231,9.0479],[18262,8.9542],[18293,9.2972],[18322,10.1833],[18353,10.4511],[18383,10.1191],[18414,9.5616],[18444,9.3296],[18475,8.9633],[18506,9.1421],[18536,9.3023],[18567,9.1136],[18597,8.7244],[18628,8.5324],[18659,8.5174],[18687,8.5305],[18718,8.4123],[18748,8.3147],[18779,8.4306],[18809,8.7676],[18840,8.8613],[18871,8.6775],[18901,8.4893],[18932,8.7252],[18962,8.9763],[18993,8.8605],[19024,8.8782],[19052,8.8445],[19083,8.8949],[19113,9.6058],[19144,9.7451],[19174,10.0004],[19205,9.7316],[19236,10.2494],[19266,10.5877],[19297,10.17],[19327,9.8924],[19358,9.9485],[19389,10.2144],[19417,10.5343],[19448,10.5152],[19478,10.8059],[19509,10.8279],[19539,10.3128],[19570,10.4505],[19601,10.7225],[19631,10.9979],[19662,10.9422],[19692,10.5579],[19723,10.4011],[19754,10.5558]],"NPR":[[14791,74.722],[14822,74.39],[14853,73.705],[14883,71.131],[14914,71.867],[14944,72.655],[14975,72.551],[15006,72.57],[15034,71.874],[15065,71.119],[15095,71.635],[15126,71.695],[15156,70.996],[15187,72.314],[15218,75.907],[15248,78.437],[15279,80.59],[15309,83.85],[15340,82.15],[15371,78.721],[15400,80.03],[15431,82.06],[15461,86.32],[15492,88.09],[15522,88.35],[15553,88.75],[15584,86.95],[15614,84.98],[15645,87.31],[15675,87.64],[15706,86.89],[15737,85.8],[15765,87.06],[15796,87.14],[15826,87.62],[15857,93.11],[15887,95.82],[15918,99.72],[15949,102.18],[15979,98.77],[16010,99.64],[16040,99.01],[16071,99.06],[16102,99.64],[16130,98.14],[16161,96.76],[16191,95.61],[16222,95.62],[16252,96.31],[16283,97.23],[16314,97.44],[16344,98.22],[16375,98.61],[16405,100.06],[16436,99.71],[16467,98.87],[16495,99.67],[16526,100.23],[16556,101.27],[16587,101.76],[16617,101.52],[16648,104.04],[16679,106.15],[16709,104.44],[16740,105.65],[16770,106.44],[16801,107.57],[16832,109.05],[16861,107.35],[16892,106.28],[16922,106.9],[16953,107.77],[16983,107.75],[17014,107.11],[17045,106.94],[17075,106.79],[17106,108.15],[17136,108.91],[17167,108.89],[17198,107.68],[17226,105.41],[17257,103.6],[17287,103.16],[17318,103.22],[17348,102.96],[17379,102.36],[17410,103.12],[17440,104.2],[17471,103.88],[17501,102.94],[17532,101.85],[17563,102.88],[17591,104.03],[17622,105.04],[17652,107.83],[17683,108.68],[17713,109.89],[17744,111.28],[17775,115.06],[17805,117.58],[17836,115.17],[17866,112.91],[17897,112.86],[17928,113.95],[17956,111.46],[17987,111.01],[18017,111.83],[18048,111.13],[18078,110.13],[18109,113.09],[18140,114.35],[18170,113.8],[18201,114.17],[18231,113.98],[18262,113.88],[18293,114.31],[18322,118.37],[18353,121.66],[18383,121.16],[18414,121.08],[18444,120.28],[18475,119.59],[18506,117.64],[18536,117.53],[18567,118.91],[18597,117.86],[18628,117.15],[18659,116.59],[18687,116.28],[18718,118.59],[18748,117.81],[18779,117.54],[18809,119.16],[18840,118.81],[18871,117.91],[18901,119.57],[18932,119.39],[18962,120.27],[18993,119.4],[19024,120.16],[19052,121.68],[19083,121.62],[19113,123.54],[19144,124.64],[19174,126.89],[19205,127.5],[19236,128.13],[19266,131.1],[19297,131.04],[19327,131.75],[19358,131.12],[19389,131.73],[19417,131.82],[19448,131.39],[19478,131.46],[19509,131.61],[19539,131.4],[19570,132.17],[19601,132.76],[19631,133.14],[19662,133.26],[19692,133.2],[19723,132.97],[19754,132.79]],"NZD":[[14791,1.4062],[14822,1.3993],[14853,1.3797],[14883,1.3331],[14914,1.2996],[14944,1.3307],[14975,1.3055],[15006,1.3117],[15034,1.3464],[15065,1.2719],[15095,1.2541],[15126,1.226],[15156,1.1849],[15187,1.1903],[15218,1.2244],[15248,1.2682],[15279,1.2959],[15309,1.2994],[15340,1.252],[15371,1.1994],[15400,1.216],[15431,1.2227],[15461,1.2917],[15492,1.2862],[15522,1.2529],[15553,1.235],[15584,1.2219],[15614,1.2186],[15645,1.2208],[15675,1.2042],[15706,1.1956],[15737,1.1921],[15765,1.2073],[15796,1.1803],[15826,1.2106],[15857,1.2643],[15887,1.2674],[15918,1.2634],[15949,1.2311],[15979,1.1993],[16010,1.2102],[16040,1.2166],[16071,1.2105],[16102,1.2067],[16130,1.1739],[16161,1.1611],[16191,1.1627],[16222,1.1592],[16252,1.1502],[16283,1.1847],[16314,1.2243],[16344,1.2707],[16375,1.2767],[16405,1.2861],[16436,1.3063],[16467,1.3426],[16495,1.3382],[16526,1.3184],[16556,1.3528],[16587,1.4314],[16617,1.5019],[16648,1.5267],[16679,1.5757],[16709,1.4971],[16740,1.5206],[16770,1.4837],[16801,1.5264],[16832,1.5094],[16861,1.4826],[16892,1.4511],[16922,1.4692],[16953,1.4248],[16983,1.4019],[17014,1.3835],[17045,1.3685],[17075,1.3959],[17106,1.3984],[17136,1.4207],[17167,1.4057],[17198,1.3838],[17226,1.4258],[17257,1.4341],[17287,1.4398],[17318,1.3847],[17348,1.3599],[17379,1.3677],[17410,1.3796],[17440,1.4185],[17471,1.4524],[17501,1.4352],[17532,1.3788],[17563,1.3675],[17591,1.3779],[17622,1.3804],[17652,1.4375],[17683,1.4415],[17713,1.4729],[17744,1.4981],[17775,1.5145],[17805,1.5299],[17836,1.4788],[17866,1.4659],[17897,1.4757],[17928,1.4628],[17956,1.4635],[17987,1.4869],[18017,1.5229],[18048,1.5147],[18078,1.4966],[18109,1.5528],[18140,1.5771],[18170,1.5787],[18201,1.5625],[18231,1.5185],[18262,1.5119],[18293,1.5631],[18322,1.6478],[18353,1.6654],[18383,1.6423],[18414,1.5554],[18444,1.5201],[18475,1.5147],[18506,1.4993],[18536,1.5065],[18567,1.459],[18597,1.4098],[18628,1.3906],[18659,1.3815],[18687,1.399],[18718,1.4046],[18748,1.3841],[18779,1.4057],[18809,1.4311],[18840,1.4334],[18871,1.4192],[18901,1.4191],[18932,1.423],[18962,1.4709],[18993,1.4832],[19024,1.4971],[19052,1.4578],[19083,1.4746],[19113,1.5608],[19144,1.5726],[19174,1.6115],[19205,1.5963],[19236,1.6818],[19266,1.7558],[19297,1.6519],[19327,1.5759],[19358,1.5631],[19389,1.5849],[19417,1.6103],[19448,1.61],[19478,1.6101],[19509,1.6325],[19539,1.6088],[19570,1.6638],[19601,1.6843],[19631,1.6943],[19662,1.6717],[19692,1.6098],[19723,1.6178],[19754,1.6327]],"OMR":[[14791,0.38582],[14822,0.38462],[14853,0.38487],[14883,0.38431],[14914,0.38474],[14944,0.38561],[14975,0.38496],[15006,0.38509],[15034,0.38549],[15065,0.38489],[15095,0.38508],[15126,0.38571],[15156,0.38469],[15187,0.38493],[15218,0.38458],[15248,0.38473],[15279,0.3847],[15309,0.38495],[15340,0.38448],[15371,0.38508],[15400,0.38469],[15431,0.38488],[15461,0.38498],[15492,0.38592],[15522,0.38485],[15553,0.38461],[15584,0.38465],[15614,0.38472],[15645,0.38487],[15675,0.38504],[15706,0.38486],[15737,0.38485],[15765,0.38551],[15796,0.3848],[15826,0.38454],[15857,0.38575],[15887,0.38481],[15918,0.3849],[15949,0.38535],[15979,0.38466],[16010,0.38483],[16040,0.38494],[16071,0.38477],[16102,0.38492],[16130,0.38556],[16161,0.3848],[16191,0.38473],[16222,0.38472],[16252,0.38486],[16283,0.38475],[16314,0.38577],[16344,0.38476],[16375,0.38493],[16405,0.38459],[16436,0.38482],[16467,0.3849],[16495,0.38502],[16526,0.3849],[16556,0.38454],[16587,0.385],[16617,0.38478],[16648,0.38465],[16679,0.38468],[16709,0.38475],[16740,0.38474],[16770,0.38471],[16801,0.38493],[16832,0.38468],[16861,0.38474],[16892,0.38473],[16922,0.38493],[16953,0.3847],[16983,0.38463],[17014,0.38471],[17045,0.38481],[17075,0.38477],[17106,0.38503],[17136,0.38488],[17167,0.38448],[17198,0.38478],[17226,0.38468],[17257,0.38284],[17287,0.38429],[17318,0.38411],[17348,0.38431],[17379,0.38482],[17410,0.38484],[17440,0.38468],[17471,0.38464],[17501,0.38461],[17532,0.38476],[17563,0.38475],[17591,0.38478],[17622,0.38504],[17652,0.38706],[17683,0.38482],[17713,0.38474],[17744,0.38497],[17775,0.38478],[17805,0.38496],[17836,0.38474],[17866,0.38468],[17897,0.38478],[17928,0.38467],[17956,0.38481],[17987,0.38466],[18017,0.38469],[18048,0.38468],[18078,0.38465],[18109,0.38473],[18140,0.38468],[18170,0.3845],[18201,0.38468],[18231,0.38472],[18262,0.38476],[18293,0.38474],[18322,0.38495],[18353,0.38486],[18383,0.38478],[18414,0.38488],[18444,0.38471],[18475,0.38469],[18506,0.38475],[18536,0.38472],[18567,0.38463],[18597,0.38475],[18628,0.38473],[18659,0.38467],[18687,0.3846],[18718,0.38468],[18748,0.38459],[18779,0.38486],[18809,0.38448],[18840,0.38469],[18871,0.38458],[18901,0.38459],[18932,0.38498],[18962,0.38464],[18993,0.38469],[19024,0.3851],[19052,0.38515],[19083,0.38451],[19113,0.3847],[19144,0.38456],[19174,0.38438],[19205,0.38459],[19236,0.38448],[19266,0.38493],[19297,0.3847],[19327,0.38476],[19358,0.38465],[19389,0.38495],[19417,0.38481],[19448,0.38482],[19478,0.38477],[19509,0.38475],[19539,0.38483],[19570,0.38474],[19601,0.38475],[19631,0.38496],[19662,0.38508],[19692,0.3848],[19723,0.38477],[19754,0.38474]],"PAB":[[14791,1.0004],[14822,1.0011],[14853,0.99856],[14883,0.9995],[14914,1.0021],[14944,0.99902],[14975,1.0007],[15006,1.0007],[15034,0.99976],[15065,1.0001],[15095,1.0025],[15126,0.99989],[15156,1.0003],[15187,0.99923],[15218,1.0011],[15248,0.99932],[15279,1.0004],[15309,1.0008],[15340,0.99889],[15371,1.0003],[15400,0.99972],[15431,1.0001],[15461,1.0011],[15492,1.0004],[15522,1.0016],[15553,0.99908],[15584,0.99858],[15614,0.99987],[15645,1.0002],[15675,0.99999],[15706,1.0003],[15737,1.0007],[15765,1.0007],[15796,0.99993],[15826,0.9997],[15857,1.0009],[15887,1.0],[15918,1.0003],[15949,0.99901],[15979,0.99953],[16010,1.0003],[16040,1.0002],[16071,1.0001],[16102,1.0002],[16130,0.9997],[16161,0.9999],[16191,0.99999],[16222,0.99974],[16252,1.0002],[16283,1.0002],[16314,1.0012],[16344,1.0001],[16375,1.0001],[16405,0.99938],[16436,0.99424],[16467,0.99851],[16495,0.99936],[16526,1.0043],[16556,0.99459],[16587,0.99895],[16617,0.99943],[16648,1.0022],[16679,0.99958],[16709,0.99466],[16740,0.99702],[16770,1.0009],[16801,1.001],[16832,0.99806],[16861,1.0041],[16892,1.0002],[16922,0.99706],[16953,0.99756],[16983,0.99965],[17014,0.99917],[17045,1.0003],[17075,0.99847],[17106,0.9965],[17136,0.99864],[17167,0.99978],[17198,0.99858],[17226,1.0005],[17257,1.0032],[17287,1.0019],[17318,1.003],[17348,1.0031],[17379,1.002],[17410,0.99749],[17440,0.99762],[17471,1.0021],[17501,1.0015],[17532,1.0037],[17563,0.99875],[17591,1.0],[17622,0.99716],[17652,0.99641],[17683,0.99821],[17713,0.99952],[17744,1.003],[17775,1.0007],[17805,0.9978],[17836,1.0003],[17866,1.0009],[17897,1.0002],[17928,1.0007],[17956,0.99862],[17987,0.99861],[18017,0.9988],[18048,1.0013],[18078,0.99832],[18109,0.99902],[18140,0.99909],[18170,1.0005],[18201,0.999],[18231,1.0015],[18262,0.99872],[18293,1.0011],[18322,1.001],[18353,1.0001],[18383,1.0021],[18414,1.0013],[18444,1.0047],[18475,1.0016],[18506,0.99846],[18536,0.99872],[18567,1.0011],[18597,1.0021],[18628,0.99919],[18659,1.0017],[18687,0.99753],[18718,1.0021],[18748,0.99979],[18779,0.99827],[18809,1.0],[18840,1.0005],[18871,0.99819],[18901,0.99943],[18932,1.0001],[18962,1.0013],[18993,0.99867],[19024,1.0007],[19052,1.0033],[19083,0.99607],[19113,1.0034],[19144,0.99999],[19174,0.99938],[19205,0.99932],[19236,0.99727],[19266,1.0035],[19297,1.0037],[19327,1.002],[19358,1.0029],[19389,0.99956],[19417,1.0033],[19448,1.0019],[19478,0.9993],[19509,1.0023],[19539,1.0004],[19570,1.0023],[19601,0.99901],[19631,1.0022],[19662,1.0041],[19692,1.0037],[19723,1.0001],[19754,1.0008]],"PEN":[[14791,2.828],[14822,2.81],[14853,2.7937],[14883,2.7932],[14914,2.8122],[14944,2.8166],[14975,2.7946],[15006,2.7744],[15034,2.7826],[15065,2.8129],[15095,2.793],[15126,2.7609],[15156,2.7472],[15187,2.7382],[15218,2.7497],[15248,2.738],[15279,2.7106],[15309,2.701],[15340,2.6936],[15371,2.6875],[15400,2.674],[15431,2.6609],[15461,2.6719],[15492,2.682],[15522,2.6421],[15553,2.6206],[15584,2.6033],[15614,2.5932],[15645,2.5966],[15675,2.5695],[15706,2.558],[15737,2.5794],[15765,2.5942],[15796,2.6028],[15826,2.6488],[15857,2.747],[15887,2.7817],[15918,2.802],[15949,2.7856],[15979,2.7687],[16010,2.7929],[16040,2.7935],[16071,2.8076],[16102,2.8164],[16130,2.809],[16161,2.8015],[16191,2.791],[16222,2.7936],[16252,2.7934],[16283,2.8146],[16314,2.8639],[16344,2.9057],[16375,2.9262],[16405,2.9601],[16436,3.0066],[16467,3.0718],[16495,3.0946],[16526,3.1143],[16556,3.149],[16587,3.1662],[16617,3.1842],[16648,3.2303],[16679,3.232],[16709,3.2473],[16740,3.331],[16770,3.3899],[16801,3.4416],[16832,3.5037],[16861,3.4231],[16892,3.309],[16922,3.3273],[16953,3.3149],[16983,3.3177],[17014,3.3497],[17045,3.3811],[17075,3.3787],[17106,3.3934],[17136,3.3914],[17167,3.3405],[17198,3.2642],[17226,3.2614],[17257,3.2559],[17287,3.2753],[17318,3.2716],[17348,3.2552],[17379,3.2472],[17410,3.2468],[17440,3.2493],[17471,3.2453],[17501,3.2493],[17532,3.2272],[17563,3.2453],[17591,3.2491],[17622,3.2285],[17652,3.2645],[17683,3.2714],[17713,3.277],[17744,3.2945],[17775,3.3106],[17805,3.3324],[17836,3.374],[17866,3.3717],[17897,3.3508],[17928,3.325],[17956,3.3073],[17987,3.3066],[18017,3.3365],[18048,3.3296],[18078,3.2932],[18109,3.3718],[18140,3.3671],[18170,3.3607],[18201,3.3703],[18231,3.3535],[18262,3.3296],[18293,3.3943],[18322,3.4706],[18353,3.409],[18383,3.4265],[18414,3.4835],[18444,3.5259],[18475,3.561],[18506,3.5651],[18536,3.5972],[18567,3.6091],[18597,3.6111],[18628,3.6227],[18659,3.6476],[18687,3.7061],[18718,3.72],[18748,3.7811],[18779,3.8774],[18809,3.9551],[18840,4.0573],[18871,4.1072],[18901,4.034],[18932,4.011],[18962,4.0269],[18993,3.9051],[19024,3.8057],[19052,3.7501],[19083,3.7443],[19113,3.7586],[19144,3.7555],[19174,3.8617],[19205,3.8712],[19236,3.898],[19266,3.9738],[19297,3.9057],[19327,3.8321],[19358,3.8356],[19389,3.8341],[19417,3.7922],[19448,3.7636],[19478,3.7],[19509,3.6554],[19539,3.6122],[19570,3.6652],[19601,3.7293],[19631,3.8291],[19662,3.7865],[19692,3.7326],[19723,3.7415],[19754,3.8103]],"PGK":[[14791,2.768],[14822,2.6973],[14853,2.6614],[14883,2.6401],[14914,2.6323],[14944,2.6007],[14975,2.6245],[15006,2.6149],[15034,2.5643],[15065,2.535],[15095,2.4182],[15126,2.3092],[15156,2.2759],[15187,2.2627],[15218,2.2286],[15248,2.2193],[15279,2.1707],[15309,2.149],[15340,2.1282],[15371,2.0952],[15400,2.0599],[15431,2.0724],[15461,2.0624],[15492,2.0459],[15522,2.0653],[15553,2.0767],[15584,2.055],[15614,2.075],[15645,2.08],[15675,2.0737],[15706,2.0955],[15737,2.0917],[15765,2.0973],[15796,2.1518],[15826,2.1685],[15857,2.1856],[15887,2.252],[15918,2.3186],[15949,2.417],[15979,2.5105],[16010,2.5419],[16040,2.4888],[16071,2.5037],[16102,2.5212],[16130,2.4268],[16161,2.6237],[16191,2.6433],[16222,2.4234],[16252,2.4471],[16283,2.4663],[16314,2.4824],[16344,2.5147],[16375,2.5529],[16405,2.5765],[16436,2.613],[16467,2.6452],[16495,2.6589],[16526,2.6924],[16556,2.7162],[16587,2.7309],[16617,2.7653],[16648,2.7892],[16679,2.8293],[16709,2.9001],[16740,2.9502],[16770,2.9869],[16801,3.0228],[16832,3.0442],[16861,3.0731],[16892,3.1271],[16922,3.1601],[16953,3.142],[16983,3.1703],[17014,3.1768],[17045,3.1791],[17075,3.1826],[17106,3.1857],[17136,3.175],[17167,3.1863],[17198,3.1845],[17226,3.1743],[17257,3.197],[17287,3.1922],[17318,3.1781],[17348,3.1912],[17379,3.1941],[17410,3.1969],[17440,3.2164],[17471,3.2217],[17501,3.2161],[17532,3.2442],[17563,3.248],[17591,3.2493],[17622,3.2672],[17652,3.272],[17683,3.274],[17713,3.3038],[17744,3.3251],[17775,3.3411],[17805,3.3645],[17836,3.3765],[17866,3.3681],[17897,3.383],[17928,3.3843],[17956,3.3735],[17987,3.391],[18017,3.3924],[18048,3.3851],[18078,3.4009],[18109,3.4101],[18140,3.4036],[18170,3.4166],[18201,3.4203],[18231,3.4093],[18262,3.4278],[18293,3.4267],[18322,3.4392],[18353,3.4567],[18383,3.4698],[18414,3.4625],[18444,3.4861],[18475,3.5032],[18506,3.4953],[18536,3.5165],[18567,3.5253],[18597,3.5245],[18628,3.5281],[18659,3.528],[18687,3.514],[18718,3.5289],[18748,3.5275],[18779,3.5133],[18809,3.5209],[18840,3.531],[18871,3.5133],[18901,3.5247],[18932,3.5289],[18962,3.5234],[18993,3.5286],[19024,3.5331],[19052,3.5218],[19083,3.5333],[19113,3.5397],[19144,3.5399],[19174,3.5333],[19205,3.5351],[19236,3.5203],[19266,3.5371],[19297,3.5362],[19327,3.5234],[19358,3.5357],[19389,3.5396],[19417,3.5276],[19448,3.549],[19478,3.5465],[19509,3.5639],[19539,3.5925],[19570,3.6132],[19601,3.6351],[19631,3.6994],[19662,3.7496],[19692,3.7421],[19723,3.7517],[19754,3.7747]],"PHP":[[14791,46.239],[14822,45.277],[14853,44.388],[14883,43.416],[14914,43.536],[14944,43.951],[14975,44.176],[15006,43.683],[15034,43.509],[15065,43.172],[15095,43.154],[15126,43.363],[15156,42.798],[15187,42.363],[15218,43.077],[15248,43.352],[15279,43.266],[15309,43.656],[15340,43.572],[15371,42.687],[15400,42.866],[15431,42.689],[15461,43.0],[15492,42.84],[15522,41.94],[15553,42.048],[15584,41.771],[15614,41.448],[15645,41.103],[15675,40.991],[15706,40.739],[15737,40.672],[15765,40.736],[15796,41.115],[15826,41.332],[15857,42.881],[15887,43.351],[15918,43.866],[15949,43.793],[15979,43.153],[16010,43.512],[16040,44.132],[16071,44.931],[16102,44.869],[16130,44.742],[16161,44.614],[16191,43.941],[16222,43.776],[16252,43.492],[16283,43.738],[16314,44.199],[16344,44.824],[16375,44.941],[16405,44.699],[16436,44.548],[16467,44.174],[16495,44.45],[16526,44.424],[16556,44.566],[16587,44.99],[16617,45.283],[16648,46.198],[16679,46.718],[16709,46.389],[16740,47.034],[16770,47.143],[16801,47.48],[16832,47.606],[16861,46.596],[16892,46.337],[16922,46.777],[16953,46.533],[16983,47.039],[17014,46.655],[17045,47.499],[17075,48.322],[17106,49.154],[17136,49.751],[17167,49.705],[17198,49.957],[17226,50.229],[17257,49.854],[17287,49.843],[17318,49.921],[17348,50.596],[17379,50.894],[17410,50.96],[17440,51.351],[17471,50.947],[17501,50.272],[17532,50.505],[17563,51.804],[17591,52.108],[17622,52.048],[17652,52.244],[17683,53.051],[17713,53.408],[17744,53.26],[17775,53.92],[17805,53.927],[17836,52.805],[17866,52.722],[17897,52.421],[17928,52.133],[17956,52.419],[17987,52.062],[18017,52.217],[18048,51.73],[18078,51.099],[18109,52.016],[18140,52.053],[18170,51.459],[18201,50.739],[18231,50.743],[18262,50.833],[18293,50.807],[18322,50.954],[18353,50.671],[18383,50.554],[18414,50.064],[18444,49.416],[18475,48.778],[18506,48.492],[18536,48.463],[18567,48.259],[18597,48.077],[18628,48.05],[18659,48.248],[18687,48.561],[18718,48.463],[18748,47.953],[18779,48.174],[18809,49.867],[18840,50.178],[18871,50.24],[18901,50.667],[18932,50.386],[18962,50.389],[18993,51.162],[19024,51.296],[19052,51.995],[19083,52.045],[19113,52.404],[19144,53.641],[19174,55.743],[19205,55.834],[19236,57.523],[19266,58.696],[19297,57.596],[19327,55.755],[19358,55.048],[19389,54.795],[19417,54.769],[19448,55.24],[19478,55.8],[19509,55.812],[19539,54.974],[19570,56.123],[19601,56.766],[19631,56.797],[19662,55.982],[19692,55.553],[19723,55.981],[19754,56.072]],"PKR":[[14791,85.62],[14822,85.57],[14853,85.65],[14883,85.73],[14914,85.74],[14944,85.97],[14975,85.77],[15006,85.42],[15034,85.27],[15065,84.78],[15095,85.2],[15126,85.74],[15156,86.03],[15187,86.78],[15218,87.49],[15248,86.87],[15279,86.79],[15309,89.31],[15340,90.24],[15371,90.62],[15400,90.7],[15431,90.65],[15461,91.49],[15492,93.99],[15522,94.54],[15553,94.41],[15584,94.61],[15614,95.41],[15645,96.11],[15675,97.08],[15706,97.59],[15737,97.95],[15765,98.27],[15796,98.39],[15826,98.36],[15857,99.0],[15887,100.7],[15918,102.71],[15949,105.07],[15979,106.44],[16010,107.76],[16040,107.17],[16071,105.57],[16102,105.53],[16130,101.28],[16161,97.96],[16191,98.87],[16222,98.78],[16252,98.99],[16283,100.21],[16314,102.7],[16344,103.1],[16375,102.47],[16405,101.08],[16436,100.84],[16467,101.62],[16495,102.05],[16526,102.13],[16556,101.73],[16587,101.96],[16617,102.03],[16648,102.93],[16679,104.63],[16709,104.6],[16740,105.49],[16770,105.18],[16801,105.23],[16832,104.8],[16861,105.09],[16892,104.86],[16922,104.81],[16953,104.77],[16983,105.1],[17014,104.83],[17045,104.89],[17075,104.89],[17106,104.86],[17136,104.91],[17167,105.0],[17198,105.07],[17226,105.04],[17257,105.25],[17287,105.11],[17318,105.19],[17348,105.66],[17379,105.58],[17410,105.46],[17440,105.51],[17471,105.82],[17501,108.97],[17532,110.99],[17563,110.88],[17591,113.14],[17622,115.87],[17652,115.89],[17683,118.99],[17713,124.3],[17744,123.99],[17775,123.44],[17805,129.66],[17836,134.6],[17866,139.02],[17897,139.63],[17928,139.68],[17956,139.96],[17987,141.81],[18017,145.88],[18048,154.18],[18078,159.31],[18109,159.18],[18140,156.96],[18170,156.62],[18201,155.94],[18231,155.41],[18262,155.04],[18293,154.73],[18322,159.22],[18353,163.87],[18383,160.94],[18414,165.56],[18444,167.69],[18475,168.13],[18506,166.21],[18536,162.9],[18567,159.95],[18597,160.59],[18628,160.59],[18659,159.87],[18687,156.18],[18718,154.05],[18748,153.85],[18779,156.81],[18809,160.34],[18840,164.48],[18871,168.62],[18901,171.74],[18932,174.04],[18962,177.5],[18993,176.83],[19024,176.5],[19052,180.39],[19083,184.1],[19113,194.18],[19144,203.34],[19174,219.11],[19205,222.12],[19236,228.52],[19266,223.7],[19297,222.94],[19327,225.75],[19358,239.08],[19389,266.01],[19417,280.13],[19448,284.7],[19478,284.69],[19509,286.62],[19539,283.49],[19570,292.94],[19601,296.8],[19631,281.83],[19662,285.48],[19692,283.56],[19723,281.1],[19754,280.04]],"PLN":[[14791,3.1972],[14822,3.0918],[14853,3.043],[14883,2.8507],[14914,2.8812],[14944,3.0271],[14975,2.916],[15006,2.8735],[15034,2.8667],[15065,2.7509],[15095,2.738],[15126,2.7598],[15156,2.7928],[15187,2.8674],[15218,3.1501],[15248,3.1831],[15279,3.2644],[15309,3.3968],[15340,3.3923],[15371,3.1639],[15400,3.1229],[15431,3.1664],[15461,3.3445],[15492,3.4381],[15522,3.4029],[15553,3.3066],[15584,3.216],[15614,3.1694],[15645,3.2186],[15675,3.1242],[15706,3.1095],[15737,3.118],[15765,3.205],[15796,3.1868],[15826,3.2136],[15857,3.2562],[15887,3.2651],[15918,3.179],[15949,3.1733],[15979,3.0757],[16010,3.099],[16040,3.048],[16071,3.0646],[16102,3.0616],[16130,3.0377],[16161,3.0296],[16191,3.0414],[16222,3.0423],[16252,3.0602],[16283,3.1446],[16314,3.2476],[16344,3.317],[16375,3.374],[16405,3.4255],[16436,3.6702],[16467,3.6816],[16495,3.8004],[16526,3.7268],[16556,3.6575],[16587,3.7159],[16617,3.7738],[16648,3.7683],[16679,3.7562],[16709,3.7831],[16740,3.9516],[16770,3.9484],[16801,4.0404],[16832,3.9714],[16861,3.8631],[16892,3.7984],[16922,3.8942],[16953,3.9244],[16983,3.9711],[17014,3.8456],[17045,3.8581],[17075,3.9026],[17106,4.059],[17136,4.2039],[17167,4.1158],[17198,4.0495],[17226,4.0148],[17257,3.9518],[17287,3.8057],[17318,3.7457],[17348,3.6825],[17379,3.6135],[17410,3.5865],[17440,3.6305],[17471,3.6036],[17501,3.5465],[17532,3.4174],[17563,3.3705],[17591,3.4105],[17622,3.4169],[17652,3.6124],[17683,3.6891],[17713,3.7053],[17744,3.7087],[17775,3.6884],[17805,3.7438],[17836,3.7859],[17866,3.7684],[17897,3.7575],[17928,3.7979],[17956,3.8026],[17987,3.8153],[18017,3.8405],[18048,3.7783],[18078,3.7937],[18109,3.9041],[18140,3.9583],[18170,3.8991],[18201,3.8745],[18231,3.8458],[18262,3.8275],[18293,3.917],[18322,4.0079],[18353,4.1789],[18383,4.153],[18414,3.9548],[18444,3.8869],[18475,3.7214],[18506,3.7905],[18536,3.8603],[18567,3.8071],[18597,3.6861],[18628,3.7211],[18659,3.7207],[18687,3.8553],[18718,3.827],[18748,3.7328],[18779,3.7342],[18809,3.8501],[18840,3.8794],[18871,3.8843],[18901,3.9585],[18932,4.0609],[18962,4.0873],[18993,4.0304],[19024,4.018],[19052,4.2901],[19083,4.2922],[19113,4.3909],[19144,4.388],[19174,4.6635],[19205,4.6646],[19236,4.7909],[19266,4.8901],[19297,4.6121],[19327,4.4296],[19358,4.3627],[19389,4.4159],[19417,4.3861],[19448,4.2378],[19478,4.1761],[19509,4.1246],[19539,4.0286],[19570,4.0792],[19601,4.2824],[19631,4.2807],[19662,4.0864],[19692,3.969],[19723,3.9947],[19754,4.0093]],"PYG":[[14791,4773.22],[14822,4759.18],[14853,4792.16],[14883,4900.17],[14914,4831.33],[14944,4654.59],[14975,4623.19],[15006,4600.56],[15034,4358.31],[15065,4070.45],[15095,4005.73],[15126,4006.8],[15156,3926.41],[15187,3882.51],[15218,4021.21],[15248,4172.24],[15279,4314.86],[15309,4438.08],[15340,4597.48],[15371,4480.39],[15400,4292.72],[15431,4321.18],[15461,4392.63],[15492,4535.37],[15522,4464.48],[15553,4418.25],[15584,4430.73],[15614,4454.3],[15645,4460.7],[15675,4309.48],[15706,4202.39],[15737,4045.54],[15765,4000.49],[15796,4091.64],[15826,4187.53],[15857,4395.12],[15887,4475.15],[15918,4439.62],[15949,4442.47],[15979,4444.55],[16010,4431.15],[16040,4527.14],[16071,4634.51],[16102,4532.93],[16130,4433.5],[16161,4438.62],[16191,4427.87],[16222,4406.15],[16252,4293.91],[16283,4280.11],[16314,4365.94],[16344,4563.59],[16375,4640.11],[16405,4634.84],[16436,4742.29],[16467,4769.21],[16495,4771.16],[16526,4945.22],[16556,5070.0],[16587,5150.0],[16617,5171.0],[16648,5263.0],[16679,5521.0],[16709,5646.0],[16740,5650.0],[16770,5779.0],[16801,5901.0],[16832,5810.0],[16861,5689.0],[16892,5589.0],[16922,5623.0],[16953,5640.0],[16983,5582.0],[17014,5532.0],[17045,5559.0],[17075,5636.0],[17106,5780.0],[17136,5800.0],[17167,5768.0],[17198,5690.0],[17226,5574.0],[17257,5592.0],[17287,5597.0],[17318,5582.0],[17348,5557.0],[17379,5603.0],[17410,5662.0],[17440,5652.0],[17471,5664.0],[17501,5632.0],[17532,5621.0],[17563,5589.0],[17591,5550.0],[17622,5554.0],[17652,5639.0],[17683,5691.0],[17713,5724.0],[17744,5791.0],[17775,5860.0],[17805,5953.0],[17836,5951.0],[17866,5940.0],[17897,6035.0],[17928,6068.0],[17956,6139.0],[17987,6235.0],[18017,6314.0],[18048,6237.0],[18078,6072.0],[18109,6145.0],[18140,6341.0],[18170,6434.0],[18201,6467.0],[18231,6456.0],[18262,6521.0],[18293,6527.0],[18322,6577.0],[18353,6528.0],[18383,6595.0],[18414,6720.0],[18444,6883.0],[18475,6958.0],[18506,6993.0],[18536,7023.0],[18567,7040.0],[18597,6958.0],[18628,6910.0],[18659,6770.0],[18687,6530.0],[18718,6449.0],[18748,6658.0],[18779,6748.0],[18809,6844.0],[18840,6919.0],[18871,6911.0],[18901,6908.0],[18932,6880.0],[18962,6838.0],[18993,6952.0],[19024,6997.0],[19052,6964.0],[19083,6882.0],[19113,6859.0],[19144,6857.0],[19174,6871.0],[19205,6881.0],[19236,6970.0],[19266,7149.0],[19297,7201.0],[19327,7258.0],[19358,7349.0],[19389,7325.0],[19417,7213.0],[19448,7186.0],[19478,7217.0],[19509,7257.0],[19539,7285.0],[19570,7285.0],[19601,7291.0],[19631,7378.0],[19662,7451.0],[19692,7344.0],[19723,7295.0],[19754,7294.0]],"QAR":[[14791,3.6487],[14822,3.6439],[14853,3.6403],[14883,3.6401],[14914,3.6462],[14944,3.644],[14975,3.6452],[15006,3.6458],[15034,3.6436],[15065,3.6442],[15095,3.6497],[15126,3.6445],[15156,3.6457],[15187,3.6445],[15218,3.6481],[15248,3.6452],[15279,3.6497],[15309,3.6482],[15340,3.6428],[15371,3.6458],[15400,3.6444],[15431,3.6451],[15461,3.6438],[15492,3.6419],[15522,3.6439],[15553,3.6398],[15584,3.6375],[15614,3.6418],[15645,3.6421],[15675,3.6413],[15706,3.6415],[15737,3.6424],[15765,3.6417],[15796,3.6425],[15826,3.6417],[15857,3.6457],[15887,3.6446],[15918,3.6439],[15949,3.642],[15979,3.6436],[16010,3.6421],[16040,3.6423],[16071,3.6423],[16102,3.6418],[16130,3.6413],[16161,3.6415],[16191,3.6414],[16222,3.6418],[16252,3.6428],[16283,3.6419],[16314,3.6437],[16344,3.643],[16375,3.6436],[16405,3.6417],[16436,3.6328],[16467,3.6392],[16495,3.6422],[16526,3.6493],[16556,3.6329],[16587,3.6412],[16617,3.6418],[16648,3.6465],[16679,3.6425],[16709,3.6339],[16740,3.6383],[16770,3.6448],[16801,3.6457],[16832,3.6408],[16861,3.6502],[16892,3.6442],[16922,3.6412],[16953,3.6402],[16983,3.6422],[17014,3.641],[17045,3.6429],[17075,3.6404],[17106,3.6378],[17136,3.6419],[17167,3.6451],[17198,3.6405],[17226,3.6452],[17257,3.6538],[17287,3.6503],[17318,3.6767],[17348,3.6745],[17379,3.6656],[17410,3.655],[17440,3.6753],[17471,3.6899],[17501,3.6533],[17532,3.6591],[17563,3.6476],[17591,3.6506],[17622,3.644],[17652,3.6396],[17683,3.6425],[17713,3.645],[17744,3.6487],[17775,3.6447],[17805,3.6409],[17836,3.6439],[17866,3.6434],[17897,3.6438],[17928,3.6455],[17956,3.6449],[17987,3.6445],[18017,3.6431],[18048,3.6462],[18078,3.6417],[18109,3.6439],[18140,3.6437],[18170,3.6466],[18201,3.6443],[18231,3.6472],[18262,3.6424],[18293,3.6454],[18322,3.6486],[18353,3.6468],[18383,3.6514],[18414,3.6483],[18444,3.6501],[18475,3.6474],[18506,3.6435],[18536,3.6437],[18567,3.6484],[18597,3.6523],[18628,3.6452],[18659,3.6475],[18687,3.6481],[18718,3.652],[18748,3.6582],[18779,3.6664],[18809,3.6627],[18840,3.6624],[18871,3.6495],[18901,3.6473],[18932,3.6483],[18962,3.6499],[18993,3.6418],[19024,3.6475],[19052,3.6541],[19083,3.6431],[19113,3.6582],[19144,3.6543],[19174,3.6572],[19205,3.6601],[19236,3.6569],[19266,3.6557],[19297,3.6643],[19327,3.6594],[19358,3.6567],[19389,3.6487],[19417,3.6577],[19448,3.6534],[19478,3.6425],[19509,3.6459],[19539,3.6419],[19570,3.6454],[19601,3.6411],[19631,3.6475],[19662,3.6491],[19692,3.648],[19723,3.642],[19754,3.6439]],"RON":[[14791,3.3417],[14822,3.2863],[14853,3.2729],[14883,3.0874],[14914,3.1298],[14944,3.2463],[14975,3.1914],[15006,3.1089],[15034,2.9851],[15065,2.8411],[15095,2.8531],[15126,2.9117],[15156,2.9661],[15187,2.9626],[15218,3.1176],[15248,3.1582],[15279,3.2062],[15309,3.2833],[15340,3.3565],[15371,3.2884],[15400,3.2935],[15431,3.3223],[15461,3.458],[15492,3.5631],[15522,3.6808],[15553,3.6484],[15584,3.5084],[15614,3.5141],[15645,3.523],[15675,3.4227],[15706,3.3011],[15737,3.2779],[15765,3.3885],[15796,3.37],[15826,3.3362],[15857,3.3931],[15887,3.3826],[15918,3.3302],[15949,3.3397],[15979,3.2685],[16010,3.2834],[16040,3.2576],[16071,3.3105],[16102,3.291],[16130,3.2548],[16161,3.2302],[16191,3.2204],[16222,3.2287],[16252,3.2546],[16283,3.3201],[16314,3.4187],[16344,3.4835],[16375,3.5452],[16405,3.6166],[16436,3.8437],[16467,3.9086],[16495,4.0733],[16526,4.0776],[16556,3.9907],[16587,3.9925],[16617,4.0328],[16648,3.976],[16679,3.9379],[16709,3.9398],[16740,4.1305],[16770,4.146],[16801,4.1668],[16832,4.0543],[16861,4.0213],[16892,3.9433],[16922,3.9779],[16953,4.0307],[16983,4.0529],[17014,3.9837],[17045,3.9714],[17075,4.0657],[17106,4.171],[17136,4.2809],[17167,4.2467],[17198,4.2389],[17226,4.2578],[17257,4.2232],[17287,4.1215],[17318,4.0675],[17348,3.9705],[17379,3.8788],[17410,3.8604],[17440,3.9051],[17471,3.9471],[17501,3.9154],[17532,3.818],[17563,3.7691],[17591,3.7796],[17622,3.7925],[17652,3.9249],[17683,3.9904],[17713,3.9817],[17744,4.0147],[17775,3.9874],[17805,4.0576],[17836,4.1012],[17866,4.0864],[17897,4.113],[17928,4.1788],[17956,4.2046],[17987,4.2342],[18017,4.2545],[18048,4.1893],[18078,4.2093],[18109,4.2503],[18140,4.3061],[18170,4.3037],[18201,4.3127],[18231,4.2994],[18262,4.3018],[18293,4.376],[18322,4.371],[18353,4.4445],[18383,4.4362],[18414,4.3093],[18444,4.2245],[18475,4.091],[18506,4.1192],[18536,4.1435],[18567,4.1174],[18597,4.0073],[18628,4.0005],[18659,4.0284],[18687,4.1014],[18718,4.1202],[18748,4.057],[18779,4.0852],[18809,4.1599],[18840,4.1784],[18871,4.2065],[18901,4.2603],[18932,4.325],[18962,4.3781],[18993,4.373],[19024,4.3688],[19052,4.4718],[19083,4.558],[19113,4.6677],[19144,4.6749],[19174,4.8126],[19205,4.8346],[19236,4.9515],[19266,5.0172],[19297,4.838],[19327,4.662],[19358,4.5793],[19389,4.5733],[19417,4.5965],[19448,4.5097],[19478,4.5464],[19509,4.5822],[19539,4.4837],[19570,4.5184],[19601,4.6338],[19631,4.6991],[19662,4.6142],[19692,4.5502],[19723,4.5551],[19754,4.6045]],"RSD":[[14791,82.53],[14822,81.57],[14853,80.88],[14883,76.806],[14914,78.511],[14944,80.33],[14975,78.695],[15006,75.821],[15034,73.729],[15065,70.392],[15095,68.351],[15126,69.299],[15156,71.457],[15187,71.399],[15218,73.283],[15248,73.506],[15279,75.142],[15309,78.399],[15340,81.02],[15371,81.55],[15400,83.47],[15431,84.56],[15461,88.61],[15492,92.39],[15522,94.29],[15553,95.29],[15584,90.6],[15614,87.97],[15645,87.86],[15675,86.51],[15706,84.33],[15737,83.35],[15765,86.2],[15796,85.74],[15826,85.35],[15857,86.49],[15887,87.08],[15918,85.74],[15949,85.69],[15979,83.81],[16010,84.16],[16040,83.65],[16071,84.58],[16102,84.93],[16130,83.93],[16161,83.71],[16191,84.13],[16222,84.91],[16252,85.78],[16283,87.99],[16314,91.86],[16344,94.05],[16375,95.99],[16405,98.52],[16436,104.91],[16467,107.14],[16495,110.51],[16526,110.9],[16556,108.47],[16587,108.07],[16617,109.06],[16648,108.13],[16679,106.81],[16709,107.23],[16740,112.19],[16770,112.06],[16801,112.68],[16832,111.24],[16861,110.58],[16892,108.29],[16922,108.71],[16953,110.19],[16983,111.4],[17014,110.29],[17045,110.14],[17075,111.45],[17106,113.97],[17136,116.93],[17167,116.72],[17198,116.43],[17226,116.14],[17257,115.1],[17287,111.44],[17318,108.08],[17348,104.7],[17379,101.36],[17410,100.35],[17440,101.52],[17471,101.45],[17501,100.52],[17532,97.41],[17563,95.78],[17591,95.91],[17622,96.21],[17652,99.92],[17683,101.16],[17713,101.05],[17744,102.02],[17775,101.44],[17805,102.85],[17836,104.07],[17866,103.84],[17897,103.52],[17928,104.02],[17956,104.42],[17987,104.97],[18017,105.49],[18048,104.44],[18078,104.74],[18109,105.81],[18140,106.8],[18170,106.37],[18201,106.24],[18231,105.74],[18262,105.85],[18293,107.39],[18322,106.49],[18353,107.96],[18383,107.82],[18414,104.74],[18444,102.59],[18475,99.51],[18506,99.67],[18536,100.07],[18567,99.41],[18597,96.73],[18628,96.53],[18659,97.12],[18687,98.52],[18718,98.45],[18748,96.86],[18779,97.55],[18809,99.19],[18840,99.76],[18871,100.08],[18901,101.14],[18932,102.75],[18962,104.01],[18993,103.95],[19024,104.02],[19052,106.19],[19083,108.45],[19113,110.86],[19144,111.06],[19174,114.43],[19205,115.79],[19236,118.34],[19266,119.32],[19297,115.62],[19327,111.1],[19358,109.11],[19389,109.3],[19417,109.54],[19448,107.16],[19478,107.74],[19509,108.31],[19539,106.27],[19570,107.06],[19601,109.48],[19631,110.81],[19662,108.79],[19692,107.14],[19723,107.3],[19754,108.43]],"RUB":[[14791,30.684],[14822,30.372],[14853,30.79],[14883,30.359],[14914,30.973],[14944,30.889],[14975,30.14],[15006,29.291],[15034,28.493],[15065,28.074],[15095,27.872],[15126,27.976],[15156,27.904],[15187,28.687],[15218,30.595],[15248,31.322],[15279,30.765],[15309,31.476],[15340,31.374],[15371,29.847],[15400,29.333],[15431,29.456],[15461,30.625],[15492,32.84],[15522,32.519],[15553,31.99],[15584,31.462],[15614,31.101],[15645,31.362],[15675,30.731],[15706,30.254],[15737,30.17],[15765,30.789],[15796,31.283],[15826,31.296],[15857,32.289],[15887,32.75],[15918,32.974],[15949,32.616],[15979,32.085],[16010,32.613],[16040,32.874],[16071,33.67],[16102,35.264],[16130,36.125],[16161,35.722],[16191,34.913],[16222,34.395],[16252,34.624],[16283,36.118],[16314,37.908],[16344,40.72],[16375,45.832],[16405,56.186],[16436,63.547],[16467,64.4],[16495,60.08],[16526,52.781],[16556,50.623],[16587,54.495],[16617,57.09],[16648,65.557],[16679,66.65],[16709,63.203],[16740,64.82],[16770,69.651],[16801,76.753],[16832,77.038],[16861,70.121],[16892,66.659],[16922,65.802],[16953,65.306],[16983,64.506],[17014,65.14],[17045,64.52],[17075,62.8],[17106,64.374],[17136,62.098],[17167,59.956],[17198,58.539],[17226,57.869],[17257,56.44],[17287,57.05],[17318,57.949],[17348,59.687],[17379,59.558],[17410,57.796],[17440,57.791],[17471,58.882],[17501,58.544],[17532,56.7],[17563,56.761],[17591,57.068],[17622,60.722],[17652,62.295],[17683,62.695],[17713,62.794],[17744,66.197],[17775,67.459],[17805,65.779],[17836,66.35],[17866,67.368],[17897,67.009],[17928,65.832],[17956,65.176],[17987,64.649],[18017,64.905],[18048,64.064],[18078,63.236],[18109,65.568],[18140,65.023],[18170,64.316],[18201,63.869],[18231,63.0],[18262,61.953],[18293,64.1],[18322,73.882],[18353,75.188],[18383,72.814],[18414,69.509],[18444,71.451],[18475,73.78],[18506,75.995],[18536,77.723],[18567,77.051],[18597,74.244],[18628,74.398],[18659,74.474],[18687,74.456],[18718,76.052],[18748,74.111],[18779,72.696],[18809,73.754],[18840,73.574],[18871,72.942],[18901,71.488],[18932,72.627],[18962,73.902],[18993,76.276],[19024,77.594],[19052,106.18],[19083,79.558],[19113,64.977],[19144,57.682],[19174,58.731],[19205,60.709],[19236,59.892],[19266,61.61],[19297,61.167],[19327,65.976],[19358,69.723],[19389,73.0],[19417,76.179],[19448,80.91],[19478,79.494],[19509,83.14],[19539,90.37],[19570,95.33],[19601,96.31],[19631,97.01],[19662,90.58],[19692,90.68],[19723,89.7],[19754,91.42]],"RWF":[[14791,590.49],[14822,585.86],[14853,588.13],[14883,586.69],[14914,592.95],[14944,596.76],[14975,594.82],[15006,598.35],[15034,599.57],[15065,601.06],[15095,599.03],[15126,599.55],[15156,598.45],[15187,600.47],[15218,599.87],[15248,598.55],[15279,599.06],[15309,603.39],[15340,604.47],[15371,603.38],[15400,603.64],[15431,605.31],[15461,605.77],[15492,608.65],[15522,609.53],[15553,609.04],[15584,613.06],[15614,621.92],[15645,622.33],[15675,624.65],[15706,625.21],[15737,625.74],[15765,634.85],[15796,636.19],[15826,642.32],[15857,645.06],[15887,647.76],[15918,648.2],[15949,655.98],[15979,665.25],[16010,669.45],[16040,669.96],[16071,674.34],[16102,678.76],[16130,678.26],[16161,679.6],[16191,680.27],[16222,680.75],[16252,683.04],[16283,688.36],[16314,688.2],[16344,689.3],[16375,692.02],[16405,690.48],[16436,693.09],[16467,695.59],[16495,700.92],[16526,699.22],[16556,697.09],[16587,715.43],[16617,725.94],[16648,714.81],[16679,719.8],[16709,727.35],[16740,743.99],[16770,746.43],[16801,750.05],[16832,752.92],[16861,761.74],[16892,759.89],[16922,764.84],[16953,771.91],[16983,768.71],[17014,780.05],[17045,788.62],[17075,788.22],[17106,813.67],[17136,821.69],[17167,824.44],[17198,828.44],[17226,822.92],[17257,831.97],[17287,830.36],[17318,832.37],[17348,830.24],[17379,831.22],[17410,834.8],[17440,840.65],[17471,850.16],[17501,850.91],[17532,850.65],[17563,853.73],[17591,858.04],[17622,860.96],[17652,864.65],[17683,866.67],[17713,869.82],[17744,872.66],[17775,875.31],[17805,879.07],[17836,883.31],[17866,886.58],[17897,889.1],[17928,891.78],[17956,894.29],[17987,899.78],[18017,900.49],[18048,905.01],[18078,908.22],[18109,911.55],[18140,915.04],[18170,919.26],[18201,924.26],[18231,931.24],[18262,938.54],[18293,941.08],[18322,941.28],[18353,942.0],[18383,940.22],[18414,945.08],[18444,949.33],[18475,956.7],[18506,960.15],[18536,969.94],[18567,978.29],[18597,977.8],[18628,983.44],[18659,986.85],[18687,984.55],[18718,990.74],[18748,992.26],[18779,993.73],[18809,998.14],[18840,999.44],[18871,1000.9],[18901,1008.56],[18932,1015.63],[18962,1018.92],[18993,1025.08],[19024,1029.4],[19052,1018.13],[19083,1017.69],[19113,1023.18],[19144,1021.5],[19174,1025.98],[19205,1034.61],[19236,1038.34],[19266,1053.69],[19297,1062.17],[19327,1066.02],[19358,1072.33],[19389,1085.64],[19417,1094.35],[19448,1103.66],[19478,1118.37],[19509,1140.34],[19539,1165.36],[19570,1180.55],[19601,1195.46],[19631,1218.11],[19662,1232.77],[19692,1249.72],[19723,1266.23],[19754,1274.61]],"SAR":[[14791,3.7478],[14822,3.7462],[14853,3.7592],[14883,3.751],[14914,3.751],[14944,3.7508],[14975,3.7529],[15006,3.7481],[15034,3.7494],[15065,3.7517],[15095,3.7511],[15126,3.7491],[15156,3.7482],[15187,3.7534],[15218,3.7538],[15248,3.7496],[15279,3.7507],[15309,3.7541],[15340,3.7534],[15371,3.7495],[15400,3.7494],[15431,3.7523],[15461,3.7514],[15492,3.7562],[15522,3.7539],[15553,3.7467],[15584,3.7494],[15614,3.7523],[15645,3.7509],[15675,3.7504],[15706,3.7515],[15737,3.7501],[15765,3.751],[15796,3.7511],[15826,3.7508],[15857,3.7529],[15887,3.7521],[15918,3.7549],[15949,3.7573],[15979,3.7518],[16010,3.7511],[16040,3.7545],[16071,3.7508],[16102,3.7508],[16130,3.7503],[16161,3.7508],[16191,3.7508],[16222,3.7512],[16252,3.7513],[16283,3.7507],[16314,3.7521],[16344,3.7524],[16375,3.7534],[16405,3.7533],[16436,3.7546],[16467,3.7514],[16495,3.7532],[16526,3.7547],[16556,3.7459],[16587,3.7511],[16617,3.7511],[16648,3.7534],[16679,3.7512],[16709,3.7464],[16740,3.7502],[16770,3.7541],[16801,3.7542],[16832,3.7503],[16861,3.7554],[16892,3.7517],[16922,3.7503],[16953,3.75],[16983,3.7513],[17014,3.7499],[17045,3.7523],[17075,3.7505],[17106,3.7491],[17136,3.7514],[17167,3.7529],[17198,3.7504],[17226,3.7525],[17257,3.7568],[17287,3.7543],[17318,3.7531],[17348,3.7559],[17379,3.7552],[17410,3.7493],[17440,3.7498],[17471,3.7525],[17501,3.7528],[17532,3.7564],[17563,3.7522],[17591,3.7532],[17622,3.7539],[17652,3.7483],[17683,3.7502],[17713,3.7511],[17744,3.7536],[17775,3.7511],[17805,3.7493],[17836,3.7517],[17866,3.752],[17897,3.7513],[17928,3.7515],[17956,3.7513],[17987,3.7518],[18017,3.7509],[18048,3.7521],[18078,3.7494],[18109,3.7505],[18140,3.751],[18170,3.7522],[18201,3.7507],[18231,3.7522],[18262,3.7501],[18293,3.7525],[18322,3.7539],[18353,3.7637],[18383,3.7637],[18414,3.756],[18444,3.7572],[18475,3.7543],[18506,3.7519],[18536,3.7497],[18567,3.7522],[18597,3.7553],[18628,3.7514],[18659,3.7527],[18687,3.7495],[18718,3.7528],[18748,3.7516],[18779,3.7524],[18809,3.7538],[18840,3.7535],[18871,3.752],[18901,3.7531],[18932,3.7547],[18962,3.76],[18993,3.7518],[19024,3.7539],[19052,3.7561],[19083,3.7517],[19113,3.7583],[19144,3.7551],[19174,3.7566],[19205,3.7581],[19236,3.7581],[19266,3.7645],[19297,3.7657],[19327,3.7637],[19358,3.7607],[19389,3.7538],[19417,3.7588],[19448,3.7546],[19478,3.7508],[19509,3.753],[19539,3.7533],[19570,3.754],[19601,3.7514],[19631,3.7533],[19662,3.7549],[19692,3.7548],[19723,3.751],[19754,3.7514]],"SBD":[[14791,8.0543],[14822,8.0289],[14853,7.9582],[14883,8.0188],[14914,7.9873],[14944,7.895],[14975,7.9595],[15006,7.9127],[15034,7.7851],[15065,7.837],[15095,7.779],[15126,7.5073],[15156,7.4464],[15187,7.5116],[15218,7.4042],[15248,7.4768],[15279,7.3655],[15309,7.2971],[15340,7.3044],[15371,7.2149],[15400,7.1618],[15431,7.2201],[15461,7.2686],[15492,7.1175],[15522,7.2244],[15553,7.2496],[15584,7.1782],[15614,7.2323],[15645,7.233],[15675,7.2903],[15706,7.2487],[15737,7.223],[15765,7.2365],[15796,7.2659],[15826,7.2594],[15857,7.2163],[15887,7.2268],[15918,7.1888],[15949,7.1719],[15979,7.2522],[16010,7.2561],[16040,7.2266],[16071,7.2841],[16102,7.3146],[16130,7.2909],[16161,7.327],[16191,7.3269],[16222,7.2607],[16252,7.2664],[16283,7.2666],[16314,7.3041],[16344,7.3825],[16375,7.4237],[16405,7.4512],[16436,7.6609],[16467,7.7883],[16495,7.6744],[16526,7.7515],[16556,7.7694],[16587,7.8397],[16617,7.8744],[16648,7.9401],[16679,7.9969],[16709,8.063],[16740,8.0356],[16770,8.0625],[16801,8.1003],[16832,8.0747],[16861,7.9537],[16892,7.8838],[16922,7.9208],[16953,7.8384],[16983,7.8701],[17014,7.856],[17045,7.8023],[17075,7.8573],[17106,7.9266],[17136,7.9761],[17167,7.9032],[17198,7.8664],[17226,7.8271],[17257,7.8921],[17287,7.9011],[17318,7.8431],[17348,7.887],[17379,7.7962],[17410,7.7819],[17440,7.8383],[17471,7.8713],[17501,7.8096],[17532,7.8067],[17563,7.7783],[17591,7.7379],[17622,7.8067],[17652,7.8627],[17683,7.8485],[17713,7.9359],[17744,7.9536],[17775,7.9176],[17805,8.0187],[17836,8.0785],[17866,8.0224],[17897,8.0753],[17928,8.0607],[17956,8.0352],[17987,8.0702],[18017,8.1078],[18048,8.067],[18078,8.1148],[18109,8.1544],[18140,8.1134],[18170,8.1916],[18201,8.1948],[18231,8.182],[18262,8.2041],[18293,8.2409],[18322,8.2194],[18353,8.3005],[18383,8.3398],[18414,8.2688],[18444,8.2517],[18475,8.221],[18506,8.1482],[18536,8.1357],[18567,8.1039],[18597,8.0444],[18628,8.0631],[18659,8.0283],[18687,7.99],[18718,8.0219],[18748,7.9978],[18779,7.9851],[18809,8.0416],[18840,8.0458],[18871,8.05],[18901,8.059],[18932,8.0893],[18962,8.0701],[18993,8.105],[19024,8.1252],[19052,8.0432],[19083,8.0736],[19113,8.0971],[19144,8.1166],[19174,8.1853],[19205,8.206],[19236,8.1781],[19266,8.2034],[19297,8.259],[19327,8.2296],[19358,8.3154],[19389,8.3682],[19417,8.343],[19448,8.4168],[19478,8.3661],[19509,8.4065],[19539,8.4313],[19570,8.4632],[19601,8.4504],[19631,8.5163],[19662,8.5214],[19692,8.4507],[19723,8.4984],[19754,8.4927]],"SCR":[[14791,12.4301],[14822,12.3649],[14853,12.3852],[14883,12.199],[14914,12.2072],[14944,12.1714],[14975,12.223],[15006,12.1586],[15034,12.1136],[15065,12.1193],[15095,12.0341],[15126,12.0559],[15156,12.1012],[15187,12.2005],[15218,12.2564],[15248,12.445],[15279,12.6998],[15309,13.2826],[15340,13.7736],[15371,13.9781],[15400,13.8815],[15431,14.0122],[15461,14.1069],[15492,14.3421],[15522,14.3886],[15553,13.2966],[15584,12.9399],[15614,13.0052],[15645,13.0456],[15675,13.019],[15706,12.927],[15737,12.5428],[15765,11.8283],[15796,11.6435],[15826,11.7172],[15857,11.8152],[15887,11.9292],[15918,11.9446],[15949,11.9783],[15979,11.9799],[16010,12.006],[16040,12.017],[16071,12.0372],[16102,12.1106],[16130,12.1074],[16161,12.0954],[16191,12.2148],[16222,12.1661],[16252,12.2587],[16283,12.5562],[16314,13.0408],[16344,13.59],[16375,14.0838],[16405,14.0251],[16436,13.9362],[16467,13.8498],[16495,13.5968],[16526,13.4686],[16556,13.2857],[16587,13.1308],[16617,13.0818],[16648,13.0616],[16679,13.0482],[16709,12.957],[16740,12.992],[16770,12.9803],[16801,13.0374],[16832,13.2444],[16861,13.2687],[16892,13.2796],[16922,13.2804],[16953,13.1573],[16983,13.1944],[17014,13.1929],[17045,13.2366],[17075,13.2817],[17106,13.2871],[17136,13.3753],[17167,13.3612],[17198,13.4634],[17226,13.4046],[17257,13.52],[17287,13.5065],[17318,13.4559],[17348,13.5095],[17379,13.5536],[17410,13.5704],[17440,13.6037],[17471,13.6567],[17501,13.6012],[17532,13.6529],[17563,13.6572],[17591,13.6354],[17622,13.6551],[17652,13.6655],[17683,13.6654],[17713,13.6955],[17744,13.7053],[17775,13.685],[17805,13.7718],[17836,13.7965],[17866,13.7437],[17897,13.7354],[17928,13.782],[17956,13.7677],[17987,13.8401],[18017,13.8256],[18048,13.7756],[18078,13.7857],[18109,13.7933],[18140,13.8271],[18170,13.8516],[18201,13.8193],[18231,13.8054],[18262,13.8049],[18293,13.8255],[18322,13.7875],[18353,15.8048],[18383,17.6402],[18414,17.7071],[18444,17.8252],[18475,17.9951],[18506,18.4276],[18536,18.5989],[18567,20.332],[18597,21.121],[18628,21.334],[18659,21.256],[18687,20.994],[18718,17.2597],[18748,15.8828],[18779,15.7607],[18809,15.4066],[18840,14.4049],[18871,13.8201],[18901,14.2409],[18932,14.2113],[18962,14.0164],[18993,14.2573],[19024,14.097],[19052,14.2534],[19083,14.2024],[19113,13.9393],[19144,13.6927],[19174,13.7143],[19205,13.7734],[19236,13.7918],[19266,14.0134],[19297,13.9475],[19327,13.7331],[19358,13.7989],[19389,13.7944],[19417,13.4499],[19448,13.6747],[19478,13.525],[19509,13.5343],[19539,13.7451],[19570,13.8083],[19601,13.5582],[19631,13.83],[19662,13.8944],[19692,13.8377],[19723,13.6737],[19754,13.6687]],"SDG":[[14791,2.3881],[14822,2.3944],[14853,2.3919],[14883,2.3888],[14914,2.4246],[14944,2.5047],[14975,2.5376],[15006,2.6009],[15034,2.7325],[15065,2.6813],[15095,2.7122],[15126,2.7113],[15156,2.6875],[15187,2.7097],[15218,2.7075],[15248,2.7073],[15279,2.7077],[15309,2.71],[15340,2.7051],[15371,2.7105],[15400,2.7071],[15431,2.7082],[15461,2.7104],[15492,3.1806],[15522,4.4142],[15553,4.4149],[15584,4.4089],[15614,4.4091],[15645,4.4117],[15675,4.4245],[15706,4.4204],[15737,4.4206],[15765,4.4182],[15796,4.4113],[15826,4.4056],[15857,4.416],[15887,4.4063],[15918,4.4083],[15949,5.2173],[15979,5.686],[16010,5.6196],[16040,5.7674],[16071,5.7633],[16102,5.7626],[16130,5.759],[16161,5.7589],[16191,5.7594],[16222,5.693],[16252,5.761],[16283,5.7589],[16314,5.7058],[16344,5.7976],[16375,5.8705],[16405,6.0287],[16436,6.044],[16467,5.9745],[16495,5.9696],[16526,5.9736],[16556,5.9663],[16587,6.1318],[16617,6.1016],[16648,6.0926],[16679,6.0894],[16709,6.0951],[16740,6.0967],[16770,6.0937],[16801,6.0992],[16832,6.0903],[16861,6.0932],[16892,6.0983],[16922,6.0908],[16953,6.0885],[16983,6.0838],[17014,6.0878],[17045,6.3225],[17075,6.3979],[17106,6.555],[17136,6.7814],[17167,6.7034],[17198,6.6715],[17226,6.7479],[17257,6.7579],[17287,6.6801],[17318,6.6778],[17348,6.6797],[17379,6.6792],[17410,6.6801],[17440,6.6822],[17471,6.6864],[17501,6.7787],[17532,13.9463],[17563,17.9777],[17591,18.064],[17622,18.0668],[17652,18.0252],[17683,18.0074],[17713,17.9808],[17744,18.0086],[17775,17.9845],[17805,28.815],[17836,47.562],[17866,47.446],[17897,47.492],[17928,47.556],[17956,47.445],[17987,47.297],[18017,45.033],[18048,45.051],[18078,44.979],[18109,45.119],[18140,45.081],[18170,45.115],[18201,45.002],[18231,45.122],[18262,45.915],[18293,51.481],[18322,54.813],[18353,55.156],[18383,55.092],[18414,55.06],[18444,55.052],[18475,55.087],[18506,55.014],[18536,55.039],[18567,55.172],[18597,55.083],[18628,55.08],[18659,71.748],[18687,378.28],[18718,382.26],[18748,400.02],[18779,435.54],[18809,446.19],[18840,444.66],[18871,442.74],[18901,441.65],[18932,439.4],[18962,438.69],[18993,438.55],[19024,443.67],[19052,447.22],[19083,444.92],[19113,449.44],[19144,517.84],[19174,568.04],[19205,570.24],[19236,569.6],[19266,570.55],[19297,570.98],[19327,571.42],[19358,569.64],[19389,573.4],[19417,580.06],[19448,578.45],[19478,596.35],[19509,594.27],[19539,599.07],[19570,597.02],[19601,599.05],[19631,600.4],[19662,604.31],[19692,601.32],[19723,600.26],[19754,600.09]],"SEK":[[14791,7.4704],[14822,7.3076],[14853,7.1046],[14883,6.6964],[14914,6.8249],[14944,6.8774],[14975,6.6858],[15006,6.4498],[15034,6.3611],[15065,6.2186],[15095,6.2427],[15126,6.3347],[15156,6.4009],[15187,6.4036],[15218,6.6434],[15248,6.6741],[15279,6.7494],[15309,6.8669],[15340,6.867],[15371,6.6819],[15400,6.7235],[15431,6.7365],[15461,7.023],[15492,7.0966],[15522,6.964],[15553,6.697],[15584,6.6118],[15614,6.6527],[15645,6.7198],[15675,6.6096],[15706,6.4971],[15737,6.3782],[15765,6.4466],[15796,6.4981],[15826,6.6083],[15857,6.5971],[15887,6.6306],[15918,6.5519],[15949,6.5084],[15979,6.4286],[16010,6.5882],[16040,6.5451],[16071,6.4945],[16102,6.5034],[16130,6.4258],[16161,6.5478],[16191,6.5855],[16222,6.6914],[16252,6.8235],[16283,6.9046],[16314,7.1276],[16344,7.2475],[16375,7.4161],[16405,7.6316],[16436,8.0957],[16467,8.357],[16495,8.5411],[16526,8.6445],[16556,8.3531],[16587,8.2861],[16617,8.5251],[16648,8.56],[16679,8.3772],[16709,8.341],[16740,8.6774],[16770,8.5196],[16801,8.5485],[16832,8.4895],[16861,8.3677],[16892,8.1334],[16922,8.2264],[16953,8.3274],[16983,8.5674],[17014,8.4819],[17045,8.5448],[17075,8.8009],[17106,9.1276],[17136,9.2179],[17167,8.973],[17198,8.9094],[17226,8.9324],[17257,8.9647],[17287,8.7966],[17318,8.6893],[17348,8.3497],[17379,8.1023],[17410,8.0171],[17440,8.1844],[17471,8.3934],[17501,8.3978],[17532,8.0673],[17563,8.0476],[17591,8.2507],[17622,8.4534],[17652,8.7644],[17683,8.815],[17713,8.8412],[17744,9.0612],[17775,8.9711],[17805,9.0426],[17836,9.0715],[17866,9.0407],[17897,8.9991],[17928,9.2475],[17956,9.2963],[17987,9.3385],[18017,9.6019],[18048,9.4213],[18078,9.416],[18109,9.6558],[18140,9.7285],[18170,9.7811],[18201,9.6482],[18231,9.443],[18262,9.5077],[18293,9.6934],[18322,9.818],[18353,10.0276],[18383,9.7448],[18414,9.3308],[18444,9.0598],[18475,8.7251],[18506,8.8471],[18536,8.8513],[18567,8.6634],[18597,8.3759],[18628,8.2977],[18659,8.3481],[18687,8.5402],[18718,8.5173],[18748,8.3633],[18779,8.4038],[18809,8.624],[18840,8.6833],[18871,8.6527],[18901,8.6786],[18932,8.7953],[18962,9.0907],[18993,9.1505],[19024,9.3039],[19052,9.5571],[19083,9.5408],[19113,9.9213],[19144,10.0333],[19174,10.3747],[19205,10.3783],[19236,10.8857],[19266,11.1479],[19297,10.7073],[19327,10.4012],[19358,10.4062],[19389,10.4386],[19417,10.4929],[19448,10.3583],[19478,10.4647],[19509,10.7818],[19539,10.5518],[19570,10.821],[19601,11.0786],[19631,11.0344],[19662,10.7217],[19692,10.2717],[19723,10.3366],[19754,10.4315]],"SGD":[[14791,1.3789],[14822,1.3567],[14853,1.3371],[14883,1.305],[14914,1.2993],[14944,1.3078],[14975,1.2876],[15006,1.2771],[15034,1.2691],[15065,1.248],[15095,1.2377],[15126,1.2344],[15156,1.2181],[15187,1.2086],[15218,1.2496],[15248,1.2784],[15279,1.2867],[15309,1.2959],[15340,1.281],[15371,1.2541],[15400,1.2582],[15431,1.252],[15461,1.2618],[15492,1.2793],[15522,1.2628],[15553,1.2486],[15584,1.2325],[15614,1.2246],[15645,1.2236],[15675,1.2209],[15706,1.2281],[15737,1.2384],[15765,1.2459],[15796,1.2381],[15826,1.2477],[15857,1.2607],[15887,1.2702],[15918,1.2725],[15949,1.264],[15979,1.2444],[16010,1.2472],[16040,1.2587],[16071,1.2727],[16102,1.2673],[16130,1.2671],[16161,1.2559],[16191,1.2521],[16222,1.2514],[16252,1.2435],[16283,1.2482],[16314,1.2624],[16344,1.2748],[16375,1.2952],[16405,1.3149],[16436,1.3367],[16467,1.3547],[16495,1.3757],[16526,1.3498],[16556,1.3343],[16587,1.3459],[16617,1.3605],[16648,1.3968],[16679,1.4146],[16709,1.4025],[16740,1.4127],[16770,1.4087],[16801,1.4313],[16832,1.4066],[16861,1.3738],[16892,1.3503],[16922,1.369],[16953,1.3559],[16983,1.3507],[17014,1.3481],[17045,1.3598],[17075,1.3824],[17106,1.4095],[17136,1.4356],[17167,1.4304],[17198,1.4154],[17226,1.4056],[17257,1.3985],[17287,1.3953],[17318,1.3839],[17348,1.3722],[17379,1.3613],[17410,1.351],[17440,1.3606],[17471,1.3565],[17501,1.3467],[17532,1.3226],[17563,1.3193],[17591,1.3153],[17622,1.3156],[17652,1.3384],[17683,1.3477],[17713,1.3632],[17744,1.3687],[17775,1.3708],[17805,1.3787],[17836,1.3755],[17866,1.3702],[17897,1.357],[17928,1.3537],[17956,1.3541],[17987,1.3563],[18017,1.3707],[18048,1.3634],[18078,1.3609],[18109,1.3842],[18140,1.3805],[18170,1.3713],[18201,1.3619],[18231,1.3573],[18262,1.351],[18293,1.3882],[18322,1.4145],[18353,1.424],[18383,1.4183],[18414,1.3948],[18444,1.3882],[18475,1.3698],[18506,1.3659],[18536,1.3601],[18567,1.3481],[18597,1.3329],[18628,1.3261],[18659,1.3279],[18687,1.3414],[18718,1.3353],[18748,1.3297],[18779,1.3334],[18809,1.3541],[18840,1.3553],[18871,1.3485],[18901,1.3516],[18932,1.3566],[18962,1.3632],[18993,1.3518],[19024,1.3469],[19052,1.3579],[19083,1.3654],[19113,1.3819],[19144,1.3837],[19174,1.3942],[19205,1.3846],[19236,1.4128],[19266,1.4249],[19297,1.3885],[19327,1.3532],[19358,1.3273],[19389,1.3302],[19417,1.3411],[19448,1.3321],[19478,1.3395],[19509,1.347],[19539,1.3362],[19570,1.3495],[19601,1.3624],[19631,1.369],[19662,1.3504],[19692,1.3322],[19723,1.3351],[19754,1.3445]],"SHP":[[14791,0.65938],[14822,0.63949],[14853,0.64283],[14883,0.63188],[14914,0.62704],[14944,0.64178],[14975,0.64091],[15006,0.62401],[15034,0.62024],[15065,0.61757],[15095,0.6063],[15126,0.61217],[15156,0.62202],[15187,0.61203],[15218,0.62331],[15248,0.63724],[15279,0.62675],[15309,0.64028],[15340,0.64671],[15371,0.63522],[15400,0.63118],[15431,0.6271],[15461,0.62237],[15492,0.64233],[15522,0.64238],[15553,0.63649],[15584,0.62578],[15614,0.6192],[15645,0.62433],[15675,0.62149],[15706,0.62318],[15737,0.63974],[15765,0.66155],[15796,0.65674],[15826,0.64886],[15857,0.65351],[15887,0.65679],[15918,0.64918],[15949,0.63751],[15979,0.62093],[16010,0.62158],[16040,0.61129],[16071,0.60714],[16102,0.60549],[16130,0.60121],[16161,0.59932],[16191,0.59421],[16222,0.59312],[16252,0.58645],[16283,0.59476],[16314,0.60806],[16344,0.61927],[16375,0.62935],[16405,0.63694],[16436,0.65158],[16467,0.65665],[16495,0.6564],[16526,0.67236],[16556,0.64897],[16587,0.64831],[16617,0.63962],[16648,0.64054],[16679,0.65071],[16709,0.65599],[16740,0.65693],[16770,0.66589],[16801,0.68515],[16832,0.69916],[16861,0.70919],[16892,0.69632],[16922,0.68648],[16953,0.6937],[16983,0.75211],[17014,0.7611],[17045,0.76138],[17075,0.78918],[17106,0.81391],[17136,0.80144],[17167,0.8136],[17198,0.80013],[17226,0.80783],[17257,0.79686],[17287,0.77478],[17318,0.779],[17348,0.7704],[17379,0.76771],[17410,0.76253],[17440,0.75087],[17471,0.75707],[17501,0.74613],[17532,0.73394],[17563,0.7119],[17591,0.71716],[17622,0.70851],[17652,0.73463],[17683,0.7524],[17713,0.76174],[17744,0.76945],[17775,0.76674],[17805,0.76432],[17836,0.77962],[17866,0.78568],[17897,0.78206],[17928,0.76649],[17956,0.75513],[17987,0.76475],[18017,0.77689],[18048,0.78931],[18078,0.79413],[18109,0.82228],[18140,0.81365],[18170,0.80195],[18201,0.7761],[18231,0.76821],[18262,0.76331],[18293,0.76722],[18322,0.79191],[18353,0.80585],[18383,0.8097],[18414,0.80656],[18444,0.80021],[18475,0.76514],[18506,0.76106],[18536,0.77397],[18567,0.76479],[18597,0.74745],[18628,0.73439],[18659,0.72708],[18687,0.71425],[18718,0.72475],[18748,0.71337],[18779,0.70958],[18809,0.72359],[18840,0.72054],[18871,0.72729],[18901,0.73589],[18932,0.73531],[18962,0.75048],[18993,0.73927],[19024,0.74277],[19052,0.75283],[19083,0.76663],[19113,0.80385],[19144,0.80136],[19174,0.82836],[19205,0.83003],[19236,0.86834],[19266,0.90399],[19297,0.85861],[19327,0.82644],[19358,0.82533],[19389,0.81666],[19417,0.82914],[19448,0.80664],[19478,0.80088],[19509,0.79796],[19539,0.78315],[19570,0.78224],[19601,0.79785],[19631,0.82047],[19662,0.81449],[19692,0.78899],[19723,0.7854],[19754,0.79062]],"SLE":[[14791,3.9163],[14822,3.9712],[14853,4.0811],[14883,4.1145],[14914,4.1915],[14944,4.198],[14975,4.2616],[15006,4.271],[15034,4.327],[15065,4.3545],[15095,4.3402],[15126,4.3545],[15156,4.3768],[15187,4.4048],[15218,4.4228],[15248,4.4184],[15279,4.4091],[15309,4.3777],[15340,4.3651],[15371,4.3614],[15400,4.35],[15431,4.3462],[15461,4.3408],[15492,4.3282],[15522,4.3322],[15553,4.3347],[15584,4.3352],[15614,4.3401],[15645,4.3367],[15675,4.3341],[15706,4.3184],[15737,4.3214],[15765,4.3256],[15796,4.3212],[15826,4.3268],[15857,4.3286],[15887,4.3302],[15918,4.3298],[15949,4.3361],[15979,4.3418],[16010,4.3619],[16040,4.3564],[16071,4.3369],[16102,4.3585],[16130,4.36],[16161,4.3624],[16191,4.3669],[16222,4.4065],[16252,4.4983],[16283,4.5742],[16314,4.7034],[16344,4.7715],[16375,4.8847],[16405,4.9533],[16436,4.9245],[16467,4.8918],[16495,4.8645],[16526,4.837],[16556,4.8454],[16587,4.8612],[16617,4.9114],[16648,5.107],[16679,5.2926],[16709,5.3843],[16740,5.5724],[16770,5.6391],[16801,5.7344],[16832,5.8067],[16861,5.9466],[16892,6.0449],[16922,6.0906],[16953,6.164],[16983,6.0965],[17014,6.2839],[17045,6.5728],[17075,7.0376],[17106,7.212],[17136,7.1954],[17167,7.2704],[17198,7.2357],[17226,7.264],[17257,7.2608],[17287,7.3228],[17318,7.3405],[17348,7.4182],[17379,7.487],[17410,7.5291],[17440,7.5395],[17471,7.5177],[17501,7.537],[17532,7.5572],[17563,7.5734],[17591,7.623],[17622,7.6618],[17652,7.653],[17683,7.7405],[17713,8.0059],[17744,8.3375],[17775,8.2604],[17805,8.3096],[17836,8.3794],[17866,8.396],[17897,8.4894],[17928,8.5511],[17956,8.6757],[17987,8.7343],[18017,8.785],[18048,8.7375],[18078,8.9559],[18109,9.2604],[18140,9.4443],[18170,9.5564],[18201,9.6902],[18231,9.7167],[18262,9.7375],[18293,9.753],[18322,9.7642],[18353,9.6919],[18383,9.7192],[18414,9.7415],[18444,9.7561],[18475,9.8],[18506,9.8282],[18536,10.0128],[18567,10.024],[18597,10.1334],[18628,10.171],[18659,10.2036],[18687,10.2322],[18718,10.2142],[18748,10.2327],[18779,10.2675],[18809,10.2582],[18840,10.3591],[18871,10.571],[18901,10.8876],[18932,11.1176],[18962,11.2246],[18993,11.4646],[19024,11.6969],[19052,11.8892],[19083,12.5962],[19113,12.7469],[19144,12.9836],[19174,13.5191],[19205,13.9928],[19236,14.8398],[19266,16.5116],[19297,18.0427],[19327,18.793],[19358,19.2116],[19389,19.8204],[19417,20.804],[19448,21.828],[19478,22.612],[19509,20.906],[19539,19.8652],[19570,20.837],[19601,21.494],[19631,22.603],[19662,22.607],[19692,22.694],[19723,22.691],[19754,22.723]],"SOS":[[14791,1583.13],[14822,1587.19],[14853,1589.79],[14883,1582.65],[14914,1585.28],[14944,1583.29],[14975,1579.78],[15006,1587.65],[15034,1587.32],[15065,1585.28],[15095,1579.08],[15126,1593.58],[15156,1603.9],[15187,1626.33],[15218,1614.59],[15248,1606.5],[15279,1606.97],[15309,1610.3],[15340,1614.84],[15371,1611.54],[15400,1604.29],[15431,1608.46],[15461,1610.76],[15492,1615.26],[15522,1611.17],[15553,1614.03],[15584,1606.09],[15614,1604.0],[15645,1596.22],[15675,1596.72],[15706,1585.84],[15737,1565.41],[15765,1589.8],[15796,1535.58],[15826,1471.2],[15857,1423.24],[15887,1373.27],[15918,1324.09],[15949,1271.76],[15979,1228.68],[16010,1205.16],[16040,1151.04],[16071,1091.05],[16102,1055.33],[16130,1012.92],[16161,973.37],[16191,952.93],[16222,926.81],[16252,888.62],[16283,819.79],[16314,787.19],[16344,774.59],[16375,776.48],[16405,714.75],[16436,706.73],[16467,709.1],[16495,705.63],[16526,699.67],[16556,696.18],[16587,695.13],[16617,681.82],[16648,666.67],[16679,655.63],[16709,643.76],[16740,633.55],[16770,623.39],[16801,622.05],[16832,613.35],[16861,613.35],[16892,609.24],[16922,599.51],[16953,589.79],[16983,581.47],[17014,579.76],[17045,583.73],[17075,581.2],[17106,580.95],[17136,569.48],[17167,569.8],[17198,579.7],[17226,577.24],[17257,580.81],[17287,577.87],[17318,581.49],[17348,580.58],[17379,579.8],[17410,581.78],[17440,577.52],[17471,582.97],[17501,581.29],[17532,582.9],[17563,578.37],[17591,578.85],[17622,580.0],[17652,577.4],[17683,575.22],[17713,579.21],[17744,579.21],[17775,577.24],[17805,578.71],[17836,579.3],[17866,577.35],[17897,578.4],[17928,579.45],[17956,577.85],[17987,579.8],[18017,578.99],[18048,578.78],[18078,579.51],[18109,578.65],[18140,578.61],[18170,578.64],[18201,579.41],[18231,578.07],[18262,579.06],[18293,580.4],[18322,579.47],[18353,582.93],[18383,580.54],[18414,578.36],[18444,578.29],[18475,580.74],[18506,577.18],[18536,578.72],[18567,581.93],[18597,578.69],[18628,580.64],[18659,580.87],[18687,579.27],[18718,582.38],[18748,581.34],[18779,581.56],[18809,581.63],[18840,581.28],[18871,579.89],[18901,581.56],[18932,582.58],[18962,581.04],[18993,581.67],[19024,583.18],[19052,579.58],[19083,580.79],[19113,579.12],[19144,578.14],[19174,577.29],[19205,570.88],[19236,566.61],[19266,571.01],[19297,569.62],[19327,568.37],[19358,568.6],[19389,568.83],[19417,567.75],[19448,568.0],[19478,567.8],[19509,567.76],[19539,568.99],[19570,568.95],[19601,569.59],[19631,571.42],[19662,571.5],[19692,570.81],[19723,570.53],[19754,571.07]],"SRD":[[14791,2.7647],[14822,2.7497],[14853,2.7689],[14883,2.7463],[14914,2.7657],[14944,2.7784],[14975,2.8761],[15006,3.3133],[15034,3.3165],[15065,3.2912],[15095,3.293],[15126,3.3103],[15156,3.2986],[15187,3.3011],[15218,3.3072],[15248,3.2986],[15279,3.2992],[15309,3.3094],[15340,3.2959],[15371,3.3019],[15400,3.3072],[15431,3.2993],[15461,3.3022],[15492,3.3105],[15522,3.2999],[15553,3.2986],[15584,3.3067],[15614,3.2993],[15645,3.3002],[15675,3.3105],[15706,3.301],[15737,3.3006],[15765,3.3083],[15796,3.3003],[15826,3.2978],[15857,3.3107],[15887,3.3018],[15918,3.3011],[15949,3.3065],[15979,3.2986],[16010,3.3004],[16040,3.3095],[16071,3.2998],[16102,3.3011],[16130,3.3086],[16161,3.3077],[16191,3.2999],[16222,3.3072],[16252,3.3005],[16283,3.2993],[16314,3.3109],[16344,3.2996],[16375,3.3012],[16405,3.3061],[16436,3.2979],[16467,3.2904],[16495,3.2978],[16526,3.2898],[16556,3.2877],[16587,3.3028],[16617,3.2904],[16648,3.2913],[16679,3.2985],[16709,3.2903],[16740,3.5475],[16770,3.9674],[16801,3.9949],[16832,3.992],[16861,4.4221],[16892,5.3644],[16922,6.1338],[16953,6.8907],[16983,7.0998],[17014,7.2493],[17045,7.6103],[17075,7.165],[17106,7.2765],[17136,7.396],[17167,7.457],[17198,7.5267],[17226,7.5544],[17257,7.5504],[17287,7.5371],[17318,7.523],[17348,7.4766],[17379,7.4426],[17410,7.4479],[17440,7.4443],[17471,7.4603],[17501,7.4683],[17532,7.4653],[17563,7.466],[17591,7.4838],[17622,7.4863],[17652,7.4897],[17683,7.4897],[17713,7.4728],[17744,7.4793],[17775,7.4792],[17805,7.4742],[17836,7.472],[17866,7.4796],[17897,7.4751],[17928,7.4724],[17956,7.4823],[17987,7.4761],[18017,7.4851],[18048,7.5025],[18078,7.4477],[18109,7.4599],[18140,7.4762],[18170,7.4629],[18201,7.4694],[18231,7.4705],[18262,7.4667],[18293,7.4673],[18322,7.4818],[18353,7.4769],[18383,7.4901],[18414,7.5208],[18444,7.5324],[18475,7.5297],[18506,10.2049],[18536,14.13],[18567,14.1259],[18597,14.1604],[18628,14.148],[18659,14.1501],[18687,14.1644],[18718,14.1492],[18748,14.2416],[18779,18.0441],[18809,21.055],[18840,21.105],[18871,21.341],[18901,21.424],[18932,21.488],[18962,21.387],[18993,21.089],[19024,21.006],[19052,20.771],[19083,20.871],[19113,21.074],[19144,21.72],[19174,22.733],[19205,24.266],[19236,26.32],[19266,28.519],[19297,30.163],[19327,31.632],[19358,31.888],[19389,32.511],[19417,34.359],[19448,36.182],[19478,37.319],[19509,37.477],[19539,38.139],[19570,38.458],[19601,38.321],[19631,38.291],[19662,38.108],[19692,37.446],[19723,36.884],[19754,36.236]],"SSP":[[15156,3.1053],[15187,3.31],[15218,2.95],[15248,2.8773],[15279,2.8773],[15309,2.8773],[15340,2.8773],[15371,2.8773],[15400,2.8773],[15431,2.8773],[15461,2.8773],[15492,2.8773],[15522,4.0229],[15553,4.0102],[15584,2.95],[15614,2.95],[15645,2.95],[15675,2.95],[15706,3.0628],[15737,3.071],[15765,3.0587],[15796,3.0594],[15826,3.0431],[15857,3.0287],[15887,3.0322],[15918,3.0215],[15949,3.0265],[15979,3.039],[16010,3.1742],[16040,3.1157],[16071,3.0678],[16102,3.0737],[16130,3.0892],[16161,2.9813],[16191,2.95],[16222,2.95],[16252,2.95],[16283,2.95],[16314,2.95],[16344,2.95],[16375,2.95],[16405,2.95],[16436,2.95],[16467,2.95],[16495,2.9625],[16526,2.95],[16556,2.95],[16587,2.9625],[16617,2.95],[16648,2.95],[16679,2.9625],[16709,2.95],[16740,2.95],[16770,12.3018],[16801,17.8454],[16832,22.856],[16861,31.05],[16892,31.778],[16922,32.692],[16953,40.19],[16983,45.445],[17014,51.385],[17045,61.019],[17075,61.181],[17106,63.224],[17136,70.574],[17167,73.615],[17198,80.56],[17226,91.61],[17257,88.34],[17287,116.32],[17318,121.66],[17348,119.66],[17379,101.26],[17410,120.04],[17440,123.58],[17471,126.16],[17501,127.45],[17532,129.9],[17563,131.63],[17591,132.97],[17622,134.64],[17652,136.56],[17683,139.25],[17713,141.11],[17744,145.41],[17775,148.75],[17805,149.82],[17836,151.15],[17866,153.14],[17897,154.47],[17928,154.99],[17956,155.68],[17987,156.64],[18017,157.59],[18048,158.4],[18078,158.58],[18109,158.95],[18140,159.53],[18170,158.61],[18201,160.24],[18231,160.06],[18262,160.4],[18293,160.93],[18322,161.38],[18353,161.44],[18383,162.41],[18414,163.23],[18444,163.52],[18475,164.04],[18506,166.26],[18536,169.78],[18567,175.24],[18597,176.77],[18628,177.13],[18659,177.44],[18687,180.98],[18718,195.75],[18748,206.76],[18779,283.44],[18809,314.13],[18840,404.62],[18871,405.91],[18901,405.94],[18932,411.42],[18962,420.64],[18993,432.52],[19024,433.56],[19052,430.78],[19083,428.24],[19113,443.59],[19144,484.39],[19174,566.94],[19205,650.95],[19236,633.42],[19266,618.26],[19297,627.26],[19327,662.57],[19358,694.04],[19389,745.23],[19417,793.91],[19448,856.35],[19478,910.71],[19509,975.67],[19539,991.89],[19570,1003.92],[19601,1009.6],[19631,1024.67],[19662,1056.94],[19692,1070.26],[19723,1084.69],[19754,1277.69]],"STN":[[17532,20.126],[17563,19.9393],[17591,20.072],[17622,20.071],[17652,20.642],[17683,21.134],[17713,21.091],[17744,21.183],[17775,21.07],[17805,21.222],[17836,21.593],[17866,21.541],[17897,21.464],[17928,21.563],[17956,21.656],[17987,21.831],[18017,21.934],[18048,21.735],[18078,21.744],[18109,22.047],[18140,22.338],[18170,22.327],[18201,22.266],[18231,22.241],[18262,22.172],[18293,22.475],[18322,22.368],[18353,22.516],[18383,22.543],[18414,22.071],[18444,21.757],[18475,21.142],[18506,21.075],[18536,21.221],[18567,21.149],[18597,20.58],[18628,20.555],[18659,20.716],[18687,20.796],[18718,21.015],[18748,20.636],[18779,20.492],[18809,20.737],[18840,20.832],[18871,20.884],[18901,21.149],[18932,21.459],[18962,21.728],[18993,21.705],[19024,21.855],[19052,22.178],[19083,22.604],[19113,23.308],[19144,23.142],[19174,23.854],[19205,24.354],[19236,24.88],[19266,25.114],[19297,24.302],[19327,23.302],[19358,22.909],[19389,22.887],[19417,22.987],[19448,22.507],[19478,22.593],[19509,22.693],[19539,22.38],[19570,22.502],[19601,22.866],[19631,23.291],[19662,22.902],[19692,22.434],[19723,22.418],[19754,22.727]],"SVC":[[14791,8.7547],[14822,8.7535],[14853,8.7469],[14883,8.7457],[14914,8.7595],[14944,8.7352],[14975,8.7557],[15006,8.7606],[15034,8.7487],[15065,8.7535],[15095,8.7593],[15126,8.757],[15156,8.7445],[15187,8.7526],[15218,8.7447],[15248,8.7447],[15279,8.7468],[15309,8.7536],[15340,8.7366],[15371,8.7558],[15400,8.7444],[15431,8.7471],[15461,8.7568],[15492,8.7579],[15522,8.749],[15553,8.7447],[15584,8.7429],[15614,8.7471],[15645,8.7502],[15675,8.7579],[15706,8.7527],[15737,8.7516],[15765,8.7492],[15796,8.7502],[15826,8.7421],[15857,8.7587],[15887,8.7495],[15918,8.7527],[15949,8.7422],[15979,8.7448],[16010,8.7508],[16040,8.7542],[16071,8.7488],[16102,8.7531],[16130,8.7496],[16161,8.7496],[16191,8.7475],[16222,8.7448],[16252,8.751],[16283,8.7471],[16314,8.7582],[16344,8.7479],[16375,8.7533],[16405,8.74],[16436,8.7488],[16467,8.7521],[16495,8.7443],[16526,8.7521],[16556,8.7422],[16587,8.7607],[16617,8.749],[16648,8.7457],[16679,8.7399],[16709,8.7456],[16740,8.7462],[16770,8.7439],[16801,8.749],[16832,8.7396],[16861,8.7428],[16892,8.7466],[16922,8.7459],[16953,8.7453],[16983,8.737],[17014,8.7436],[17045,8.7452],[17075,8.7487],[17106,8.7531],[17136,8.749],[17167,8.739],[17198,8.7423],[17226,8.7395],[17257,8.7472],[17287,8.7436],[17318,8.7415],[17348,8.7394],[17379,8.7426],[17410,8.7435],[17440,8.7456],[17471,8.7496],[17501,8.7408],[17532,8.7484],[17563,8.7423],[17591,8.757],[17622,8.7575],[17652,8.7421],[17683,8.7524],[17713,8.7466],[17744,8.7623],[17775,8.7489],[17805,8.7508],[17836,8.7529],[17866,8.7474],[17897,8.7527],[17928,8.7545],[17956,8.7525],[17987,8.7529],[18017,8.7465],[18048,8.7498],[18078,8.7417],[18109,8.7514],[18140,8.7568],[18170,8.7438],[18201,8.7484],[18231,8.7543],[18262,8.7525],[18293,8.754],[18322,8.7658],[18353,8.7591],[18383,8.7557],[18414,8.7624],[18444,8.7489],[18475,8.7517],[18506,8.7533],[18536,8.7538],[18567,8.7471],[18597,8.7544],[18628,8.7552],[18659,8.7509],[18687,8.7458],[18718,8.7505],[18748,8.7451],[18779,8.7593],[18809,8.7396],[18840,8.7515],[18871,8.7482],[18901,8.7448],[18932,8.7603],[18962,8.7505],[18993,8.7495],[19024,8.7694],[19052,8.7613],[19083,8.7388],[19113,8.7496],[19144,8.7431],[19174,8.7368],[19205,8.7444],[19236,8.7345],[19266,8.7623],[19297,8.754],[19327,8.7531],[19358,8.7506],[19389,8.7594],[19417,8.7505],[19448,8.7499],[19478,8.7519],[19509,8.7467],[19539,8.7506],[19570,8.7505],[19601,8.7498],[19631,8.7601],[19662,8.7686],[19692,8.7484],[19723,8.7475],[19754,8.7441]],"SYP":[[14791,46.883],[14822,46.686],[14853,46.69],[14883,46.086],[14914,46.464],[14944,46.845],[14975,46.795],[15006,46.745],[15034,46.631],[15065,47.391],[15095,47.528],[15126,47.399],[15156,47.361],[15187,47.578],[15218,48.119],[15248,49.31],[15279,49.568],[15309,52.907],[15340,56.217],[15371,57.423],[15400,57.509],[15431,57.829],[15461,61.56],[15492,64.041],[15522,64.409],[15553,65.322],[15584,66.241],[15614,68.338],[15645,70.024],[15675,70.903],[15706,70.935],[15737,70.952],[15765,70.752],[15796,70.293],[15826,85.03],[15857,99.22],[15887,103.65],[15918,107.65],[15949,129.47],[15979,137.14],[16010,139.53],[16040,140.75],[16071,142.34],[16102,143.51],[16130,144.34],[16161,146.5],[16191,148.69],[16222,149.31],[16252,149.73],[16283,150.76],[16314,156.75],[16344,162.11],[16375,167.97],[16405,175.98],[16436,182.87],[16467,200.14],[16495,209.04],[16526,216.03],[16556,216.47],[16587,217.12],[16617,217.77],[16648,220.29],[16679,220.55],[16709,220.06],[16740,220.87],[16770,220.54],[16801,220.65],[16832,219.94],[16861,220.02],[16892,218.76],[16922,217.61],[16953,216.81],[16983,216.48],[17014,215.24],[17045,213.77],[17075,316.32],[17106,518.02],[17136,520.15],[17167,517.52],[17198,515.01],[17226,516.17],[17257,516.71],[17287,513.07],[17318,514.53],[17348,514.26],[17379,514.93],[17410,514.89],[17440,515.14],[17471,516.8],[17501,511.75],[17532,499.62],[17563,516.05],[17591,513.44],[17622,496.13],[17652,515.39],[17683,515.87],[17713,515.79],[17744,515.06],[17775,514.58],[17805,513.77],[17836,515.15],[17866,515.3],[17897,515.99],[17928,516.65],[17956,514.08],[17987,514.13],[18017,514.84],[18048,515.92],[18078,514.24],[18109,513.89],[18140,475.39],[18170,436.21],[18201,435.45],[18231,436.77],[18262,435.47],[18293,437.22],[18322,503.51],[18353,512.77],[18383,514.1],[18414,514.32],[18444,1261.58],[18475,1257.86],[18506,1263.72],[18536,1266.42],[18567,1269.42],[18597,1265.73],[18628,1260.02],[18659,1268.71],[18687,1247.95],[18718,1747.79],[18748,2516.28],[18779,2505.05],[18809,2510.91],[18840,2515.93],[18871,2507.96],[18901,2507.56],[18932,2507.41],[18962,2517.61],[18993,2509.5],[19024,2516.72],[19052,2518.83],[19083,2490.4],[19113,2571.11],[19144,2506.74],[19174,2499.89],[19205,2511.57],[19236,2500.13],[19266,2523.78],[19297,2518.53],[19327,2511.0],[19358,2520.64],[19389,2500.78],[19417,2522.12],[19448,2510.11],[19478,2495.03],[19509,2520.63],[19539,2509.94],[19570,8046.0],[19601,12959.0],[19631,13018.0],[19662,12566.0],[19692,12709.0],[19723,12900.0],[19754,12865.0]],"SZL":[[14791,7.5152],[14822,7.3387],[14853,7.1874],[14883,6.9372],[14914,7.0377],[14944,6.8971],[14975,6.9107],[15006,7.1226],[15034,6.9159],[15065,6.7214],[15095,6.8062],[15126,6.8276],[15156,6.7905],[15187,7.0632],[15218,7.6564],[15248,7.9114],[15279,8.0968],[15309,8.1981],[15340,7.9857],[15371,7.6537],[15400,7.6094],[15431,7.7845],[15461,8.1579],[15492,8.3832],[15522,8.2759],[15553,8.2758],[15584,8.3083],[15614,8.649],[15645,8.7627],[15675,8.6118],[15706,8.759],[15737,8.9416],[15765,9.1403],[15796,9.1038],[15826,9.4679],[15857,10.0166],[15887,9.9069],[15918,10.1047],[15949,10.0769],[15979,9.9602],[16010,10.1137],[16040,10.3964],[16071,10.8736],[16102,10.9693],[16130,10.702],[16161,10.565],[16191,10.4633],[16222,10.61],[16252,10.6552],[16283,10.6438],[16314,11.0416],[16344,11.0967],[16375,11.0202],[16405,11.4131],[16436,11.6057],[16467,11.6086],[16495,11.9834],[16526,11.9942],[16556,11.9975],[16587,12.245],[16617,12.4448],[16648,12.9597],[16679,13.6341],[16709,13.6736],[16740,14.1351],[16770,15.0619],[16801,16.0689],[16832,15.983],[16861,15.2046],[16892,14.589],[16922,15.2068],[16953,15.1226],[16983,14.4896],[17014,14.0579],[17045,14.0054],[17075,13.8801],[17106,13.94],[17136,13.8126],[17167,13.5622],[17198,13.2641],[17226,13.0815],[17257,13.259],[17287,13.2413],[17318,13.0151],[17348,13.0714],[17379,13.1444],[17410,13.2729],[17440,13.7571],[17471,13.9562],[17501,12.9067],[17532,12.1456],[17563,11.8128],[17591,11.81],[17622,12.0904],[17652,12.5182],[17683,13.3476],[17713,13.4392],[17744,13.98],[17775,14.5283],[17805,14.4497],[17836,14.1441],[17866,14.1502],[17897,13.8477],[17928,13.7976],[17956,14.2913],[17987,14.3296],[18017,14.4708],[18048,14.4179],[18078,14.1137],[18109,15.1789],[18140,15.0675],[18170,15.0217],[18201,14.7897],[18231,14.6413],[18262,14.4573],[18293,15.0614],[18322,16.8465],[18353,18.2602],[18383,18.0547],[18414,17.2637],[18444,16.8734],[18475,16.9847],[18506,16.7599],[18536,16.5352],[18567,15.6974],[18597,14.8915],[18628,14.9423],[18659,14.9174],[18687,14.8431],[18718,14.6095],[18748,14.072],[18779,14.0574],[18809,14.4382],[18840,14.6541],[18871,14.8427],[18901,14.9355],[18932,15.3318],[18962,15.8659],[18993,15.7297],[19024,15.3641],[19052,14.9098],[19083,15.0067],[19113,15.7612],[19144,15.9549],[19174,16.4322],[19205,16.8715],[19236,17.4656],[19266,18.0692],[19297,17.6606],[19327,17.0813],[19358,17.1719],[19389,17.6993],[19417,18.0828],[19448,18.2149],[19478,19.0151],[19509,19.0268],[19539,18.2284],[19570,18.5447],[19601,18.8329],[19631,18.9653],[19662,18.7128],[19692,18.5827],[19723,18.6379],[19754,19.0372]],"THB":[[14791,32.334],[14822,31.791],[14853,30.872],[14883,30.009],[14914,29.913],[14944,30.12],[14975,30.547],[15006,30.732],[15034,30.395],[15065,30.087],[15095,30.232],[15126,30.519],[15156,30.158],[15187,29.871],[15218,30.442],[15248,30.895],[15279,30.936],[15309,31.204],[15340,31.553],[15371,30.75],[15400,30.686],[15431,30.882],[15461,31.317],[15492,31.705],[15522,31.667],[15553,31.445],[15584,31.005],[15614,30.713],[15645,30.719],[15675,30.64],[15706,30.101],[15737,29.823],[15765,29.538],[15796,29.097],[15826,29.75],[15857,30.786],[15887,31.128],[15918,31.583],[15949,31.703],[15979,31.211],[16010,31.605],[16040,32.356],[16071,32.927],[16102,32.662],[16130,32.4],[16161,32.335],[16191,32.521],[16222,32.517],[16252,32.145],[16283,32.012],[16314,32.192],[16344,32.455],[16375,32.778],[16405,32.892],[16436,32.757],[16467,32.578],[16495,32.612],[16526,32.54],[16556,33.489],[16587,33.736],[16617,34.305],[16648,35.407],[16679,36.025],[16709,35.741],[16740,35.78],[16770,36.003],[16801,36.128],[16832,35.631],[16861,35.242],[16892,35.093],[16922,35.408],[16953,35.314],[16983,35.057],[17014,34.729],[17045,34.718],[17075,35.034],[17106,35.328],[17136,35.78],[17167,35.485],[17198,35.026],[17226,34.861],[17257,34.465],[17287,34.463],[17318,34.008],[17348,33.747],[17379,33.264],[17410,33.165],[17440,33.24],[17471,32.927],[17501,32.64],[17532,31.936],[17563,31.481],[17591,31.279],[17622,31.314],[17652,31.956],[17683,32.476],[17713,33.265],[17744,33.054],[17775,32.603],[17805,32.763],[17836,32.979],[17866,32.71],[17897,31.85],[17928,31.319],[17956,31.711],[17987,31.852],[18017,31.806],[18048,31.14],[18078,30.796],[18109,30.759],[18140,30.574],[18170,30.376],[18201,30.244],[18231,30.185],[18262,30.416],[18293,31.312],[18322,32.096],[18353,32.641],[18383,32.089],[18414,31.179],[18444,31.38],[18475,31.225],[18506,31.374],[18536,31.275],[18567,30.509],[18597,30.089],[18628,30.006],[18659,30.016],[18687,30.754],[18718,31.324],[18748,31.276],[18779,31.457],[18809,32.556],[18840,33.062],[18871,33.071],[18901,33.48],[18932,33.123],[18962,33.526],[18993,33.255],[19024,32.68],[19052,33.181],[19083,33.755],[19113,34.381],[19144,34.888],[19174,36.247],[19205,35.919],[19236,36.991],[19266,37.916],[19297,36.487],[19327,34.855],[19358,33.391],[19389,33.94],[19417,34.483],[19448,34.24],[19478,34.222],[19509,34.902],[19539,34.699],[19570,34.967],[19601,35.795],[19631,36.479],[19662,35.522],[19692,34.964],[19723,35.137],[19754,35.822]],"TJS":[[14791,4.3831],[14822,4.3844],[14853,4.3791],[14883,4.3817],[14914,4.3981],[14944,4.3994],[14975,4.4042],[15006,4.4063],[15034,4.4437],[15065,4.474],[15095,4.5227],[15126,4.6167],[15156,4.6898],[15187,4.7292],[15218,4.7625],[15248,4.7499],[15279,4.758],[15309,4.7604],[15340,4.755],[15371,4.7586],[15400,4.7585],[15431,4.7593],[15461,4.7629],[15492,4.7635],[15522,4.7655],[15553,4.7613],[15584,4.7592],[15614,4.7624],[15645,4.7634],[15675,4.7634],[15706,4.7614],[15737,4.7586],[15765,4.749],[15796,4.7562],[15826,4.7529],[15857,4.759],[15887,4.7647],[15918,4.766],[15949,4.7592],[15979,4.756],[16010,4.7693],[16040,4.7712],[16071,4.7715],[16102,4.7911],[16130,4.7906],[16161,4.827],[16191,4.903],[16222,4.9368],[16252,4.9587],[16283,4.9898],[16314,4.9995],[16344,5.019],[16375,5.0899],[16405,5.2034],[16436,5.342],[16467,5.4425],[16495,5.5955],[16526,6.087],[16556,6.2574],[16587,6.2769],[16617,6.2582],[16648,6.2866],[16679,6.3861],[16709,6.4991],[16740,6.6047],[16770,6.7708],[16801,7.4342],[16832,7.8439],[16861,7.8765],[16892,7.8702],[16922,7.8589],[16953,7.8471],[16983,7.862],[17014,7.8594],[17045,7.8642],[17075,7.8667],[17106,7.8633],[17136,7.863],[17167,7.8898],[17198,7.9447],[17226,8.1337],[17257,8.5064],[17287,8.6824],[17318,8.8192],[17348,8.8176],[17379,8.8165],[17410,8.7902],[17440,8.791],[17471,8.8169],[17501,8.8177],[17532,8.8332],[17563,8.816],[17591,8.8155],[17622,8.8404],[17652,8.951],[17683,9.0818],[17713,9.3135],[17744,9.4322],[17775,9.4169],[17805,9.4114],[17836,9.4227],[17866,9.421],[17897,9.4338],[17928,9.4388],[17956,9.4292],[17987,9.4313],[18017,9.433],[18048,9.4399],[18078,9.4298],[18109,9.5473],[18140,9.6815],[18170,9.693],[18201,9.6885],[18231,9.6903],[18262,9.6863],[18293,9.6955],[18322,9.8682],[18353,10.2335],[18383,10.27],[18414,10.2921],[18444,10.3303],[18475,10.3177],[18506,10.315],[18536,10.3243],[18567,11.1118],[18597,11.3219],[18628,11.3086],[18659,11.3305],[18687,11.3109],[18718,11.346],[18748,11.3338],[18779,11.3242],[18809,11.3356],[18840,11.3361],[18871,11.3064],[18901,11.2752],[18932,11.2687],[18962,11.287],[18993,11.274],[19024,11.2645],[19052,12.2555],[19083,12.5242],[19113,12.1418],[19144,10.9801],[19174,10.3875],[19205,10.2398],[19236,10.1576],[19266,10.2154],[19297,10.1813],[19327,10.1878],[19358,10.2537],[19389,10.4713],[19417,10.8866],[19448,10.9154],[19478,10.9005],[19509,10.9129],[19539,10.9228],[19570,10.9511],[19601,10.9479],[19631,10.9581],[19662,10.9627],[19692,10.9494],[19723,10.9294],[19754,10.9467]],"TMT":[[14791,2.85],[14822,2.8519],[14853,2.8531],[14883,2.85],[14914,2.852],[14944,2.8493],[14975,2.8499],[15006,2.8494],[15034,2.8486],[15065,2.8501],[15095,2.8534],[15126,2.8479],[15156,2.8515],[15187,2.8479],[15218,2.8512],[15248,2.8482],[15279,2.8506],[15309,2.8497],[15340,2.8491],[15371,2.8468],[15400,2.8493],[15431,2.8507],[15461,2.8521],[15492,2.8496],[15522,2.8536],[15553,2.8499],[15584,2.8472],[15614,2.8504],[15645,2.8507],[15675,2.8485],[15706,2.8494],[15737,2.851],[15765,2.851],[15796,2.8496],[15826,2.8505],[15857,2.849],[15887,2.8498],[15918,2.8508],[15949,2.8498],[15979,2.8501],[16010,2.8521],[16040,2.849],[16071,2.8504],[16102,2.8509],[16130,2.8493],[16161,2.8498],[16191,2.8507],[16222,2.8496],[16252,2.8503],[16283,2.8503],[16314,2.8488],[16344,2.8509],[16375,2.8501],[16405,2.849],[16436,3.4509],[16467,3.4953],[16495,3.4989],[16526,3.5074],[16556,3.4898],[16587,3.4938],[16617,3.4972],[16648,3.5045],[16679,3.4979],[16709,3.4888],[16740,3.4903],[16770,3.4822],[16801,3.478],[16832,3.4749],[16861,3.4847],[16892,3.4754],[16922,3.4856],[16953,3.4851],[16983,3.4684],[17014,3.4933],[17045,3.4982],[17075,3.4948],[17106,3.491],[17136,3.4949],[17167,3.4994],[17198,3.4966],[17226,3.5006],[17257,3.5048],[17287,3.5035],[17318,3.5038],[17348,3.5044],[17379,3.502],[17410,3.4945],[17440,3.496],[17471,3.5032],[17501,3.4999],[17532,3.5029],[17563,3.4952],[17591,3.4973],[17622,3.4924],[17652,3.4918],[17683,3.4949],[17713,3.4967],[17744,3.5024],[17775,3.4986],[17805,3.4937],[17836,3.498],[17866,3.4981],[17897,3.4979],[17928,3.4988],[17956,3.4944],[17987,3.4948],[18017,3.4952],[18048,3.4988],[18078,3.4945],[18109,3.4956],[18140,3.4956],[18170,3.498],[18201,3.4955],[18231,3.499],[18262,3.4953],[18293,3.5],[18322,3.4916],[18353,3.4986],[18383,3.5026],[18414,3.4985],[18444,3.5063],[18475,3.4994],[18506,3.4953],[18536,3.4957],[18567,3.4987],[18597,3.5018],[18628,3.4969],[18659,3.5027],[18687,3.4939],[18718,3.5035],[18748,3.4992],[18779,3.4957],[18809,3.4995],[18840,3.4997],[18871,3.4955],[18901,3.4986],[18932,3.4975],[18962,3.4994],[18993,3.4944],[19024,3.4926],[19052,3.4983],[19083,3.4905],[19113,3.5041],[19144,3.4963],[19174,3.4943],[19205,3.4963],[19236,3.4916],[19266,3.5034],[19297,3.5046],[19327,3.5006],[19358,3.4997],[19389,3.4955],[19417,3.5027],[19448,3.5005],[19478,3.4961],[19509,3.5014],[19539,3.4988],[19570,3.5023],[19601,3.4947],[19631,3.5001],[19662,3.5049],[19692,3.5033],[19723,3.496],[19754,3.4979]],"TND":[[14791,1.4823],[14822,1.4649],[14853,1.4644],[14883,1.4013],[14914,1.4121],[14944,1.4475],[14975,1.435],[15006,1.4131],[15034,1.3958],[15065,1.3686],[15095,1.3708],[15126,1.3691],[15156,1.3735],[15187,1.3717],[15218,1.4073],[15248,1.4173],[15279,1.4343],[15309,1.4784],[15340,1.5076],[15371,1.498],[15400,1.5023],[15431,1.5191],[15461,1.5695],[15492,1.6031],[15522,1.617],[15553,1.6096],[15584,1.5766],[15614,1.5681],[15645,1.5788],[15675,1.5604],[15706,1.554],[15737,1.5564],[15765,1.5843],[15796,1.5981],[15826,1.6332],[15857,1.6355],[15887,1.6555],[15918,1.6431],[15949,1.645],[15979,1.6364],[16010,1.6609],[16040,1.659],[16071,1.6388],[16102,1.597],[16130,1.5787],[16161,1.5869],[16191,1.6148],[16222,1.6558],[16252,1.7031],[16283,1.7259],[16314,1.7695],[16344,1.7996],[16375,1.8288],[16405,1.8522],[16436,1.9009],[16467,1.9297],[16495,1.9539],[16526,1.9523],[16556,1.9311],[16587,1.9438],[16617,1.9627],[16648,1.9593],[16679,1.957],[16709,1.964],[16740,2.029],[16770,2.0314],[16801,2.0383],[16832,2.0211],[16861,2.0255],[16892,2.0063],[16922,2.0419],[16953,2.1494],[16983,2.2131],[17014,2.2036],[17045,2.2031],[17075,2.2307],[17106,2.269],[17136,2.3092],[17167,2.3078],[17198,2.2896],[17226,2.2877],[17257,2.3431],[17287,2.4253],[17318,2.4484],[17348,2.4479],[17379,2.4249],[17410,2.4444],[17440,2.4762],[17471,2.4987],[17501,2.4815],[17532,2.4415],[17563,2.3967],[17591,2.4067],[17622,2.41],[17652,2.5251],[17683,2.5928],[17713,2.6539],[17744,2.7348],[17775,2.7762],[17805,2.8314],[17836,2.9074],[17866,2.9715],[17897,3.0123],[17928,3.0404],[17956,3.0219],[17987,3.0124],[18017,2.9925],[18048,2.9334],[18078,2.8806],[18109,2.8669],[18140,2.8616],[18170,2.8448],[18201,2.845],[18231,2.83],[18262,2.8133],[18293,2.8514],[18322,2.8627],[18353,2.8946],[18383,2.8988],[18414,2.8581],[18444,2.8171],[18475,2.7423],[18506,2.7476],[18536,2.7591],[18567,2.747],[18597,2.7069],[18628,2.698],[18659,2.7135],[18687,2.7514],[18718,2.7653],[18748,2.7359],[18779,2.7576],[18809,2.783],[18840,2.7906],[18871,2.7979],[18901,2.8202],[18932,2.8574],[18962,2.8855],[18993,2.8855],[19024,2.8887],[19052,2.9424],[19083,2.9972],[19113,3.0707],[19144,3.0726],[19174,3.1369],[19205,3.1678],[19236,3.2325],[19266,3.2664],[19297,3.234],[19327,3.1534],[19358,3.0924],[19389,3.0956],[19417,3.1011],[19448,3.0608],[19478,3.0701],[19509,3.1018],[19539,3.069],[19570,3.0886],[19601,3.1409],[19631,3.1754],[19662,3.1409],[19692,3.0995],[19723,3.1],[19754,3.1247]],"TOP":[[14791,1.9347],[14822,1.92],[14853,1.9028],[14883,1.8703],[14914,1.8424],[14944,1.8186],[14975,1.8224],[15006,1.8219],[15034,1.8136],[15065,1.7932],[15095,1.746],[15126,1.7034],[15156,1.6542],[15187,1.6121],[15218,1.6581],[15248,1.6813],[15279,1.6882],[15309,1.7035],[15340,1.7046],[15371,1.6682],[15400,1.6516],[15431,1.6825],[15461,1.7332],[15492,1.7498],[15522,1.7403],[15553,1.7261],[15584,1.7082],[15614,1.7135],[15645,1.7154],[15675,1.713],[15706,1.7216],[15737,1.7113],[15765,1.708],[15796,1.7171],[15826,1.7345],[15857,1.771],[15887,1.8159],[15918,1.8224],[15949,1.8028],[15979,1.784],[16010,1.7983],[16040,1.8094],[16071,1.8348],[16102,1.8487],[16130,1.8194],[16161,1.8034],[16191,1.8132],[16222,1.7959],[16252,1.8041],[16283,1.8203],[16314,1.8572],[16344,1.9056],[16375,1.9313],[16405,1.9308],[16436,1.9726],[16467,2.0068],[16495,1.9867],[16526,1.9992],[16556,2.0138],[16587,2.0441],[16617,2.1197],[16648,2.1564],[16679,2.1739],[16709,2.2105],[16740,2.2145],[16770,2.2026],[16801,2.2422],[16832,2.2567],[16861,2.2177],[16892,2.2104],[16922,2.2306],[16953,2.2101],[16983,2.2062],[17014,2.2003],[17045,2.1706],[17075,2.1861],[17106,2.2057],[17136,2.2144],[17167,2.2299],[17198,2.2105],[17226,2.2031],[17257,2.2385],[17287,2.2491],[17318,2.2023],[17348,2.1772],[17379,2.1509],[17410,2.1549],[17440,2.1893],[17471,2.2223],[17501,2.2095],[17532,2.1848],[17563,2.1705],[17591,2.171],[17622,2.1909],[17652,2.2246],[17683,2.2282],[17713,2.2649],[17744,2.2781],[17775,2.2734],[17805,2.2871],[17836,2.2726],[17866,2.246],[17897,2.2647],[17928,2.2617],[17956,2.2499],[17987,2.2689],[18017,2.2905],[18048,2.2743],[18078,2.2813],[18109,2.3074],[18140,2.3093],[18170,2.3304],[18201,2.3251],[18231,2.2967],[18262,2.3055],[18293,2.3212],[18322,2.327],[18353,2.3401],[18383,2.3155],[18414,2.2783],[18444,2.2886],[18475,2.285],[18506,2.2817],[18536,2.3197],[18567,2.3125],[18597,2.2755],[18628,2.2866],[18659,2.2937],[18687,2.2727],[18718,2.2788],[18748,2.2573],[18779,2.2371],[18809,2.2595],[18840,2.2664],[18871,2.2598],[18901,2.2561],[18932,2.2612],[18962,2.2788],[18993,2.2854],[19024,2.2909],[19052,2.2503],[19083,2.2606],[19113,2.3107],[19144,2.3176],[19174,2.3496],[19205,2.3443],[19236,2.3831],[19266,2.4343],[19297,2.3996],[19327,2.3549],[19358,2.3551],[19389,2.3552],[19417,2.3646],[19448,2.3687],[19478,2.3674],[19509,2.3652],[19539,2.355],[19570,2.3723],[19601,2.3803],[19631,2.3981],[19662,2.3891],[19692,2.3472],[19723,2.3507],[19754,2.3677]],"TTD":[[14791,6.4034],[14822,6.3678],[14853,6.3572],[14883,6.3478],[14914,6.39],[14944,6.4147],[14975,6.4108],[15006,6.4066],[15034,6.3895],[15065,6.4474],[15095,6.4105],[15126,6.4092],[15156,6.4132],[15187,6.4257],[15218,6.4117],[15248,6.4106],[15279,6.3794],[15309,6.4363],[15340,6.432],[15371,6.4015],[15400,6.4078],[15431,6.4226],[15461,6.436],[15492,6.4364],[15522,6.4254],[15553,6.4142],[15584,6.4252],[15614,6.4428],[15645,6.4311],[15675,6.4292],[15706,6.4272],[15737,6.4214],[15765,6.4405],[15796,6.448],[15826,6.4305],[15857,6.4336],[15887,6.4459],[15918,6.4332],[15949,6.4452],[15979,6.4458],[16010,6.4626],[16040,6.4359],[16071,6.4387],[16102,6.4522],[16130,6.4421],[16161,6.4872],[16191,6.4737],[16222,6.4425],[16252,6.4023],[16283,6.3913],[16314,6.3879],[16344,6.3878],[16375,6.3886],[16405,6.3944],[16436,6.4029],[16467,6.3847],[16495,6.3934],[16526,6.4142],[16556,6.3769],[16587,6.4076],[16617,6.4149],[16648,6.4039],[16679,6.4023],[16709,6.3834],[16740,6.4432],[16770,6.4571],[16801,6.4838],[16832,6.5128],[16861,6.6055],[16892,6.6653],[16922,6.6726],[16953,6.7006],[16983,6.755],[17014,6.7481],[17045,6.7686],[17075,6.7814],[17106,6.7992],[17136,6.7982],[17167,6.7775],[17198,6.817],[17226,6.7797],[17257,6.8134],[17287,6.7939],[17318,6.7954],[17348,6.7898],[17379,6.8015],[17410,6.786],[17440,6.8178],[17471,6.8214],[17501,6.746],[17532,6.8305],[17563,6.8046],[17591,6.8104],[17622,6.8275],[17652,6.8322],[17683,6.8041],[17713,6.8297],[17744,6.8211],[17775,6.7761],[17805,6.8112],[17836,6.8185],[17866,6.8153],[17897,6.8282],[17928,6.8207],[17956,6.8189],[17987,6.839],[18017,6.8294],[18048,6.8141],[18078,6.8194],[18109,6.8292],[18140,6.8267],[18170,6.849],[18201,6.8332],[18231,6.8084],[18262,6.7825],[18293,6.8182],[18322,6.7912],[18353,6.8368],[18383,6.7843],[18414,6.7858],[18444,6.8193],[18475,6.8166],[18506,6.8097],[18536,6.8342],[18567,6.8403],[18597,6.8327],[18628,6.8186],[18659,6.8304],[18687,6.8022],[18718,6.8418],[18748,6.8243],[18779,6.8278],[18809,6.8437],[18840,6.8203],[18871,6.8131],[18901,6.8077],[18932,6.8369],[18962,6.8294],[18993,6.8082],[19024,6.8442],[19052,6.8377],[19083,6.8052],[19113,6.8429],[19144,6.8233],[19174,6.8137],[19205,6.8339],[19236,6.8058],[19266,6.8357],[19297,6.826],[19327,6.8187],[19358,6.8231],[19389,6.8018],[19417,6.7941],[19448,6.7947],[19478,6.7937],[19509,6.7924],[19539,6.7938],[19570,6.791],[19601,6.7901],[19631,6.8],[19662,6.7989],[19692,6.7995],[19723,6.7953],[19754,6.791]],"TWD":[[14791,32.143],[14822,31.949],[14853,31.757],[14883,30.895],[14914,30.428],[14944,29.995],[14975,29.157],[15006,29.284],[15034,29.541],[15065,29.039],[15095,28.759],[15126,28.827],[15156,28.844],[15187,28.967],[15218,29.706],[15248,30.286],[15279,30.217],[15309,30.288],[15340,30.019],[15371,29.569],[15400,29.528],[15431,29.48],[15461,29.498],[15492,29.904],[15522,29.984],[15553,29.97],[15584,29.538],[15614,29.274],[15645,29.152],[15675,29.074],[15706,29.108],[15737,29.614],[15765,29.747],[15796,29.836],[15826,29.756],[15857,29.973],[15887,29.973],[15918,29.965],[15949,29.683],[15979,29.422],[16010,29.528],[16040,29.735],[16071,30.153],[16102,30.32],[16130,30.393],[16161,30.242],[16191,30.137],[16222,29.997],[16252,29.887],[16283,29.983],[16314,30.131],[16344,30.412],[16375,30.713],[16405,31.363],[16436,31.608],[16467,31.506],[16495,31.426],[16526,31.023],[16556,30.559],[16587,30.884],[16617,31.186],[16648,32.186],[16679,32.669],[16709,32.496],[16740,32.592],[16770,32.84],[16801,33.427],[16832,33.276],[16861,32.689],[16892,32.33],[16922,32.513],[16953,32.336],[16983,32.107],[17014,31.584],[17045,31.49],[17075,31.547],[17106,31.723],[17136,32.029],[17167,31.711],[17198,30.911],[17226,30.635],[17257,30.381],[17287,30.16],[17318,30.295],[17348,30.416],[17379,30.243],[17410,30.164],[17440,30.24],[17471,30.109],[17501,29.954],[17532,29.458],[17563,29.228],[17591,29.203],[17622,29.356],[17652,29.845],[17683,30.096],[17713,30.555],[17744,30.73],[17775,30.713],[17805,30.874],[17836,30.844],[17866,30.782],[17897,30.808],[17928,30.81],[17956,30.838],[17987,30.854],[18017,31.234],[18048,31.286],[18078,31.069],[18109,31.353],[18140,31.107],[18170,30.708],[18201,30.475],[18231,30.265],[18262,30.029],[18293,30.169],[18322,30.138],[18353,30.08],[18383,29.962],[18414,29.653],[18444,29.462],[18475,29.407],[18506,29.176],[18536,28.723],[18567,28.588],[18597,28.253],[18628,28.043],[18659,27.99],[18687,28.226],[18718,28.276],[18748,27.901],[18779,27.792],[18809,27.989],[18840,27.878],[18871,27.726],[18901,27.906],[18932,27.805],[18962,27.759],[18993,27.69],[19024,27.84],[19052,28.389],[19083,29.031],[19113,29.54],[19144,29.594],[19174,29.823],[19205,30.089],[19236,31.137],[19266,31.952],[19297,31.472],[19327,30.725],[19358,30.421],[19389,30.252],[19417,30.571],[19448,30.575],[19478,30.695],[19509,30.861],[19539,31.17],[19570,31.716],[19601,31.994],[19631,32.294],[19662,31.938],[19692,31.236],[19723,31.194],[19754,31.449]],"TZS":[[14791,1452.45],[14822,1497.37],[14853,1502.5],[14883,1492.12],[14914,1490.33],[14944,1465.34],[14975,1478.91],[15006,1497.55],[15034,1500.68],[15065,1502.11],[15095,1517.33],[15126,1570.66],[15156,1571.83],[15187,1602.9],[15218,1633.38],[15248,1680.43],[15279,1711.66],[15309,1614.74],[15340,1587.82],[15371,1585.43],[15400,1585.06],[15431,1580.42],[15461,1580.18],[15492,1585.1],[15522,1583.31],[15553,1571.54],[15584,1570.1],[15614,1576.58],[15645,1587.71],[15675,1586.65],[15706,1592.12],[15737,1607.56],[15765,1613.23],[15796,1611.59],[15826,1615.1],[15857,1623.97],[15887,1618.22],[15918,1615.56],[15949,1612.48],[15979,1606.58],[16010,1607.44],[16040,1597.55],[16071,1604.71],[16102,1622.64],[16130,1629.13],[16161,1633.98],[16191,1646.43],[16222,1665.07],[16252,1659.88],[16283,1661.97],[16314,1667.0],[16344,1684.05],[16375,1717.58],[16405,1725.94],[16436,1753.01],[16467,1802.96],[16495,1824.16],[16526,1877.95],[16556,2002.75],[16587,2095.96],[16617,2103.0],[16648,2121.52],[16679,2156.43],[16709,2165.53],[16740,2155.91],[16770,2155.49],[16801,2170.06],[16832,2180.14],[16861,2188.57],[16892,2182.82],[16922,2182.14],[16953,2184.73],[16983,2184.67],[17014,2180.56],[17045,2182.26],[17075,2178.75],[17106,2174.01],[17136,2175.41],[17167,2209.99],[17198,2232.25],[17226,2221.54],[17257,2238.33],[17287,2234.6],[17318,2236.91],[17348,2233.67],[17379,2236.76],[17410,2234.52],[17440,2240.65],[17471,2246.65],[17501,2239.25],[17532,2243.96],[17563,2246.03],[17591,2252.04],[17622,2263.55],[17652,2272.32],[17683,2269.92],[17713,2274.95],[17744,2283.69],[17775,2281.11],[17805,2285.45],[17836,2292.7],[17866,2293.39],[17897,2296.68],[17928,2312.79],[17956,2318.46],[17987,2303.24],[18017,2294.66],[18048,2296.08],[18078,2293.22],[18109,2292.52],[18140,2294.24],[18170,2298.2],[18201,2294.74],[18231,2296.04],[18262,2294.76],[18293,2302.29],[18322,2298.71],[18353,2300.83],[18383,2309.34],[18414,2308.3],[18444,2317.6],[18475,2315.5],[18506,2308.63],[18536,2309.27],[18567,2314.79],[18597,2313.43],[18628,2310.32],[18659,2315.72],[18687,2308.08],[18718,2316.23],[18748,2311.0],[18779,2312.03],[18809,2313.4],[18840,2311.93],[18871,2307.19],[18901,2303.22],[18932,2298.33],[18962,2301.31],[18993,2300.04],[19024,2309.92],[19052,2316.87],[19083,2303.86],[19113,2325.78],[19144,2318.67],[19174,2321.44],[19205,2323.5],[19236,2318.76],[19266,2326.34],[19297,2330.03],[19327,2327.54],[19358,2328.14],[19389,2329.12],[19417,2336.39],[19448,2337.78],[19478,2344.61],[19509,2376.37],[19539,2412.5],[19570,2460.54],[19601,2486.36],[19631,2499.89],[19662,2505.21],[19692,2510.77],[19723,2517.41],[19754,2538.79]],"UAH":[[14791,7.9063],[14822,7.8825],[14853,7.9047],[14883,7.9297],[14914,7.9493],[14944,7.9658],[14975,7.9449],[15006,7.9435],[15034,7.9483],[15065,7.969],[15095,7.9753],[15126,7.9776],[15156,7.978],[15187,7.9945],[15218,7.9917],[15248,7.9895],[15279,7.9911],[15309,8.0066],[15340,8.0243],[15371,8.0067],[15400,8.0031],[15431,8.0069],[15461,8.0227],[15492,8.064],[15522,8.0596],[15553,8.0652],[15584,8.0833],[15614,8.1001],[15645,8.1156],[15675,8.0718],[15706,8.0655],[15737,8.0834],[15765,8.0889],[15796,8.0892],[15826,8.0859],[15857,8.1011],[15887,8.1023],[15918,8.0801],[15949,8.1161],[15979,8.1244],[16010,8.1346],[16040,8.1668],[16071,8.2266],[16102,8.8728],[16130,9.918],[16161,11.6192],[16191,11.7397],[16222,11.815],[16252,11.7982],[16283,12.9519],[16314,13.0017],[16344,12.9476],[16375,14.4964],[16405,15.5747],[16436,15.774],[16467,24.03],[16495,23.08],[16526,22.495],[16556,20.974],[16587,21.184],[16617,21.663],[16648,21.669],[16679,21.671],[16709,21.963],[16740,23.321],[16770,23.575],[16801,24.333],[16832,26.421],[16861,26.407],[16892,25.58],[16922,25.186],[16953,24.916],[16983,24.825],[17014,25.137],[17045,26.13],[17075,25.704],[17106,25.649],[17136,26.342],[17167,27.147],[17198,27.048],[17226,26.998],[17257,26.869],[17287,26.44],[17318,26.139],[17348,25.994],[17379,25.667],[17410,26.159],[17440,26.632],[17471,26.762],[17501,27.592],[17532,28.384],[17563,27.108],[17591,26.376],[17622,26.127],[17652,26.147],[17683,26.213],[17713,26.431],[17744,27.574],[17775,28.188],[17805,28.101],[17836,28.001],[17866,27.764],[17897,27.85],[17928,27.165],[17956,26.913],[17987,26.74],[18017,26.447],[18048,26.424],[18078,25.658],[18109,25.22],[18140,24.656],[18170,24.798],[18201,24.331],[18231,23.616],[18262,24.228],[18293,24.586],[18322,26.464],[18353,27.211],[18383,26.846],[18414,26.709],[18444,27.371],[18475,27.517],[18506,27.992],[18536,28.326],[18567,28.319],[18597,28.217],[18628,28.173],[18659,27.927],[18687,27.795],[18718,27.925],[18748,27.626],[18779,27.23],[18809,27.193],[18840,26.818],[18871,26.68],[18901,26.39],[18932,26.506],[18962,27.181],[18993,27.991],[19024,28.586],[19052,29.613],[19083,29.476],[19113,29.565],[19144,29.473],[19174,31.367],[19205,36.77],[19236,36.834],[19266,36.835],[19297,36.841],[19327,36.727],[19358,36.775],[19389,36.662],[19417,36.852],[19448,36.846],[19478,36.838],[19509,36.868],[19539,36.825],[19570,36.86],[19601,36.838],[19631,36.572],[19662,36.261],[19692,37.202],[19723,37.817],[19754,38.009]],"UGX":[[14791,2257.09],[14822,2236.89],[14853,2253.08],[14883,2256.94],[14914,2295.12],[14944,2308.65],[14975,2318.21],[15006,2337.7],[15034,2380.49],[15065,2379.14],[15095,2382.91],[15126,2478.54],[15156,2572.68],[15187,2741.96],[15218,2815.41],[15248,2803.6],[15279,2580.47],[15309,2489.8],[15340,2404.12],[15371,2339.9],[15400,2464.12],[15431,2498.76],[15461,2488.48],[15492,2493.43],[15522,2480.43],[15553,2487.35],[15584,2527.56],[15614,2578.45],[15645,2633.47],[15675,2681.23],[15706,2684.78],[15737,2658.9],[15765,2637.82],[15796,2602.49],[15826,2588.17],[15857,2598.06],[15887,2599.95],[15918,2585.84],[15949,2575.45],[15979,2539.99],[16010,2532.02],[16040,2523.02],[16071,2502.35],[16102,2492.42],[16130,2531.81],[16161,2541.44],[16191,2538.13],[16222,2582.3],[16252,2632.41],[16283,2625.2],[16314,2632.91],[16344,2683.74],[16375,2745.92],[16405,2768.36],[16436,2855.44],[16467,2884.97],[16495,2952.91],[16526,3006.89],[16556,2999.64],[16587,3200.8],[16617,3362.68],[16648,3536.62],[16679,3675.46],[16709,3644.65],[16740,3471.49],[16770,3381.92],[16801,3452.53],[16832,3438.76],[16861,3384.53],[16892,3352.16],[16922,3351.13],[16953,3390.4],[16983,3394.53],[17014,3378.66],[17045,3398.34],[17075,3445.93],[17106,3573.13],[17136,3617.59],[17167,3611.65],[17198,3605.45],[17226,3586.58],[17257,3635.65],[17287,3625.19],[17318,3605.31],[17348,3600.82],[17379,3611.21],[17410,3605.78],[17440,3656.39],[17471,3664.79],[17501,3635.29],[17532,3642.68],[17563,3642.59],[17591,3679.69],[17622,3708.0],[17652,3749.04],[17683,3853.76],[17713,3788.19],[17744,3744.82],[17775,3804.68],[17805,3797.8],[17836,3758.15],[17866,3724.47],[17897,3708.32],[17928,3689.26],[17956,3711.96],[17987,3752.67],[18017,3774.65],[18048,3736.04],[18078,3704.54],[18109,3705.27],[18140,3690.56],[18170,3709.85],[18201,3713.7],[18231,3687.63],[18262,3685.08],[18293,3694.8],[18322,3771.06],[18353,3808.13],[18383,3804.67],[18414,3751.8],[18444,3721.02],[18475,3693.47],[18506,3704.46],[18536,3731.14],[18567,3731.87],[18597,3676.27],[18628,3685.32],[18659,3685.31],[18687,3669.37],[18718,3644.48],[18748,3573.31],[18779,3561.38],[18809,3560.82],[18840,3548.22],[18871,3542.75],[18901,3574.02],[18932,3578.39],[18962,3555.49],[18993,3542.44],[19024,3545.2],[19052,3585.63],[19083,3562.42],[19113,3643.2],[19144,3723.82],[19174,3802.85],[19205,3841.59],[19236,3831.78],[19266,3837.82],[19297,3780.78],[19327,3719.78],[19358,3702.86],[19389,3701.1],[19417,3747.75],[19448,3762.84],[19478,3747.95],[19509,3712.78],[19539,3673.43],[19570,3685.43],[19601,3745.98],[19631,3769.38],[19662,3795.53],[19692,3790.18],[19723,3804.67],[19754,3876.98]],"USD":[[14791,1.0],[14822,1.0],[14853,1.0],[14883,1.0],[14914,1.0],[14944,1.0],[14975,1.0],[15006,1.0],[15034,1.0],[15065,1.0],[15095,1.0],[15126,1.0],[15156,1.0],[15187,1.0],[15218,1.0],[15248,1.0],[15279,1.0],[15309,1.0],[15340,1.0],[15371,1.0],[15400,1.0],[15431,1.0],[15461,1.0],[15492,1.0],[15522,1.0],[15553,1.0],[15584,1.0],[15614,1.0],[15645,1.0],[15675,1.0],[15706,1.0],[15737,1.0],[15765,1.0],[15796,1.0],[15826,1.0],[15857,1.0],[15887,1.0],[15918,1.0],[15949,1.0],[15979,1.0],[16010,1.0],[16040,1.0],[16071,1.0],[16102,1.0],[16130,1.0],[16161,1.0],[16191,1.0],[16222,1.0],[16252,1.0],[16283,1.0],[16314,1.0],[16344,1.0],[16375,1.0],[16405,1.0],[16436,1.0],[16467,1.0],[16495,1.0],[16526,1.0],[16556,1.0],[16587,1.0],[16617,1.0],[16648,1.0],[16679,1.0],[16709,1.0],[16740,1.0],[16770,1.0],[16801,1.0],[16832,1.0],[16861,1.0],[16892,1.0],[16922,1.0],[16953,1.0],[16983,1.0],[17014,1.0],[17045,1.0],[17075,1.0],[17106,1.0],[17136,1.0],[17167,1.0],[17198,1.0],[17226,1.0],[17257,1.0],[17287,1.0],[17318,1.0],[17348,1.0],[17379,1.0],[17410,1.0],[17440,1.0],[17471,1.0],[17501,1.0],[17532,1.0],[17563,1.0],[17591,1.0],[17622,1.0],[17652,1.0],[17683,1.0],[17713,1.0],[17744,1.0],[17775,1.0],[17805,1.0],[17836,1.0],[17866,1.0],[17897,1.0],[17928,1.0],[17956,1.0],[17987,1.0],[18017,1.0],[18048,1.0],[18078,1.0],[18109,1.0],[18140,1.0],[18170,1.0],[18201,1.0],[18231,1.0],[18262,1.0],[18293,1.0],[18322,1.0],[18353,1.0],[18383,1.0],[18414,1.0],[18444,1.0],[18475,1.0],[18506,1.0],[18536,1.0],[18567,1.0],[18597,1.0],[18628,1.0],[18659,1.0],[18687,1.0],[18718,1.0],[18748,1.0],[18779,1.0],[18809,1.0],[18840,1.0],[18871,1.0],[18901,1.0],[18932,1.0],[18962,1.0],[18993,1.0],[19024,1.0],[19052,1.0],[19083,1.0],[19113,1.0],[19144,1.0],[19174,1.0],[19205,1.0],[19236,1.0],[19266,1.0],[19297,1.0],[19327,1.0],[19358,1.0],[19389,1.0],[19417,1.0],[19448,1.0],[19478,1.0],[19509,1.0],[19539,1.0],[19570,1.0],[19601,1.0],[19631,1.0],[19662,1.0],[19692,1.0],[19723,1.0],[19754,1.0]],"UYU":[[14791,21.015],[14822,20.867],[14853,20.593],[14883,20.221],[14914,20.006],[14944,19.9334],[14975,19.8638],[15006,19.5972],[15034,19.3313],[15065,19.0797],[15095,18.8479],[15126,18.476],[15156,18.4074],[15187,18.6117],[15218,19.4153],[15248,19.8906],[15279,19.7932],[15309,19.9262],[15340,19.6431],[15371,19.4522],[15400,19.4753],[15431,19.6384],[15461,20.189],[15492,21.075],[15522,21.73],[15553,21.28],[15584,21.212],[15614,20.267],[15645,19.7118],[15675,19.3178],[15706,19.2757],[15737,19.1147],[15765,18.9787],[15796,18.9358],[15826,19.25],[15857,20.451],[15887,20.973],[15918,21.789],[15949,22.067],[15979,21.57],[16010,21.37],[16040,21.279],[16071,21.549],[16102,22.312],[16130,22.525],[16161,22.85],[16191,23.011],[16222,22.952],[16252,23.003],[16283,23.607],[16314,24.328],[16344,24.389],[16375,23.99],[16405,24.044],[16436,24.317],[16467,24.535],[16495,25.326],[16526,26.277],[16556,26.572],[16587,26.86],[16617,27.742],[16648,28.567],[16679,28.818],[16709,29.199],[16740,29.47],[16770,29.802],[16801,30.711],[16832,31.671],[16861,32.161],[16892,31.679],[16922,31.336],[16953,30.814],[16983,30.17],[17014,28.953],[17045,28.666],[17075,28.17],[17106,28.566],[17136,28.903],[17167,28.493],[17198,28.399],[17226,28.47],[17257,28.445],[17287,28.253],[17318,28.413],[17348,28.592],[17379,28.701],[17410,28.876],[17440,29.244],[17471,29.249],[17501,28.903],[17532,28.637],[17563,28.441],[17591,28.401],[17622,28.301],[17652,30.686],[17683,31.381],[17713,31.108],[17744,31.477],[17775,32.807],[17805,32.849],[17836,32.593],[17866,32.301],[17897,32.573],[17928,32.637],[17956,33.289],[17987,34.188],[18017,35.099],[18048,35.266],[18078,34.752],[18109,35.841],[18140,36.723],[18170,37.289],[18201,37.661],[18231,37.615],[18262,37.348],[18293,38.129],[18322,43.03],[18353,43.344],[18383,43.431],[18414,42.577],[18444,42.924],[18475,42.69],[18506,42.519],[18536,42.708],[18567,42.783],[18597,42.439],[18628,42.274],[18659,42.73],[18687,44.007],[18718,44.214],[18748,44.012],[18779,43.636],[18809,43.775],[18840,43.288],[18871,42.734],[18901,43.44],[18932,43.949],[18962,44.357],[18993,44.358],[19024,43.362],[19052,42.217],[19083,41.122],[19113,40.789],[19144,39.74],[19174,40.781],[19205,40.754],[19236,40.895],[19266,41.15],[19297,40.115],[19327,39.394],[19358,39.343],[19389,39.026],[19417,39.066],[19448,38.85],[19478,38.816],[19509,38.208],[19539,37.79],[19570,37.862],[19601,38.089],[19631,39.469],[19662,39.698],[19692,39.337],[19723,39.139],[19754,39.153]],"UZS":[[14791,1603.72],[14822,1613.07],[14853,1618.5],[14883,1624.3],[14914,1632.01],[14944,1636.73],[14975,1644.86],[15006,1656.35],[15034,1668.45],[15065,1680.27],[15095,1692.71],[15126,1708.26],[15156,1719.29],[15187,1734.7],[15218,1743.13],[15248,1753.31],[15279,1778.3],[15309,1793.76],[15340,1805.75],[15371,1821.72],[15400,1837.86],[15431,1849.68],[15461,1862.01],[15492,1877.35],[15522,1892.38],[15553,1907.7],[15584,1927.94],[15614,1941.66],[15645,1956.17],[15675,1972.55],[15706,1989.17],[15737,2000.5],[15765,2033.35],[15796,2050.53],[15826,2077.98],[15857,2096.16],[15887,2104.24],[15918,2121.81],[15949,2144.31],[15979,2161.85],[16010,2180.58],[16040,2197.28],[16071,2206.94],[16102,2223.48],[16130,2247.21],[16161,2259.2],[16191,2284.49],[16222,2304.59],[16252,2316.15],[16283,2336.71],[16314,2355.88],[16344,2372.19],[16375,2393.89],[16405,2415.07],[16436,2422.95],[16467,2450.8],[16495,2480.13],[16526,2501.92],[16556,2517.33],[16587,2547.14],[16617,2564.49],[16648,2589.39],[16679,2613.2],[16709,2646.6],[16740,2705.62],[16770,2771.47],[16801,2812.73],[16832,2837.57],[16861,2867.9],[16892,2885.57],[16922,2905.67],[16953,2937.52],[16983,2951.32],[17014,2976.84],[17045,3001.29],[17075,3054.24],[17106,3128.05],[17136,3204.0],[17167,3247.46],[17198,3314.05],[17226,3500.88],[17257,3665.59],[17287,3788.51],[17318,3916.15],[17348,4027.89],[17379,4152.62],[17410,7729.0],[17440,8056.0],[17471,8088.0],[17501,8103.0],[17532,8161.0],[17563,8174.0],[17591,8141.0],[17622,8081.0],[17652,8044.0],[17683,7952.0],[17713,7839.0],[17744,7837.0],[17775,7984.0],[17805,8185.0],[17836,8274.0],[17866,8339.0],[17897,8377.0],[17928,8426.0],[17956,8406.0],[17987,8424.0],[18017,8451.0],[18048,8534.0],[18078,8594.0],[18109,9014.0],[18140,9402.0],[18170,9440.0],[18201,9474.0],[18231,9526.0],[18262,9538.0],[18293,9544.0],[18322,9512.0],[18353,9882.0],[18383,10136.0],[18414,10165.0],[18444,10218.0],[18475,10248.0],[18506,10274.0],[18536,10340.0],[18567,10376.0],[18597,10462.0],[18628,10471.0],[18659,10535.0],[18687,10492.0],[18718,10514.0],[18748,10550.0],[18779,10572.0],[18809,10613.0],[18840,10646.0],[18871,10669.0],[18901,10683.0],[18932,10724.0],[18962,10805.0],[18993,10809.0],[19024,10822.0],[19052,11155.0],[19083,11281.0],[19113,11140.0],[19144,10948.0],[19174,10884.0],[19205,10917.0],[19236,10949.0],[19266,11104.0],[19297,11200.0],[19327,11249.0],[19358,11294.0],[19389,11316.0],[19417,11402.0],[19448,11407.0],[19478,11413.0],[19509,11461.0],[19539,11567.0],[19570,11869.0],[19601,12130.0],[19631,12207.0],[19662,12280.0],[19692,12342.0],[19723,12364.0],[19754,12453.0]],"VES":[[17652,80267.0],[17683,41631.0],[17713,62998.0],[17744,43.259],[17775,61.455],[17805,62.952],[17836,68.478],[17866,307.59],[17897,884.03],[17928,3301.84],[17956,3294.33],[17987,3923.08],[18017,5420.0],[18048,6195.0],[18078,7994.0],[18109,14506.0],[18140,21049.0],[18170,19923.0],[18201,28970.0],[18231,42510.0],[18262,63975.0],[18293,73955.0],[18322,74981.0],[18353,126232.0],[18383,182412.0],[18414,204703.0],[18444,229377.0],[18475,295095.0],[18506,382931.0],[18536,461911.0],[18567,715992.0],[18597,1056632.0],[18628,1443304.0],[18659,1772042.0],[18687,1876847.0],[18718,2385017.0],[18748,2940943.0],[18779,3146472.0],[18809,3567486.0],[18840,4069941.0],[18871,4102930.0],[18901,4.4645],[18932,4.4831],[18962,4.599],[18993,4.5877],[19024,4.4576],[19052,4.3559],[19083,4.4115],[19113,4.8347],[19144,5.3361],[19174,5.6491],[19205,6.7449],[19236,8.0281],[19266,8.3529],[19297,9.644],[19327,14.8439],[19358,19.6836],[19389,23.542],[19417,24.397],[19448,24.565],[19478,25.502],[19509,27.24],[19539,28.609],[19570,31.146],[19601,33.548],[19631,34.826],[19662,35.449],[19692,35.767],[19723,35.981],[19754,36.181]],"VND":[[14791,19034.0],[14822,19184.0],[14853,19417.0],[14883,19384.0],[14914,19540.0],[14944,19426.0],[14975,19448.0],[15006,20173.0],[15034,20838.0],[15065,20820.0],[15095,20688.0],[15126,20604.0],[15156,20606.0],[15187,20701.0],[15218,20816.0],[15248,20847.0],[15279,21008.0],[15309,21009.0],[15340,20961.0],[15371,20884.0],[15400,20852.0],[15431,20829.0],[15461,20879.0],[15492,20921.0],[15522,20891.0],[15553,20825.0],[15584,20781.0],[15614,20848.0],[15645,20790.0],[15675,20782.0],[15706,20754.0],[15737,20751.0],[15765,20842.0],[15796,20839.0],[15826,20885.0],[15857,20987.0],[15887,21164.0],[15918,21188.0],[15949,21078.0],[15979,21086.0],[16010,21145.0],[16040,21080.0],[16071,21096.0],[16102,21144.0],[16130,21161.0],[16161,21166.0],[16191,21007.0],[16222,21158.0],[16252,21183.0],[16283,21171.0],[16314,21154.0],[16344,21185.0],[16375,21280.0],[16405,21333.0],[16436,21328.0],[16467,21373.0],[16495,21468.0],[16526,21614.0],[16556,21672.0],[16587,21726.0],[16617,21822.0],[16648,22104.0],[16679,22397.0],[16709,22251.0],[16740,22314.0],[16770,22406.0],[16801,22346.0],[16832,22260.0],[16861,22322.0],[16892,22257.0],[16922,22242.0],[16953,22238.0],[16983,22272.0],[17014,22263.0],[17045,22282.0],[17075,22280.0],[17106,22356.0],[17136,22646.0],[17167,22570.0],[17198,22641.0],[17226,22714.0],[17257,22644.0],[17287,22642.0],[17318,22751.0],[17348,22729.0],[17379,22676.0],[17410,22661.0],[17440,22644.0],[17471,22666.0],[17501,22651.0],[17532,22654.0],[17563,22627.0],[17591,22701.0],[17622,22724.0],[17652,22692.0],[17683,22775.0],[17713,22991.0],[17744,23186.0],[17775,23206.0],[17805,23275.0],[17836,23269.0],[17866,23273.0],[17897,23228.0],[17928,23239.0],[17956,23216.0],[17987,23251.0],[18017,23366.0],[18048,23380.0],[18078,23267.0],[18109,23168.0],[18140,23172.0],[18170,23170.0],[18201,23131.0],[18231,23160.0],[18262,23144.0],[18293,23225.0],[18322,23353.0],[18353,23451.0],[18383,23355.0],[18414,23240.0],[18444,23257.0],[18475,23223.0],[18506,23198.0],[18536,23193.0],[18567,23204.0],[18597,23179.0],[18628,23111.0],[18659,23079.0],[18687,23085.0],[18718,23128.0],[18748,23095.0],[18779,23038.0],[18809,23053.0],[18840,22924.0],[18871,22725.0],[18901,22718.0],[18932,22688.0],[18962,22915.0],[18993,22705.0],[19024,22718.0],[19052,22851.0],[19083,22901.0],[19113,23116.0],[19144,23227.0],[19174,23374.0],[19205,23348.0],[19236,23572.0],[19266,24212.0],[19297,24719.0],[19327,23874.0],[19358,23504.0],[19389,23599.0],[19417,23621.0],[19448,23498.0],[19478,23469.0],[19509,23515.0],[19539,23619.0],[19570,23814.0],[19601,24195.0],[19631,24478.0],[19662,24376.0],[19692,24307.0],[19723,24418.0],[19754,24480.0]],"VUV":[[14791,101.17],[14822,99.29],[14853,98.06],[14883,94.67],[14914,94.33],[14944,94.43],[14975,94.58],[15006,93.74],[15034,93.14],[15065,91.76],[15095,90.16],[15126,89.62],[15156,89.15],[15187,89.79],[15218,91.76],[15248,92.16],[15279,92.11],[15309,93.74],[15340,93.07],[15371,90.87],[15400,90.47],[15431,91.76],[15461,93.99],[15492,95.47],[15522,93.22],[15553,92.44],[15584,91.65],[15614,92.26],[15645,92.19],[15675,91.52],[15706,91.33],[15737,91.08],[15765,92.11],[15796,92.15],[15826,93.03],[15857,95.05],[15887,96.81],[15918,97.13],[15949,96.11],[15979,94.62],[16010,95.24],[16040,95.86],[16071,97.39],[16102,97.92],[16130,96.62],[16161,94.95],[16191,95.03],[16222,94.34],[16252,94.53],[16283,94.89],[16314,96.98],[16344,98.0],[16375,99.51],[16405,101.15],[16436,103.72],[16467,104.54],[16495,105.98],[16526,106.48],[16556,105.75],[16587,106.51],[16617,108.83],[16648,110.08],[16679,111.16],[16709,111.73],[16740,111.68],[16770,110.69],[16801,111.93],[16832,112.06],[16861,108.67],[16892,107.28],[16922,108.87],[16953,109.81],[16983,108.75],[17014,107.93],[17045,107.4],[17075,108.09],[17106,108.98],[17136,111.1],[17167,110.27],[17198,108.9],[17226,108.88],[17257,109.75],[17287,110.4],[17318,108.55],[17348,107.25],[17379,106.45],[17410,105.84],[17440,107.09],[17471,108.55],[17501,107.75],[17532,106.29],[17563,105.96],[17591,106.61],[17622,107.77],[17652,109.62],[17683,109.7],[17713,111.12],[17744,111.44],[17775,112.21],[17805,113.48],[17836,113.0],[17866,112.35],[17897,113.21],[17928,113.0],[17956,113.11],[17987,113.75],[18017,115.06],[18048,114.57],[18078,114.74],[18109,116.31],[18140,116.81],[18170,116.77],[18201,116.66],[18231,115.27],[18262,115.48],[18293,117.14],[18322,120.49],[18353,121.88],[18383,119.54],[18414,115.89],[18444,114.3],[18475,112.5],[18506,112.15],[18536,113.16],[18567,112.17],[18597,109.05],[18628,108.39],[18659,107.8],[18687,107.82],[18718,108.45],[18748,107.59],[18779,108.46],[18809,110.08],[18840,111.15],[18871,111.39],[18901,110.79],[18932,110.94],[18962,112.31],[18993,113.18],[19024,113.46],[19052,111.8],[19083,111.98],[19113,114.49],[19144,115.71],[19174,117.03],[19205,117.33],[19236,118.83],[19266,121.46],[19297,121.66],[19327,119.36],[19358,118.15],[19389,117.52],[19417,118.27],[19448,118.39],[19478,118.93],[19509,119.06],[19539,118.36],[19570,119.5],[19601,121.19],[19631,122.23],[19662,121.38],[19692,118.12],[19723,118.49],[19754,120.2]],"WST":[[14791,2.5686],[14822,2.5455],[14853,2.4825],[14883,2.3974],[14914,2.3884],[14944,2.3527],[14975,2.374],[15006,2.3729],[15034,2.3539],[15065,2.3329],[15095,2.2955],[15126,2.265],[15156,2.2513],[15187,2.2352],[15218,2.2894],[15248,2.3359],[15279,2.3343],[15309,2.3533],[15340,2.3324],[15371,2.2695],[15400,2.2426],[15431,2.2668],[15461,2.3274],[15492,2.3283],[15522,2.3155],[15553,2.2991],[15584,2.2711],[15614,2.2782],[15645,2.286],[15675,2.2647],[15706,2.2654],[15737,2.266],[15765,2.2597],[15796,2.2587],[15826,2.2903],[15857,2.3273],[15887,2.365],[15918,2.3698],[15949,2.3326],[15979,2.3113],[16010,2.3162],[16040,2.326],[16071,2.3434],[16102,2.3425],[16130,2.3115],[16161,2.2953],[16191,2.2868],[16222,2.2751],[16252,2.2856],[16283,2.3048],[16314,2.3465],[16344,2.3941],[16375,2.4038],[16405,2.4169],[16436,2.4641],[16467,2.4905],[16495,2.4837],[16526,2.4833],[16556,2.4948],[16587,2.5328],[16617,2.6035],[16648,2.6285],[16679,2.661],[16709,2.6441],[16740,2.6381],[16770,2.6067],[16801,2.6429],[16832,2.6412],[16861,2.5825],[16892,2.5728],[16922,2.5978],[16953,2.5632],[16983,2.5509],[17014,2.5376],[17045,2.5089],[17075,2.5338],[17106,2.5409],[17136,2.5566],[17167,2.5579],[17198,2.5248],[17226,2.532],[17257,2.5606],[17287,2.5517],[17318,2.499],[17348,2.4761],[17379,2.4613],[17410,2.4889],[17440,2.5043],[17471,2.5613],[17501,2.5151],[17532,2.5105],[17563,2.4655],[17591,2.489],[17622,2.5041],[17652,2.5556],[17683,2.5366],[17713,2.5883],[17744,2.6068],[17775,2.6118],[17805,2.6404],[17836,2.6209],[17866,2.5745],[17897,2.6194],[17928,2.6104],[17956,2.5774],[17987,2.6195],[18017,2.6558],[18048,2.616],[18078,2.6441],[18109,2.6531],[18140,2.653],[18170,2.6609],[18201,2.6595],[18231,2.6244],[18262,2.6584],[18293,2.695],[18322,2.733],[18353,2.7632],[18383,2.7574],[18414,2.6516],[18444,2.6534],[18475,2.6296],[18506,2.615],[18536,2.6414],[18567,2.6105],[18597,2.5175],[18628,2.536],[18659,2.5244],[18687,2.4974],[18718,2.5401],[18748,2.5255],[18779,2.5157],[18809,2.5683],[18840,2.5828],[18871,2.5807],[18901,2.5649],[18932,2.5841],[18962,2.6156],[18993,2.6303],[19024,2.6346],[19052,2.6019],[19083,2.6101],[19113,2.6497],[19144,2.6721],[19174,2.7115],[19205,2.7113],[19236,2.7592],[19266,2.8325],[19297,2.7939],[19327,2.7178],[19358,2.7078],[19389,2.7095],[19417,2.7279],[19448,2.7412],[19478,2.7411],[19509,2.7208],[19539,2.7215],[19570,2.7438],[19601,2.7518],[19631,2.7905],[19662,2.7709],[19692,2.721],[19723,2.7269],[19754,2.7423]],"XAF":[[14791,517.26],[14822,508.52],[14853,503.65],[14883,474.69],[14914,484.18],[14944,496.97],[14975,490.29],[15006,479.3],[15034,470.1],[15065,454.31],[15095,454.63],[15126,456.2],[15156,458.79],[15187,457.29],[15218,475.89],[15248,477.87],[15279,480.83],[15309,499.21],[15340,505.81],[15371,495.59],[15400,493.44],[15431,497.49],[15461,513.02],[15492,525.05],[15522,532.84],[15553,529.13],[15584,511.63],[15614,506.72],[15645,509.7],[15675,500.46],[15706,493.6],[15737,492.05],[15765,507.41],[15796,505.79],[15826,504.42],[15857,500.75],[15887,500.72],[15918,493.98],[15949,490.43],[15979,482.19],[16010,483.6],[16040,479.02],[16071,481.71],[16102,480.51],[16130,476.27],[16161,475.58],[16191,478.06],[16222,481.81],[16252,485.59],[16283,493.49],[16314,510.86],[16344,518.96],[16375,525.27],[16405,534.27],[16436,562.94],[16467,579.82],[16495,602.48],[16526,603.73],[16556,592.1],[16587,588.99],[16617,595.5],[16648,590.49],[16679,584.89],[16709,587.23],[16740,612.25],[16770,606.87],[16801,605.14],[16832,595.96],[16861,589.41],[16892,579.57],[16922,583.15],[16953,589.08],[16983,594.73],[17014,588.79],[17045,589.35],[17075,596.23],[17106,609.25],[17136,622.4],[17167,620.07],[17198,618.58],[17226,616.86],[17257,610.96],[17287,594.81],[17318,584.48],[17348,571.14],[17379,563.23],[17410,562.59],[17440,569.04],[17471,565.87],[17501,563.72],[17532,543.75],[17563,538.77],[17591,541.99],[17622,544.13],[17652,563.72],[17683,572.03],[17713,569.28],[17744,574.8],[17775,578.81],[17805,584.8],[17836,591.88],[17866,593.54],[17897,589.31],[17928,593.68],[17956,599.79],[17987,600.11],[18017,602.95],[18048,593.93],[18078,589.97],[18109,596.08],[18140,605.82],[18170,596.44],[18201,596.62],[18231,592.01],[18262,594.04],[18293,601.49],[18322,597.47],[18353,605.73],[18383,602.6],[18414,587.88],[18444,574.84],[18475,556.99],[18506,558.38],[18536,559.8],[18567,557.24],[18597,541.06],[18628,540.1],[18659,544.78],[18687,550.82],[18718,552.59],[18748,542.59],[18779,546.12],[18809,555.58],[18840,558.3],[18871,562.51],[18901,566.33],[18932,575.5],[18962,583.1],[18993,584.0],[19024,584.27],[19052,594.71],[19083,607.13],[19113,621.39],[19144,625.15],[19174,640.73],[19205,652.09],[19236,666.78],[19266,671.27],[19297,651.28],[19327,625.37],[19358,615.63],[19389,613.57],[19417,613.1],[19448,602.68],[19478,605.51],[19509,607.08],[19539,596.97],[19570,600.22],[19601,614.68],[19631,620.99],[19662,612.92],[19692,602.22],[19723,601.41],[19754,608.46]],"XAG":[[14791,0.0556],[14822,0.05448],[14853,0.04892],[14883,0.04289],[14914,0.03777],[14944,0.03424],[14975,0.03497],[15006,0.03249],[15034,0.02808],[15065,0.0235],[15095,0.02672],[15126,0.02782],[15156,0.0265],[15187,0.02486],[15218,0.02586],[15248,0.03134],[15279,0.0302],[15309,0.03288],[15340,0.0327],[15371,0.02941],[15400,0.03011],[15431,0.03164],[15461,0.03467],[15492,0.03563],[15522,0.03648],[15553,0.03479],[15584,0.02984],[15614,0.03003],[15645,0.03055],[15675,0.03133],[15706,0.03217],[15737,0.03289],[15765,0.03472],[15796,0.03924],[15826,0.04324],[15857,0.04699],[15887,0.05085],[15918,0.0457],[15949,0.04415],[15979,0.04555],[16010,0.04804],[16040,0.05093],[16071,0.05019],[16102,0.04821],[16130,0.04809],[16161,0.05068],[16191,0.05161],[16222,0.05068],[16252,0.04774],[16283,0.05045],[16314,0.05391],[16344,0.05805],[16375,0.06256],[16405,0.06162],[16436,0.05852],[16467,0.05935],[16495,0.06172],[16526,0.06106],[16556,0.05949],[16587,0.06199],[16617,0.06613],[16648,0.06691],[16679,0.06782],[16709,0.06374],[16740,0.06889],[16770,0.07092],[16801,0.07118],[16832,0.06643],[16861,0.0651],[16892,0.06176],[16922,0.05905],[16953,0.05842],[16983,0.05038],[17014,0.05101],[17045,0.05186],[17075,0.0563],[17106,0.05751],[17136,0.06106],[17167,0.0596],[17198,0.05608],[17226,0.05681],[17257,0.05522],[17287,0.05953],[17318,0.05882],[17348,0.06177],[17379,0.0591],[17410,0.05723],[17440,0.05902],[17471,0.05879],[17501,0.06182],[17532,0.05829],[17563,0.05993],[17591,0.06067],[17622,0.06018],[17652,0.06068],[17683,0.06046],[17713,0.06347],[17744,0.06656],[17775,0.07006],[17805,0.06857],[17836,0.06954],[17866,0.06825],[17897,0.06425],[17928,0.06319],[17956,0.06521],[17987,0.06645],[18017,0.06826],[18048,0.06679],[18078,0.06365],[18109,0.05851],[18140,0.05497],[18170,0.05681],[18201,0.0582],[18231,0.05839],[18262,0.05572],[18293,0.05577],[18322,0.06596],[18353,0.06661],[18383,0.06209],[18414,0.0565],[18444,0.04964],[18475,0.03738],[18506,0.03844],[18536,0.04125],[18567,0.04153],[18597,0.04029],[18628,0.03876],[18659,0.03661],[18687,0.03882],[18718,0.03922],[18748,0.03658],[18779,0.03694],[18809,0.03881],[18840,0.04156],[18871,0.04273],[18901,0.04305],[18932,0.04126],[18962,0.04433],[18993,0.0432],[19024,0.04274],[19052,0.03964],[19083,0.04048],[19113,0.04551],[19144,0.0463],[19174,0.05226],[19205,0.05054],[19236,0.05313],[19266,0.05159],[19297,0.04779],[19327,0.04308],[19358,0.04203],[19389,0.04521],[19417,0.04592],[19448,0.04009],[19478,0.04124],[19509,0.04263],[19539,0.04182],[19570,0.04263],[19601,0.04293],[19631,0.04481],[19662,0.0428],[19692,0.0415],[19723,0.04352],[19754,0.044]],"XAU":[[14791,0.00084],[14822,0.00082],[14853,0.00079],[14883,0.00075],[14914,0.00073],[14944,0.00072],[14975,0.00074],[15006,0.00073],[15034,0.0007],[15065,0.00068],[15095,0.00066],[15126,0.00065],[15156,0.00064],[15187,0.00057],[15218,0.00056],[15248,0.0006],[15279,0.00057],[15309,0.0006],[15340,0.00061],[15371,0.00057],[15400,0.00059],[15431,0.00061],[15461,0.00063],[15492,0.00063],[15522,0.00063],[15553,0.00061],[15584,0.00057],[15614,0.00057],[15645,0.00058],[15675,0.00059],[15706,0.0006],[15737,0.00061],[15765,0.00063],[15796,0.00067],[15826,0.0007],[15857,0.00074],[15887,0.00078],[15918,0.00074],[15949,0.00074],[15979,0.00076],[16010,0.00078],[16040,0.00082],[16071,0.0008],[16102,0.00077],[16130,0.00075],[16161,0.00077],[16191,0.00078],[16222,0.00078],[16252,0.00076],[16283,0.00077],[16314,0.00081],[16344,0.00082],[16375,0.00085],[16405,0.00083],[16436,0.0008],[16467,0.00081],[16495,0.00085],[16526,0.00083],[16556,0.00083],[16587,0.00085],[16617,0.00088],[16648,0.00089],[16679,0.00089],[16709,0.00086],[16740,0.00092],[16770,0.00094],[16801,0.00091],[16832,0.00084],[16861,0.0008],[16892,0.00081],[16922,0.00079],[16953,0.00079],[16983,0.00075],[17014,0.00075],[17045,0.00075],[17075,0.00079],[17106,0.00081],[17136,0.00087],[17167,0.00084],[17198,0.00081],[17226,0.00081],[17257,0.00079],[17287,0.0008],[17318,0.00079],[17348,0.00081],[17379,0.00078],[17410,0.00076],[17440,0.00078],[17471,0.00078],[17501,0.00079],[17532,0.00075],[17563,0.00075],[17591,0.00075],[17622,0.00075],[17652,0.00077],[17683,0.00078],[17713,0.00081],[17744,0.00083],[17775,0.00083],[17805,0.00082],[17836,0.00082],[17866,0.0008],[17897,0.00077],[17928,0.00076],[17956,0.00077],[17987,0.00078],[18017,0.00078],[18048,0.00074],[18078,0.00071],[18109,0.00067],[18140,0.00066],[18170,0.00067],[18201,0.00068],[18231,0.00068],[18262,0.00064],[18293,0.00063],[18322,0.00063],[18353,0.0006],[18383,0.00058],[18414,0.00058],[18444,0.00054],[18475,0.00051],[18506,0.00052],[18536,0.00053],[18567,0.00053],[18597,0.00054],[18628,0.00054],[18659,0.00055],[18687,0.00058],[18718,0.00057],[18748,0.00054],[18779,0.00054],[18809,0.00055],[18840,0.00056],[18871,0.00056],[18901,0.00056],[18932,0.00055],[18962,0.00056],[18993,0.00055],[19024,0.00054],[19052,0.00051],[19083,0.00052],[19113,0.00054],[19144,0.00054],[19174,0.00058],[19205,0.00057],[19236,0.00059],[19266,0.0006],[19297,0.00058],[19327,0.00056],[19358,0.00053],[19389,0.00054],[19417,0.00052],[19448,0.0005],[19478,0.0005],[19509,0.00051],[19539,0.00051],[19570,0.00052],[19601,0.00052],[19631,0.00052],[19662,0.0005],[19692,0.00049],[19723,0.00049],[19754,0.00049]],"XCD":[[14791,2.7063],[14822,2.7053],[14853,2.7041],[14883,2.704],[14914,2.7052],[14944,2.7015],[14975,2.706],[15006,2.7071],[15034,2.7041],[15065,2.7057],[15095,2.7107],[15126,2.7111],[15156,2.7105],[15187,2.7122],[15218,2.7089],[15248,2.7104],[15279,2.7108],[15309,2.7103],[15340,2.7088],[15371,2.7128],[15400,2.7112],[15431,2.7126],[15461,2.7143],[15492,2.7127],[15522,2.7133],[15553,2.7121],[15584,2.7104],[15614,2.7124],[15645,2.7132],[15675,2.7125],[15706,2.7135],[15737,2.7136],[15765,2.7109],[15796,2.7132],[15826,2.7113],[15857,2.7128],[15887,2.7132],[15918,2.7139],[15949,2.7101],[15979,2.7117],[16010,2.7133],[16040,2.7121],[16071,2.7129],[16102,2.7108],[16130,2.6969],[16161,2.7087],[16191,2.7181],[16222,2.7151],[16252,2.7215],[16283,2.7195],[16314,2.7117],[16344,2.7078],[16375,2.7098],[16405,2.7071],[16436,2.7167],[16467,2.7187],[16495,2.7149],[16526,2.7187],[16556,2.717],[16587,2.7179],[16617,2.7185],[16648,2.7181],[16679,2.7145],[16709,2.7184],[16740,2.7184],[16770,2.713],[16801,2.7171],[16832,2.7172],[16861,2.7158],[16892,2.719],[16922,2.7184],[16953,2.7157],[16983,2.7167],[17014,2.7181],[17045,2.7157],[17075,2.719],[17106,2.7199],[17136,2.7164],[17167,2.7172],[17198,2.7175],[17226,2.7145],[17257,2.7187],[17287,2.7179],[17318,2.7151],[17348,2.717],[17379,2.7175],[17410,2.7152],[17440,2.719],[17471,2.7208],[17501,2.716],[17532,2.7195],[17563,2.7182],[17591,2.7177],[17622,2.7206],[17652,2.7183],[17683,2.7172],[17713,2.7191],[17744,2.7214],[17775,2.7163],[17805,2.7197],[17836,2.7201],[17866,2.7163],[17897,2.7198],[17928,2.7203],[17956,2.7169],[17987,2.72],[18017,2.7187],[18048,2.7164],[18078,2.7182],[18109,2.7192],[18140,2.7173],[18170,2.718],[18201,2.7182],[18231,2.7171],[18262,2.7196],[18293,2.7199],[18322,2.7182],[18353,2.7195],[18383,2.7179],[18414,2.7176],[18444,2.7184],[18475,2.7189],[18506,2.7166],[18536,2.7196],[18567,2.7185],[18597,2.7167],[18628,2.7169],[18659,2.7161],[18687,2.7134],[18718,2.7167],[18748,2.716],[18779,2.7156],[18809,2.715],[18840,2.7171],[18871,2.7144],[18901,2.7157],[18932,2.7189],[18962,2.7142],[18993,2.7165],[19024,2.72],[19052,2.7164],[19083,2.7149],[19113,2.7164],[19144,2.7138],[19174,2.7145],[19205,2.7163],[19236,2.7128],[19266,2.7185],[19297,2.7172],[19327,2.7149],[19358,2.7169],[19389,2.7184],[19417,2.716],[19448,2.7182],[19478,2.7182],[19509,2.7159],[19539,2.7182],[19570,2.7181],[19601,2.7161],[19631,2.7198],[19662,2.7208],[19692,2.7161],[19723,2.718],[19754,2.718]],"XDR":[[14791,0.66461],[14822,0.65781],[14853,0.65506],[14883,0.63651],[14914,0.64018],[14944,0.64983],[14975,0.64745],[15006,0.63966],[15034,0.63292],[15065,0.62557],[15095,0.62741],[15126,0.62664],[15156,0.6277],[15187,0.62413],[15218,0.63388],[15248,0.63696],[15279,0.63777],[15309,0.64601],[15340,0.65163],[15371,0.64539],[15400,0.65023],[15431,0.64907],[15461,0.65369],[15492,0.65923],[15522,0.66234],[15553,0.65992],[15584,0.6497],[15614,0.64794],[15645,0.65212],[15675,0.64894],[15706,0.65085],[15737,0.65545],[15765,0.66478],[15796,0.66469],[15826,0.66693],[15857,0.6611],[15887,0.66475],[15918,0.65999],[15949,0.6565],[15979,0.64956],[16010,0.65289],[16040,0.6498],[16071,0.65115],[16102,0.64934],[16130,0.64616],[16161,0.64589],[16191,0.64631],[16222,0.64864],[16252,0.64848],[16283,0.65491],[16314,0.66653],[16344,0.67245],[16375,0.68135],[16405,0.68637],[16436,0.70253],[16467,0.70764],[16495,0.72136],[16526,0.72212],[16556,0.71194],[16587,0.71149],[16617,0.71576],[16648,0.71305],[16679,0.71077],[16709,0.71091],[16740,0.72296],[16770,0.72172],[16801,0.72321],[16832,0.71756],[16861,0.71679],[16892,0.71002],[16922,0.70867],[16953,0.71025],[16983,0.71868],[17014,0.71452],[17045,0.71466],[17075,0.72358],[17106,0.73176],[17136,0.74202],[17167,0.7398],[17198,0.73755],[17226,0.73806],[17257,0.7344],[17287,0.72704],[17318,0.72258],[17348,0.71661],[17379,0.70849],[17410,0.70449],[17440,0.70916],[17471,0.70971],[17501,0.70637],[17532,0.69528],[17563,0.6886],[17591,0.68823],[17622,0.68942],[17652,0.70189],[17683,0.70686],[17713,0.71136],[17744,0.71634],[17775,0.71394],[17805,0.71874],[17836,0.72212],[17866,0.72159],[17897,0.7172],[17928,0.71808],[17956,0.71837],[17987,0.72064],[18017,0.72361],[18048,0.72182],[18078,0.72352],[18109,0.72828],[18140,0.73069],[18170,0.72877],[18201,0.72696],[18231,0.72503],[18262,0.72463],[18293,0.73007],[18322,0.72745],[18353,0.73356],[18383,0.73338],[18414,0.72541],[18444,0.71997],[18475,0.70797],[18506,0.7079],[18536,0.7072],[18567,0.7031],[18597,0.69524],[18628,0.69318],[18659,0.69422],[18687,0.70012],[18718,0.6998],[18748,0.69409],[18779,0.69637],[18809,0.70224],[18840,0.70309],[18871,0.70351],[18901,0.70779],[18932,0.71233],[18962,0.71497],[18993,0.71401],[19024,0.71308],[19052,0.72241],[19083,0.73234],[19113,0.74486],[19144,0.74663],[19174,0.75925],[19205,0.76041],[19236,0.77399],[19266,0.78053],[19297,0.76836],[19327,0.75238],[19358,0.74378],[19389,0.74668],[19417,0.74845],[19448,0.74127],[19478,0.7456],[19509,0.74956],[19539,0.74421],[19570,0.74984],[19601,0.75738],[19631,0.76219],[19662,0.75558],[19692,0.74907],[19723,0.7501],[19754,0.75385]],"XOF":[[14791,518.23],[14822,507.75],[14853,504.65],[14883,475.26],[14914,482.46],[14944,496.57],[14975,491.47],[15006,479.74],[15034,470.54],[15065,455.3],[15095,454.52],[15126,456.32],[15156,459.06],[15187,457.68],[15218,474.39],[15248,478.73],[15279,480.26],[15309,498.49],[15340,506.53],[15371,496.48],[15400,493.92],[15431,497.12],[15461,511.1],[15492,524.81],[15522,532.27],[15553,529.95],[15584,512.08],[15614,506.54],[15645,509.75],[15675,500.87],[15706,493.51],[15737,490.74],[15765,506.58],[15796,505.74],[15826,504.04],[15857,500.56],[15887,500.92],[15918,493.56],[15949,490.77],[15979,482.52],[16010,483.48],[16040,479.16],[16071,481.43],[16102,480.91],[16130,476.51],[16161,475.61],[16191,477.62],[16222,482.03],[16252,485.04],[16283,492.98],[16314,509.42],[16344,518.48],[16375,524.9],[16405,533.51],[16436,560.42],[16467,579.17],[16495,601.07],[16526,604.35],[16556,591.66],[16587,588.97],[16617,595.04],[16648,592.1],[16679,585.26],[16709,587.49],[16740,610.67],[16770,606.67],[16801,604.7],[16832,595.13],[16861,589.21],[16892,578.97],[16922,580.84],[16953,587.31],[16983,592.3],[17014,589.07],[17045,586.92],[17075,594.63],[17106,608.05],[17136,622.43],[17167,620.32],[17198,618.22],[17226,616.75],[17257,610.78],[17287,595.11],[17318,584.04],[17348,572.78],[17379,558.99],[17410,550.82],[17440,557.44],[17471,560.89],[17501,560.31],[17532,541.44],[17563,531.0],[17591,532.28],[17622,538.16],[17652,559.86],[17683,572.11],[17713,566.14],[17744,568.95],[17775,564.49],[17805,572.76],[17836,580.84],[17866,576.9],[17897,574.73],[17928,577.4],[17956,579.48],[17987,584.0],[18017,586.78],[18048,580.24],[18078,585.11],[18109,591.02],[18140,596.96],[18170,594.21],[18201,593.07],[18231,589.42],[18262,589.75],[18293,598.97],[18322,593.34],[18353,600.98],[18383,600.99],[18414,586.7],[18444,573.5],[18475,555.86],[18506,555.77],[18536,558.04],[18567,555.53],[18597,540.08],[18628,538.56],[18659,542.41],[18687,550.82],[18718,551.69],[18748,540.64],[18779,544.42],[18809,552.94],[18840,556.07],[18871,559.92],[18901,564.39],[18932,573.17],[18962,581.79],[18993,580.48],[19024,582.89],[19052,591.2],[19083,603.71],[19113,617.65],[19144,620.22],[19174,637.39],[19205,648.08],[19236,663.19],[19266,666.98],[19297,648.67],[19327,625.66],[19358,611.08],[19389,610.4],[19417,612.35],[19448,600.53],[19478,602.53],[19509,611.26],[19539,595.49],[19570,601.31],[19601,611.32],[19631,620.49],[19662,610.67],[19692,598.78],[19723,599.46],[19754,605.83]],"XPD":[[14791,0.0022],[14822,0.00204],[14853,0.00186],[14883,0.0017],[14914,0.00147],[14944,0.00133],[14975,0.00126],[15006,0.00122],[15034,0.0013],[15065,0.0013],[15095,0.00135],[15126,0.0013],[15156,0.00127],[15187,0.00131],[15218,0.0014],[15248,0.00163],[15279,0.0016],[15309,0.00155],[15340,0.00152],[15371,0.00143],[15400,0.00145],[15431,0.00153],[15461,0.0016],[15492,0.00163],[15522,0.00173],[15553,0.00166],[15584,0.00152],[15614,0.00157],[15645,0.00158],[15675,0.00145],[15706,0.0014],[15737,0.00133],[15765,0.00132],[15796,0.00141],[15826,0.00139],[15857,0.00139],[15887,0.0014],[15918,0.00135],[15949,0.00141],[15979,0.00138],[16010,0.00136],[16040,0.0014],[16071,0.00136],[16102,0.00138],[16130,0.0013],[16161,0.00126],[16191,0.00122],[16222,0.0012],[16252,0.00115],[16283,0.00114],[16314,0.00118],[16344,0.00128],[16375,0.00128],[16405,0.00124],[16436,0.00127],[16467,0.00127],[16495,0.00126],[16526,0.00131],[16556,0.00128],[16587,0.00136],[16617,0.00154],[16648,0.00168],[16679,0.00166],[16709,0.00145],[16740,0.00173],[16770,0.00181],[16801,0.002],[16832,0.00198],[16861,0.0018],[16892,0.00175],[16922,0.00172],[16953,0.00181],[16983,0.00155],[17014,0.00143],[17045,0.00147],[17075,0.00153],[17106,0.00144],[17136,0.00142],[17167,0.00133],[17198,0.00129],[17226,0.00129],[17257,0.00125],[17287,0.00126],[17318,0.00116],[17348,0.00117],[17379,0.0011],[17410,0.00107],[17440,0.00105],[17471,0.001],[17501,0.00097],[17532,0.00091],[17563,0.00098],[17591,0.00101],[17622,0.00103],[17652,0.00102],[17683,0.00101],[17713,0.00107],[17744,0.00109],[17775,0.00099],[17805,0.00092],[17836,0.00088],[17866,0.0008],[17897,0.00075],[17928,0.0007],[17956,0.00066],[17987,0.00072],[18017,0.00075],[18048,0.00069],[18078,0.00065],[18109,0.00069],[18140,0.00062],[18170,0.00058],[18201,0.00056],[18231,0.00053],[18262,0.00044],[18293,0.0004],[18322,0.00045],[18353,0.00047],[18383,0.00052],[18414,0.00051],[18444,0.00049],[18475,0.00046],[18506,0.00044],[18536,0.00042],[18567,0.00043],[18597,0.00043],[18628,0.00042],[18659,0.00043],[18687,0.00041],[18718,0.00036],[18748,0.00035],[18779,0.00037],[18809,0.00036],[18840,0.00039],[18871,0.00047],[18901,0.0005],[18932,0.0005],[18962,0.00055],[18993,0.00049],[19024,0.00043],[19052,0.00038],[19083,0.00043],[19113,0.00049],[19144,0.00052],[19174,0.00051],[19205,0.00047],[19236,0.00047],[19266,0.00048],[19297,0.00052],[19327,0.00055],[19358,0.00057],[19389,0.00064],[19417,0.00069],[19448,0.00067],[19478,0.00067],[19509,0.00073],[19539,0.00079],[19570,0.0008],[19601,0.00081],[19631,0.00087],[19662,0.00094],[19692,0.00092],[19723,0.00103],[19754,0.00106]],"XPF":[[14791,94.92],[14822,91.93],[14853,92.37],[14883,86.51],[14914,86.78],[14944,90.12],[14975,89.66],[15006,87.27],[15034,85.73],[15065,83.31],[15095,82.31],[15126,83.02],[15156,83.18],[15187,83.23],[15218,85.02],[15248,87.27],[15279,86.62],[15309,89.99],[15340,92.25],[15371,90.49],[15400,89.75],[15431,90.26],[15461,92.02],[15492,95.31],[15522,96.41],[15553,96.57],[15584,93.54],[15614,92.03],[15645,92.62],[15675,91.29],[15706,90.01],[15737,88.77],[15765,91.49],[15796,91.98],[15826,91.46],[15857,90.99],[15887,91.3],[15918,89.64],[15949,89.44],[15979,87.69],[16010,87.72],[16040,87.31],[16071,87.18],[16102,87.53],[16130,86.54],[16161,86.38],[16191,86.45],[16222,87.57],[16252,87.82],[16283,89.24],[16314,91.63],[16344,93.93],[16375,95.18],[16405,96.2],[16436,100.64],[16467,104.87],[16495,108.31],[16526,110.39],[16556,107.27],[16587,107.27],[16617,107.82],[16648,107.79],[16679,105.92],[16709,106.04],[16740,110.05],[16770,110.57],[16801,109.56],[16832,107.96],[16861,107.8],[16892,105.25],[16922,105.16],[16953,106.4],[16983,107.52],[17014,106.74],[17045,106.39],[17075,107.41],[17106,109.82],[17136,112.79],[17167,112.94],[17198,111.93],[17226,112.02],[17257,111.18],[17287,108.52],[17318,106.24],[17348,104.06],[17379,101.16],[17410,100.09],[17440,101.23],[17471,101.99],[17501,100.75],[17532,98.55],[17563,96.35],[17591,96.73],[17622,96.7],[17652,99.92],[17683,102.15],[17713,102.29],[17744,102.88],[17775,102.24],[17805,103.06],[17836,104.87],[17866,104.75],[17897,104.28],[17928,104.8],[17956,105.24],[17987,106.08],[18017,106.59],[18048,105.95],[18078,105.7],[18109,106.98],[18140,108.06],[18170,108.26],[18201,107.67],[18231,107.66],[18262,107.19],[18293,108.85],[18322,108.3],[18353,109.25],[18383,109.71],[18414,106.7],[18444,104.88],[18475,101.19],[18506,100.78],[18536,101.53],[18567,101.13],[18597,98.76],[18628,97.76],[18659,98.62],[18687,99.14],[18718,100.44],[18748,98.46],[18779,98.46],[18809,100.43],[18840,101.13],[18871,101.31],[18901,102.41],[18932,103.66],[18962,105.6],[18993,105.44],[19024,105.72],[19052,107.16],[19083,108.93],[19113,112.33],[19144,112.34],[19174,115.1],[19205,117.46],[19236,119.69],[19266,121.64],[19297,118.9],[19327,113.85],[19358,111.41],[19389,110.51],[19417,111.96],[19448,109.43],[19478,109.08],[19509,110.54],[19539,108.42],[19570,108.42],[19601,110.74],[19631,112.7],[19662,111.6],[19692,109.24],[19723,108.76],[19754,110.17]],"XPT":[[14791,0.00066],[14822,0.00065],[14853,0.00063],[14883,0.00059],[14914,0.00059],[14944,0.00059],[14975,0.00056],[15006,0.00055],[15034,0.00056],[15065,0.00056],[15095,0.00056],[15126,0.00056],[15156,0.00057],[15187,0.00056],[15218,0.00057],[15248,0.00065],[15279,0.00063],[15309,0.00068],[15340,0.00066],[15371,0.00061],[15400,0.0006],[15431,0.00063],[15461,0.00068],[15492,0.00069],[15522,0.0007],[15553,0.00069],[15584,0.00062],[15614,0.00061],[15645,0.00063],[15675,0.00063],[15706,0.00061],[15737,0.0006],[15765,0.00063],[15796,0.00067],[15826,0.00068],[15857,0.00069],[15887,0.00072],[15918,0.00067],[15949,0.00068],[15979,0.00071],[16010,0.0007],[16040,0.00074],[16071,0.0007],[16102,0.00071],[16130,0.00069],[16161,0.0007],[16191,0.00069],[16222,0.00069],[16252,0.00067],[16283,0.00069],[16314,0.00073],[16344,0.00079],[16375,0.00083],[16405,0.00082],[16436,0.0008],[16467,0.00083],[16495,0.00087],[16526,0.00087],[16556,0.00088],[16587,0.00092],[16617,0.00098],[16648,0.00102],[16679,0.00103],[16709,0.00103],[16740,0.00112],[16770,0.00116],[16801,0.00117],[16832,0.00109],[16861,0.00104],[16892,0.00101],[16922,0.00096],[16953,0.00102],[16983,0.00092],[17014,0.00089],[17045,0.00096],[17075,0.00104],[17106,0.00105],[17136,0.00109],[17167,0.00103],[17198,0.00099],[17226,0.00103],[17257,0.00104],[17287,0.00107],[17318,0.00107],[17348,0.00109],[17379,0.00103],[17410,0.00109],[17440,0.00108],[17471,0.00107],[17501,0.0011],[17532,0.00101],[17563,0.00101],[17591,0.00104],[17622,0.00108],[17652,0.0011],[17683,0.00113],[17713,0.0012],[17744,0.00124],[17775,0.00124],[17805,0.00121],[17836,0.00118],[17866,0.00126],[17897,0.00124],[17928,0.00122],[17956,0.00119],[17987,0.00113],[18017,0.00119],[18048,0.00123],[18078,0.00118],[18109,0.00116],[18140,0.00106],[18170,0.00112],[18201,0.0011],[18231,0.00108],[18262,0.00101],[18293,0.00104],[18322,0.00126],[18353,0.00132],[18383,0.00126],[18414,0.00121],[18444,0.00116],[18475,0.00106],[18506,0.0011],[18536,0.00114],[18567,0.0011],[18597,0.00097],[18628,0.00092],[18659,0.00083],[18687,0.00084],[18718,0.00083],[18748,0.00082],[18779,0.00089],[18809,0.00092],[18840,0.00099],[18871,0.00102],[18901,0.00098],[18932,0.00096],[18962,0.00105],[18993,0.00101],[19024,0.00095],[19052,0.00095],[19083,0.00103],[19113,0.00104],[19144,0.00104],[19174,0.00114],[19205,0.0011],[19236,0.00114],[19266,0.00109],[19297,0.00101],[19327,0.00099],[19358,0.00095],[19389,0.00104],[19417,0.00103],[19448,0.00096],[19478,0.00094],[19509,0.00101],[19539,0.00106],[19570,0.00108],[19601,0.00108],[19631,0.00112],[19662,0.00109],[19692,0.00105],[19723,0.00109],[19754,0.00112]],"YER":[[14791,229.42],[14822,232.29],[14853,216.62],[14883,214.22],[14914,214.34],[14944,214.0],[14975,214.22],[15006,214.24],[15034,213.96],[15065,214.01],[15095,214.49],[15126,214.88],[15156,214.56],[15187,214.34],[15218,216.38],[15248,215.72],[15279,216.17],[15309,216.49],[15340,215.73],[15371,215.81],[15400,215.37],[15431,214.69],[15461,214.88],[15492,214.74],[15522,215.18],[15553,214.94],[15584,214.83],[15614,215.12],[15645,215.21],[15675,215.06],[15706,215.17],[15737,215.16],[15765,215.08],[15796,214.93],[15826,214.84],[15857,215.22],[15887,214.96],[15918,215.04],[15949,214.64],[15979,214.87],[16010,215.06],[16040,214.92],[16071,215.02],[16102,215.02],[16130,214.81],[16161,214.89],[16191,214.96],[16222,214.83],[16252,215.01],[16283,214.98],[16314,215.07],[16344,214.99],[16375,215.04],[16405,214.75],[16436,213.75],[16467,214.69],[16495,214.84],[16526,217.87],[16556,215.77],[16587,216.37],[16617,216.83],[16648,215.52],[16679,214.76],[16709,213.46],[16740,213.9],[16770,214.99],[16801,215.27],[16832,214.41],[16861,215.75],[16892,254.61],[16922,249.14],[16953,249.58],[16983,250.08],[17014,249.98],[17045,250.37],[17075,249.66],[17106,249.2],[17136,249.98],[17167,250.23],[17198,249.83],[17226,250.53],[17257,251.25],[17287,250.86],[17318,251.03],[17348,251.17],[17379,250.77],[17410,249.75],[17440,249.52],[17471,250.88],[17501,250.86],[17532,251.85],[17563,249.66],[17591,342.9],[17622,248.89],[17652,249.27],[17683,249.6],[17713,249.98],[17744,251.05],[17775,250.29],[17805,249.41],[17836,250.19],[17866,250.35],[17897,250.19],[17928,250.14],[17956,249.46],[17987,249.24],[18017,249.47],[18048,250.07],[18078,249.18],[18109,249.27],[18140,249.15],[18170,249.75],[18201,249.12],[18231,249.84],[18262,249.15],[18293,249.96],[18322,250.28],[18353,249.94],[18383,250.45],[18414,250.29],[18444,251.26],[18475,249.58],[18506,248.82],[18536,248.62],[18567,249.45],[18597,249.89],[18628,249.98],[18659,252.33],[18687,251.61],[18718,252.93],[18748,250.45],[18779,249.53],[18809,251.44],[18840,251.41],[18871,250.21],[18901,250.66],[18932,249.91],[18962,250.43],[18993,249.37],[19024,250.1],[19052,250.98],[19083,248.44],[19113,251.03],[19144,249.8],[19174,249.61],[19205,249.58],[19236,248.95],[19266,251.07],[19297,251.12],[19327,250.53],[19358,250.79],[19389,249.73],[19417,251.08],[19448,250.46],[19478,249.62],[19509,250.58],[19539,250.0],[19570,250.68],[19601,249.54],[19631,250.65],[19662,251.35],[19692,251.14],[19723,249.88],[19754,250.18]],"ZAR":[[14791,7.5444],[14822,7.3021],[14853,7.1538],[14883,6.921],[14914,6.981],[14944,6.8537],[14975,6.9055],[15006,7.1793],[15034,6.9211],[15065,6.7304],[15095,6.8372],[15126,6.8015],[15156,6.7926],[15187,7.0615],[15218,7.502],[15248,7.9565],[15279,8.1379],[15309,8.1938],[15340,8.0174],[15371,7.6628],[15400,7.5941],[15431,7.8149],[15461,8.1316],[15492,8.3836],[15522,8.2499],[15553,8.2645],[15584,8.2845],[15614,8.633],[15645,8.7887],[15675,8.6316],[15706,8.767],[15737,8.902],[15765,9.1677],[15796,9.114],[15826,9.3015],[15857,10.0134],[15887,9.9138],[15918,10.0607],[15949,10.01],[15979,9.9252],[16010,10.1748],[16040,10.3699],[16071,10.8463],[16102,10.9822],[16130,10.7375],[16161,10.5526],[16191,10.4206],[16222,10.655],[16252,10.6672],[16283,10.6653],[16314,10.9691],[16344,11.0835],[16375,11.0792],[16405,11.4639],[16436,11.5747],[16467,11.584],[16495,12.0711],[16526,12.0046],[16556,11.9702],[16587,12.2874],[16617,12.4428],[16648,12.8995],[16679,13.6197],[16709,13.5278],[16740,14.1137],[16770,14.9735],[16801,16.3074],[16832,15.8076],[16861,15.3849],[16892,14.6334],[16922,15.3129],[16953,15.1041],[16983,14.4389],[17014,13.765],[17045,14.0518],[17075,13.9182],[17106,13.9249],[17136,13.8584],[17167,13.5715],[17198,13.2166],[17226,12.9576],[17257,13.4211],[17287,13.2709],[17318,12.9258],[17348,13.1319],[17379,13.2288],[17410,13.1673],[17440,13.6941],[17471,14.0663],[17501,13.2014],[17532,12.2003],[17563,11.8267],[17591,11.8386],[17622,12.0838],[17652,12.5294],[17683,13.288],[17713,13.4201],[17744,14.0699],[17775,14.7202],[17805,14.4915],[17836,14.1179],[17866,14.1957],[17897,13.8769],[17928,13.7917],[17956,14.3575],[17987,14.178],[18017,14.4215],[18048,14.5618],[18078,14.0534],[18109,15.122],[18140,14.8856],[18170,14.9318],[18201,14.7942],[18231,14.4319],[18262,14.4003],[18293,15.0091],[18322,16.5873],[18353,18.5203],[18383,18.155],[18414,17.1503],[18444,16.7985],[18475,17.2067],[18506,16.7315],[18536,16.4758],[18567,15.5745],[18597,14.9268],[18628,15.088],[18659,14.7927],[18687,14.9585],[18718,14.4561],[18748,14.072],[18779,13.9213],[18809,14.4911],[18840,14.7568],[18871,14.606],[18901,14.8707],[18932,15.5107],[18962,15.8817],[18993,15.5394],[19024,15.25],[19052,14.9779],[19083,14.9783],[19113,15.8939],[19144,15.8211],[19174,16.7982],[19205,16.7146],[19236,17.5142],[19266,18.0973],[19297,17.5177],[19327,17.229],[19358,17.0933],[19389,17.8516],[19417,18.2332],[19448,18.1636],[19478,18.993],[19509,18.7909],[19539,18.1993],[19570,18.7045],[19601,18.9289],[19631,19.0305],[19662,18.5541],[19692,18.6159],[19723,18.7556],[19754,18.9907]],"ZMW":[[14791,4.88],[14822,4.88],[14853,4.85],[14883,4.66],[14914,4.93],[14944,4.8],[14975,4.84],[15006,4.76],[15034,4.72],[15065,4.7],[15095,4.75],[15126,4.84],[15156,4.8],[15187,4.97],[15218,4.81],[15248,4.91],[15279,5.1],[15309,5.12],[15340,5.12],[15371,5.24],[15400,5.27],[15431,5.23],[15461,5.34],[15492,5.15],[15522,4.96],[15553,5.0],[15584,5.09],[15614,5.28],[15645,5.22],[15675,5.15],[15706,5.2891],[15737,5.3055],[15765,5.3752],[15796,5.3626],[15826,5.3232],[15857,5.4128],[15887,5.4792],[15918,5.432],[15949,5.3463],[15979,5.3212],[16010,5.5033],[16040,5.5207],[16071,5.5236],[16102,5.6798],[16130,6.0427],[16161,6.2101],[16191,6.6021],[16222,6.3397],[16252,6.1445],[16283,6.1064],[16314,6.1459],[16344,6.3049],[16375,6.3553],[16405,6.3429],[16436,6.462],[16467,6.7122],[16495,7.3216],[16526,7.4443],[16556,7.2733],[16587,7.3434],[16617,7.6254],[16648,7.973],[16679,10.0344],[16709,12.0287],[16740,12.2576],[16770,10.8686],[16801,11.1214],[16832,11.3094],[16861,11.2882],[16892,9.7274],[16922,10.028],[16953,10.6445],[16983,9.9605],[17014,10.0452],[17045,9.9804],[17075,9.8601],[17106,9.8254],[17136,9.8667],[17167,9.9143],[17198,9.7674],[17226,9.6081],[17257,9.4631],[17287,9.2719],[17318,9.2313],[17348,8.9727],[17379,8.996],[17410,9.3695],[17440,9.7476],[17471,10.0146],[17501,10.039],[17532,9.8286],[17563,9.7719],[17591,9.6254],[17622,9.5604],[17652,10.036],[17683,10.0739],[17713,9.9156],[17744,10.0842],[17775,10.7473],[17805,11.8692],[17836,11.8176],[17866,11.8957],[17897,11.9199],[17928,11.9351],[17956,12.0342],[17987,12.295],[18017,13.1875],[18048,13.0483],[18078,12.7807],[18109,12.9989],[18140,13.1414],[18170,13.1834],[18201,13.9746],[18231,14.3791],[18262,14.3299],[18293,14.7062],[18322,16.3201],[18353,18.5293],[18383,18.2875],[18414,18.1288],[18444,18.1154],[18475,18.6393],[18506,19.7707],[18536,20.162],[18567,20.78],[18597,21.053],[18628,21.252],[18659,21.566],[18687,21.907],[18718,22.176],[18748,22.381],[18779,22.562],[18809,21.555],[18840,18.3931],[18871,16.4045],[18901,16.9695],[18932,17.4915],[18962,16.884],[18993,17.0271],[19024,17.9888],[19052,17.9116],[19083,17.4393],[19113,17.1349],[19144,16.9673],[19174,16.5806],[19205,16.1557],[19236,15.7347],[19266,15.8845],[19297,16.498],[19327,17.5008],[19358,18.3947],[19389,19.2971],[19417,20.438],[19448,18.9714],[19478,18.5453],[19509,18.6707],[19539,18.5749],[19570,19.4567],[19601,20.584],[19631,21.443],[19662,22.841],[19692,24.651],[19723,26.231],[19754,25.476]]},"provenance":{"acquired":"2026-10-15","cryptoInputSHA256":"a908b384811818640e1068cde80d51e5ca499342862ce427d00f4c5754797349","cryptoMetric":"PriceUSD","cryptoSampling":"Last available positive daily observation per completed month; cryptoFX preserves observations matching FX monthly bucket dates.","cryptoSource":"https://github.com/coinmetrics/data","fawazSampling":"First daily publication of each completed month March2024 through September2026, March2024 uses earliest available March2; USD quote table; CC0-1.0.","fawazSource":"https://github.com/fawazahmed0/exchange-api","fiatInputSHA256":"c95d2964a09075afb29f94e7084bce753e3856af8a25e884b423fc35d2229012","fiatSampling":"group=month monthly reference buckets; cross-source joins are approximations.","fiatSource":"https://api.frankfurter.dev/v2/rates","licenses":["CC-BY-NC-4.0.txt","CC-BY-4.0.txt","Fawaz-CC0-1.0.txt"],"notice":"DataSources.md"},"providers":["AMCM","BAM","BANREP","BCB","BCBO","BCC","BCCH","BCP","BCRA","BCT","BDI","BDP","BI","BIS","BNA","BNM","BNR","BOA","BOC","BOJA","BOM","BOT","BOTA","BOZ","BRB","CBA","CBAR","CBC","CBG","CBI","CBK","CBLLR","CBM","CBS","CBSL","CBU","DAB","DNB","ECB","HMRC","HNB","INFOREURO","JPC","LB","MAS","MMA","MNB","NBC","NBE","NBG","NBK","NBKR","NBM","NBP","NBRB","NBRM","NBT","NRB","NRBT","RB","RBF","RBM","RBV","SARB","SBI","UST"],"version":1} diff --git a/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift b/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift index 6e7a522..5a69a1b 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift @@ -136,8 +136,11 @@ extension CurrencyDisplay { ) -> String { let descriptions = [snapshot.quotes[from], snapshot.quotes[to]].compactMap { $0 } .map { - RateMessages.providerDescription($0.source, locale: locale) + " · " + let detail = + RateMessages.providerDescription($0.source, locale: locale) + " · " + publicationDate($0.published, locale: locale) + return [detail, RateMessages.conversionDescription($0.source, locale: locale)] + .compactMap { $0 }.joined(separator: "\n") } return Set(descriptions).sorted().joined(separator: " / ") } diff --git a/Domain/ExchangeRates/Sources/ExchangeRatesUI/RateMessages.swift b/Domain/ExchangeRates/Sources/ExchangeRatesUI/RateMessages.swift index d788eab..ea2c143 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRatesUI/RateMessages.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRatesUI/RateMessages.swift @@ -32,10 +32,21 @@ public enum RateMessages { case .ecb: provider = "ECB" case .frankfurter: provider = "Frankfurter" case .fawaz: provider = "Fawaz" - case .coinbase: provider = "Coinbase" + case .coinMetrics: provider = "Coin Metrics" case .custom(let name): provider = name } - var description = observationDescription(source.observation, provider: provider, locale: locale) + let contributors = (source.contributingProviders ?? []).map(providerName) + let names = ([provider] + contributors) + .reduce(into: [String]()) { + if !$0.contains($1) { $0.append($1) } + } + .joined(separator: " + ") + var description = observationDescription(source.observation, provider: names, locale: locale) + if source.conversion != nil { + var approximate = LocalizedStringResource.Support.approximateSource(description) + approximate.locale = locale + description = String(localized: approximate) + } if source.latestObservation == .hourlyClose { var endpoint = LocalizedStringResource.Support.sourceWithLatestHourlyClose(description) endpoint.locale = locale @@ -48,6 +59,29 @@ public enum RateMessages { return String(localized: withZone) } + /// Describes the reference FX rate used to calculate a converted quote. + public static func conversionDescription( + _ source: RateSource, locale: Locale = .current + ) -> String? { + guard let conversion = source.conversion else { return nil } + let value = NSDecimalNumber(decimal: conversion.value).description(withLocale: locale) + var resource = LocalizedStringResource.Support.conversionReference( + conversion.base, value, conversion.quote, providerName(conversion.provider), + CurrencyDisplay.publicationDate(conversion.published, locale: locale)) + resource.locale = locale + return String(localized: resource) + } + + private static func providerName(_ provider: RateProviderID) -> String { + switch provider { + case .ecb: "ECB" + case .frankfurter: "Frankfurter" + case .fawaz: "Fawaz" + case .coinMetrics: "Coin Metrics" + case .custom(let name): name + } + } + private static func observationDescription( _ observation: RateObservation, provider: String, locale: Locale ) -> String { @@ -56,6 +90,7 @@ public enum RateMessages { case .unspecified, .trade: return provider case .exchangeRate: resource = .Support.retrievedRates(provider) case .dailyRate: resource = .Support.dailyRates(provider) + case .minuteReference: resource = .Support.minuteReference(provider) case .dailyReference: resource = .Support.dailyReference(provider) case .monthlyReference: resource = .Support.monthlyReference(provider) case .dailyClose: resource = .Support.dailyCloses(provider) diff --git a/Domain/ExchangeRates/Sources/ExchangeRatesUI/Resources/Support.xcstrings b/Domain/ExchangeRates/Sources/ExchangeRatesUI/Resources/Support.xcstrings index 9d35da7..d18590a 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRatesUI/Resources/Support.xcstrings +++ b/Domain/ExchangeRates/Sources/ExchangeRatesUI/Resources/Support.xcstrings @@ -3395,6 +3395,273 @@ } } } + }, + "minuteReference": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · minute reference prices" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ · Minutenreferenzpreise" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ · precios de referencia por minuto" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "%@ · minutilised võrdlushinnad" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ · prix de référence par minute" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@ · prezzi di riferimento al minuto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · 分次参考価格" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ · 분 단위 참고 가격" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@ · preços de referência por minuto" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ · поминутные справочные цены" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@ · щохвилинні довідкові ціни" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ · dakikalık referans fiyatları" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ · 分钟参考价格" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ · 分鐘參考價格" + } + } + } + }, + "approximateSource": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ · approximate conversion" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ · ungefähre Umrechnung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ · conversión aproximada" + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "%@ · ligikaudne teisendus" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ · conversion approximative" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@ · conversione approssimativa" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ · 概算換算" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ · 근사 환산" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@ · conversão aproximada" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ · приблизительный пересчёт" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@ · приблизний перерахунок" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ · yaklaşık dönüşüm" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ · 近似换算" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ · 近似換算" + } + } + } + }, + "conversionReference": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Approximate conversion: 1 %@ = %@ %@ (%@, %@)." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungefähre Umrechnung: 1 %@ = %@ %@ (%@, %@)." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conversión aproximada: 1 %@ = %@ %@ (%@, %@)." + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "Ligikaudne teisendus: 1 %@ = %@ %@ (%@, %@)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Conversion approximative : 1 %@ = %@ %@ (%@, %@)." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Conversione approssimativa: 1 %@ = %@ %@ (%@, %@)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "概算換算:1 %@ = %@ %@(%@、%@)。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "근사 환산: 1 %@ = %@ %@ (%@, %@)." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Conversão aproximada: 1 %@ = %@ %@ (%@, %@)." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Приблизительный пересчёт: 1 %@ = %@ %@ (%@, %@)." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Приблизний перерахунок: 1 %@ = %@ %@ (%@, %@)." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yaklaşık dönüşüm: 1 %@ = %@ %@ (%@, %@)." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "近似换算:1 %@ = %@ %@(%@,%@)。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "近似換算:1 %@ = %@ %@(%@,%@)。" + } + } + } } }, "version": "1.0" diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/BatchRefreshTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/BatchRefreshTests.swift index 038d210..c446b49 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/BatchRefreshTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/BatchRefreshTests.swift @@ -7,28 +7,36 @@ private actor BatchHTTP: HTTPClient { private(set) var urls: [URL] = [] func get(_ url: URL) async throws -> Data { urls.append(url) - var rates = Dictionary(uniqueKeysWithValues: CurrencyCatalog.crypto.map { ($0, "0.25") }) - rates["EUR"] = "1" - return try JSONSerialization.data(withJSONObject: ["data": ["currency": "EUR", "rates": rates]]) + if url.host == "community-api.coinmetrics.io" { + let rows = CurrencyCatalog.crypto.map { + [ + "asset": $0.lowercased(), "time": Date().addingTimeInterval(-60).ISO8601Format(), + "ReferenceRateUSD": "8" + ] + } + return try JSONSerialization.data(withJSONObject: ["data": rows]) + } + return Data( + "{\"date\":\"\(String(Date().ISO8601Format().prefix(10)))\",\"eur\":{\"usd\":2,\"btc\":0.25}}" + .utf8) } } @Suite struct BatchRefreshTests { - @Test func wholeCryptoCatalogUsesOneRequest() async throws { + @Test func wholeCryptoCatalogUsesOneMetricsRequestAndOneDailyFXRequest() async throws { let client = BatchHTTP() - let quotes = try await CoinbaseProvider(client: client).fetch() + let quotes = try await CoinMetricsProvider(client: client).fetch() #expect(Set(quotes.keys) == CurrencyCatalog.crypto) - #expect( - await client.urls.map(\.absoluteString) == [ - "https://api.coinbase.com/v2/exchange-rates?currency=EUR" - ]) + #expect(quotes.values.allSatisfy { $0.value == Decimal(string: "0.25") }) + #expect(await client.urls.count == 2) + #expect(await client.urls.filter { $0.host == "community-api.coinmetrics.io" }.count == 1) } @Test func batchOverlayFallsBackAndCannotLeakIntoDailyCache() async throws { let time = Date(timeIntervalSince1970: 1_767_355_200) let daily = ExchangeRate(1, published: "2026-01-02", source: .init(provider: .fawaz)) let live = ExchangeRate( - 2, published: "2026-01-02", source: .init(provider: .coinbase, observation: .exchangeRate), + 2, published: "2026-01-02", source: .init(provider: .coinMetrics, observation: .exchangeRate), retrievedAt: time) let previous = RateSnapshot(quotes: ["BTC": live], checkedAt: time) let service = RateService( @@ -57,7 +65,7 @@ private actor BatchHTTP: HTTPClient { let now = Date() let daily = ExchangeRate(1, published: "2026-01-02", source: .init(provider: .fawaz)) let live = ExchangeRate( - 2, published: "2026-01-02", source: .init(provider: .coinbase, observation: .exchangeRate), + 2, published: "2026-01-02", source: .init(provider: .coinMetrics, observation: .exchangeRate), retrievedAt: now) let result = await RateService( fiat: StubRateProvider(quotes: nil), diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/BootstrapTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/BootstrapTests.swift index 09528f9..a03660a 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/BootstrapTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/BootstrapTests.swift @@ -100,9 +100,9 @@ private func bootstrapQuotes(_ usd: Decimal = 2) -> [String: ExchangeRate] { @Test func pendingCryptoKeepsUsableCachedPairUntilSuccess() async { let cachedQuote = ExchangeRate( - 5, published: "2026-09-10", source: .init(provider: .coinbase), observedAt: .now) + 5, published: "2026-09-10", source: .init(provider: .coinMetrics), observedAt: .now) let freshQuote = ExchangeRate( - 6, published: "2026-09-10", source: .init(provider: .coinbase), observedAt: .now) + 6, published: "2026-09-10", source: .init(provider: .coinMetrics), observedAt: .now) var live = bootstrapQuotes() live["BTC"] = cachedQuote let saved = RateSnapshot(quotes: live, fetchedAt: .now, dailyQuotes: bootstrapQuotes()) @@ -127,7 +127,7 @@ private func bootstrapQuotes(_ usd: Decimal = 2) -> [String: ExchangeRate] { func pendingCryptoFallsBackOnlyAfterFailure(hasDailyFallback: Bool) async { var live = bootstrapQuotes() live["BTC"] = ExchangeRate( - 5, published: "2026-09-10", source: .init(provider: .coinbase), observedAt: .now) + 5, published: "2026-09-10", source: .init(provider: .coinMetrics), observedAt: .now) var daily = bootstrapQuotes() if hasDailyFallback { daily["BTC"] = ExchangeRate(4, published: "2026-09-10", source: .init(provider: .fawaz)) diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsNormalizationTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsNormalizationTests.swift new file mode 100644 index 0000000..11752c2 --- /dev/null +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsNormalizationTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing + +@testable import ExchangeRates + +private let normalizationNow = Date(timeIntervalSince1970: 1_767_355_200) + +private func normalizationUSD(_ value: Decimal, day: String) -> ExchangeRate { + ExchangeRate(value, published: day, source: .init(provider: .fawaz), cachedAt: normalizationNow) +} + +private func normalizationCrypto(anchor: Decimal, day: String) -> ExchangeRate { + ExchangeRate( + anchor / 100, published: "2026-01-02", + source: .init( + provider: .coinMetrics, observation: .minuteReference, + conversion: .init(provider: .fawaz, published: day, value: anchor, base: "EUR", quote: "USD")), + observedAt: normalizationNow.addingTimeInterval(-60), retrievedAt: normalizationNow) +} + +private func normalizationPrevious(live: Bool = false) -> RateSnapshot { + let usd = normalizationUSD(2, day: "2026-01-01") + var quotes = ["USD": usd] + if live { quotes["BTC"] = normalizationCrypto(anchor: 2, day: "2026-01-01") } + return RateSnapshot( + quotes: quotes, fetchedAt: normalizationNow, + dailyQuotes: ["USD": usd], dailyFetchedAt: normalizationNow, + supplementalFetchedAt: normalizationNow, supplementalQuotes: ["USD": usd]) +} + +private actor NormalizationGate: RateProvider { + let quotes: [String: ExchangeRate] + private var released = false + private var waiter: CheckedContinuation? + init(quotes: [String: ExchangeRate]) { self.quotes = quotes } + func fetch() async throws -> [String: ExchangeRate] { + if !released { await withCheckedContinuation { waiter = $0 } } + return quotes + } + func release() { + released = true + waiter?.resume() + waiter = nil + } +} + +@Suite struct CoinMetricsNormalizationTests { + private func expectUSD100(_ snapshot: RateSnapshot, anchor: Decimal, day: String) { + #expect(snapshot.convert(1, from: "BTC", to: "USD") == 100) + #expect(snapshot.quotes["BTC"]?.source.conversion?.value == anchor) + #expect(snapshot.quotes["BTC"]?.source.conversion?.published == day) + #expect(snapshot.quotes["BTC"]?.observedAt == normalizationNow.addingTimeInterval(-60)) + #expect(snapshot.quotes["BTC"]?.retrievedAt == normalizationNow) + } + + @Test func refreshUsesFreshCachedDailyUSDWhenProviderFXPublicationAdvances() async { + let result = await RateService( + fiat: StubRateProvider(quotes: nil), daily: StubRateProvider(quotes: nil), + crypto: StubRateProvider(quotes: ["BTC": normalizationCrypto(anchor: 4, day: "2026-01-02")]) + ) + .refresh(previous: normalizationPrevious(), now: normalizationNow) + #expect(result.snapshot.quotes["BTC"]?.value == Decimal(string: "0.02")) + expectUSD100(result.snapshot, anchor: 2, day: "2026-01-01") + } + + @Test func bootstrapRebasesEachDeliveryAsDailyAnchorArrives() async throws { + let daily = NormalizationGate(quotes: ["USD": normalizationUSD(4, day: "2026-01-02")]) + let service = RateService( + fiat: StubRateProvider(quotes: nil), daily: daily, + crypto: StubRateProvider(quotes: ["BTC": normalizationCrypto(anchor: 4, day: "2026-01-02")])) + var sawCached = false + var sawUpdated = false + for await update in await service.bootstrap( + previous: normalizationPrevious(), now: normalizationNow) + where update.snapshot.quotes["BTC"] != nil { + let updated = update.snapshot.quotes["USD"]?.value == 4 + expectUSD100( + update.snapshot, anchor: updated ? 4 : 2, day: updated ? "2026-01-02" : "2026-01-01") + if updated { sawUpdated = true } else { sawCached = true; await daily.release() } + } + #expect(sawCached) + #expect(sawUpdated) + } + + @Test func bootstrapPendingPreviousLiveQuoteRebasesToNewDailyAnchor() async { + let crypto = NormalizationGate(quotes: [ + "BTC": normalizationCrypto(anchor: 4, day: "2026-01-02") + ]) + let service = RateService( + fiat: StubRateProvider(quotes: nil), + daily: StubRateProvider(quotes: ["USD": normalizationUSD(4, day: "2026-01-02")]), + crypto: crypto) + var sawUpdatedBeforeCompletion = false + for await update in await service.bootstrap( + previous: normalizationPrevious(live: true), now: normalizationNow) + where update.snapshot.quotes["USD"]?.value == 4 { + expectUSD100(update.snapshot, anchor: 4, day: "2026-01-02") + if !update.isFinal { sawUpdatedBeforeCompletion = true; await crypto.release() } + } + #expect(sawUpdatedBeforeCompletion) + } + + @Test func cachedPolicyReadRealignsLaterDailyPublicationWithoutChangingMarketTime() { + let newUSD = normalizationUSD(4, day: "2026-01-02") + let staleAnchor = normalizationCrypto(anchor: 2, day: "2026-01-01") + let snapshot = RateSnapshot( + quotes: ["USD": newUSD, "BTC": staleAnchor], fetchedAt: normalizationNow, + dailyQuotes: ["USD": newUSD], dailyFetchedAt: normalizationNow) + let aligned = RateProviderPolicy.intradayEnhanced.filter(snapshot) + expectUSD100(aligned, anchor: 4, day: "2026-01-02") + #expect(RateProviderPolicy.intradayEnhanced.filter(aligned).quotes == aligned.quotes) + } +} diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsTests.swift new file mode 100644 index 0000000..af8e018 --- /dev/null +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsTests.swift @@ -0,0 +1,209 @@ +import Foundation +import Testing + +@testable import ExchangeRates + +private actor MetricsHTTP: HTTPClient { + var responses: [Result] + private(set) var urls: [URL] = [] + private(set) var starts: [ContinuousClock.Instant] = [] + init(_ responses: [Result]) { self.responses = responses } + func get(_ url: URL) async throws -> Data { + urls.append(url) + starts.append(ContinuousClock().now) + guard !responses.isEmpty else { throw RateError.unavailable } + return try responses.removeFirst().get() + } +} + +private let metricsNow = Date(timeIntervalSince1970: 1_767_355_200) + +private func metricsPage(_ rows: [[String: Any]], next: String? = nil) throws -> Data { + var object: [String: Any] = ["data": rows] + if let next { object["next_page_url"] = next } + return try JSONSerialization.data(withJSONObject: object) +} + +private func metricsRow(_ asset: String, _ value: String, age: TimeInterval = 60) -> [String: Any] { + [ + "asset": asset, "ReferenceRateUSD": value, + "time": metricsNow.addingTimeInterval(-age).ISO8601Format() + ] +} + +private func metricsClient(_ http: MetricsHTTP) -> CoinMetricsClient { + CoinMetricsClient(client: http, acquire: {}, sleep: { _ in }) +} + +@Suite struct CoinMetricsTests { + @Test func communityNanosecondTimestampDecodesWithoutInventingPrecision() throws { + let parsed = try #require(CoinMetricsClient.date("2026-01-02T11:59:00.000000000Z")) + #expect(parsed == metricsNow.addingTimeInterval(-60)) + } + + @Test func pagingKeepsOnlyValidRequestedObservationsAndSortsUniqueTimes() async throws { + let next = + "https://community-api.coinmetrics.io/v4/timeseries/asset-metrics?next_page_token=opaque" + let http = MetricsHTTP([ + .success( + try metricsPage( + [ + metricsRow("btc", "10"), metricsRow("btc", "0"), metricsRow("eth", "100"), + metricsRow("btc", "NaN"), metricsRow("btc", "11", age: -60), + metricsRow("btc", "20", age: 900), + ["asset": "btc", "time": "bad", "ReferenceRateUSD": 1] + ], next: next)), + .success(try metricsPage([metricsRow("btc", "12", age: 120), metricsRow("btc", "10")])) + ]) + let points = try await metricsClient(http) + .observations( + assets: ["BTC"], start: metricsNow.addingTimeInterval(-600), end: metricsNow) + #expect(points.keys.sorted() == ["BTC"]) + #expect(points["BTC"]?.map(\.value) == [12, 10]) + #expect(await http.urls.count == 2) + #expect(await http.urls.last?.absoluteString == next) + } + + @Test(arguments: [ + "https://evil.example/v4/timeseries/asset-metrics", + "http://community-api.coinmetrics.io/v4/timeseries/asset-metrics", + "https://community-api.coinmetrics.io/v4/catalog/assets", + "https://user@community-api.coinmetrics.io/v4/timeseries/asset-metrics" + ]) func unsafePaginationIsRejectedBeforeRequest(_ next: String) async throws { + let http = MetricsHTTP([.success(try metricsPage([], next: next))]) + await #expect(throws: RateError.self) { + try await metricsClient(http) + .observations( + assets: ["btc"], start: metricsNow.addingTimeInterval(-600), end: metricsNow) + } + #expect(await http.urls.count == 1) + } + + @Test func repeatedPaginationCannotLoop() async throws { + let next = + "https://community-api.coinmetrics.io/v4/timeseries/asset-metrics?next_page_token=same" + let page = try metricsPage([], next: next) + let http = MetricsHTTP([.success(page), .success(page)]) + await #expect(throws: RateError.self) { + try await metricsClient(http) + .observations( + assets: ["btc"], start: metricsNow.addingTimeInterval(-600), end: metricsNow) + } + #expect(await http.urls.count == 2) + } + + @Test func rateLimitRetriesAreBoundedAndCancellationPropagates() async throws { + let http = MetricsHTTP([.failure(.http(429)), .failure(.http(429)), .failure(.http(429))]) + await #expect(throws: RateError.self) { + try await metricsClient(http) + .observations( + assets: ["btc"], start: metricsNow.addingTimeInterval(-600), end: metricsNow) + } + #expect(await http.urls.count == 3) + let cancelled = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try await metricsClient(http) + .observations( + assets: ["btc"], start: metricsNow.addingTimeInterval(-600), end: metricsNow) + } + await #expect(throws: CancellationError.self) { try await cancelled.value } + #expect(await http.urls.count == 3) + } + + @Test func separateClientsShareCommunityRequestPacing() async throws { + let page = try metricsPage([metricsRow("btc", "100")]) + let http = MetricsHTTP([.success(page), .success(page), .success(page)]) + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<3 { + group.addTask { + _ = try await CoinMetricsClient(client: http) + .observations( + assets: ["btc"], start: metricsNow.addingTimeInterval(-600), end: metricsNow) + } + } + try await group.waitForAll() + } + let starts = await http.starts.sorted() + #expect(starts.count == 3) + for (first, second) in zip(starts, starts.dropFirst()) { + #expect(first.duration(to: second) >= .milliseconds(640)) + } + } + + @Test func malformedEnvelopeIsRejected() async { + let http = MetricsHTTP([.success(Data(#"{"error":"unavailable"}"#.utf8))]) + await #expect(throws: (any Error).self) { + try await metricsClient(http) + .observations( + assets: ["btc"], start: metricsNow.addingTimeInterval(-600), end: metricsNow) + } + } + + @Test func currentQuotesPreserveObservationAndDailyConversionProvenance() async throws { + let http = MetricsHTTP([.success(try metricsPage([metricsRow("btc", "50000")]))]) + let daily = StubRateProvider(quotes: [ + "USD": ExchangeRate(2, published: "2026-01-02", source: .init(provider: .fawaz)) + ]) + let quotes = try await CoinMetricsProvider( + metrics: metricsClient(http), daily: daily, now: { metricsNow } + ) + .fetch() + let btc = try #require(quotes["BTC"]) + #expect(btc.value == Decimal(string: "0.00004")) + #expect(btc.observedAt == metricsNow.addingTimeInterval(-60)) + #expect(btc.retrievedAt == metricsNow) + #expect(btc.source.provider == .coinMetrics) + #expect(btc.source.observation == .minuteReference) + #expect(btc.source.conversion?.provider == .fawaz) + #expect(btc.source.conversion?.value == 2) + #expect(btc.source.conversion?.published == "2026-01-02") + } + + @Test(arguments: ["2025-12-20", "2026-01-03"]) + func staleOrFutureFXCannotCreateCurrentQuote(_ day: String) async throws { + let http = MetricsHTTP([.success(try metricsPage([metricsRow("btc", "50000")]))]) + await #expect(throws: RateError.self) { + try await CoinMetricsProvider( + metrics: metricsClient(http), + daily: StubRateProvider(quotes: [ + "USD": ExchangeRate(2, published: day, source: .init(provider: .fawaz)) + ]), now: { metricsNow } + ) + .fetch() + } + } + + @Test func staleAndFutureMinuteDataCannotCreateCurrentQuote() async throws { + let http = MetricsHTTP([ + .success( + try metricsPage([ + metricsRow("btc", "50000", age: 601), metricsRow("btc", "50000", age: -1) + ])) + ]) + await #expect(throws: RateError.self) { + try await CoinMetricsProvider( + metrics: metricsClient(http), + daily: StubRateProvider(quotes: [ + "USD": ExchangeRate(2, published: "2026-01-02", source: .init(provider: .fawaz)) + ]), now: { metricsNow } + ) + .fetch() + } + } + + @Test func oldProviderCacheDecodesButCannotReenterEitherPolicy() throws { + let source = try JSONDecoder() + .decode( + RateSource.self, + from: Data(#"{"provider":{"coinbase":{}},"observation":"exchangeRate"}"#.utf8)) + #expect(source.provider == .custom("Coinbase")) + let saved = RateSnapshot( + quotes: [ + "BTC": ExchangeRate(1, published: "2026-01-02", source: source, retrievedAt: metricsNow) + ], fetchedAt: metricsNow) + for policy in [RateProviderPolicy.daily, .intradayEnhanced] { + #expect(policy.filter(saved).quotes.isEmpty) + #expect(policy.filter(saved).checkedAt == nil) + } + } +} diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/FrankfurterPolicyTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/FrankfurterPolicyTests.swift new file mode 100644 index 0000000..9465205 --- /dev/null +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/FrankfurterPolicyTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing + +@testable import ExchangeRates + +private actor FrankfurterPolicyHTTP: HTTPClient { + var urls: [URL] = [] + func get(_ url: URL) async throws -> Data { + urls.append(url) + return Data( + #"[{"date":"2026-01-02","base":"EUR","quote":"USD","rate":1.2},{"date":"2026-01-02","base":"EUR","quote":"GBP","rate":0.8}]"# + .utf8) + } +} + +@Suite struct FrankfurterPolicyTests { + @Test func everyCurrentRequestSelectsApprovedProvidersAndMarksSterlingParity() async throws { + let client = FrankfurterPolicyHTTP() + let quotes = try await FrankfurterProvider(client: client).fetch() + let url = try #require(await client.urls.first) + let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + let providers = try #require(query?.first { $0.name == "providers" }?.value) + #expect(providers.split(separator: ",").count == 66) + #expect(providers == FrankfurterProvider.allowedProviders) + #expect(!providers.split(separator: ",").contains("BOE")) + for code in ["GGP", "IMP", "JEP"] { + #expect(quotes[code]?.value == quotes["GBP"]?.value) + #expect( + quotes[code]?.source.conversion + == RateConversion( + provider: .frankfurter, + published: "2026-01-02", value: 1, base: "GBP", quote: code)) + } + } +} diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryConcurrencyTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryConcurrencyTests.swift index 1e0fc3a..2c018b9 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryConcurrencyTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryConcurrencyTests.swift @@ -40,27 +40,12 @@ private actor SlowCandles: HTTPClient { } @Suite struct HistoryConcurrencyTests { - @Test func pagesOverlapWithinBoundAndBoundaryOwnershipIsDeterministic() async throws { - let client = SlowCandles() - let start = Date(timeIntervalSince1970: 0) - let end = start.addingTimeInterval(1200 * 86400) - let components = try #require(URLComponents(string: "https://example.test/candles")) - let points = try await HistoryService.fetchCandles( - client: client, components: components, start: start, end: end) - #expect(await client.calls == 5) - #expect(await client.maximum > 1) - #expect(await client.maximum <= 3) - #expect(points.count == 10) - #expect(!points.values.contains(99)) - #expect(points[start.addingTimeInterval(299 * 86400)] == 3) - } - @Test func cancellingHistoryStopsPagesAndDoesNotWriteCache() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let client = SlowCandles(delay: .seconds(10)) let service = HistoryService(directory: directory, client: client) - let task = Task { await service.load(base: "BTC", quote: "USD", range: .all) } + let task = Task { await service.load(base: "BTC", quote: "USD", range: .year) } for _ in 0..<300 { if await client.active > 0 { break } try await Task.sleep(for: .milliseconds(10)) diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryCrossRateTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryCrossRateTests.swift index 5d23581..06a97cc 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryCrossRateTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryCrossRateTests.swift @@ -3,116 +3,89 @@ import Testing @testable import ExchangeRates -private actor CrossRateHTTP: HTTPClient { - var urls: [URL] = [] - private var failEthereum = false - - func failEthereumRequests() { failEthereum = true } +actor MinuteHistoryHTTP: HTTPClient { + private(set) var urls: [URL] = [] + var shortened = false + var failing = false + var missingQuoteMinute = false + func shorten() { shortened = true } + func fail() { failing = true } + func omitQuoteMinute() { missingQuoteMinute = true } func get(_ url: URL) async throws -> Data { urls.append(url) - let isEthereum = url.path == "/products/ETH-USD/candles" - if isEthereum && failEthereum { throw RateError.http(503) } + if failing { throw RateError.http(503) } + if url.host == "cdn.jsdelivr.net" { + return Data( + "{\"date\":\"2026-01-05\",\"eur\":{\"usd\":2,\"gbp\":1,\"czk\":50,\"btc\":0.00002}}".utf8) + } let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] - if query.contains(URLQueryItem(name: "granularity", value: "3600")) { - let start = Date(timeIntervalSince1970: 1_767_484_800).timeIntervalSince1970 - if url.path == "/products/USDC-EUR/candles" { - return try JSONEncoder() - .encode([ - [start + 3_600, 1, 200, 1, 1, 1], - [start + 7_200, 1, 200, 1, 2, 1], - [start + 10_800, 1, 200, 1, 3, 1] - ]) - } - return try JSONEncoder() - .encode([ - [start + 3_600, 1, 200, 1, isEthereum ? 10 : 100, 1], - [start + 7_200, 1, 200, 1, isEthereum ? 20 : 110, 1] + let assets = try #require(query.first { $0.name == "assets" }?.value).split(separator: ",") + let start = try historyDate(try #require(query.first { $0.name == "start_time" }?.value)) + var rows: [[String: String]] = [] + for asset in assets { + for minute in (shortened ? 1433 : 0)..<1440 { + if missingQuoteMinute && asset == "eth" && minute == 500 { continue } + rows.append([ + "asset": String(asset), + "time": start.addingTimeInterval(Double(minute) * 60).ISO8601Format(), + "ReferenceRateUSD": asset == "eth" ? "10" : "100" ]) + } } - throw RateError.invalidData + return try JSONSerialization.data(withJSONObject: ["data": rows]) } } @Suite struct HistoryCrossRateTests { - private let now = Date(timeIntervalSince1970: 1_767_571_200) + let now = Date(timeIntervalSince1970: 1_767_571_200) - @Test func cryptoCryptoDividesMatchingHourlyClosesForOneDay() async throws { + @Test func cryptoCryptoDividesOnlyAlignedFiveMinuteSamples() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let client = CrossRateHTTP() + let client = MinuteHistoryHTTP() + await client.omitQuoteMinute() let result = await HistoryService( - directory: directory, client: client, policy: .coinbaseEnhanced + directory: directory, client: client, policy: .intradayEnhanced ) .load(base: "BTC", quote: "ETH", range: .day, now: now) #expect(result.issue == nil) - #expect(result.series?.points.map(\.value) == [10, 5.5]) - #expect(result.series?.source.observation == .hourlyClose) - #expect(await client.urls.count == 2) - let urls = await client.urls - #expect(Set(urls.map(\.path)) == ["/products/BTC-USD/candles", "/products/ETH-USD/candles"]) + let series = try #require(result.series) + #expect(series.points.count == 287) #expect( - urls.allSatisfy { url in - URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems? - .contains( - URLQueryItem(name: "granularity", value: "3600")) == true - }) + series.points.allSatisfy { $0.value == 10 && Int($0.date.timeIntervalSince1970) % 300 == 0 }) + #expect(series.source.provider == .coinMetrics) + #expect(series.source.conversion == nil) + #expect(series.source.observation == .minuteReference) + #expect(await client.urls.count == 1) } - @Test(arguments: [("USDC", "BTC"), ("BTC", "USDC")]) - func cryptoWithoutBothDollarMarketsJoinsMatchingHourlyEuroCloses( - base: String, quote: String - ) async throws { + @Test func cryptoFiatUsesDatedDailyApproximationAndReciprocal() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let client = CrossRateHTTP() - #expect(HistoryService.supportsIntraday(base: base, quote: quote)) - let result = await HistoryService( - directory: directory, client: client, policy: .coinbaseEnhanced - ) - .load(base: base, quote: quote, range: .day, now: now) - #expect(result.issue == nil) - let values = try #require(result.series?.points.map(\.value)) - #expect(values.count == 2) - #expect(values == (base == "USDC" ? [1.0 / 100, 2.0 / 110] : [100, 55])) + let client = MinuteHistoryHTTP() + let service = HistoryService(directory: directory, client: client, policy: .intradayEnhanced) + let direct = await service.load(base: "BTC", quote: "CZK", range: .day, now: now) + let inverse = await service.load(base: "CZK", quote: "BTC", range: .day, now: now) + #expect(direct.issue == nil) + #expect(inverse.issue == nil) + #expect(direct.series?.points.allSatisfy { $0.value == 2500 } == true) + #expect(inverse.series?.points.allSatisfy { $0.value == 0.0004 } == true) #expect( - result.series?.source == .init(provider: .coinbase, observation: .hourlyClose, timeZone: .gmt) - ) - let urls = await client.urls - #expect(Set(urls.map(\.path)) == ["/products/USDC-EUR/candles", "/products/BTC-EUR/candles"]) - #expect( - urls.allSatisfy { url in - URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems? - .contains( - URLQueryItem(name: "granularity", value: "3600")) == true - }) + direct.series?.source.conversion + == RateConversion( + provider: .fawaz, published: "2026-01-05", value: 25, base: "USD", quote: "CZK")) } - @Test func failedHourlyQuoteLegNeverSavesPartialSeries() async throws { + @Test func shortenedCommunityWindowNeverClaimsTwentyFourHoursOrOverwritesCache() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let client = CrossRateHTTP() - await client.failEthereumRequests() - let result = await HistoryService( - directory: directory, client: client, policy: .coinbaseEnhanced - ) - .load(base: "BTC", quote: "ETH", range: .day, now: now) - #expect(result.series == nil) - #expect(result.issue == .unavailable) + let client = MinuteHistoryHTTP() + let service = HistoryService(directory: directory, client: client, policy: .intradayEnhanced) + await client.shorten() + let missing = await service.load(base: "BTC", quote: "USD", range: .day, now: now) + #expect(missing.issue == .unavailable) + #expect(missing.series == nil) #expect(!FileManager.default.fileExists(atPath: directory.path)) } - - @Test func cryptoFiatWithoutHourlyMarketReturnsUnsupportedWithoutRequestOrCache() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let client = CrossRateHTTP() - let result = await HistoryService( - directory: directory, client: client, policy: .coinbaseEnhanced - ) - .load(base: "BTC", quote: "CZK", range: .day, now: now) - #expect(result.series == nil) - #expect(result.issue == .intradayUnavailable) - #expect(await client.urls.isEmpty) - } - } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryFailureTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryFailureTests.swift index 67e26da..0623ddb 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryFailureTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryFailureTests.swift @@ -11,7 +11,7 @@ import Testing let now = try historyDate("2026-01-08T12:00:00Z") let service = HistoryService(directory: directory, client: client) let first = await service.load(base: "BTC", quote: "USD", range: .week, now: now) - let file = directory.appendingPathComponent("history-daily-fawaz-BTC-USD-7.json") + let file = directory.appendingPathComponent("history-v2-daily-BTC-USD-7.json") let original = try Data(contentsOf: file) await client.failRequests() let failed = await service.load( @@ -22,7 +22,9 @@ import Testing #expect(try Data(contentsOf: file) == original) } - @Test(arguments: [RateProviderID.coinbase, .frankfurter, .custom("Coinbase + Frankfurter")]) + @Test(arguments: [ + RateProviderID.custom("Coinbase"), .frankfurter, .custom("Coinbase + Frankfurter") + ]) func incompatibleCacheNeverReturnsFreshOrOffline(provider: RateProviderID) async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } @@ -33,7 +35,7 @@ import Testing .init(date: now.addingTimeInterval(-86400), value: 2), .init(date: now, value: 3) ], source: .init(provider: provider, observation: .dailyReference), fetchedAt: now) let data = try JSONEncoder().encode(saved) - try data.write(to: directory.appendingPathComponent("history-daily-fawaz-BTC-USD-7.json")) + try data.write(to: directory.appendingPathComponent("history-v2-daily-BTC-USD-7.json")) try data.write(to: directory.appendingPathComponent("history-BTC-USD-7.json")) let client = DailyHistoryHTTP() await client.failRequests() @@ -50,7 +52,7 @@ import Testing defer { try? FileManager.default.removeItem(at: directory) } let now = try historyDate("2026-01-08T12:00:00Z") let client = DailyHistoryHTTP() - let enhanced = HistoryService(directory: directory, client: client, policy: .coinbaseEnhanced) + let enhanced = HistoryService(directory: directory, client: client, policy: .intradayEnhanced) #expect(enhanced.supportsIntraday(base: "BTC", quote: "USD")) _ = await enhanced.load(base: "BTC", quote: "USD", range: .week, now: now) let daily = HistoryService(directory: directory, client: client) @@ -62,7 +64,8 @@ import Testing .init(date: now.addingTimeInterval(-7200), value: 2), .init(date: now.addingTimeInterval(-3600), value: 3) ], - source: .init(provider: .coinbase, observation: .hourlyClose, timeZone: .gmt), fetchedAt: now) + source: .init(provider: .custom("Coinbase"), observation: .hourlyClose, timeZone: .gmt), + fetchedAt: now) try JSONEncoder().encode(saved) .write( to: directory.appendingPathComponent("history-coinbaseEnhanced-coinbase-BTC-USD-1.json")) @@ -70,11 +73,11 @@ import Testing #expect(result.series == nil) #expect(result.issue == .intradayUnavailable) #expect(await client.urls.count == before) - for mode in ["daily", "coinbaseEnhanced"] { + for mode in ["daily", "intradayEnhanced"] { #expect( FileManager.default.fileExists( atPath: - directory.appendingPathComponent("history-\(mode)-fawaz-BTC-USD-7.json").path)) + directory.appendingPathComponent("history-v2-\(mode)-BTC-USD-7.json").path)) } } } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift index dbdf63a..94538af 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift @@ -3,210 +3,110 @@ import Testing @testable import ExchangeRates -private actor IntervalHistoryHTTP: HTTPClient { - private(set) var urls: [URL] = [] - private var shouldFail = false - let data: Data - - init(data: Data) { self.data = data } - - func failRequests() { shouldFail = true } - - func get(_ url: URL) async throws -> Data { - urls.append(url) - if shouldFail { throw RateError.http(503) } - return data - } -} - -private func intervalDate(_ value: String) throws -> Date { - try #require(ISO8601DateFormatter().date(from: value)) -} - -private func candleData(_ dates: [Date]) throws -> Data { - try JSONEncoder() - .encode( - dates.enumerated() - .map { index, date in - [date.timeIntervalSince1970, 1, 100, 2, Double(index + 3), 1] - }) -} - @Suite struct HistoryIntervalTests { - @Test func hourlyCandlesExcludeOlderAndUnfinishedBuckets() throws { - let start = try intervalDate("2026-09-26T12:37:00Z") - let end = start.addingTimeInterval(86400) - let dates = try [ - "2026-09-26T12:00:00Z", "2026-09-26T13:00:00Z", "2026-09-27T11:00:00Z", - "2026-09-27T12:00:00Z", "2026-09-27T13:00:00Z" - ] - .map(intervalDate) - let points = try HistoryService.decodeCandles( - candleData(dates), start: start, end: end, granularity: .hour) - #expect(points.map(\.date) == [dates[1], dates[2]]) - #expect(points.map(\.value) == [4, 5]) - let atBoundary = try HistoryService.decodeCandles( - candleData([dates[2]]), start: dates[2], end: dates[3], granularity: .hour) - #expect(atBoundary.map(\.date) == [dates[2]]) - } - - @Test func hourlyWindowsKeepTheThreeHundredBucketLimit() { - let start = Date(timeIntervalSince1970: 0) - let end = start.addingTimeInterval(600 * 3600) - let windows = HistoryService.candleWindows(start: start, end: end, granularity: .hour) - #expect(windows.count == 3) - #expect(windows.first?.start == start) - #expect(windows.last?.end == end) - #expect(windows[0].end == windows[1].start) - #expect(windows[1].end == windows[2].start) - #expect(windows.allSatisfy { $0.end.timeIntervalSince($0.start) <= 299 * 3600 }) + @Test(arguments: Array(CurrencyCatalog.crypto)) + func allCatalogCryptoSupportFiatAndCryptoIntraday(code: String) { + #expect(HistoryService.supportsIntraday(base: code, quote: "USD")) + #expect(HistoryService.supportsIntraday(base: code, quote: "CZK")) + if code != "BTC" { #expect(HistoryService.supportsIntraday(base: code, quote: "BTC")) } } - @Test func dayUsesActualTwentyFourHourWindowAndHourlyProvenance() async throws { + @Test(arguments: [("USD", "EUR"), ("BTC", "XAU"), ("XAG", "BTC"), ("BTC", "BTC")]) + func unsupportedPairsDoNotRequest(base: String, quote: String) async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let now = try intervalDate("2026-09-27T12:37:00Z") - let dates = try [ - "2026-09-26T12:00:00Z", "2026-09-26T13:00:00Z", "2026-09-27T11:00:00Z", - "2026-09-27T12:00:00Z" - ] - .map(intervalDate) - let client = IntervalHistoryHTTP(data: try candleData(dates)) + let client = MinuteHistoryHTTP() let result = await HistoryService( - directory: directory, client: client, policy: .coinbaseEnhanced + directory: directory, client: client, policy: .intradayEnhanced ) - .load(base: "BTC", quote: "USD", range: .day, now: now) - #expect(result.issue == nil) - #expect(result.series?.points.map(\.date) == [dates[1], dates[2]]) - #expect( - result.series?.source == .init(provider: .coinbase, observation: .hourlyClose, timeZone: .gmt) - ) - let url = try #require(await client.urls.first) - let query = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) - #expect(query.contains(URLQueryItem(name: "granularity", value: "3600"))) - #expect(query.contains(URLQueryItem(name: "start", value: "2026-09-26T12:37:00Z"))) - #expect(query.contains(URLQueryItem(name: "end", value: "2026-09-27T12:37:00Z"))) + .load(base: base, quote: quote, range: .day) + #expect(result.series == nil) + #expect(result.issue == (base == quote ? .unsupportedPair : .intradayUnavailable)) + #expect(await client.urls.isEmpty) } - @Test(arguments: [("BTC", "EUR"), ("ETH", "GBP"), ("USDC", "EUR"), ("USDC", "GBP")]) - func nonDollarIntradayUsesDirectHourlyMarketAndItsOwnCache( - base: String, quote: String - ) async throws { + @Test func halfHourCacheExpiryPreservesOfflineSeries() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let now = try intervalDate("2026-09-27T12:37:00Z") - let dates = try [ - "2026-09-26T12:00:00Z", "2026-09-26T13:00:00Z", "2026-09-27T11:00:00Z", - "2026-09-27T12:00:00Z" - ] - .map(intervalDate) - let client = IntervalHistoryHTTP(data: try candleData(dates)) - let service = HistoryService(directory: directory, client: client, policy: .coinbaseEnhanced) - let result = await service.load(base: base, quote: quote, range: .day, now: now) - #expect(result.issue == nil) - #expect(result.series?.points.map(\.date) == [dates[1], dates[2]]) - #expect( - result.series?.source == .init(provider: .coinbase, observation: .hourlyClose, timeZone: .gmt) - ) - let urls = await client.urls - #expect(urls.count == 1) - let url = try #require(urls.first) - #expect(url.host == "api.exchange.coinbase.com") - #expect(url.path == "/products/\(base)-\(quote)/candles") - let query = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) - #expect(query.contains(URLQueryItem(name: "granularity", value: "3600"))) - #expect(query.contains(URLQueryItem(name: "start", value: "2026-09-26T12:37:00Z"))) - #expect(query.contains(URLQueryItem(name: "end", value: "2026-09-27T12:37:00Z"))) - let file = directory.appendingPathComponent( - "history-coinbaseEnhanced-coinbase-\(base)-\(quote)-1.json") - #expect( - try JSONDecoder().decode(HistorySeries.self, from: Data(contentsOf: file)).points - == result.series?.points) - let expiry = HistoryService.nextHour(after: now) + let now = try historyDate("2026-01-05T00:00:00Z") + let client = MinuteHistoryHTTP() + let service = HistoryService(directory: directory, client: client, policy: .intradayEnhanced) + let first = await service.load(base: "BTC", quote: "USD", range: .day, now: now) + #expect(first.issue == nil) + await client.fail() let fresh = await service.load( - base: base, quote: quote, range: .day, now: expiry.addingTimeInterval(-1)) + base: "BTC", quote: "USD", range: .day, now: now.addingTimeInterval(1799)) + #expect(fresh.issue == nil) #expect(await client.urls.count == 1) - #expect(fresh.series?.fetchedAt == now) - await client.failRequests() - let expired = await service.load( - base: base, quote: quote, range: .day, now: expiry) + let stale = await service.load( + base: "BTC", quote: "USD", range: .day, now: now.addingTimeInterval(1800)) + #expect(stale.issue == .usingCachedSeries) + #expect(stale.series?.points == first.series?.points) #expect(await client.urls.count == 2) - #expect(expired.issue == .usingCachedSeries) - #expect(expired.series?.points == result.series?.points) } - @Test(arguments: [ - ("USD", "EUR"), ("BTC", "CZK"), ("AVAX", "GBP"), ("USDC", "USD"), - ("BTC", "BTC") - ]) - func unsupportedIntradayRoutesNeverRequestOrSave(base: String, quote: String) async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let client = IntervalHistoryHTTP(data: Data()) - #expect(!HistoryService.supportsIntraday(base: base, quote: quote)) - let result = await HistoryService( - directory: directory, client: client, policy: .coinbaseEnhanced - ) - .load(base: base, quote: quote, range: .day) - #expect(result.series == nil) - #expect(result.issue == (base == quote ? .unsupportedPair : .intradayUnavailable)) - #expect(await client.urls.isEmpty) - #expect(!FileManager.default.fileExists(atPath: directory.path)) + @Test func coverageRejectsLargeInteriorGapsAndShortenedWindows() { + let end = Date(timeIntervalSince1970: 86400) + let full = (0..<288) + .map { HistoryPoint(date: Date(timeIntervalSince1970: Double($0) * 300), value: 1) } + #expect(HistoryService.coversDay(full, ending: end)) + #expect(!HistoryService.coversDay(Array(full.suffix(7)), ending: end)) + #expect( + !HistoryService.coversDay( + full.filter { + $0.date.timeIntervalSince1970 < 3600 || $0.date.timeIntervalSince1970 > 7200 + }, ending: end)) } +} - @Test func directIntradayQuoteCachesRemainIndependent() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let now = try intervalDate("2026-09-27T12:00:00Z") - let client = IntervalHistoryHTTP( - data: try candleData([now.addingTimeInterval(-7200), now.addingTimeInterval(-3600)])) - let service = HistoryService(directory: directory, client: client, policy: .coinbaseEnhanced) - for quote in ["EUR", "GBP", "EUR", "GBP"] { - #expect(await service.load(base: "BTC", quote: quote, range: .day, now: now).issue == nil) +@Suite struct BundledHistoryTests { + @Test func historicalCryptoNeverPredatesObservedCoverage() throws { + let now = try historyDate("2026-01-05T00:00:00Z") + let btc = try BundledHistory.points(base: "BTC", quote: "USD", through: now) + #expect(!btc.isEmpty) + #expect(btc.first!.date >= (try historyDate("2010-07-18T00:00:00Z"))) + #expect(btc.last!.date < (try historyDate("2024-03-01T00:00:00Z"))) + #expect(try BundledHistory.points(base: "SOL", quote: "USD", through: now).isEmpty) + let inverse = try BundledHistory.points(base: "USD", quote: "BTC", through: now) + #expect(btc.map(\.date) == inverse.map(\.date)) + #expect(zip(btc, inverse).allSatisfy { abs($0.value * $1.value - 1) < 0.00000001 }) + } + + @Test func sterlingParityUsesOnlyObservedMonthlyDates() throws { + let now = try historyDate("2026-01-05T00:00:00Z") + let gbp = try BundledHistory.points(base: "BTC", quote: "GBP", through: now) + for code in ["GGP", "IMP", "JEP"] { + #expect(try BundledHistory.points(base: "BTC", quote: code, through: now) == gbp) } #expect( - await client.urls.map(\.path) == ["/products/BTC-EUR/candles", "/products/BTC-GBP/candles"]) + try BundledHistory.points(base: "XAU", quote: "USD", through: now) + .allSatisfy { $0.value.isFinite && $0.value > 0 }) } +} - @Test func dayDefaultCacheExpiresAtOneHourAndPreservesOfflineHistory() async throws { +@Suite struct BundledAllHistoryTests { + @Test func completedAllHistoryLoadsOfflineWithoutRequests() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let now = try intervalDate("2026-09-27T12:00:00Z") - let client = IntervalHistoryHTTP( - data: try candleData([now.addingTimeInterval(-7200), now.addingTimeInterval(-3600)])) - let service = HistoryService(directory: directory, client: client, policy: .coinbaseEnhanced) - let first = await service.load(base: "BTC", quote: "USD", range: .day, now: now) + let client = DailyHistoryHTTP() await client.failRequests() - let fresh = await service.load( - base: "BTC", quote: "USD", range: .day, now: now.addingTimeInterval(3599)) - #expect(await client.urls.count == 1) - #expect(fresh.issue == nil) - let expired = await service.load( - base: "BTC", quote: "USD", range: .day, now: now.addingTimeInterval(3600)) - #expect(await client.urls.count == 2) - #expect(expired.series?.points == first.series?.points) - #expect(expired.series?.fetchedAt == first.series?.fetchedAt) - #expect(expired.issue == .usingCachedSeries) - } - - @Test func fiatIntradayIgnoresCacheAndNeverRequestsDailyRates() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let now = try intervalDate("2026-09-27T12:00:00Z") - let saved = HistorySeries( - points: [HistoryPoint(date: now, value: 0.9)], - source: .init(provider: .frankfurter, observation: .dailyReference), fetchedAt: now) - try JSONEncoder().encode(saved) - .write(to: directory.appendingPathComponent("history-USD-EUR-1.json")) - let client = IntervalHistoryHTTP(data: Data()) - let result = await HistoryService( - directory: directory, client: client, policy: .coinbaseEnhanced - ) - .load(base: "USD", quote: "EUR", range: .day, now: now) - #expect(result.series == nil) - #expect(result.issue == .intradayUnavailable) + let service = HistoryService(directory: directory, client: client) + let now = try historyDate("2026-10-15T00:00:00Z") + let result = await service.load(base: "BTC", quote: "USD", range: .all, now: now) + #expect(result.issue == nil) + let series = try #require(result.series) + #expect(series.points.count > 150) + let months = series.points.map { String($0.date.ISO8601Format().prefix(7)) } + #expect(Set(months).count == months.count) + #expect( + series.points.filter { String($0.date.ISO8601Format().prefix(7)) == "2024-03" }.map(\.date) + == [try historyDate("2024-03-02T00:00:00Z")]) + #expect(series.points.last?.date == (try historyDate("2026-09-01T00:00:00Z"))) + #expect(series.source.provider == .fawaz) + #expect(series.source.contributingProviders == [.coinMetrics]) + #expect(await client.urls.isEmpty) + let sol = await service.load(base: "SOL", quote: "USD", range: .all, now: now) + #expect(sol.series?.points.first?.date == (try historyDate("2024-03-02T00:00:00Z"))) + #expect(sol.series?.source.contributingProviders == nil) #expect(await client.urls.isEmpty) } - } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift index b11a490..68afd80 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift @@ -62,7 +62,7 @@ func historyDate(_ string: String) throws -> Date { defer { try? FileManager.default.removeItem(at: directory) } let client = DailyHistoryHTTP() let now = try historyDate("2026-01-08T12:00:00Z") - let service = HistoryService(directory: directory, client: client, policy: .coinbaseEnhanced) + let service = HistoryService(directory: directory, client: client, policy: .intradayEnhanced) let first = await service.load(base: "BTC", quote: "EUR", range: .week, now: now) #expect(first.series?.source.latestObservation == nil) _ = await service.load(base: "USD", quote: "XAU", range: .week, now: now) @@ -80,10 +80,10 @@ func historyDate(_ string: String) throws -> Date { let result = await HistoryService(directory: directory, client: client) .load(base: "EUR", quote: "USD", range: .all, now: now) #expect(result.series?.source.observation == .monthlyReference) - #expect(result.series?.points.count == 32) - #expect(result.series?.points.first?.date == (try historyDate("2024-03-31T00:00:00Z"))) - #expect(result.series?.points.last?.date == (try historyDate("2026-10-09T00:00:00Z"))) - #expect(await client.urls.count == 32) + #expect((result.series?.points.count ?? 0) > 32) + #expect(result.series?.points.first?.date == (try historyDate("2010-07-01T00:00:00Z"))) + #expect(result.series?.points.last?.date == (try historyDate("2026-09-01T00:00:00Z"))) + #expect(await client.urls.isEmpty) } @Test func yearToDateUsesJanuaryFirstAndArchiveBoundary() async throws { @@ -143,13 +143,4 @@ func historyDate(_ string: String) throws -> Date { #expect(await client.urls.count == 4) } - @Test func historySortsAndUsesCompletedClose() throws { - let rows = Data("[[172800,1,9,3,5,20],[86400,1,9,3,4,20],[259200,1,9,3,8,20]]".utf8) - let points = try HistoryService.decodeCandles( - rows, start: Date(timeIntervalSince1970: 86400), end: Date(timeIntervalSince1970: 300000)) - #expect(points.map(\.value) == [4, 5]) - #expect(throws: (any Error).self) { - try HistoryService.decodeCandles(Data("[[1,2]]".utf8), start: .distantPast, end: .now) - } - } } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/NetworkClientTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/NetworkClientTests.swift index 0fcb089..d3db1ab 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/NetworkClientTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/NetworkClientTests.swift @@ -56,7 +56,7 @@ private final class ControlledProtocol: URLProtocol, @unchecked Sendable { override func stopLoading() { Self.state.stop(self) } } -@Suite(.serialized) struct CoinbaseTransportTests { +@Suite(.serialized) struct IndependentTransportTests { private func setup() -> (NetworkClient, URLSession) { let config = URLSessionConfiguration.ephemeral config.protocolClasses = [ControlledProtocol.self] @@ -65,27 +65,12 @@ private final class ControlledProtocol: URLProtocol, @unchecked Sendable { } private func url( - host: String = "api.exchange.coinbase.com", path: String = "/products/BTC-USD/candles" + host: String = "community-api.coinmetrics.io", path: String = "/v4/timeseries/asset-metrics" ) throws -> URL { try #require(URL(string: "https://\(host)\(path)?request=\(UUID().uuidString)")) } - @Test func concurrentCandleAdmissionsStayAtLeast150MillisecondsApart() async throws { - let pacer = CandlePacer() - let urls = try [ - url(), url(path: "/products/ETH-USD/candles"), url(path: "/products/BTC-EUR/candles") - ] - let tasks = urls.map { url in Task { try await pacer.acquire(for: url) } } - defer { for task in tasks { task.cancel() } } - var starts: [ContinuousClock.Instant] = [] - for task in tasks { starts.append(try #require(try await task.value)) } - starts.sort() - for (earlier, later) in zip(starts, starts.dropFirst()) { - #expect(earlier.duration(to: later) >= .milliseconds(150)) - } - } - - @Test func separateTransportsLoadCandlesWhileOtherEndpointsComplete() async throws { + @Test func separateTransportsCompleteIndependentEndpoints() async throws { let (firstClient, firstSession) = setup() let (secondClient, secondSession) = setup() defer { @@ -93,9 +78,9 @@ private final class ControlledProtocol: URLProtocol, @unchecked Sendable { secondSession.invalidateAndCancel() } let firstURL = try url() - let secondURL = try url(path: "/products/ETH-USD/candles") - let thirdURL = try url(path: "/products/BTC-EUR/candles") - let tickerURL = try url(path: "/products/BTC-USD/ticker") + let secondURL = try url(path: "/v4/timeseries/other-metrics") + let thirdURL = try url(path: "/v4/timeseries/third-metrics") + let tickerURL = try url(path: "/v4/catalog/assets") let otherHostURL = try url(host: "example.test") let first = Task { try await firstClient.get(firstURL) } defer { first.cancel() } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/ProviderTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/ProviderTests.swift index 020d6a7..06dc3ac 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/ProviderTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/ProviderTests.swift @@ -39,30 +39,6 @@ private actor FallbackHTTP: HTTPClient { #expect(rates["EUR"]?.source.provider == .custom("ECB")) } - @Test func coinbaseBatchUsesEURRatesAndHonestRetrievalMetadata() throws { - let now = try #require(ISO8601DateFormatter().date(from: "2026-01-02T12:00:00Z")) - let data = Data( - #"{"data":{"currency":"EUR","rates":{"EUR":"1.0","BTC":"0.00002","ADA":"2.5","SHIB":"123456.12345678","USD":"1.2","ETH":"0","XRP":"-1","DOT":"2junk"}}}"# - .utf8) - let quotes = try CoinbaseProvider.decode(data, now: now) - #expect(quotes.count == 3) - #expect(quotes["BTC"]?.value == Decimal(string: "0.00002")) - #expect(quotes["SHIB"]?.value == Decimal(string: "123456.12345678")) - #expect(quotes["BTC"]?.observedAt == nil) - #expect(quotes["BTC"]?.retrievedAt == now) - #expect(quotes["BTC"]?.source.observation == .exchangeRate) - #expect(quotes["BTC"]?.published == "2026-01-02") - } - - @Test(arguments: [ - #"{"data":{"currency":"USD","rates":{"EUR":"1","BTC":"1"}}}"#, - #"{"data":{"currency":"EUR","rates":{"EUR":"2","BTC":"1"}}}"#, - #"{"data":{"currency":"EUR","rates":{"EUR":"1","BTC":"NaN"}}}"#, - #"{"data":{"currency":"EUR","rates":{}}}"# - ]) func coinbaseRejectsInvalidBatch(_ json: String) { - #expect(throws: (any Error).self) { try CoinbaseProvider.decode(Data(json.utf8)) } - } - @Test func xmlParsing() throws { let xml = Data( "" diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateProviderPolicyTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateProviderPolicyTests.swift index 8dc1a4c..bff2b22 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateProviderPolicyTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateProviderPolicyTests.swift @@ -10,9 +10,19 @@ private actor PolicyHTTP: HTTPClient { func get(_ url: URL) async throws -> Data { urls.append(url) if failing { throw RateError.http(503) } - if url.host == "api.coinbase.com" { - return Data( - #"{"data":{"currency":"EUR","rates":{"EUR":"1","BTC":"0.00003","ETH":"0.0006"}}}"#.utf8) + if url.host == "community-api.coinmetrics.io" { + return try JSONSerialization.data(withJSONObject: [ + "data": [ + [ + "asset": "btc", "time": Date().addingTimeInterval(-60).ISO8601Format(), + "ReferenceRateUSD": "50000" + ], + [ + "asset": "eth", "time": Date().addingTimeInterval(-60).ISO8601Format(), + "ReferenceRateUSD": "2500" + ] + ] + ]) } guard url.host == "cdn.jsdelivr.net" || url.host == "latest.currency-api.pages.dev" else { throw RateError.invalidData @@ -23,7 +33,7 @@ private actor PolicyHTTP: HTTPClient { } @Suite struct RateProviderPolicyTests { - @Test func dailyRefreshAndBootstrapNeverContactCoinbaseOrFrankfurter() async throws { + @Test func dailyRefreshAndBootstrapNeverContactIntradayOrFrankfurter() async throws { let client = PolicyHTTP() let service = RateService(client: client) let first = await service.refresh(previous: RateSnapshot(), force: true) @@ -43,11 +53,11 @@ private actor PolicyHTTP: HTTPClient { @Test func enabledRefreshOverlaysCryptoAndFallsBackToUnmodifiedDailyOnFailure() async throws { let client = PolicyHTTP() - let service = RateService(policy: .coinbaseEnhanced, client: client) + let service = RateService(policy: .intradayEnhanced, client: client) let now = Date() let first = await service.refresh(previous: RateSnapshot(), force: true, now: now) - #expect(first.snapshot.quotes["BTC"]?.value == Decimal(string: "0.00003")) - #expect(first.snapshot.quotes["BTC"]?.source.provider == .coinbase) + #expect(first.snapshot.quotes["BTC"]?.value == Decimal(string: "0.00004")) + #expect(first.snapshot.quotes["BTC"]?.source.provider == .coinMetrics) #expect(first.snapshot.dailyQuotes?["BTC"]?.value == Decimal(string: "0.00002")) #expect(first.snapshot.quotes["USD"]?.source.provider == .fawaz) await client.failRequests() @@ -56,8 +66,8 @@ private actor PolicyHTTP: HTTPClient { #expect(fallback.snapshot.quotes["BTC"]?.value == Decimal(string: "0.00002")) #expect(fallback.snapshot.quotes["BTC"]?.source.provider == .fawaz) #expect(fallback.warning == .partialCryptoFallback) - #expect(await client.urls.filter { $0.host == "api.coinbase.com" }.count == 2) - #expect(await client.urls.filter { $0.host == "cdn.jsdelivr.net" }.count == 1) + #expect(await client.urls.filter { $0.host == "community-api.coinmetrics.io" }.count == 2) + #expect(await client.urls.filter { $0.host == "cdn.jsdelivr.net" }.count == 3) } @Test(arguments: [false, true]) @@ -69,7 +79,7 @@ private actor PolicyHTTP: HTTPClient { let live = ExchangeRate( 3, published: "2026-01-02", source: .init( - provider: .coinbase, + provider: .coinMetrics, observation: .exchangeRate), retrievedAt: now) let foreign = ExchangeRate(4, published: "2026-01-02", source: .init(provider: .frankfurter)) let old = RateSnapshot( @@ -99,7 +109,7 @@ private actor PolicyHTTP: HTTPClient { let live = ExchangeRate( 3, published: "2026-01-02", source: .init( - provider: .coinbase, + provider: .coinMetrics, observation: .exchangeRate), retrievedAt: now) let old = RateSnapshot( quotes: ["BTC": live], fetchedAt: now, dailyQuotes: ["BTC": daily], @@ -115,7 +125,7 @@ private actor PolicyHTTP: HTTPClient { #expect(store.loadRates().quotes["BTC"]?.source.provider == .fawaz) } - @Test func disablingDoesNotAssignCoinbaseRetrievalTimeToDailyFallback() throws { + @Test func disablingDoesNotAssignIntradayRetrievalTimeToDailyFallback() throws { let now = Date() let dailyTime = now.addingTimeInterval(-12 * 3600) let daily = ExchangeRate( @@ -124,7 +134,7 @@ private actor PolicyHTTP: HTTPClient { let live = ExchangeRate( 3, published: "2026-01-02", source: .init( - provider: .coinbase, + provider: .coinMetrics, observation: .exchangeRate), retrievedAt: now) let saved = RateSnapshot( quotes: ["BTC": live], fetchedAt: now, dailyQuotes: ["BTC": daily], @@ -134,7 +144,7 @@ private actor PolicyHTTP: HTTPClient { #expect(filtered.fetchedAt == dailyTime) #expect(filtered.dailyFetchedAt == dailyTime) #expect(filtered.checkedAt == nil) - #expect(RateProviderPolicy.coinbaseEnhanced.filter(saved).fetchedAt == now) + #expect(RateProviderPolicy.intradayEnhanced.filter(saved).fetchedAt == now) let unknown = ExchangeRate(2, published: "2026-01-01", source: .init(provider: .fawaz)) let legacy = RateSnapshot(quotes: ["BTC": live, "USD": unknown], fetchedAt: now) #expect(RateProviderPolicy.daily.filter(legacy).fetchedAt == .distantPast) @@ -152,6 +162,6 @@ private actor PolicyHTTP: HTTPClient { #expect(await client.urls.count == 1) _ = try await store.refreshRates(using: service, now: now.addingTimeInterval(21600)) #expect(await client.urls.count == 2) - #expect(RateProviderPolicy.coinbaseEnhanced.refreshInterval == 1800) + #expect(RateProviderPolicy.intradayEnhanced.refreshInterval == 1800) } } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateServiceTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateServiceTests.swift index cb9f6d0..f9f5a0e 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateServiceTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateServiceTests.swift @@ -6,14 +6,14 @@ import Testing private let day = "2026-01-02" @Suite struct RateServiceTests { - @Test func coinbaseFailureRestoresDailyAndDailyCacheIsRetained() async { + @Test func intradayFailureRestoresDailyAndDailyCacheIsRetained() async { let daily = [ "EUR": ExchangeRate(1, published: day, source: .init(provider: .custom("daily"))), "BTC": ExchangeRate(2, published: day, source: .init(provider: .custom("Fawaz · daily"))) ] let live = [ "BTC": ExchangeRate( - 3, published: day, source: .init(provider: .custom("Coinbase")), observedAt: .now) + 3, published: day, source: .init(provider: .custom("Test intraday")), observedAt: .now) ] let fresh = await RateService( fiat: StubRateProvider(quotes: daily), daily: StubRateProvider(quotes: daily), diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateSourceTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateSourceTests.swift index 867724e..af57880 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateSourceTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateSourceTests.swift @@ -7,7 +7,7 @@ import Testing let source = try JSONDecoder() .decode( RateSource.self, from: Data(#""Coinbase · monthly last available close · UTC""#.utf8)) - #expect(source.provider == .coinbase) + #expect(source.provider == .custom("Coinbase")) #expect(source.observation == .monthlyLastClose) #expect(source.timeZone == .gmt) #expect(try JSONDecoder().decode(RateSource.self, from: JSONEncoder().encode(source)) == source) @@ -18,17 +18,33 @@ import Testing observation: RateObservation ) throws { let source = RateSource( - provider: .coinbase, observation: observation, timeZone: .gmt, + provider: .coinMetrics, observation: observation, timeZone: .gmt, latestObservation: .hourlyClose) let restored = try JSONDecoder().decode(RateSource.self, from: JSONEncoder().encode(source)) #expect(restored == source) #expect(restored.observation == observation) #expect(restored.latestObservation == .hourlyClose) - let original = RateSource(provider: .coinbase, observation: observation, timeZone: .gmt) + let original = RateSource(provider: .coinMetrics, observation: observation, timeZone: .gmt) let old = try JSONDecoder().decode(RateSource.self, from: JSONEncoder().encode(original)) #expect(old.latestObservation == nil) } + @Test func compositeConversionMetadataRoundTripsAndOldSourceHasNoAssumedFX() throws { + let source = RateSource( + provider: .coinMetrics, observation: .minuteReference, + conversion: .init( + provider: .fawaz, published: "2026-01-02", value: 1.2, base: "EUR", quote: "USD"), + contributingProviders: [.fawaz, .coinMetrics]) + let restored = try JSONDecoder().decode(RateSource.self, from: JSONEncoder().encode(source)) + #expect(restored == source) + let old = try JSONDecoder() + .decode( + RateSource.self, + from: Data(#"{"provider":{"fawaz":{}},"observation":"dailyReference"}"#.utf8)) + #expect(old.conversion == nil) + #expect(old.contributingProviders == nil) + } + @Test func customProviderRoundTripsWithoutInterpretingDisplayText() throws { let source = RateSource(provider: .custom("My reference feed"), observation: .dailyReference) #expect(try JSONDecoder().decode(RateSource.self, from: JSONEncoder().encode(source)) == source) diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/SnapshotMergeTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/SnapshotMergeTests.swift index 3d9d47f..16c5600 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/SnapshotMergeTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/SnapshotMergeTests.swift @@ -7,7 +7,7 @@ import Testing _ value: Decimal, day: String = "2026-01-02", time: Double? = nil ) -> ExchangeRate { ExchangeRate( - value, published: day, source: .init(provider: .coinbase), + value, published: day, source: .init(provider: .coinMetrics), observedAt: time.map { Date(timeIntervalSince1970: $0) }) } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesUITests/ResourceTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesUITests/ResourceTests.swift index e203324..06f4438 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesUITests/ResourceTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesUITests/ResourceTests.swift @@ -9,16 +9,16 @@ import UniformTypeIdentifiers @Test func provenanceInterpolationUsesTheOwningCatalog() { #expect( RateMessages.providerDescription( - RateSource(provider: .coinbase, observation: .dailyClose, timeZone: .gmt), - locale: Locale(identifier: "en_US")) == "Coinbase · daily closes · GMT") + RateSource(provider: .coinMetrics, observation: .dailyClose, timeZone: .gmt), + locale: Locale(identifier: "en_US")) == "Coin Metrics · daily closes · GMT") #expect( RateMessages.providerDescription( RateSource(provider: .custom("Example"), observation: .exchangeRate), locale: Locale(identifier: "en_US")) == "Example · retrieved") } @Test(arguments: [ - (RateObservation.dailyClose, "Coinbase · daily closes · latest hourly close · GMT"), - (.monthlyLastClose, "Coinbase · monthly last available close · latest hourly close · GMT") + (RateObservation.dailyClose, "Coin Metrics · daily closes · latest hourly close · GMT"), + (.monthlyLastClose, "Coin Metrics · monthly last available close · latest hourly close · GMT") ]) func mixedHistoryDescribesBothAggregationAndHourlyEndpoint( observation: RateObservation, expected: String @@ -26,7 +26,7 @@ import UniformTypeIdentifiers #expect( RateMessages.providerDescription( RateSource( - provider: .coinbase, observation: observation, timeZone: .gmt, + provider: .coinMetrics, observation: observation, timeZone: .gmt, latestObservation: .hourlyClose), locale: Locale(identifier: "en_US")) == expected) } @@ -34,9 +34,26 @@ import UniformTypeIdentifiers #expect( RateMessages.providerDescription( RateSource( - provider: .custom("Coinbase + Frankfurter"), timeZone: .gmt, + provider: .custom("Coin Metrics + Frankfurter"), timeZone: .gmt, latestObservation: .hourlyClose), locale: Locale(identifier: "en_US")) - == "Coinbase + Frankfurter · latest hourly close · GMT") + == "Coin Metrics + Frankfurter · latest hourly close · GMT") + } + + @Test func convertedReferenceNamesTheActualFXInputAndEverySource() throws { + let source = RateSource( + provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt, + conversion: RateConversion( + provider: .fawaz, published: "2026-10-14", value: 1.25, + base: "EUR", quote: "USD"), contributingProviders: [.fawaz, .coinMetrics]) + let locale = Locale(identifier: "en_US") + let title = RateMessages.providerDescription(source, locale: locale) + #expect( + title == "Coin Metrics + Fawaz · minute reference prices · approximate conversion · GMT") + let note = try #require(RateMessages.conversionDescription(source, locale: locale)) + #expect(note.contains("1 EUR = 1.25 USD")) + #expect(note.contains("Fawaz")) + #expect(note.contains("2026")) + #expect(RateMessages.conversionDescription(.init(provider: .fawaz)) == nil) } @Test(arguments: CurrencyCatalog.codes) diff --git a/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift b/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift index c16fb0c..d56c038 100644 --- a/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift +++ b/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift @@ -43,7 +43,7 @@ struct CryptoHistoryWidgetHarness: View { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let history = HistoryService( - directory: directory, client: CryptoHistoryFixture(), policy: .coinbaseEnhanced) + directory: directory, client: CryptoHistoryFixture(), policy: .intradayEnhanced) let pair = HistoryWidgetPair(app: ConverterState(), base: "BTC", quote: "USD") for range: HistoryRange in [.day, .week, .month] { let result = await history.load(base: "BTC", quote: "USD", range: range, now: date) @@ -56,25 +56,28 @@ struct CryptoHistoryWidgetHarness: View { private struct CryptoHistoryFixture: HTTPClient { func get(_ url: URL) async throws -> Data { - let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] - let hourly = query.first { $0.name == "granularity" }?.value == "3600" - let rows: [(String, Double)] = - hourly - ? [("2026-09-29T18:00:00Z", 83_055), ("2026-09-30T14:00:00Z", 84_608.1)] - : [ - ("2026-09-05T00:00:00Z", 81_000), ("2026-09-23T00:00:00Z", 84_381), - ("2026-09-26T00:00:00Z", 84_100), ("2026-09-29T00:00:00Z", 83_638.4) - ] - return try JSONEncoder() - .encode( - rows.map { date, value in - guard let observation = ISO8601DateFormatter().date(from: date) else { - throw RateError.invalidData - } - return [ - observation.timeIntervalSince1970, - value, value, value, value, 1 + if url.host == "community-api.coinmetrics.io" { + let query = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + guard let rawEnd = query.first(where: { $0.name == "end_time" })?.value, + let end = ISO8601DateFormatter().date(from: rawEnd) + else { throw RateError.invalidData } + let rows = (0...1440) + .map { index in + [ + "asset": "btc", + "time": end.addingTimeInterval(Double(index - 1440) * 60).ISO8601Format(), + "ReferenceRateUSD": String(83_055 + Double(index) * 1553.1 / 1440) ] - }) + } + return try JSONSerialization.data(withJSONObject: ["data": rows]) + } + guard url.host == "cdn.jsdelivr.net" || url.host?.hasSuffix("currency-api.pages.dev") == true + else { throw RateError.unavailable } + let date = + url.path.components(separatedBy: "@").last?.components(separatedBy: "/").first + ?? "2026-09-30" + return try JSONSerialization.data(withJSONObject: [ + "date": date, "eur": ["usd": 1, "btc": 1 / 84_000.0] + ]) } } diff --git a/Domain/Widgets/README.md b/Domain/Widgets/README.md index 736af23..d4a054a 100644 --- a/Domain/Widgets/README.md +++ b/Domain/Widgets/README.md @@ -6,7 +6,7 @@ Keep canonical selections independent of widget size. Default widgets share app ## History widget -`HistoryWidgetPair` follows the app base and first displayed destination unless overridden. History supports distinct currency pairs across fiat, metals, and cryptocurrencies. The domain history service derives crypto cross-rates from observations on matching dates; the widget inverts the resulting series when crypto is the displayed comparison. With Coinbase enhancement enabled, hourly 1D history follows the shared ExchangeRates capability for direct USD/EUR/GBP markets and supported crypto/crypto pairs. Daily mode hides 1D and resolves saved 1D selections to one month. Longer ranges use Fawaz daily or monthly observations. Only supported 1D series request timeline refresh at UTC hour boundaries; other ranges retain daily refresh. `HistoryWidgetSnapshot` derives the value, observation date, and percentage change from one historical series; it never mixes current quotes with historical endpoints. Changes below half a basis point display as neutral. Unsupported or missing history has no synthetic graph. Saved history retains its original observation date and is marked in the layout. +`HistoryWidgetPair` follows the app base and first displayed destination unless overridden. History supports distinct currency pairs across fiat, metals, and cryptocurrencies. The domain history service derives crypto cross-rates from observations on matching dates; the widget inverts the resulting series when crypto is the displayed comparison. With Intraday enhancement enabled, sampled intraday 1D history follows the shared ExchangeRates capability for all catalog crypto assets with aligned crypto legs or approximate daily fiat conversion. Daily mode hides 1D and resolves saved 1D selections to one month. Longer ranges use Fawaz daily or monthly observations. Only supported 1D series request timeline refresh at UTC hour boundaries; other ranges retain daily refresh. `HistoryWidgetSnapshot` derives the value, observation date, and percentage change from one historical series; it never mixes current quotes with historical endpoints. Changes below half a basis point display as neutral. Unsupported or missing history has no synthetic graph. Saved history retains its original observation date and is marked in the layout. `HistoryWidgetView` renders small and medium widgets with the shared rounded typography and currency icons. The full-bleed graph adapts to light, dark, increased-contrast, and accented rendering. The harness and widget extension use the same layout. @@ -27,7 +27,7 @@ Select the `WidgetsHarness` scheme and add launch arguments under **Run → Argu | `history-down` | Negative rate movement. | | `history-flat` | Neutral line and percentage. | | `history-tiny` | Small fractional rates retain significant digits. | -| `history-usd-btc` | USD/BTC history from inverted Coinbase values, including icon alignment. | +| `history-usd-btc` | USD/BTC history from inverted Coin Metrics values, including icon alignment. | | `history-cached` | Saved-series indicator and original observation date. | | `history-unavailable` | No available history; no sample data. | | `history-unsupported` | Identical currencies cannot form a history pair. | diff --git a/Domain/Widgets/Sources/Widgets/HistoryWidgetSnapshot.swift b/Domain/Widgets/Sources/Widgets/HistoryWidgetSnapshot.swift index 8373bed..ddf15be 100644 --- a/Domain/Widgets/Sources/Widgets/HistoryWidgetSnapshot.swift +++ b/Domain/Widgets/Sources/Widgets/HistoryWidgetSnapshot.swift @@ -16,7 +16,7 @@ public struct HistoryWidgetPair: Sendable, Equatable { /// Nil overrides follow the app base and first displayed destination independently. public init( app: ConverterState, base: String? = nil, quote: String? = nil, - policy: RateProviderPolicy = .coinbaseEnhanced + policy: RateProviderPolicy = .intradayEnhanced ) { self.policy = policy metalUnit = app.metalUnit @@ -46,9 +46,9 @@ public struct HistoryWidgetPair: Sendable, Equatable { /// Whether a historical series can be loaded for the displayed pair. public var isSupported: Bool { historyRequest != nil } - /// Whether the supported pair can load completed hourly observations. + /// Whether the supported pair can load intraday reference observations. public var supportsIntradayHistory: Bool { - guard policy == .coinbaseEnhanced, let request = historyRequest else { return false } + guard policy == .intradayEnhanced, let request = historyRequest else { return false } return HistoryService.supportsIntraday(base: request.base, quote: request.quote) } /// Replaces an unavailable saved intraday range with daily one-month history. diff --git a/Domain/Widgets/Tests/WidgetsTests/HistoryWidgetTests.swift b/Domain/Widgets/Tests/WidgetsTests/HistoryWidgetTests.swift index ba82666..6158944 100644 --- a/Domain/Widgets/Tests/WidgetsTests/HistoryWidgetTests.swift +++ b/Domain/Widgets/Tests/WidgetsTests/HistoryWidgetTests.swift @@ -77,8 +77,8 @@ import Testing #expect(HistoryWidgetPair(app: app, base: "BTC", quote: "EUR").supportsIntradayHistory) #expect(HistoryWidgetPair(app: app, base: "EUR", quote: "BTC").supportsIntradayHistory) #expect(HistoryWidgetPair(app: app, base: "ETH", quote: "GBP").supportsIntradayHistory) - #expect(!HistoryWidgetPair(app: app, base: "AVAX", quote: "GBP").supportsIntradayHistory) - #expect(!HistoryWidgetPair(app: app, base: "USDC", quote: "USD").supportsIntradayHistory) + #expect(HistoryWidgetPair(app: app, base: "AVAX", quote: "GBP").supportsIntradayHistory) + #expect(HistoryWidgetPair(app: app, base: "USDC", quote: "USD").supportsIntradayHistory) #expect(HistoryWidgetPair(app: app, base: "BTC", quote: "USDC").supportsIntradayHistory) #expect(HistoryWidgetPair(app: app, base: "BTC", quote: "ETH").supportsIntradayHistory) #expect(HistoryWidgetPair(app: app, base: "ETH", quote: "BTC").supportsIntradayHistory) @@ -93,7 +93,7 @@ import Testing points: [ HistoryPoint(date: now.addingTimeInterval(-86_400), value: 80_000), HistoryPoint(date: now, value: 100_000) - ], source: .init(provider: .custom("Coinbase + Frankfurter")), fetchedAt: now) + ], source: .init(provider: .custom("Coin Metrics + Frankfurter")), fetchedAt: now) let shown = HistoryWidgetSnapshot( pair: pair, range: .month, result: HistoryResult(series: provider, issue: nil)) @@ -217,7 +217,7 @@ import Testing HistoryPoint(date: now, value: 25) ], source: .init( - provider: intraday ? .coinbase : .fawaz, + provider: intraday ? .coinMetrics : .fawaz, observation: intraday ? .hourlyClose : .dailyReference, timeZone: .gmt), fetchedAt: now) let pair = HistoryWidgetPair(app: ConverterState(), base: "BTC", quote: "USD") diff --git a/Features/CurrencyDetails/HarnessApp/CurrencyDetailsHarnessApp.swift b/Features/CurrencyDetails/HarnessApp/CurrencyDetailsHarnessApp.swift index 3300075..6deb382 100644 --- a/Features/CurrencyDetails/HarnessApp/CurrencyDetailsHarnessApp.swift +++ b/Features/CurrencyDetails/HarnessApp/CurrencyDetailsHarnessApp.swift @@ -31,7 +31,8 @@ struct CurrencyDetailsHarnessApp: App { "BAM": ExchangeRate(1, published: "2026-09-19", source: .init(provider: .ecb)), "EUR": ExchangeRate(1, published: "2026-09-19", source: .init(provider: .ecb)), "USD": ExchangeRate(1.08, published: "2026-09-19", source: .init(provider: .ecb)), - "BTC": ExchangeRate(0.000018, published: "2026-09-19", source: .init(provider: .coinbase)) + "BTC": ExchangeRate( + 0.000018, published: "2026-09-19", source: .init(provider: .coinMetrics)) ], fetchedAt: .now) } @@ -75,7 +76,7 @@ struct CurrencyDetailsHarnessApp: App { series: HistorySeries( points: points, source: .init( - provider: isCrypto ? .coinbase : .frankfurter, + provider: isCrypto ? .coinMetrics : .frankfurter, observation: isCrypto ? (range == .day ? .hourlyClose : .dailyClose) : .dailyReference, timeZone: .gmt), fetchedAt: .now), diff --git a/Features/CurrencyDetails/Sources/CurrencyDetailsUI/RateDetailsScreen.swift b/Features/CurrencyDetails/Sources/CurrencyDetailsUI/RateDetailsScreen.swift index fc9cafa..92074f9 100644 --- a/Features/CurrencyDetails/Sources/CurrencyDetailsUI/RateDetailsScreen.swift +++ b/Features/CurrencyDetails/Sources/CurrencyDetailsUI/RateDetailsScreen.swift @@ -404,6 +404,9 @@ struct RateDetailsScreen: View { DisclosureGroup(isExpanded: $sourceDetailsExpanded) { VStack(alignment: .leading, spacing: AppStyle.Space.medium) { if let series, !series.points.isEmpty { + if let conversion = RateMessages.conversionDescription(series.source, locale: locale) { + Text(conversion) + } VStack(alignment: .leading, spacing: AppStyle.Space.xs) { if let first = series.points.first, let last = series.points.last { Text(verbatim: "\(dayLabel(first.date)) – \(dayLabel(last.date))") diff --git a/Features/CurrencyDetails/Sources/CurrencyDetailsUI/Resources/Details.xcstrings b/Features/CurrencyDetails/Sources/CurrencyDetailsUI/Resources/Details.xcstrings index 76e9f59..14aca04 100644 --- a/Features/CurrencyDetails/Sources/CurrencyDetailsUI/Resources/Details.xcstrings +++ b/Features/CurrencyDetails/Sources/CurrencyDetailsUI/Resources/Details.xcstrings @@ -367,85 +367,85 @@ "en": { "stringUnit": { "state": "translated", - "value": "Crypto history uses daily rates and completed hourly candles when available. Historical and current conversion sources may differ." + "value": "Crypto history combines daily and monthly reference data with intraday prices when available. Converted prices may be approximate. Historical and current sources can differ." } }, - "zh-Hans": { + "de": { "stringUnit": { "state": "translated", - "value": "加密货币历史使用每日汇率,以及可用时已收盘的小时K线。历史和当前换算的数据来源可能不同。" + "value": "Der Krypto-Verlauf kombiniert tägliche und monatliche Referenzdaten mit verfügbaren Preisen im Tagesverlauf. Umgerechnete Preise können Näherungswerte sein. Historische und aktuelle Quellen können abweichen." } }, - "ja": { + "es": { "stringUnit": { "state": "translated", - "value": "暗号資産の履歴には日次レートと、利用可能な場合は確定済みの時間足を使用します。過去と現在の換算データは異なる提供元を使う場合があります。" + "value": "El historial de criptomonedas combina datos de referencia diarios y mensuales con precios intradía cuando están disponibles. Los precios convertidos pueden ser aproximados. Las fuentes históricas y actuales pueden diferir." } }, - "es": { + "et": { "stringUnit": { "state": "translated", - "value": "El historial de criptomonedas usa tipos diarios y velas horarias cerradas cuando están disponibles. Las fuentes históricas y actuales pueden diferir." + "value": "Krüptovaluutade ajalugu ühendab päeva- ja kuupõhised võrdlusandmed võimaluse korral päevasiseste hindadega. Teisendatud hinnad võivad olla ligikaudsed. Ajaloolised ja praegused allikad võivad erineda." } }, - "de": { + "fr": { "stringUnit": { "state": "translated", - "value": "Der Kryptoverlauf verwendet Tageskurse und, wenn verfügbar, abgeschlossene Stundenkerzen. Historische und aktuelle Umrechnungskurse können aus unterschiedlichen Quellen stammen." + "value": "L’historique des cryptomonnaies combine des données de référence quotidiennes et mensuelles avec les prix intrajournaliers disponibles. Les prix convertis peuvent être approximatifs. Les sources historiques et actuelles peuvent différer." } }, - "fr": { + "it": { "stringUnit": { "state": "translated", - "value": "L’historique des cryptomonnaies utilise des taux quotidiens et, si disponibles, des bougies horaires clôturées. Les sources historiques et actuelles peuvent différer." + "value": "La cronologia delle criptovalute combina dati di riferimento giornalieri e mensili con prezzi infragiornalieri, quando disponibili. I prezzi convertiti possono essere approssimativi. Le fonti storiche e attuali possono differire." } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "O histórico de criptomoedas usa taxas diárias e candles horários fechados, quando disponíveis. As fontes históricas e atuais podem diferir." + "value": "暗号資産の履歴は、日次・月次の参考データと、利用可能な日中価格を組み合わせます。換算価格は概算の場合があります。過去と現在のデータ提供元は異なる場合があります。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "암호화폐의 과거 데이터는 일별 환율과 사용 가능한 경우 마감된 시간별 캔들을 사용합니다. 과거 환율과 현재 환율의 데이터 출처가 다를 수 있습니다." + "value": "암호화폐 기록은 일별 및 월별 참고 데이터와 제공 가능한 일중 가격을 결합합니다. 환산 가격은 근삿값일 수 있습니다. 과거 및 현재 데이터 제공처는 다를 수 있습니다." } }, - "zh-Hant": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "加密貨幣歷史使用每日匯率,以及可用時已收盤的小時K線。歷史和目前換算的資料來源可能不同。" + "value": "O histórico de criptomoedas combina dados de referência diários e mensais com preços intradiários quando disponíveis. Os preços convertidos podem ser aproximados. As fontes históricas e atuais podem ser diferentes." } }, - "it": { + "ru": { "stringUnit": { "state": "translated", - "value": "La cronologia delle criptovalute usa tassi giornalieri e, se disponibili, candele orarie completate. Le fonti storiche e attuali possono differire." + "value": "История криптовалют сочетает дневные и месячные справочные данные с доступными внутридневными ценами. Пересчитанные цены могут быть приблизительными. Источники исторических и текущих данных могут различаться." } }, - "tr": { + "uk": { "stringUnit": { "state": "translated", - "value": "Kripto para geçmişi günlük kurları ve varsa tamamlanmış saatlik mumları kullanır. Geçmiş ve güncel kurların kaynakları farklı olabilir." + "value": "Історія криптовалют поєднує денні й місячні довідкові дані з доступними внутрішньоденними цінами. Перераховані ціни можуть бути приблизними. Джерела історичних і поточних даних можуть відрізнятися." } }, - "ru": { + "tr": { "stringUnit": { "state": "translated", - "value": "История криптовалют использует ежедневные курсы и, если доступны, завершённые часовые свечи. Источники исторических и текущих курсов могут отличаться." + "value": "Kripto geçmişi, günlük ve aylık referans verilerini mevcut olduğunda gün içi fiyatlarla birleştirir. Dönüştürülen fiyatlar yaklaşık olabilir. Geçmiş ve güncel kaynaklar farklı olabilir." } }, - "uk": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Історія криптовалют використовує щоденні курси та, за наявності, завершені годинні свічки. Джерела історичних і поточних курсів можуть відрізнятися." + "value": "加密货币历史记录结合每日、每月参考数据及可用的日内价格。换算后的价格可能为近似值。历史数据和当前数据的来源可能不同。" } }, - "et": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Krüptovaluutade ajalugu kasutab päevakursse ja võimaluse korral suletud tunniküünlaid. Ajalooliste ja praeguste vahetuskursside allikad võivad erineda." + "value": "加密貨幣歷史記錄結合每日、每月參考資料及可用的日內價格。換算後的價格可能為近似值。歷史資料和目前資料的來源可能不同。" } } } @@ -816,85 +816,85 @@ "en": { "stringUnit": { "state": "translated", - "value": "Last trade %@" + "value": "Observed %@" } }, - "zh-Hans": { + "de": { "stringUnit": { "state": "translated", - "value": "最后交易 %@" + "value": "Beobachtet %@" } }, - "ja": { + "es": { "stringUnit": { "state": "translated", - "value": "最終取引 %@" + "value": "Observado %@" } }, - "es": { + "et": { "stringUnit": { "state": "translated", - "value": "Última operación %@" + "value": "Vaadeldud %@" } }, - "de": { + "fr": { "stringUnit": { "state": "translated", - "value": "Letzter Handel %@" + "value": "Observé %@" } }, - "fr": { + "it": { "stringUnit": { "state": "translated", - "value": "Dernière transaction %@" + "value": "Osservato %@" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Última negociação %@" + "value": "観測 %@" } }, "ko": { "stringUnit": { "state": "translated", - "value": "마지막 거래 %@" + "value": "관측 시각 %@" } }, - "zh-Hant": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "最後交易 %@" + "value": "Observado %@" } }, - "it": { + "ru": { "stringUnit": { "state": "translated", - "value": "Ultimo scambio %@" + "value": "Наблюдение %@" } }, - "tr": { + "uk": { "stringUnit": { "state": "translated", - "value": "Son işlem %@" + "value": "Спостереження %@" } }, - "ru": { + "tr": { "stringUnit": { "state": "translated", - "value": "Последняя сделка %@" + "value": "Gözlem zamanı %@" } }, - "uk": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Остання угода %@" + "value": "观测时间 %@" } }, - "et": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Viimane tehing %@" + "value": "觀測時間 %@" } } } @@ -1986,85 +1986,85 @@ "en": { "stringUnit": { "state": "translated", - "value": "Retrieved %@ · market timestamp unavailable" + "value": "Retrieved %@" } }, - "zh-Hans": { + "de": { "stringUnit": { "state": "translated", - "value": "获取于 %@ · 无市场时间戳" + "value": "Abgerufen %@" } }, - "ja": { + "es": { "stringUnit": { "state": "translated", - "value": "取得日時 %@ · 市場のタイムスタンプなし" + "value": "Obtenido %@" } }, - "es": { + "et": { "stringUnit": { "state": "translated", - "value": "Obtenido %@ · hora del mercado no disponible" + "value": "Hangitud %@" } }, - "de": { + "fr": { "stringUnit": { "state": "translated", - "value": "Abgerufen %@ · Marktzeitstempel nicht verfügbar" + "value": "Récupéré %@" } }, - "fr": { + "it": { "stringUnit": { "state": "translated", - "value": "Récupéré %@ · horodatage du marché indisponible" + "value": "Recuperato %@" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Obtido %@ · horário do mercado indisponível" + "value": "取得 %@" } }, "ko": { "stringUnit": { "state": "translated", - "value": "가져옴 %@ · 시장 타임스탬프 없음" + "value": "가져온 시각 %@" } }, - "zh-Hant": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "取得於 %@ · 無市場時間戳記" + "value": "Obtido %@" } }, - "it": { + "ru": { "stringUnit": { "state": "translated", - "value": "Recuperati %@ · data e ora di mercato non disponibili" + "value": "Получено %@" } }, - "tr": { + "uk": { "stringUnit": { "state": "translated", - "value": "Alındı %@ · piyasa zaman damgası kullanılamıyor" + "value": "Отримано %@" } }, - "ru": { + "tr": { "stringUnit": { "state": "translated", - "value": "Получено %@ · время рыночных данных недоступно" + "value": "Alınma zamanı %@" } }, - "uk": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Отримано %@ · час ринкових даних недоступний" + "value": "获取时间 %@" } }, - "et": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Hangitud %@ · turu ajatempel pole saadaval" + "value": "擷取時間 %@" } } } diff --git a/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift b/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift index d48fe3b..53cb85a 100644 --- a/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift +++ b/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift @@ -62,7 +62,7 @@ struct CurrencyDetailsTests { @Test func requestedFiatCryptoPairInvertsProviderSeriesAndRetainsProvenance() async { let date = Date(timeIntervalSince1970: 1_700_000_000) - let source = RateSource(provider: .coinbase, observation: .hourlyClose, timeZone: .gmt) + let source = RateSource(provider: .coinMetrics, observation: .hourlyClose, timeZone: .gmt) let received = HistorySeries( points: [ HistoryPoint(date: date, value: 40_000), @@ -108,7 +108,7 @@ struct CurrencyDetailsTests { #expect(requestedRange == range) } - @Test(arguments: [("BTC", true), ("ETH", true), ("USDC", false), ("EUR", false), ("USD", false)]) + @Test(arguments: [("BTC", true), ("ETH", true), ("USDC", true), ("EUR", false), ("USD", false)]) func chartOffersIntradayOnlyForSupportedHistory(code: String, supportsIntraday: Bool) { let model = CurrencyDetailsModel( input: .init(code: code, reference: "USD", snapshot: RateSnapshot()), diff --git a/Features/Onboarding/Tests/OnboardingTests/OnboardingTests.swift b/Features/Onboarding/Tests/OnboardingTests/OnboardingTests.swift index a6a09b8..535e957 100644 --- a/Features/Onboarding/Tests/OnboardingTests/OnboardingTests.swift +++ b/Features/Onboarding/Tests/OnboardingTests/OnboardingTests.swift @@ -590,7 +590,7 @@ private func waitFor(_ predicate: () -> Bool) async throws { let daily = onboardingQuotes() var live = daily live["BTC"] = ExchangeRate( - 2, published: "2026-09-10", source: .init(provider: .coinbase), observedAt: .now) + 2, published: "2026-09-10", source: .init(provider: .coinMetrics), observedAt: .now) try RateCache(directory: location) .save( RateSnapshot(quotes: live, fetchedAt: .now, dailyQuotes: daily)) diff --git a/Features/Settings/Sources/Settings/SettingsModel.swift b/Features/Settings/Sources/Settings/SettingsModel.swift index 06a006c..c6deeec 100644 --- a/Features/Settings/Sources/Settings/SettingsModel.swift +++ b/Features/Settings/Sources/Settings/SettingsModel.swift @@ -58,8 +58,6 @@ public enum SettingsIssue: Sendable, Equatable { /// The host supplies preference persistence and operations; Settings owns their presentation flow. @MainActor public struct SettingsDependencies { - /// Whether the host enables Coinbase conversion and hourly-history capabilities. - public var usesCoinbase: Bool /// Reads the host current rate information without starting network work. public var readState: () -> SettingsRateState /// Subscribes to committed host state changes; each flow owns its subscription. @@ -87,10 +85,9 @@ public struct SettingsDependencies { setAccent: @escaping (SettingsAccent) -> Void, setMetalUnit: @escaping (MetalUnit) throws -> Void, refresh: @escaping () async throws -> SettingsRateState, - replay: (() throws -> Void)?, usesCoinbase: Bool = true, + replay: (() throws -> Void)?, output: @escaping (SettingsOutput) -> Void ) { - self.usesCoinbase = usesCoinbase self.readState = readState; self.changes = changes; self.readPreferences = readPreferences self.setTheme = setTheme; self.setAccent = setAccent; self.setMetalUnit = setMetalUnit self.refresh = refresh @@ -119,8 +116,6 @@ public final class SettingsModel { } /// Whether the host supplies the optional setup replay capability. public var allowsReplay: Bool { dependencies.replay != nil } - /// Whether the host enables Coinbase conversion and hourly-history capabilities. - public var usesCoinbase: Bool { dependencies.usesCoinbase } /// Creates the owned value or model with explicit host inputs and operations. public init(dependencies: SettingsDependencies) { self.dependencies = dependencies diff --git a/Features/Settings/Sources/SettingsUI/Resources/Settings.xcstrings b/Features/Settings/Sources/SettingsUI/Resources/Settings.xcstrings index de55111..2140c40 100644 --- a/Features/Settings/Sources/SettingsUI/Resources/Settings.xcstrings +++ b/Features/Settings/Sources/SettingsUI/Resources/Settings.xcstrings @@ -3129,85 +3129,85 @@ "en": { "stringUnit": { "state": "translated", - "value": "Daily reference and historical rates from central banks and official institutions." + "value": "Historical fiat reference rates from selected official providers. Some currencies use a labelled GBP parity calculation." } }, - "zh-Hans": { + "de": { "stringUnit": { "state": "translated", - "value": "来自中央银行和官方机构的每日参考汇率及历史汇率。" + "value": "Historische Fiat-Referenzkurse ausgewählter offizieller Anbieter. Einige Währungen verwenden eine gekennzeichnete GBP-Paritätsberechnung." } }, - "ja": { + "es": { "stringUnit": { "state": "translated", - "value": "中央銀行や公的機関の日次参照レートと過去のレート。" + "value": "Tipos de referencia históricos de divisas de proveedores oficiales seleccionados. Algunas monedas usan un cálculo de paridad con GBP identificado como tal." } }, - "es": { + "et": { "stringUnit": { "state": "translated", - "value": "Tipos de referencia diarios e históricos de bancos centrales e instituciones oficiales." + "value": "Valitud ametlike pakkujate ajaloolised tavavaluutade võrdluskursid. Mõne valuuta puhul kasutatakse märgistatud GBP-pariteedi arvutust." } }, - "de": { + "fr": { "stringUnit": { "state": "translated", - "value": "Tägliche Referenzkurse und historische Kurse von Zentralbanken und offiziellen Institutionen." + "value": "Taux de référence historiques des devises issus de fournisseurs officiels sélectionnés. Certaines devises utilisent un calcul de parité avec la GBP, indiqué comme tel." } }, - "fr": { + "it": { "stringUnit": { "state": "translated", - "value": "Taux de référence quotidiens et historiques des banques centrales et institutions officielles." + "value": "Tassi di riferimento storici delle valute da fornitori ufficiali selezionati. Alcune valute usano un calcolo di parità con GBP indicato come tale." } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Taxas de referência diárias e históricas de bancos centrais e instituições oficiais." + "value": "選定された公的機関による過去の法定通貨参考レート。一部の通貨には、明示されたGBPとの等価換算を使用します。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "중앙은행 및 공식 기관의 일일 기준 환율과 과거 환율." + "value": "선정된 공식 제공업체의 과거 법정화폐 참고 환율입니다. 일부 통화에는 GBP 등가 환산을 사용하며 이를 표시합니다." } }, - "zh-Hant": { + "pt-BR": { "stringUnit": { "state": "translated", - "value": "來自中央銀行和官方機構的每日參考匯率及歷史匯率。" + "value": "Taxas de referência históricas de moedas de provedores oficiais selecionados. Algumas moedas usam um cálculo de paridade com GBP, identificado como tal." } }, - "it": { + "ru": { "stringUnit": { "state": "translated", - "value": "Tassi di riferimento giornalieri e storici di banche centrali e istituzioni ufficiali." + "value": "Исторические справочные курсы фиатных валют от выбранных официальных поставщиков. Для некоторых валют используется явно обозначенный расчёт по паритету с GBP." } }, - "tr": { + "uk": { "stringUnit": { "state": "translated", - "value": "Merkez bankaları ve resmî kurumların günlük referans ve geçmiş kurları." + "value": "Історичні довідкові курси фіатних валют від вибраних офіційних постачальників. Для деяких валют використовується явно позначений розрахунок за паритетом із GBP." } }, - "ru": { + "tr": { "stringUnit": { "state": "translated", - "value": "Ежедневные справочные и исторические курсы центральных банков и официальных учреждений." + "value": "Seçilen resmî sağlayıcılardan geçmiş itibari para referans kurları. Bazı para birimlerinde açıkça belirtilen GBP paritesi hesabı kullanılır." } }, - "uk": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Щоденні довідкові та історичні курси центральних банків і офіційних установ." + "value": "来自选定官方提供方的历史法币参考汇率。部分货币使用已标明的英镑等值换算。" } }, - "et": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Keskpankade ja ametlike asutuste päevased võrdluskursid ning ajaloolised kursid." + "value": "來自選定官方提供者的歷史法幣參考匯率。部分貨幣使用已標明的英鎊等值換算。" } } } @@ -3390,95 +3390,6 @@ } } }, - "coinbaseCredit": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cryptocurrency exchange rates and completed historical candles." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "加密货币汇率和已收盘的历史 K 线。" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "暗号資産の為替レートと確定済みの過去ローソク足。" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Tipos de cambio de criptomonedas y velas históricas cerradas." - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Kryptowährungskurse und abgeschlossene historische Kerzen." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Taux de change des cryptomonnaies et bougies historiques clôturées." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Taxas de câmbio de criptomoedas e candles históricos fechados." - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "암호화폐 환율 및 마감된 과거 캔들입니다." - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "加密貨幣匯率和已收盤的歷史 K 線。" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Tassi di cambio delle criptovalute e candele storiche completate." - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Kripto para kurları ve tamamlanmış geçmiş mumlar." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Курсы криптовалют и завершённые исторические свечи." - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Курси криптовалют і завершені історичні свічки." - } - }, - "et": { - "stringUnit": { - "state": "translated", - "value": "Krüptovaluutade vahetuskursid ja suletud ajaloolised hinnaküünlad." - } - } - } - }, "web3Credit": { "extractionState": "manual", "localizations": { @@ -6593,6 +6504,95 @@ } } } + }, + "coinMetricsCredit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Intraday crypto reference prices and historical data. CC BY-NC 4.0." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Krypto-Referenzpreise im Tagesverlauf und historische Daten. CC BY-NC 4.0." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Precios de referencia intradía de criptomonedas y datos históricos. CC BY-NC 4.0." + } + }, + "et": { + "stringUnit": { + "state": "translated", + "value": "Krüptovaluutade päevasisesed võrdlushinnad ja ajaloolised andmed. CC BY-NC 4.0." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Prix de référence intrajournaliers des cryptomonnaies et données historiques. CC BY-NC 4.0." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Prezzi di riferimento infragiornalieri delle criptovalute e dati storici. CC BY-NC 4.0." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "暗号資産の日中参考価格と過去データ。CC BY-NC 4.0。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "암호화폐의 일중 참고 가격 및 과거 데이터. CC BY-NC 4.0." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Preços de referência intradiários de criptomoedas e dados históricos. CC BY-NC 4.0." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Внутридневные справочные цены криптовалют и исторические данные. CC BY-NC 4.0." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Внутрішньоденні довідкові ціни криптовалют та історичні дані. CC BY-NC 4.0." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gün içi kripto referans fiyatları ve geçmiş veriler. CC BY-NC 4.0." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "加密货币日内参考价格及历史数据。CC BY-NC 4.0。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "加密貨幣日內參考價格及歷史資料。CC BY-NC 4.0。" + } + } + } } }, "version": "1.0" diff --git a/Features/Settings/Sources/SettingsUI/SettingsScreen.swift b/Features/Settings/Sources/SettingsUI/SettingsScreen.swift index 85e7160..885a0b8 100644 --- a/Features/Settings/Sources/SettingsUI/SettingsScreen.swift +++ b/Features/Settings/Sources/SettingsUI/SettingsScreen.swift @@ -696,6 +696,9 @@ struct SettingsScreen: View { VStack(alignment: .leading, spacing: AppStyle.Space.xs) { Text(verbatim: code).font(AppStyle.font(.body, weight: .medium)) if let rate = snapshot.quotes[code] { + if let conversion = RateMessages.conversionDescription(rate.source, locale: locale) { + Text(conversion).font(AppStyle.font(.caption)).foregroundStyle(.secondary) + } Text(RateMessages.providerDescription(rate.source, locale: locale)) .font(AppStyle.font(.subheadline)).foregroundStyle(.secondary) Group { @@ -722,14 +725,21 @@ struct SettingsScreen: View { private var sourceInformation: some View { Section(.Settings.sources) { + sourceCredit( + "Frankfurter", description: .Settings.frankfurterCredit, + website: "https://frankfurter.dev/", license: "Attribution", + licenseURL: + "https://github.com/DimaMishchenko/currency/blob/main/Documentation/DataSources.md") sourceCredit( "Fawaz Exchange API", description: .Settings.fawazCredit, - website: "https://github.com/fawazahmed0/exchange-api") - if model.usesCoinbase { - sourceCredit( - "Coinbase", description: .Settings.coinbaseCredit, - website: "https://docs.cdp.coinbase.com/coinbase-app/track-apis/exchange-rates") - } + website: "https://github.com/fawazahmed0/exchange-api", + license: "CC0 1.0", + licenseURL: "https://github.com/fawazahmed0/exchange-api/blob/main/LICENSE") + sourceCredit( + "Coin Metrics", description: .Settings.coinMetricsCredit, + website: "https://docs.coinmetrics.io/api-reference", + license: "CC BY-NC 4.0", + licenseURL: "https://creativecommons.org/licenses/by-nc/4.0/") } } diff --git a/Features/Settings/Tests/SettingsTests/SettingsModelTests.swift b/Features/Settings/Tests/SettingsTests/SettingsModelTests.swift index b760b9f..9eea074 100644 --- a/Features/Settings/Tests/SettingsTests/SettingsModelTests.swift +++ b/Features/Settings/Tests/SettingsTests/SettingsModelTests.swift @@ -11,7 +11,6 @@ import Testing SettingsRateState(snapshot: RateSnapshot(), codes: []) }, replay: (() throws -> Void)? = nil, - usesCoinbase: Bool = true, output: @escaping (SettingsOutput) -> Void = { _ in } ) -> SettingsDependencies { SettingsDependencies( @@ -19,14 +18,8 @@ import Testing changes: { AsyncStream(bufferingPolicy: .unbounded) { _ in } }, readPreferences: { SettingsPreferences(theme: .system, accent: .primary) }, setTheme: { _ in }, setAccent: { _ in }, setMetalUnit: { _ in }, refresh: refresh, - replay: replay, usesCoinbase: usesCoinbase, output: output) + replay: replay, output: output) } - @Test(arguments: [false, true]) - func sourceCreditsFollowInjectedProviderCapability(usesCoinbase: Bool) { - let model = SettingsModel(dependencies: dependencies(usesCoinbase: usesCoinbase)) - #expect(model.usesCoinbase == usesCoinbase) - } - @Test func failedReplayDoesNotEmitNavigationAndSuccessfulRetryCommitsFirst() { var failing = true var saved = false diff --git a/LICENSES/CC-BY-4.0.txt b/LICENSES/CC-BY-4.0.txt new file mode 100644 index 0000000..4ea99c2 --- /dev/null +++ b/LICENSES/CC-BY-4.0.txt @@ -0,0 +1,395 @@ +Attribution 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution 4.0 International Public License ("Public License"). To the +extent this Public License may be interpreted as a contract, You are +granted the Licensed Rights in consideration of Your acceptance of +these terms and conditions, and the Licensor grants You such rights in +consideration of benefits the Licensor receives from making the +Licensed Material available under these terms and conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/LICENSES/CC-BY-NC-4.0.txt b/LICENSES/CC-BY-NC-4.0.txt new file mode 100644 index 0000000..c657cab --- /dev/null +++ b/LICENSES/CC-BY-NC-4.0.txt @@ -0,0 +1,407 @@ +Attribution-NonCommercial 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-NonCommercial 4.0 International Public +License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-NonCommercial 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + d. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + g. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + i. NonCommercial means not primarily intended for or directed towards + commercial advantage or monetary compensation. For purposes of + this Public License, the exchange of the Licensed Material for + other material subject to Copyright and Similar Rights by digital + file-sharing or similar means is NonCommercial provided there is + no payment of monetary compensation in connection with the + exchange. + + j. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + k. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + l. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part, for NonCommercial purposes only; and + + b. produce, reproduce, and Share Adapted Material for + NonCommercial purposes only. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties, including when + the Licensed Material is used other than for NonCommercial + purposes. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's + License You apply must not prevent recipients of the Adapted + Material from complying with this Public License. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database for NonCommercial purposes + only; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. diff --git a/LICENSES/Fawaz-CC0-1.0.txt b/LICENSES/Fawaz-CC0-1.0.txt new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSES/Fawaz-CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/LICENSES/README.md b/LICENSES/README.md new file mode 100644 index 0000000..c8f2846 --- /dev/null +++ b/LICENSES/README.md @@ -0,0 +1,9 @@ +# Third-party data licences + +These texts accompany the data described in [Data sources](../Documentation/DataSources.md): + +- `CC-BY-NC-4.0.txt`: Coin Metrics Community data and Currency’s contributions to adapted Coin Metrics datasets. +- `CC-BY-4.0.txt`: covered European Commission / InforEuro material. +- `Fawaz-CC0-1.0.txt`: Fawaz Exchange API repository’s declared CC0 terms. + +Other source-specific data terms and attribution statements are linked in Data sources. These data licences do not change the project source-code [MIT licence](../LICENSE), and MIT does not relicense third-party data. Retain source notices and modification metadata when redistributing datasets. Check that each distributed package preserves Creative Commons recipients’ rights, including the prohibition on additional legal restrictions and effective technological measures that restrict the licensed rights. diff --git a/Project.swift b/Project.swift index 20917f5..e66ef10 100644 --- a/Project.swift +++ b/Project.swift @@ -573,6 +573,7 @@ let project = Project( ], additionalFiles: [ "AGENTS.md", .folderReference(path: "Apps/Currency/Tests/E2E"), + .folderReference(path: "LICENSES"), "README.md", "LICENSE", "Project.swift", "Tuist.swift", ".gitignore", ".mise.toml", ".swift-format", ".github/**/*.yml", ".github/**/*.md", "Documentation/**/*.md", "Documentation/**/*.png", "Scripts/**/*.sh", "Scripts/**/*.py", diff --git a/Scripts/History/generate_archive.py b/Scripts/History/generate_archive.py new file mode 100644 index 0000000..5ab7b5f --- /dev/null +++ b/Scripts/History/generate_archive.py @@ -0,0 +1,57 @@ +import argparse, csv, datetime, hashlib, io, json, pathlib, re, urllib.request + +parser = argparse.ArgumentParser() +parser.add_argument('--crypto-input', type=pathlib.Path) +parser.add_argument('--fiat-input', type=pathlib.Path) +parser.add_argument('--fawaz-input', type=pathlib.Path) +parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('Domain/ExchangeRates/Sources/ExchangeRates/Resources/history-monthly-v1.json')) +args = parser.parse_args() +allowed = set(re.findall(r'case \w+ = "([A-Z]+)"', pathlib.Path('Domain/ExchangeRates/Sources/ExchangeRates/CurrencyCode.swift').read_text())) +codes = 'btc eth usdc usdt doge ltc xrp ada link dot bch xlm uni etc aave algo icp'.split() +providers = 'AMCM,BAM,BANREP,BCB,BCBO,BCC,BCCH,BCP,BCRA,BCT,BDI,BDP,BI,BIS,BNA,BNM,BNR,BOA,BOC,BOJA,BOM,BOT,BOTA,BOZ,BRB,CBA,CBAR,CBC,CBG,CBI,CBK,CBLLR,CBM,CBS,CBSL,CBU,DAB,DNB,ECB,HMRC,HNB,INFOREURO,JPC,LB,MAS,MMA,MNB,NBC,NBE,NBG,NBK,NBKR,NBM,NBP,NBRB,NBRM,NBT,NRB,NRBT,RB,RBF,RBM,RBV,SARB,SBI,UST' +def fetch(url): + with urllib.request.urlopen(url, timeout=120) as response: + return response.read() +if args.crypto_input: + crypto = json.loads(args.crypto_input.read_text()) +else: + crypto = {} + for code in codes: + rows = csv.DictReader(io.StringIO(fetch(f'https://raw.githubusercontent.com/coinmetrics/data/master/csv/{code}.csv').decode())) + crypto[code] = [(row['time'], row['PriceUSD']) for row in rows if row.get('PriceUSD') and '2010-07-18' <= row['time'] <= '2024-03-01' and float(row['PriceUSD']) > 0] +fiat = json.loads(args.fiat_input.read_text()) if args.fiat_input else json.loads(fetch(f'https://api.frankfurter.dev/v2/rates?base=USD&from=2010-07-01&to=2024-02-29&group=month&providers={providers}')) +fx = {} +for row in fiat: + if row['date'] < '2024-03-01' and row['quote'] in allowed: + fx.setdefault(row['quote'], {})[row['date']] = float(row['rate']) +fx['USD'] = {row['date']: 1.0 for row in fiat if row['date'] < '2024-03-01'} +crypto_monthly = {} +for code, rows in crypto.items(): + months = {} + for day, price in sorted(rows): + if day < '2024-03-01': + months[day[:7]] = [day, float(price)] + crypto_monthly[code.upper()] = list(months.values()) +crypto_fx = {} +for code, rows in crypto.items(): + by_date = dict(rows) + crypto_fx[code.upper()] = [[day, float(by_date[day])] for day in sorted(fx['USD']) if day in by_date] +def packed(rows): + epoch = datetime.date(1970, 1, 1) + return [[(datetime.date.fromisoformat(day)-epoch).days, value] for day,value in rows] +fawaz = {} +days = ['2024-03-02'] + [f'{year:04}-{month:02}-01' for year in range(2024, 2027) for month in range(1, 13) if '2024-04' <= f'{year:04}-{month:02}' <= '2026-09'] +for day in days: + if args.fawaz_input: + table = json.loads((args.fawaz_input / f'{day}.json').read_text()) + else: + table = json.loads(fetch(f'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{day}/v1/currencies/usd.min.json')) + if table['date'] != day: + raise ValueError(f'Wrong publication date: {day}') + for code, value in table['usd'].items(): + if code.upper() in allowed and float(value) > 0: + fawaz.setdefault(code.upper(), []).append([day, float(value)]) +archive = {'version': 1, 'provenance': {'acquired': '2026-10-15', 'cryptoSource': 'https://github.com/coinmetrics/data', 'cryptoMetric': 'PriceUSD', 'cryptoSampling': 'Last available positive daily observation per completed month; cryptoFX preserves observations matching FX monthly bucket dates.', 'fiatSource': 'https://api.frankfurter.dev/v2/rates', 'fiatSampling': 'group=month monthly reference buckets; cross-source joins are approximations.', 'licenses': ['CC-BY-NC-4.0.txt', 'CC-BY-4.0.txt', 'Fawaz-CC0-1.0.txt'], 'notice': 'DataSources.md', 'fawazSource': 'https://github.com/fawazahmed0/exchange-api', 'fawazSampling': 'First daily publication of each completed month March2024 through September2026, March2024 uses earliest available March2; USD quote table; CC0-1.0.', 'cryptoInputSHA256': hashlib.sha256(json.dumps(crypto, sort_keys=True).encode()).hexdigest(), 'fiatInputSHA256': hashlib.sha256(json.dumps(fiat, sort_keys=True).encode()).hexdigest()}, 'fawaz': {code: packed(rows) for code,rows in fawaz.items()}, 'providers': providers.split(','), 'crypto': {code: packed(rows) for code, rows in crypto_monthly.items()}, 'cryptoFX': {code: packed(rows) for code,rows in crypto_fx.items()}, 'fiat': {code: packed(sorted(rows.items())) for code,rows in fx.items()}} +args.output.parent.mkdir(parents=True, exist_ok=True) +args.output.write_text(json.dumps(archive, separators=(',', ':'), sort_keys=True)+'\n') +print(f'{args.output}: {args.output.stat().st_size} bytes') From 2264995bd2063b101f60f6ba3df43bf42efceee8 Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Thu, 15 Oct 2026 11:42:39 +0200 Subject: [PATCH 06/17] =?UTF-8?q?=F0=9F=90=9B=20rates:=20finish=20referenc?= =?UTF-8?q?e-price=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * guard provider URL and history date construction * preserve legacy encoding while satisfying strict Swift lint * label reference observations accurately in every language * validate visible quote provenance and bundled history navigation --- .../Tests/E2E/tests/history-archive.e2e.ts | 2 +- .../Tests/E2E/tests/provider-policy.e2e.ts | 9 ++++++ .../ExchangeRates/CoinMetricsClient.swift | 7 +++-- .../ExchangeRates/CoinMetricsProvider.swift | 2 ++ .../ExchangeRates/HistoryService.swift | 20 +++++++++---- .../Sources/ExchangeRates/RateSource.swift | 11 ++++++-- .../SettingsUI/Resources/Settings.xcstrings | 28 +++++++++---------- 7 files changed, 53 insertions(+), 26 deletions(-) diff --git a/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts b/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts index 4b9a281..4ac6aa8 100644 --- a/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts @@ -14,7 +14,7 @@ test('All history renders the actual bundled Bitcoin archive from 2010', { tags: if (existsSync(cache)) unlinkSync(cache); await agent.act('Open Details & history for the Bitcoin destination currency.'); await expect(screen.getByTestId('currency.details.BTC')).toBeVisible(); - const all = screen.getByTestId('currency.details.historyRange').getByRole('tab', 'All', { exact: true }); + const all = screen.getByTestId('currency.details.historyRange').getByRole('tab', 'All available history', { exact: true }); await all.tap(); await expect(all).toBeSelected(); const source = screen.getByTestId('currency.details.sourceDisclosure').filter({ hasText: /Fawaz.*Coin Metrics.*monthly reference/i }); diff --git a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts index 26fa503..5fe09a0 100644 --- a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts @@ -59,6 +59,15 @@ test(`provider policy ${mode} preserves crypto fallback, ranges and credits afte await screen.getByTestId('converter.options').tap(); await screen.getByTestId('converter.settings').tap(); + await screen.getByTestId('settings.rates').tap(); + await agent.act('Scroll Rates until the BTC quote and its source information are visible. Keep Rates open.'); + await expect(screen.getByText(enhanced ? /Coin Metrics.*minute reference prices.*approximate conversion/i : /Fawaz · daily/i).first()).toBeVisible(); + if (enhanced) { + await expect(screen.getByText(/Approximate conversion: 1 EUR = 2 USD \(Fawaz,/i).first()).toBeVisible(); + await expect(screen.getByText(/Observed/i).first()).toBeVisible(); + } + await app.screenshot(`provider-${mode}-rates-provenance`); + await screen.getByRole('button', 'Settings', { exact: true }).tap(); await screen.getByTestId('settings.sources').tap(); await expect(screen.getByText('Fawaz Exchange API', { exact: true })).toBeVisible(); const coinMetrics = screen.getByText('Coin Metrics', { exact: true }); diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsClient.swift b/Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsClient.swift index aa5bdcb..c4c2892 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsClient.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsClient.swift @@ -6,6 +6,7 @@ public struct CoinMetricsClient: Sendable { private let acquire: @Sendable () async throws -> Void private let sleep: @Sendable (Duration) async throws -> Void + /// Uses a supplied transport and process-shared Community request pacing. public init(client: any HTTPClient = NetworkClient()) { self.client = client self.acquire = { try await CoinMetricsPacer.shared.acquire() } @@ -33,8 +34,10 @@ public struct CoinMetricsClient: Sendable { else { throw RateError.invalidData } - var components = URLComponents( - string: "https://community-api.coinmetrics.io/v4/timeseries/asset-metrics")! + guard + var components = URLComponents( + string: "https://community-api.coinmetrics.io/v4/timeseries/asset-metrics") + else { throw RateError.invalidData } components.queryItems = [ .init(name: "assets", value: assets.sorted().joined(separator: ",")), .init(name: "metrics", value: "ReferenceRateUSD"), diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsProvider.swift b/Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsProvider.swift index 48a9aeb..a85f935 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsProvider.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/CoinMetricsProvider.swift @@ -6,6 +6,7 @@ public struct CoinMetricsProvider: RateProvider { private let daily: any RateProvider private let now: @Sendable () -> Date + /// Fetches reference observations and their daily FX anchor through one transport. public init(client: any HTTPClient = NetworkClient()) { metrics = CoinMetricsClient(client: client) daily = FawazProvider(client: client) @@ -18,6 +19,7 @@ public struct CoinMetricsProvider: RateProvider { self.now = now } + /// Returns fresh crypto quotes with their observed time and daily FX provenance. public func fetch() async throws -> [String: ExchangeRate] { let time = now() async let observations = metrics.observations( diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift index b9cb512..70bc6ab 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift @@ -81,26 +81,34 @@ public actor HistoryService { } else { let start: Date if range == .all { - start = calendar.date(from: DateComponents(year: 2024, month: 3, day: 2))! + guard let date = calendar.date(from: DateComponents(year: 2024, month: 3, day: 2)) + else { throw RateError.invalidData } + start = date } else if range == .year { - start = calendar.date(byAdding: .year, value: -1, to: now)! + guard let date = calendar.date(byAdding: .year, value: -1, to: now) + else { throw RateError.invalidData } + start = date } else if range == .yearToDate { - start = calendar.date(from: DateComponents(year: year, month: 1, day: 1))! + guard let date = calendar.date(from: DateComponents(year: year, month: 1, day: 1)) + else { throw RateError.invalidData } + start = date } else { start = now.addingTimeInterval(-Double(range.rawValue) * 86400) } let older = range == .all ? try BundledHistory.points(base: base, quote: quote, through: now) : [] + guard let monthStart = calendar.dateInterval(of: .month, for: now)?.start + else { throw RateError.invalidData } let bundledRecent = range == .all ? BundledHistory.recentPoints( base: base, quote: quote, - through: calendar.dateInterval(of: .month, for: now)!.start.addingTimeInterval(-1)) : [] + through: monthStart.addingTimeInterval(-1)) : [] let recent: [HistoryPoint] if range == .all { - let monthStart = calendar.dateInterval(of: .month, for: now)!.start let lastBundled = bundledRecent.last?.date ?? start.addingTimeInterval(-86400) - let missingStart = calendar.date(byAdding: .month, value: 1, to: lastBundled)! + guard let missingStart = calendar.date(byAdding: .month, value: 1, to: lastBundled) + else { throw RateError.invalidData } let latest: [HistoryPoint]? if missingStart >= monthStart { latest = [] diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/RateSource.swift b/Domain/ExchangeRates/Sources/ExchangeRates/RateSource.swift index 11cce57..0368144 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/RateSource.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/RateSource.swift @@ -16,8 +16,9 @@ public enum RateProviderID: Codable, Sendable, Equatable { private enum CodingKeys: String, CodingKey { case ecb, frankfurter, fawaz, coinMetrics, custom, coinbase } - private enum ValueKeys: String, CodingKey { case _0 } + private enum ValueKeys: String, CodingKey { case value = "_0" } + /// Decodes provider identity, retaining legacy identities for cache rejection. public init(from decoder: any Decoder) throws { let values = try decoder.container(keyedBy: CodingKeys.self) guard values.allKeys.count == 1, let key = values.allKeys.first else { @@ -32,10 +33,11 @@ public enum RateProviderID: Codable, Sendable, Equatable { case .custom: self = .custom( try values.nestedContainer(keyedBy: ValueKeys.self, forKey: key) - .decode(String.self, forKey: ._0)) + .decode(String.self, forKey: .value)) } } + /// Preserves the existing provider cache representation. public func encode(to encoder: any Encoder) throws { var values = encoder.container(keyedBy: CodingKeys.self) let key: CodingKeys @@ -46,7 +48,7 @@ public enum RateProviderID: Codable, Sendable, Equatable { case .coinMetrics: key = .coinMetrics case .custom(let name): var nested = values.nestedContainer(keyedBy: ValueKeys.self, forKey: .custom) - try nested.encode(name, forKey: ._0) + try nested.encode(name, forKey: .value) return } _ = values.nestedContainer(keyedBy: ValueKeys.self, forKey: key) @@ -86,9 +88,12 @@ public struct RateConversion: Codable, Sendable, Equatable { public let published: String /// Quote currency units per one base currency unit. public let value: Decimal + /// Currency being converted from. public let base: String + /// Currency being converted to. public let quote: String + /// Records the exact daily input used for an approximate conversion. public init( provider: RateProviderID, published: String, value: Decimal, base: String, quote: String ) { diff --git a/Features/Settings/Sources/SettingsUI/Resources/Settings.xcstrings b/Features/Settings/Sources/SettingsUI/Resources/Settings.xcstrings index 2140c40..df83e4b 100644 --- a/Features/Settings/Sources/SettingsUI/Resources/Settings.xcstrings +++ b/Features/Settings/Sources/SettingsUI/Resources/Settings.xcstrings @@ -2595,85 +2595,85 @@ "en": { "stringUnit": { "state": "translated", - "value": "Last trade %@" + "value": "Observed %@" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "最后交易 %@" + "value": "观测时间 %@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "最終取引 %@" + "value": "観測 %@" } }, "es": { "stringUnit": { "state": "translated", - "value": "Última operación %@" + "value": "Observado %@" } }, "de": { "stringUnit": { "state": "translated", - "value": "Letzter Handel %@" + "value": "Beobachtet %@" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Dernière transaction %@" + "value": "Observé %@" } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Última negociação %@" + "value": "Observado %@" } }, "ko": { "stringUnit": { "state": "translated", - "value": "마지막 거래 %@" + "value": "관측 시각 %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "最後交易 %@" + "value": "觀測時間 %@" } }, "it": { "stringUnit": { "state": "translated", - "value": "Ultimo scambio %@" + "value": "Osservato %@" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Son işlem %@" + "value": "Gözlem zamanı %@" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Последняя сделка %@" + "value": "Наблюдение %@" } }, "uk": { "stringUnit": { "state": "translated", - "value": "Остання угода %@" + "value": "Спостереження %@" } }, "et": { "stringUnit": { "state": "translated", - "value": "Viimane tehing %@" + "value": "Vaadeldud %@" } } } From ad46d95105df3f0fc6da1b1d82bb498917dbf5fa Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Thu, 15 Oct 2026 12:09:15 +0200 Subject: [PATCH 07/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rates:=20make=20Coin?= =?UTF-8?q?=20Metrics=20the=20standard=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * remove the temporary intraday feature flag and its fixture branches * retain automatic daily fallback across app and extensions * validate converted crypto history and document visible FX inputs --- .../HistoryWidgetAdapterTests.swift | 57 ++++++++----------- .../Composition/CurrencyE2EStartup.swift | 3 +- .../Sources/CurrencyRateConfiguration.swift | 11 +--- Apps/Currency/Tests/E2E/README.md | 11 ++-- Apps/Currency/Tests/E2E/e2e.config.ts | 3 +- .../Tests/E2E/provider-configuration.ts | 6 -- .../E2E/tests/converted-crypto-history.e2e.ts | 39 +++++++++++++ .../Tests/E2E/tests/history-archive.e2e.ts | 3 +- .../Tests/E2E/tests/provider-policy.e2e.ts | 47 ++++++--------- .../E2E/tests/widget-live-provider.e2e.ts | 5 +- Apps/CurrencyWatch/README.md | 10 ++-- Documentation/Development.md | 6 +- Documentation/FeatureMap.md | 12 ++-- 13 files changed, 107 insertions(+), 106 deletions(-) delete mode 100644 Apps/Currency/Tests/E2E/provider-configuration.ts create mode 100644 Apps/Currency/Tests/E2E/tests/converted-crypto-history.e2e.ts diff --git a/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift b/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift index 211f82f..c464abd 100644 --- a/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift +++ b/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift @@ -10,15 +10,6 @@ import Widgets @Suite struct HistoryWidgetAdapterTests { private let now = Date(timeIntervalSince1970: 1_790_035_200) - private var cryptoRanges: [HistoryWidgetRange] { - CurrencyRateConfiguration.intradayEnabled - ? HistoryWidgetRange.allCases - : HistoryWidgetRange.allCases.filter { $0 != .day } - } - private var effectiveDay: HistoryRange { - CurrencyRateConfiguration.intradayEnabled ? .day : .month - } - @Test func liveBuildPolicyControlsSavedDayRangeAndTimeline() async { let now = now let requests = Mutex<[HistoryRange]>([]) @@ -34,12 +25,11 @@ import Widgets settings.comparison = HistoryCurrency("USD") settings.range = .day let timeline = await provider.loadTimeline(settings) - let enhanced = CurrencyRateConfiguration.intradayEnabled - #expect(requests.withLock { $0 } == [enhanced ? .day : .month]) + #expect(requests.withLock { $0 } == [.day]) #expect(settings.range == .day) - #expect(settings.availableRanges(input: ConverterState()).contains(.day) == enhanced) + #expect(settings.availableRanges(input: ConverterState()).contains(.day)) #expect(timeline.entries.first?.snapshot.pair.policy == CurrencyRateConfiguration.policy) - #expect(timeline.policy == .after(now.addingTimeInterval(enhanced ? 3_600 : 86_400))) + #expect(timeline.policy == .after(now.addingTimeInterval(3_600))) } @Test func nativeDefaultsFollowAppAndOverridesAreIndependent() { @@ -120,8 +110,8 @@ import Widgets #expect(settings.pair(input: input).quote == "USD") #expect( settings.effectiveRange(for: settings.pair(input: input)) - == (range == .day ? effectiveDay : range.range)) - #expect(settings.availableRanges(input: input) == cryptoRanges) + == range.range) + #expect(settings.availableRanges(input: input) == HistoryWidgetRange.allCases) } input.changeSource("CHF") input.setDestinations(["JPY"]) @@ -168,26 +158,26 @@ import Widgets let saved = input let settings = HistorySettings() let fiatRanges: [HistoryWidgetRange] = [.week, .month, .quarter, .year, .all] - #expect(settings.availableRanges(input: saved) == cryptoRanges) + #expect(settings.availableRanges(input: saved) == HistoryWidgetRange.allCases) #expect( try await HistoryRangeOptionsProvider(readInput: { saved }).results() - == cryptoRanges) + == HistoryWidgetRange.allCases) settings.base = HistoryCurrency("USD") settings.comparison = HistoryCurrency("BTC") - #expect(settings.availableRanges(input: saved) == cryptoRanges) + #expect(settings.availableRanges(input: saved) == HistoryWidgetRange.allCases) settings.comparison = HistoryCurrency("EUR") #expect(settings.availableRanges(input: saved) == fiatRanges) settings.base = HistoryCurrency("BTC") - #expect(settings.availableRanges(input: saved) == cryptoRanges) + #expect(settings.availableRanges(input: saved) == HistoryWidgetRange.allCases) settings.comparison = HistoryCurrency("USD") - #expect(settings.availableRanges(input: saved) == cryptoRanges) + #expect(settings.availableRanges(input: saved) == HistoryWidgetRange.allCases) settings.comparison = HistoryCurrency("ETH") - #expect(settings.availableRanges(input: saved) == cryptoRanges) + #expect(settings.availableRanges(input: saved) == HistoryWidgetRange.allCases) input.changeSource("EUR") input.setDestinations(["CZK"]) - #expect(settings.availableRanges(input: input) == cryptoRanges) + #expect(settings.availableRanges(input: input) == HistoryWidgetRange.allCases) settings.base = nil settings.comparison = nil #expect(settings.availableRanges(input: input) == fiatRanges) @@ -197,8 +187,9 @@ import Widgets settings.comparison = HistoryCurrency("@local") #expect( settings.availableRanges(input: input, localCurrency: "USD") - == cryptoRanges) - #expect(settings.availableRanges(input: input, localCurrency: "CZK") == cryptoRanges) + == HistoryWidgetRange.allCases) + #expect( + settings.availableRanges(input: input, localCurrency: "CZK") == HistoryWidgetRange.allCases) #expect(settings.availableRanges(input: input) == fiatRanges) } @@ -384,7 +375,7 @@ import Widgets settings.range = .day let entry = await provider.entry(settings) #expect(requested.withLock { $0 } == ["BTC", "USD"]) - #expect(entry.snapshot.range == effectiveDay) + #expect(entry.snapshot.range == .day) #expect(entry.snapshot.pair.base == "USD") #expect(entry.snapshot.pair.quote == "BTC") #expect(entry.snapshot.latest?.value == 0.00001) @@ -442,23 +433,23 @@ import Widgets settings.comparison = HistoryCurrency(comparison) settings.range = .day let crypto = await provider.loadTimeline(settings) - #expect(requests.withLock { $0 }.map(\.2) == [effectiveDay]) - #expect(crypto.entries.first?.snapshot.range == effectiveDay) + #expect(requests.withLock { $0 }.map(\.2) == [.day]) + #expect(crypto.entries.first?.snapshot.range == .day) #expect( crypto.policy - == .after(now.addingTimeInterval(CurrencyRateConfiguration.intradayEnabled ? 3600 : 86_400)) + == .after(now.addingTimeInterval(3_600)) ) settings.base = HistoryCurrency("EUR") settings.comparison = HistoryCurrency("CZK") let fiat = await provider.loadTimeline(settings) - #expect(requests.withLock { $0 }.map(\.2) == [effectiveDay, .month]) + #expect(requests.withLock { $0 }.map(\.2) == [.day, .month]) #expect(fiat.entries.first?.snapshot.range == .month) #expect(fiat.policy == .after(now.addingTimeInterval(86_400))) settings.range = .week _ = await provider.entry(settings) - #expect(requests.withLock { $0 }.map(\.2) == [effectiveDay, .month, .week]) + #expect(requests.withLock { $0 }.map(\.2) == [.day, .month, .week]) } @Test(arguments: HistoryWidgetRange.allCases) @@ -477,12 +468,12 @@ import Widgets settings.comparison = HistoryCurrency("EUR") settings.range = range let timeline = await provider.loadTimeline(settings) - #expect(requests.withLock { $0 } == [range == .day ? effectiveDay : range.range]) - #expect(timeline.entries.first?.snapshot.range == (range == .day ? effectiveDay : range.range)) + #expect(requests.withLock { $0 } == [range.range]) + #expect(timeline.entries.first?.snapshot.range == range.range) #expect( timeline.policy == .after( - range == .day && CurrencyRateConfiguration.intradayEnabled + range == .day ? HistoryService.nextHour(after: now) : now.addingTimeInterval(86_400))) } diff --git a/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift b/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift index 71dbaa3..fa8720d 100644 --- a/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift +++ b/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift @@ -227,8 +227,7 @@ func get(_ url: URL) async throws -> Data { try Task.checkCancellation() if url.host == "community-api.coinmetrics.io", - url.path == "/v4/timeseries/asset-metrics", - CurrencyRateConfiguration.intradayEnabled + url.path == "/v4/timeseries/asset-metrics" { let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] let assets = items.first { $0.name == "assets" }?.value?.split(separator: ",") ?? [] diff --git a/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift b/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift index 99317a5..a352d7b 100644 --- a/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift +++ b/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift @@ -3,18 +3,11 @@ import Foundation /// Shared provider configuration for the app, extensions and independent Watch app. public enum CurrencyRateConfiguration { - /// Enables Coin Metrics current crypto reference prices and 24-hour charts. - public static let intradayEnabled = true - /// Provider capabilities selected at each executable composition root. - public static var policy: RateProviderPolicy { - intradayEnabled ? .intradayEnhanced : .daily - } + public static let policy: RateProviderPolicy = .intradayEnhanced /// Foreground polling respects the available provider update frequency. - public static var foregroundRefreshInterval: TimeInterval { - intradayEnabled ? 60 : 21_600 - } + public static let foregroundRefreshInterval: TimeInterval = 60 /// Requested widget refresh cadence; the system decides actual execution times. public static var widgetRefreshInterval: TimeInterval { diff --git a/Apps/Currency/Tests/E2E/README.md b/Apps/Currency/Tests/E2E/README.md index 9b9b41a..0848ef3 100644 --- a/Apps/Currency/Tests/E2E/README.md +++ b/Apps/Currency/Tests/E2E/README.md @@ -57,9 +57,10 @@ These are representative outcomes, not exhaustive coverage of each feature ID. | ONB-01–02 | [onboarding](tests/onboarding.e2e.ts) | Currency selection, Back, completion and restart | | CONV-01, CONV-05 | [converter](tests/converter.e2e.ts) | EUR 42 → USD 84 persists after restart | | 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, approximate conversion notices, 1D capability, source credits and saved-cache filtering after restart (run both builds) | +| 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, approximate conversion 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, 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 and the intraday flag | +| HIST-03, PREF-04 | [converted-crypto-history](tests/converted-crypto-history.e2e.ts) | A live BTC/EUR day chart identifies approximate conversion and exposes its historical USD/EUR rate, provider and publication date separately from the current quote; 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 | | 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 | @@ -77,15 +78,15 @@ 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.00001 from Fawaz, or 0.00002 with Coin Metrics enabled. Its stored snapshot deliberately contains a Coin Metrics overlay plus the daily fallback, and an old-provider chart cache is retained as a rejection fixture, so disabling the flag exercises cache exclusion. 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 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. An old-provider chart cache is retained as a rejection fixture. Restart omits fixture state and preserves the real stores. Keep `-CurrencyE2EState` out of default launch arguments. -Run the same provider journey with the normal app build. It reads `CurrencyRateConfiguration.intradayEnabled` from source for its expected outcomes and cache identity. After changing that Boolean, rebuild the app before rerunning; no environment mode or special test suite is needed. +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-v8-CM`; rebuild after app or fixture changes. ```sh npm run test:e2e -- tests/provider-policy.e2e.ts ``` -The fixtures use the production provider decoders with a fixed HTTP client. Fawaz supplies every asset; Coin Metrics supplies crypto only when the flag is enabled. Fresh v2 chart caches include a full day of five-minute sampled reference observations and monthly All provenance; they avoid live network timing. These fixture journeys do not prove live provider coverage or provider-shortfall handling. This journey proves same-build relaunch filtering of a deliberately incompatible cached overlay; changing the flag and reinstalling on one retained app container is a separate upgrade check. +The fixtures use the production provider decoders with a fixed HTTP client. Fawaz supplies daily rates; Coin Metrics supplies current crypto reference prices. Fresh v2 chart caches include a full day of five-minute sampled reference observations and monthly All provenance; they avoid live network timing. These fixture journeys do not prove live provider coverage or provider-shortfall handling. This journey verifies the standard provider configuration and same-build relaunch with compatible reference caches; the legacy chart fixture must remain excluded. Home Screen setup uses [widgetctl](https://github.com/DimaMishchenko/widgetctl), pinned as a GitHub dev dependency. It installs/configures the requested kind and size, preserves other apps’ widgets and unloads its helper after the module. Tests still validate rendered data and interactions. Set `CURRENCY_E2E_WIDGET_SETUP=gallery` to exercise system installation; private-configuration cases then skip. Lock Screen installation uses system UI. diff --git a/Apps/Currency/Tests/E2E/e2e.config.ts b/Apps/Currency/Tests/E2E/e2e.config.ts index b7f8502..26aa378 100644 --- a/Apps/Currency/Tests/E2E/e2e.config.ts +++ b/Apps/Currency/Tests/E2E/e2e.config.ts @@ -2,7 +2,6 @@ import { mobile } from '@e2e-dev/mobile'; import type { E2EConfig } from 'e2e'; import { chatgpt } from 'e2e/oauth/chatgpt'; import { targetName } from './device-profile.js'; -import { intradayEnabled } from './provider-configuration.js'; const udid = process.env.CURRENCY_E2E_UDID?.trim(); if (!udid) { @@ -16,7 +15,7 @@ export default { name: targetName, engine: mobile({ platform: 'ios', device: udid, session: `currency-e2e-${targetName}` }), app: { - identity: `currency-fixtures-v7-CM-${intradayEnabled}`, + identity: 'currency-fixtures-v8-CM', 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/provider-configuration.ts b/Apps/Currency/Tests/E2E/provider-configuration.ts deleted file mode 100644 index 943b961..0000000 --- a/Apps/Currency/Tests/E2E/provider-configuration.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { readFileSync } from 'node:fs'; - -const source = readFileSync(new URL('../../Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift', import.meta.url), 'utf8'); -const flag = source.match(/intradayEnabled\s*=\s*(true|false)\b/); -if (!flag) throw new Error('Expected the Boolean CurrencyRateConfiguration.intradayEnabled in source.'); -export const intradayEnabled = flag[1] === 'true'; diff --git a/Apps/Currency/Tests/E2E/tests/converted-crypto-history.e2e.ts b/Apps/Currency/Tests/E2E/tests/converted-crypto-history.e2e.ts new file mode 100644 index 0000000..9113186 --- /dev/null +++ b/Apps/Currency/Tests/E2E/tests/converted-crypto-history.e2e.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect } from 'e2e'; +import { test } from '../fixtures.js'; + +test('converted Bitcoin day history exposes its dated USD to EUR reference input', { tags: ['extended', 'live-providers'] }, async ({ start, screen, agent, app }) => { + await start('ready-converter'); + await agent.act('Add Bitcoin (BTC) as another destination currency. Keep Euro as the source currency. Return to the converter.'); + await expect(screen.getByTestId('converter.source')).toHaveAccessibleName(/Euro/i); + 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.rate')).toHaveAccessibleName('1 BTC in EUR'); + const day = screen.getByTestId('currency.details.historyRange').getByRole('tab', 'One day', { exact: true }); + await day.tap(); + await expect(day).toBeSelected(); + const source = screen.getByTestId('currency.details.sourceDisclosure').filter({ hasText: /Coin Metrics.*minute reference prices.*approximate conversion/i }); + await expect(source).toBeVisible({ timeout: 90_000 }); + await app.screenshot('crypto-converted-day-chart-approximate-source'); + const directory = execFileSync('xcrun', ['simctl', 'get_app_container', process.env.CURRENCY_E2E_UDID!, 'com.dimasike.currency', 'group.com.dimasike.currency.shared'], { encoding: 'utf8' }).trim(); + const saved = JSON.parse(readFileSync(join(directory, 'history-v2-intradayEnhanced-BTC-EUR-1.json'), 'utf8')) as { + points: { value: number }[]; + source: { conversion: { base: string; quote: string; value: number; published: string } }; + }; + assert.ok(saved.points.length >= 270); + assert.equal(saved.source.conversion.base, 'USD'); + assert.equal(saved.source.conversion.quote, 'EUR'); + assert.ok(Number(saved.source.conversion.value) > 0); + assert.match(saved.source.conversion.published, /^\d{4}-\d{2}-\d{2}$/); + await source.tap(); + await expect(source).toHaveValue('Expanded'); + await agent.act('Scroll Details until the historical conversion input beginning "Approximate conversion: 1 USD" is visible. Keep the source disclosure expanded.'); + await expect(screen.getByText(/Approximate conversion: 1 USD = .+ EUR \(Fawaz,/i)).toBeVisible(); + await app.screenshot('crypto-converted-day-expanded-historical-fx-input'); + await agent.act('Scroll Details until the explanatory paragraph beginning "Crypto history combines daily and monthly reference data" is fully visible. Keep the source disclosure expanded.'); + await expect(screen.getByText('Crypto history combines daily and monthly reference data with intraday prices when available. Converted prices may be approximate. Historical and current sources can differ.')).toBeVisible(); + await app.screenshot('crypto-converted-day-explanation-and-observation-labels'); +}); diff --git a/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts b/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts index 4ac6aa8..6de5da4 100644 --- a/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts @@ -4,13 +4,12 @@ import { existsSync, readFileSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { expect } from 'e2e'; import { test } from '../fixtures.js'; -import { intradayEnabled } from '../provider-configuration.js'; test('All history renders the actual bundled Bitcoin archive from 2010', { tags: ['core', 'providers'] }, async ({ start, screen, agent, app }) => { await start('ready-crypto'); 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 cache = join(directory, `history-v2-${intradayEnabled ? 'intradayEnhanced' : 'daily'}-BTC-USD-0.json`); + const cache = join(directory, 'history-v2-intradayEnhanced-BTC-USD-0.json'); if (existsSync(cache)) unlinkSync(cache); await agent.act('Open Details & history for the Bitcoin destination currency.'); await expect(screen.getByTestId('currency.details.BTC')).toBeVisible(); diff --git a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts index 5fe09a0..728c469 100644 --- a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts @@ -1,14 +1,10 @@ import { expect } from 'e2e'; import { test } from '../fixtures.js'; -import { intradayEnabled } from '../provider-configuration.js'; -const enhanced = intradayEnabled; -const mode = enhanced ? 'on' : 'off'; - -test(`provider policy ${mode} preserves crypto fallback, ranges and credits after restart`, { tags: ['core', 'providers'] }, async ({ start, app, agent, screen }) => { +test('crypto reference prices preserve ranges, provenance and credits after restart', { tags: ['core', 'providers'] }, async ({ start, app, agent, screen }) => { await start('ready-crypto'); const bitcoin = screen.getByRole('button', /^Bitcoin,/i); - const expectedAmount = enhanced ? 'Bitcoin, 0.00002' : 'Bitcoin, 0.00001'; + const expectedAmount = 'Bitcoin, 0.00002'; await expect(screen.getByRole('button', 'Edit amount in USD')).toHaveValue('1'); await expect(bitcoin).toHaveAccessibleName(expectedAmount); @@ -16,62 +12,53 @@ test(`provider policy ${mode} preserves crypto fallback, ranges and credits afte 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 USD'); - await expect(screen.getByTestId('currency.details.rate')).toHaveValue(enhanced ? '50,000 USD' : '100,000 USD'); + await expect(screen.getByTestId('currency.details.rate')).toHaveValue('50,000 USD'); const ranges = screen.getByTestId('currency.details.historyRange'); const day = ranges.getByRole('tab', 'One day', { exact: true }); const source = screen.getByTestId('currency.details.sourceDisclosure') .filter({ hasText: /daily reference rates|monthly reference rates|minute reference prices/i }); await expect(source.filter({ hasText: /Fawaz.*daily reference rates/i })).toBeVisible(); - if (enhanced) { - await expect(day).toBeVisible(); - await day.tap(); - await expect(day).toBeSelected(); - await expect(source.filter({ hasText: /Coin Metrics.*minute reference prices/i })).toBeVisible(); - } else { - await expect(day).toBeHidden(); - await expect(screen.getByText(/Coin Metrics/i)).toBeHidden(); - } + await expect(day).toBeVisible(); + await day.tap(); + await expect(day).toBeSelected(); + await expect(source.filter({ hasText: /Coin Metrics.*minute reference prices/i })).toBeVisible(); const all = ranges.getByRole('tab', 'All available history', { exact: true }); await all.tap(); await expect(all).toBeSelected(); await expect(source.filter({ hasText: /monthly reference rates/i })).toBeVisible(); await expect(source.filter({ hasText: /Coin Metrics.*Frankfurter|Frankfurter.*Coin Metrics/i })).toBeVisible(); await app.screenshot(`${capture}-all-reference-history`); - if (enhanced) { - await day.tap(); - await expect(day).toBeSelected(); - } + await day.tap(); + await expect(day).toBeSelected(); 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.'); - await expect(screen.getByText(enhanced ? /Coin Metrics.*minute reference prices.*approximate conversion/i : /Fawaz · daily ·/i)).toBeVisible(); + await expect(screen.getByText(/Coin Metrics.*minute reference prices.*approximate conversion/i)).toBeVisible(); await app.screenshot(capture); await agent.act('Close Details & history with the circular X at the top right and return to the converter.'); await expect(screen.getByTestId('currency.details.close')).toBeHidden(); await expect(bitcoin).toHaveAccessibleName(expectedAmount); }; - await inspectDetails(`provider-${mode}-history`); + await inspectDetails('crypto-reference-history'); await app.restart(); await expect(screen.getByRole('button', 'Edit amount in USD')).toHaveValue('1'); await expect(bitcoin).toHaveAccessibleName(expectedAmount); - await inspectDetails(`provider-${mode}-history-after-restart`); + await inspectDetails('crypto-reference-history-after-restart'); await screen.getByTestId('converter.options').tap(); await screen.getByTestId('converter.settings').tap(); await screen.getByTestId('settings.rates').tap(); await agent.act('Scroll Rates until the BTC quote and its source information are visible. Keep Rates open.'); - await expect(screen.getByText(enhanced ? /Coin Metrics.*minute reference prices.*approximate conversion/i : /Fawaz · daily/i).first()).toBeVisible(); - if (enhanced) { - await expect(screen.getByText(/Approximate conversion: 1 EUR = 2 USD \(Fawaz,/i).first()).toBeVisible(); - await expect(screen.getByText(/Observed/i).first()).toBeVisible(); - } - await app.screenshot(`provider-${mode}-rates-provenance`); + await expect(screen.getByText(/Coin Metrics.*minute reference prices.*approximate conversion/i).first()).toBeVisible(); + await expect(screen.getByText(/Approximate conversion: 1 EUR = 2 USD \(Fawaz,/i).first()).toBeVisible(); + await expect(screen.getByText(/Observed/i).first()).toBeVisible(); + await app.screenshot('crypto-reference-rates-provenance'); await screen.getByRole('button', 'Settings', { exact: true }).tap(); await screen.getByTestId('settings.sources').tap(); await expect(screen.getByText('Fawaz Exchange API', { exact: true })).toBeVisible(); const coinMetrics = screen.getByText('Coin Metrics', { exact: true }); await expect(coinMetrics).toBeVisible(); await expect(screen.getByText('Frankfurter', { exact: true })).toBeVisible(); - await app.screenshot(`provider-${mode}-credits`); + await app.screenshot('crypto-reference-credits'); }); diff --git a/Apps/Currency/Tests/E2E/tests/widget-live-provider.e2e.ts b/Apps/Currency/Tests/E2E/tests/widget-live-provider.e2e.ts index 2c8df49..fab81c4 100644 --- a/Apps/Currency/Tests/E2E/tests/widget-live-provider.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/widget-live-provider.e2e.ts @@ -4,12 +4,11 @@ import { existsSync, readFileSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { expect } from 'e2e'; import { test } from '../fixtures.js'; -import { intradayEnabled } from '../provider-configuration.js'; import { configureWidget, placeWidget, prepareWidgetHelper, unloadWidgetHelper, widgetSetupMethod } from '../simulator-widgets.js'; test('History widget fetches a complete live Coin Metrics day with the app terminated', { tags: ['extended', 'live-providers'] }, async ({ start, app, device, screen, agent }) => { - if (!intradayEnabled || widgetSetupMethod() !== 'private') { - test.skip('Requires the intraday code flag and device-targeted widget configuration.'); + if (widgetSetupMethod() !== 'private') { + test.skip('Requires device-targeted widget configuration.'); return; } await prepareWidgetHelper(); diff --git a/Apps/CurrencyWatch/README.md b/Apps/CurrencyWatch/README.md index 5fe87a9..80e9d52 100644 --- a/Apps/CurrencyWatch/README.md +++ b/Apps/CurrencyWatch/README.md @@ -29,14 +29,14 @@ xcrun simctl install "$WATCH_SIMULATOR_UDID" "$WATCH_APP" Confirm `TeamIdentifier` matches the project team. Verify a custom pair such as GBP/CZK through the native widget editor, installed Smart Stack and corner complication, rather than accepting a default EUR/USD rendering as configuration proof. Board/Favorite Pairs must also retain configured amount/targets; Cash must use its configured currencies; History must retain its pair/range. Snapshots use configured cached data, while timeline refreshes fetch live data. -## Provider mode acceptance +## Rate provider acceptance -Use one explicitly owned Watch simulator and device-targeted controls. Set `CurrencyRateConfiguration.intradayEnabled` in code, then rebuild normally; the same value reaches both Watch executables. Keep the independent Watch networking path active. +Use one explicitly owned Watch simulator and device-targeted controls. Keep the independent Watch networking path active. Coin Metrics is the standard current crypto and intraday source; Fawaz provides the automatic daily-rate fallback. -1. With the flag set to `true`, install CurrencyWatch. Launch from a fresh container, add BTC to My Currencies and open Bitcoin details. The History range picker must offer One day alongside the daily ranges. Select it and verify a rendered BTC/USD chart or an explicit network issue; availability alone does not prove downloaded reference observations. +1. Install CurrencyWatch. Launch from a fresh container, add BTC to My Currencies and open Bitcoin details. The History range picker must offer One day alongside the daily ranges. Select it and verify a rendered BTC/USD chart or an explicit network issue; availability alone does not prove downloaded reference observations. 2. Return, terminate and relaunch the app. Verify the selected pair and available One day range persist. Capture native accessibility state and screenshots with the owned UDID. -3. Set the flag to `false`, rebuild normally, then terminate and replace the app while retaining its container. Open the same Bitcoin details. One day must be absent; daily ranges remain available. A saved Coin Metrics overlay must be excluded in favor of its Fawaz daily fallback, and an old intraday cache must not appear as a daily chart. -4. Repeat a warm launch and verify the disabled capability and saved pair. If checking deterministic amounts, seed a coherent real `rates.json` with distinct Coin Metrics/Fawaz values and both daily fallbacks before launch; record whether direct network refresh replaced it. Seed chart data only in the matching `history-v2--BTC-USD-.json` namespace. +3. Verify a failed current Coin Metrics refresh retains a valid cached quote or uses the available Fawaz daily fallback with its original provenance. An incomplete 1D response must remain explicitly cached or unavailable; it must not become a daily fallback chart. An old provider cache must not appear as a Coin Metrics chart. +4. If checking deterministic amounts, seed a coherent real `rates.json` with distinct Coin Metrics/Fawaz values and daily fallbacks before launch; record whether direct network refresh replaced it. Seed chart data only in the matching `history-v2-intradayEnhanced-BTC-USD-.json` namespace. Use `serve-sim` with the Watch UDID for native controls and screenshots; the iPhone E2E runner does not support this surface. If the Watch accessibility tree contains only an application root, stop automated picker navigation and record that gap; `simctl openurl` may also be unsupported. Save local evidence outside tracked documentation. A compiled Watch target, fixture inspection or failed driver connection does not count as a completed native journey. Spoken Siri, physical companion transport and installed complication configuration remain separate acceptance checks. diff --git a/Documentation/Development.md b/Documentation/Development.md index c58a713..6db9f11 100644 --- a/Documentation/Development.md +++ b/Documentation/Development.md @@ -20,11 +20,11 @@ handshake. The native suite starts from a stopped app and runs serially on the o For command-line Xcode builds, use `set -o pipefail` and pipe combined output through `xcbeautify`. Use a separate simulator and derived-data directory for parallel work. -## Rate provider configuration +## Rate providers -`CurrencyRateConfiguration.intradayEnabled` is the shared code-level feature flag for the phone app, widgets, independent Watch app and Watch widgets. It is `true` by default; set it to `false` and rebuild to use daily rates without intraday crypto overlays. There are no compiler options or separate build configurations. +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. Incompatible legacy provider caches are excluded. WidgetKit controls timeline delivery; no requested refresh cadence guarantees execution. -Enabled mode uses Coin Metrics Community for current crypto reference prices and supported 1D charts. Fawaz supplies daily fallback and daily history; selected Frankfurter fiat sources and Coin Metrics archives extend bundled monthly history before March 2024 where observations exist. Disabled mode hides 1D, normalizes saved 1D widgets to 1M and excludes incompatible cached data. WidgetKit controls timeline delivery; no requested refresh cadence guarantees execution. +The app uses the domain’s `.intradayEnhanced` policy. The reusable `.daily` policy remains available to package consumers and baseline/fallback tests. Crypto prices converted from USD using a dated daily fiat reference are approximate estimates, not intraday FX. All uses bundled completed-month samples where available and omits the incomplete current month. Bundled Fawaz observations use March 2, 2024 and then each completed month’s first day; missing completed months may be fetched. These are reference observations, not exchange closes. Preserve source metadata, gaps and [data licence notices](DataSources.md) when refreshing bundled data with `Scripts/History/generate_archive.py`. diff --git a/Documentation/FeatureMap.md b/Documentation/FeatureMap.md index b5dbc63..0ba0eef 100644 --- a/Documentation/FeatureMap.md +++ b/Documentation/FeatureMap.md @@ -35,11 +35,11 @@ Owners: ExchangeRates domain, [CurrencyDetails](../Features/CurrencyDetails/Sour | ID | Capability / entry | Observable outcome | | --- | --- | --- | -| RATE-01 | Initial bootstrap, foreground refresh, explicit rate refresh | Fawaz supplies daily fiat, crypto and metal rates. The shared intraday code-level flag is enabled by default and adds Coin Metrics crypto reference prices with more frequent refresh attempts. Disabled mode excludes intraday and legacy provider quotes from cached reads across the phone, widgets, Watch and system actions; observation dates, approximate daily FX conversion, failures and Fawaz fallback remain explicit. | +| 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. Legacy provider quotes are excluded from cached reads; 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. Enabled intraday adds Coin Metrics 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. Disabled mode hides 1D and rejects incompatible caches; no synthetic history is substituted. | -| HIST-03 | Rate source disclosure | Expanded content explains current/historical sources and reference-rate gaps; collapsing restores the compact disclosure. | +| 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. Incompatible legacy caches 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. Converted intraday history identifies its approximate USD/fiat rate, provider and publication date separately from current-quote normalization; 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. | @@ -74,7 +74,7 @@ Owners: [widget guide](../Features/WidgetOnboarding/Sources/WidgetOnboardingUI/O | WID-05 | Cash: medium | Configured base/comparison and cash presets/keypad produce the corresponding amounts, including Local setup/unavailable states where applicable. | | WID-06 | Pocket Rate: small | Show a convenient reference amount and conversion for the configured pair. | | WID-07 | Mental Math: small | Show the configured pair’s approximate mental-conversion rule and its accuracy information. | -| WID-08 | History: small/medium | Configured base/quote/range show history and its current, cached or unavailable state. Disabling intraday turns a saved 1D configuration into 1M; 1D requests hourly refresh and other ranges daily refresh. | +| WID-08 | History: small/medium | Configured base/quote/range show history and its current, cached or unavailable state. Coin Metrics supplies supported crypto 1D reference history; incomplete data remains explicitly cached or unavailable. 1D requests hourly refresh and other ranges daily refresh. | | WID-09 | Currency Icon: accessory circular Lock Screen widget | The configured currency symbol appears in the system accessory presentation. | | WID-10 | System gallery, Edit Widget, taps, removal and reload | Installation/configuration produce the selected widget; intents execute in the real extension; removal works and shared data remains consistent. | @@ -103,13 +103,13 @@ These capabilities target watchOS 26+. Ownership follows the Watch application c | WATCH-01 | Open the independent Watch app | The source amount and first conversion appear together on the small Watch; the app fetches rates directly and remains useful without an iPhone connection. | | WATCH-02 | Tap source amount → decimal keypad | Digits, locale decimal separator and deletion edit a local draft; Done commits the amount and updates conversions, Cancel keeps the previous amount. The keypad fits without scrolling; there are no in-app 1/10/100 presets. | | WATCH-03 | Source picker, Add currency and favorite row actions | Flag/icon currency choices update the source or destinations without duplicates. Favorite rows can become the base or be removed; selections and custom amount survive restart. | -| WATCH-04 | Favorite row → currency details/history | Show currency identity, inverse rate, selected history range/chart and the reference-rate note. The shared intraday flag controls 1D availability; daily ranges use Fawaz. No provider/source sections, saved-rate label or standalone swap action are shown in the app. Metal conversion rates and history use the shared troy-ounce/gram/kilogram preference. | +| WATCH-04 | Favorite row → currency details/history | Show currency identity, inverse rate, selected history range/chart and the reference-rate note. Supported crypto pairs offer Coin Metrics 1D reference history; daily ranges use Fawaz. No provider/source sections, saved-rate label or standalone swap action are shown in the app. Metal conversion rates and history use the shared troy-ounce/gram/kilogram preference. | | WATCH-05 | Refresh rates, offline and unavailable history | Explicit refresh preserves cached rates/history; loading, stale, missing and failed-refresh states remain distinguishable without claiming a successful fetch. | | WATCH-06 | iPhone preferred-currency synchronization | Preferred currencies and metal measurement synchronize opportunistically; Watch base and physical metal amount remain local, and connectivity is not required for direct fetch/cache use. | | WATCH-07 | Pocket Rate in Smart Stack | Configure source/comparison currencies, then use 1/10/100 and swap buttons to update the displayed pair/conversion. Setup has no amount field; taps on conversion content open the corresponding Watch route. | | WATCH-14 | Metal measurement from iPhone settings | Follow the iPhone app’s troy-ounce/gram/kilogram setting through companion preference sync, without a Watch picker; conversions, details, history, widgets and Siri show that unit, and source-metal quantity is preserved. Cash keeps the same physical gram presets as the iPhone widget and translates routes into the selected app unit. | | WATCH-08 | Currency Board and Favorite Pairs | Configure base, destinations and amount. Board aligns flag/code left and value right, with a bold source row; Favorite Pairs centers the flagged source above three flagged destination links. | -| WATCH-09 | History widget | Configured pair/range displays trend, period/change, current value and date, with explicit cached/unavailable behavior. The shared intraday flag controls 1D availability; saved 1D falls back to 1M when disabled. The chart uses the space previously occupied by the daily-reference footer. | +| WATCH-09 | History widget | Configured pair/range displays trend, period/change, current value and date, with explicit cached/unavailable behavior. Coin Metrics supplies supported crypto 1D reference history; incomplete data remains explicitly cached or unavailable. The chart uses the space previously occupied by the daily-reference footer. | | WATCH-10 | Know Your Cash widget | Configure currencies only; banknote or metal-weight buttons choose the amount and update conversion with the correct units. Opening the app preserves the displayed conversion semantics. | | WATCH-11 | Mental Math widget | Display a simple rounded conversion rule for the configured pair without an error percentage. | | WATCH-12 | Watch-face complications | Pocket Rate supports rectangular, circular, inline and corner families. Cash, Board, History and Mental Math support the first three; Favorite Pairs is rectangular only. Corner selection offers only Pocket Rate, with both compact values on the outer curve, one arrow, names/units inside and full values in accessibility. Verify slot fitting, including bottom-right 100-unit conversions. | From e199c7a4b47adfcedcda014dd437c722792df3c9 Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Thu, 15 Oct 2026 12:22:24 +0200 Subject: [PATCH 08/17] =?UTF-8?q?=E2=9C=85=20history:=20validate=20the=20c?= =?UTF-8?q?onverted=20widget=20chart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * exercise the actual non-USD widget entry point * check fresh historical FX metadata with the app closed * document the phone USD reference and configured widget pairs --- Apps/Currency/Tests/E2E/README.md | 2 +- .../E2E/tests/converted-crypto-history.e2e.ts | 76 ++++++++++++------- Documentation/FeatureMap.md | 2 +- 3 files changed, 51 insertions(+), 29 deletions(-) diff --git a/Apps/Currency/Tests/E2E/README.md b/Apps/Currency/Tests/E2E/README.md index 0848ef3..eecef4f 100644 --- a/Apps/Currency/Tests/E2E/README.md +++ b/Apps/Currency/Tests/E2E/README.md @@ -59,7 +59,7 @@ 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, approximate conversion 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-03, PREF-04 | [converted-crypto-history](tests/converted-crypto-history.e2e.ts) | A live BTC/EUR day chart identifies approximate conversion and exposes its historical USD/EUR rate, provider and publication date separately from the current quote; 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 | | 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 | | 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 | diff --git a/Apps/Currency/Tests/E2E/tests/converted-crypto-history.e2e.ts b/Apps/Currency/Tests/E2E/tests/converted-crypto-history.e2e.ts index 9113186..4018b6f 100644 --- a/Apps/Currency/Tests/E2E/tests/converted-crypto-history.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/converted-crypto-history.e2e.ts @@ -1,39 +1,61 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync, unlinkSync } from 'node:fs'; import { join } from 'node:path'; import { expect } from 'e2e'; import { test } from '../fixtures.js'; +import { configureWidget, placeWidget, prepareWidgetHelper, unloadWidgetHelper, widgetSetupMethod } from '../simulator-widgets.js'; -test('converted Bitcoin day history exposes its dated USD to EUR reference input', { tags: ['extended', 'live-providers'] }, async ({ start, screen, agent, app }) => { - await start('ready-converter'); - await agent.act('Add Bitcoin (BTC) as another destination currency. Keep Euro as the source currency. Return to the converter.'); - await expect(screen.getByTestId('converter.source')).toHaveAccessibleName(/Euro/i); - 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.rate')).toHaveAccessibleName('1 BTC in EUR'); - const day = screen.getByTestId('currency.details.historyRange').getByRole('tab', 'One day', { exact: true }); - await day.tap(); - await expect(day).toBeSelected(); - const source = screen.getByTestId('currency.details.sourceDisclosure').filter({ hasText: /Coin Metrics.*minute reference prices.*approximate conversion/i }); - await expect(source).toBeVisible({ timeout: 90_000 }); - await app.screenshot('crypto-converted-day-chart-approximate-source'); - const directory = execFileSync('xcrun', ['simctl', 'get_app_container', process.env.CURRENCY_E2E_UDID!, 'com.dimasike.currency', 'group.com.dimasike.currency.shared'], { encoding: 'utf8' }).trim(); - const saved = JSON.parse(readFileSync(join(directory, 'history-v2-intradayEnhanced-BTC-EUR-1.json'), 'utf8')) as { - points: { value: number }[]; - source: { conversion: { base: string; quote: string; value: number; published: string } }; +test('converted History widget fetches a live Bitcoin EUR day with dated USD to EUR metadata', { tags: ['extended', 'live-providers'] }, async ({ start, app, device, screen, agent }) => { + if (widgetSetupMethod() !== 'private') { + test.skip('Requires device-targeted widget configuration.'); + return; + } + await prepareWidgetHelper(); + await start('ready-crypto'); + await expect(screen.getByRole('button', /^Bitcoin,/i)).toHaveAccessibleName('Bitcoin, 0.00002'); + 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 cache = join(directory, 'history-v2-intradayEnhanced-BTC-EUR-1.json'); + await device.closeApp(); + if (existsSync(cache)) unlinkSync(cache); + const rates = join(directory, 'rates.json'); + if (existsSync(rates)) unlinkSync(rates); + assert.equal(existsSync(cache), false); + const started = Date.now(); + await device.openApp('com.apple.springboard'); + await placeWidget('History', 'Medium'); + await configureWidget('History', 'Medium', parameters => { + parameters.base = { identifier: 'BTC', title: { key: 'BTC' }, subtitle: { key: 'BTC' } }; + parameters.comparison = { identifier: 'EUR', title: { key: 'EUR' }, subtitle: { key: 'EUR' } }; + parameters.range = { identifier: 'day', value: 'day', title: { key: '1 day' } }; + }); + const widget = device.locator('id=Currency value=Widget'); + await expect(widget).toBeVisible(); + for (let attempt = 0; attempt < 180 && !existsSync(cache); attempt++) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + assert.ok(existsSync(cache), 'The independent widget must persist the fetched live chart.'); + const saved = JSON.parse(readFileSync(cache, 'utf8')) as { + points: { date: number; value: number }[]; + fetchedAt: number; + source: { provider: { coinMetrics?: object }; observation: string; conversion: { base: string; quote: string; value: number; published: string } }; }; - assert.ok(saved.points.length >= 270); + assert.ok(saved.source.provider.coinMetrics); + assert.equal(saved.source.observation, 'minuteReference'); assert.equal(saved.source.conversion.base, 'USD'); assert.equal(saved.source.conversion.quote, 'EUR'); assert.ok(Number(saved.source.conversion.value) > 0); assert.match(saved.source.conversion.published, /^\d{4}-\d{2}-\d{2}$/); - await source.tap(); - await expect(source).toHaveValue('Expanded'); - await agent.act('Scroll Details until the historical conversion input beginning "Approximate conversion: 1 USD" is visible. Keep the source disclosure expanded.'); - await expect(screen.getByText(/Approximate conversion: 1 USD = .+ EUR \(Fawaz,/i)).toBeVisible(); - await app.screenshot('crypto-converted-day-expanded-historical-fx-input'); - await agent.act('Scroll Details until the explanatory paragraph beginning "Crypto history combines daily and monthly reference data" is fully visible. Keep the source disclosure expanded.'); - await expect(screen.getByText('Crypto history combines daily and monthly reference data with intraday prices when available. Converted prices may be approximate. Historical and current sources can differ.')).toBeVisible(); - await app.screenshot('crypto-converted-day-explanation-and-observation-labels'); + assert.ok(saved.points.length >= 270); + assert.ok(saved.points.at(-1)!.date - saved.points[0]!.date >= 85_500); + assert.ok(saved.points.every(point => point.value > 0 && Number.isFinite(point.value))); + assert.ok((saved.fetchedAt + 978_307_200) * 1000 >= started); + await expect(widget.getByLabel('One day', { exact: true })).toBeVisible(); + await agent.waitFor('The installed History widget shows BTC and EUR, a loaded 1D chart with a visible trend and readable values. It does not show History unavailable or missing-value dashes.', { vision: 'only', timeout: 30_000 }); + await app.screenshot('widget-live-converted-bitcoin-eur-day'); +}); + +test.afterAll(async () => { + await unloadWidgetHelper(); }); diff --git a/Documentation/FeatureMap.md b/Documentation/FeatureMap.md index 0ba0eef..46eed56 100644 --- a/Documentation/FeatureMap.md +++ b/Documentation/FeatureMap.md @@ -39,7 +39,7 @@ Owners: ExchangeRates domain, [CurrencyDetails](../Features/CurrencyDetails/Sour | 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. Incompatible legacy caches 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. Converted intraday history identifies its approximate USD/fiat rate, provider and publication date separately from current-quote normalization; collapsing restores the compact disclosure. | +| 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 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-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. | From 3d926fbbd0fa1a702343aa9a9d29e023a5f56899 Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Thu, 15 Oct 2026 12:30:13 +0200 Subject: [PATCH 09/17] =?UTF-8?q?=F0=9F=90=9B=20history:=20preserve=20full?= =?UTF-8?q?-day=20chart=20caches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * align cache coverage with the minute-rounded query window * verify fresh cache reuse and offline fallback after expiry --- .../ExchangeRates/HistoryService.swift | 12 ++++++-- .../HistoryIntervalTests.swift | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift index 70bc6ab..31442d8 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift @@ -62,7 +62,11 @@ public actor HistoryService { series.fetchedAt <= now, zip(series.points, series.points.dropFirst()).allSatisfy({ $0.date < $1.date }) else { return nil } - if range == .day && !Self.coversDay(series.points, ending: series.fetchedAt) { return nil } + if range == .day + && !Self.coversDay(series.points, ending: Self.minuteEndpoint(series.fetchedAt)) + { + return nil + } guard range == .yearToDate else { return series } guard calendar.component(.year, from: series.fetchedAt) == year, series.points.allSatisfy({ calendar.component(.year, from: $0.date) == year }) @@ -185,7 +189,7 @@ public actor HistoryService { private func intraday( base: String, quote: String, now: Date ) async throws -> ([HistoryPoint], RateSource) { - let end = Date(timeIntervalSince1970: floor(now.timeIntervalSince1970 / 60) * 60) + let end = Self.minuteEndpoint(now) let start = end.addingTimeInterval(-86400) let assets = [base, quote].filter { CurrencyCatalog.crypto.contains($0) } let observations = try await CoinMetricsClient(client: client) @@ -237,6 +241,10 @@ public actor HistoryService { ) } + private static func minuteEndpoint(_ date: Date) -> Date { + Date(timeIntervalSince1970: floor(date.timeIntervalSince1970 / 60) * 60) + } + static func coversDay(_ points: [HistoryPoint], ending end: Date) -> Bool { guard let first = points.first, let last = points.last, points.count >= 270, first.date <= end.addingTimeInterval(-85500), last.date >= end.addingTimeInterval(-900), diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift index 94538af..c2dbf37 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift @@ -44,6 +44,34 @@ import Testing #expect(await client.urls.count == 2) } + @Test func fractionalFetchTimeReusesFullWindowAndPreservesItWhenOffline() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let now = try historyDate("2026-01-05T00:05:17Z") + let endpoint = try historyDate("2026-01-05T00:05:00Z") + let client = MinuteHistoryHTTP() + let service = HistoryService(directory: directory, client: client, policy: .intradayEnhanced) + let first = await service.load(base: "BTC", quote: "USD", range: .day, now: now) + let series = try #require(first.series) + #expect(first.issue == nil) + #expect(series.fetchedAt == now) + #expect(series.points.first?.date == endpoint.addingTimeInterval(-86400)) + #expect(series.points.count == 288) + await client.fail() + let fresh = await service.load( + base: "BTC", quote: "USD", range: .day, now: now.addingTimeInterval(1799)) + #expect(fresh.issue == nil) + #expect(fresh.series?.points == series.points) + #expect(fresh.series?.fetchedAt == now) + #expect(await client.urls.count == 1) + let expired = await service.load( + base: "BTC", quote: "USD", range: .day, now: now.addingTimeInterval(1800)) + #expect(expired.issue == .usingCachedSeries) + #expect(expired.series?.points == series.points) + #expect(expired.series?.fetchedAt == now) + #expect(await client.urls.count == 2) + } + @Test func coverageRejectsLargeInteriorGapsAndShortenedWindows() { let end = Date(timeIntervalSince1970: 86400) let full = (0..<288) From 102ac8cccaa5d76247a16c0e00e4d0cf665ebd1e Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Thu, 15 Oct 2026 12:47:04 +0200 Subject: [PATCH 10/17] =?UTF-8?q?=E2=9C=85=20history:=20keep=20chart=20fix?= =?UTF-8?q?tures=20inside=20their=20cache=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * align seeded history retrieval with its observation endpoint * verify plotted day values before and after changing ranges --- Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift | 2 +- Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift b/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift index fa8720d..b05f03d 100644 --- a/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift +++ b/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift @@ -104,7 +104,7 @@ date: end.addingTimeInterval(Double(index - 288) * 300), value: 45_000 + Double(index) * 5_000 / 288) }, source: .init(provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt), - fetchedAt: now) + fetchedAt: end) try writeHistory( intraday, filename: "history-v2-intradayEnhanced-BTC-USD-1.json", directory: directory) diff --git a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts index 728c469..430c8d7 100644 --- a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts @@ -22,6 +22,7 @@ test('crypto reference prices preserve ranges, provenance and credits after rest await day.tap(); await expect(day).toBeSelected(); await expect(source.filter({ hasText: /Coin Metrics.*minute reference prices/i })).toBeVisible(); + await expect(screen.getByText('50,000 USD', { exact: true })).toBeVisible(); const all = ranges.getByRole('tab', 'All available history', { exact: true }); await all.tap(); await expect(all).toBeSelected(); @@ -30,6 +31,8 @@ test('crypto reference prices preserve ranges, provenance and credits after rest await app.screenshot(`${capture}-all-reference-history`); await day.tap(); await expect(day).toBeSelected(); + await expect(source.filter({ hasText: /Coin Metrics.*minute reference prices/i })).toBeVisible(); + await expect(screen.getByText('50,000 USD', { exact: true })).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.'); From b1d4edad23648687de82b9be415bec2621784a68 Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Thu, 15 Oct 2026 13:10:23 +0200 Subject: [PATCH 11/17] =?UTF-8?q?=F0=9F=90=9B=20widgets:=20simplify=20hist?= =?UTF-8?q?ory=20harness=20fixture=20inference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * give generated reference rows and intermediate values explicit types * preserve fixture values while allowing every feature harness to build --- .../HarnessApp/CryptoHistoryWidgetHarness.swift | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift b/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift index d56c038..196f65d 100644 --- a/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift +++ b/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift @@ -61,12 +61,13 @@ private struct CryptoHistoryFixture: HTTPClient { guard let rawEnd = query.first(where: { $0.name == "end_time" })?.value, let end = ISO8601DateFormatter().date(from: rawEnd) else { throw RateError.invalidData } - let rows = (0...1440) - .map { index in - [ - "asset": "btc", - "time": end.addingTimeInterval(Double(index - 1440) * 60).ISO8601Format(), - "ReferenceRateUSD": String(83_055 + Double(index) * 1553.1 / 1440) + let rows: [[String: String]] = (0...1440) + .map { index -> [String: String] in + let offset: TimeInterval = Double(index - 1440) * 60 + let time: String = end.addingTimeInterval(offset).ISO8601Format() + let price: Double = 83_055 + Double(index) * 1553.1 / 1440 + return [ + "asset": "btc", "time": time, "ReferenceRateUSD": String(price) ] } return try JSONSerialization.data(withJSONObject: ["data": rows]) From ec35c55720e9fced0ef3e6d72ea6519d2b4ec156 Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Thu, 15 Oct 2026 14:07:43 +0200 Subject: [PATCH 12/17] =?UTF-8?q?=E2=9C=A8=20rates:=20refresh=20archives?= =?UTF-8?q?=20and=20clarify=20conversion=20notices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * show FX approximation only when the selected pair needs it * propose monthly history updates only for meaningful data changes * validate real phone refresh, history and relaunch outcomes --- .github/workflows/history-refresh.yml | 67 +++++ .github/workflows/tests.yml | 2 + .../Sources/Composition/AppComposition.swift | 3 +- Apps/Currency/Tests/E2E/README.md | 5 +- .../Tests/E2E/tests/live-app-provider.e2e.ts | 94 +++++++ .../Tests/E2E/tests/provider-policy.e2e.ts | 24 +- Documentation/Development.md | 2 +- Documentation/FeatureMap.md | 2 +- .../ExchangeRatesUI/CurrencyDisplay.swift | 62 ++++- .../CurrencyPairProvenanceTests.swift | 106 +++++++ .../HarnessApp/SettingsHarnessApp.swift | 2 +- .../Sources/Settings/SettingsModel.swift | 6 +- .../Sources/SettingsUI/SettingsScreen.swift | 7 +- .../SettingsFlowLifetimeTests.swift | 5 +- .../SettingsTests/SettingsModelTests.swift | 28 +- Scripts/History/.gitignore | 1 + Scripts/History/README.md | 9 + Scripts/History/generate_archive.py | 261 ++++++++++++++---- Scripts/History/propose_refresh.py | 102 +++++++ Scripts/History/test_generate_archive.py | 150 ++++++++++ Scripts/History/test_propose_refresh.py | 136 +++++++++ 21 files changed, 992 insertions(+), 82 deletions(-) create mode 100644 .github/workflows/history-refresh.yml create mode 100644 Apps/Currency/Tests/E2E/tests/live-app-provider.e2e.ts create mode 100644 Domain/ExchangeRates/Tests/ExchangeRatesUITests/CurrencyPairProvenanceTests.swift create mode 100644 Scripts/History/.gitignore create mode 100644 Scripts/History/README.md create mode 100644 Scripts/History/propose_refresh.py create mode 100644 Scripts/History/test_generate_archive.py create mode 100644 Scripts/History/test_propose_refresh.py diff --git a/.github/workflows/history-refresh.yml b/.github/workflows/history-refresh.yml new file mode 100644 index 0000000..60c3cb2 --- /dev/null +++ b/.github/workflows/history-refresh.yml @@ -0,0 +1,67 @@ +name: Refresh bundled history + +on: + schedule: + - cron: '0 6 1 * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: currency-history-refresh + cancel-in-progress: false + +jobs: + generate: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + changed: ${{ steps.generate.outputs.changed }} + base_sha: ${{ steps.generate.outputs.base_sha }} + steps: + - uses: actions/checkout@v7 + with: + ref: main + persist-credentials: false + - name: Test archive generation + run: python3 -m unittest discover -s Scripts/History -p 'test_*.py' + - name: Refresh selected history and provenance + id: generate + run: | + echo "base_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + python3 Scripts/History/generate_archive.py + - uses: actions/upload-artifact@v7 + if: steps.generate.outputs.changed == 'true' + with: + name: currency-history-refresh + path: Domain/ExchangeRates/Sources/ExchangeRates/Resources/history-monthly-v1.json + if-no-files-found: error + retention-days: 7 + propose: + needs: generate + if: needs.generate.outputs.changed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + - uses: actions/download-artifact@v8 + with: + name: currency-history-refresh + path: ${{ runner.temp }}/currency-history-refresh + - name: Update the dedicated review branch + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + BASE_SHA: ${{ needs.generate.outputs.base_sha }} + run: >- + python3 Scripts/History/propose_refresh.py + --candidate "$RUNNER_TEMP/currency-history-refresh/history-monthly-v1.json" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2d2b56b..de46f23 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,6 +38,8 @@ jobs: run: | python3 Scripts/CI/test_skip_rules.py python3 Scripts/CI/test_project_structure.py + - name: Test bundled-history generation + run: python3 -m unittest discover -s Scripts/History -p 'test_*.py' - uses: actions/setup-node@v7 with: node-version: 24 diff --git a/Apps/Currency/App/Sources/Composition/AppComposition.swift b/Apps/Currency/App/Sources/Composition/AppComposition.swift index 47dd489..4ba67ba 100644 --- a/Apps/Currency/App/Sources/Composition/AppComposition.swift +++ b/Apps/Currency/App/Sources/Composition/AppComposition.swift @@ -209,7 +209,8 @@ final class AppComposition { private var settingsState: SettingsRateState { let input = conversion.input() return SettingsRateState( - snapshot: rates.loadRates(), codes: [input.source] + input.destinations, warning: warning) + snapshot: rates.loadRates(), base: input.source, + codes: [input.source] + input.destinations, warning: warning) } func settings(scene: CurrencyScene) -> SettingsDependencies { SettingsDependencies( diff --git a/Apps/Currency/Tests/E2E/README.md b/Apps/Currency/Tests/E2E/README.md index eecef4f..7525a8e 100644 --- a/Apps/Currency/Tests/E2E/README.md +++ b/Apps/Currency/Tests/E2E/README.md @@ -57,9 +57,10 @@ These are representative outcomes, not exhaustive coverage of each feature ID. | ONB-01–02 | [onboarding](tests/onboarding.e2e.ts) | Currency selection, Back, completion and restart | | CONV-01, CONV-05 | [converter](tests/converter.e2e.ts) | EUR 42 → USD 84 persists after restart | | 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, approximate conversion notices, 1D capability, source credits and saved-cache compatibility after 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–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 | | 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 | | 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 | @@ -118,3 +119,5 @@ xcrun simctl shutdown "$CURRENCY_E2E_UDID" Watch metal measurement (WATCH-14) uses the [maintained native Watch journey](../../../CurrencyWatch/README.md#metal-measurement-acceptance). The iPhone E2E driver does not cover Watch. Numerical, stale-draft and preference-sync regressions remain in the native unit test targets. The `live-providers` journey is an explicit network acceptance check: `npx e2e run tests/widget-live-provider.e2e.ts`. It terminates the app, removes only the test-owned BTC/USD 1D cache, configures the installed History widget, and asserts the extension wrote positive minute-reference data spanning at least 23h45m before capturing its UI. Provider availability failures remain failures; no fabricated or daily history replaces 1D. + +The live phone journey uses fixtures only to create completed onboarding and a saved currency selection. It deletes the test-owned rate/1D/All caches and relaunches without `-CurrencyE2E`; network responses and persisted observations must then come from the production providers. Provider outages remain failures. diff --git a/Apps/Currency/Tests/E2E/tests/live-app-provider.e2e.ts b/Apps/Currency/Tests/E2E/tests/live-app-provider.e2e.ts new file mode 100644 index 0000000..ecc65a1 --- /dev/null +++ b/Apps/Currency/Tests/E2E/tests/live-app-provider.e2e.ts @@ -0,0 +1,94 @@ +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'; + +test('real provider refresh and phone history survive a live-network relaunch', { tags: ['extended', 'live-providers'] }, async ({ start, app, device, screen, agent }) => { + await start('ready-crypto'); + 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-v2-intradayEnhanced-BTC-USD-1.json'); + const allPath = join(directory, 'history-v2-intradayEnhanced-BTC-USD-0.json'); + await device.closeApp(); + for (const file of [ratesPath, dayPath, allPath]) { + if (existsSync(file)) unlinkSync(file); + assert.equal(existsSync(file), false); + } + const started = Date.now(); + 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 USD')).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 }; conversion?: { value: number } } }; + type Snapshot = { quotes: Record; fetchedAt: number }; + const readRates = (): Snapshot | undefined => existsSync(ratesPath) ? JSON.parse(readFileSync(ratesPath, 'utf8')) : undefined; + let rates: Snapshot | undefined; + for (let attempt = 0; attempt < 180; attempt++) { + rates = readRates(); + if (rates && Object.values(rates.quotes).filter(rate => rate.source.provider.coinMetrics && (Number(rate.retrievedAt) + 978_307_200) * 1000 >= started).length === 22) break; + await new Promise(resolve => setTimeout(resolve, 500)); + } + assert.ok(rates); + assert.equal(Object.values(rates.quotes).filter(rate => rate.source.provider.coinMetrics && (Number(rate.retrievedAt) + 978_307_200) * 1000 >= started).length, 22); + assert.ok(rates.quotes.USD?.source.provider.fawaz); + assert.notEqual(Number(rates.quotes.USD?.value), 2, 'The actual dated feed must replace the fixture FX table.'); + assert.equal(Number(rates.quotes.BTC?.source.conversion?.value), Number(rates.quotes.USD?.value)); + 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'); + 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(); + 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 } }; + 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)); + } + assert.ok(existsSync(dayPath), 'The phone must finish its live history fetch and persist a complete chart.'); + const savedDay = readHistory(dayPath); + assert.ok(savedDay.source.provider.coinMetrics); + assert.equal(savedDay.source.observation, 'minuteReference'); + 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'); + const all = ranges.getByRole('tab', 'All available history', { exact: true }); + await all.tap(); + await expect(all).toBeSelected(); + await expect(source.filter({ hasText: /Fawaz.*Coin Metrics.*monthly reference/i })).toBeVisible(); + const savedAll = readHistory(allPath); + 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 openLive(); + await expect(screen.getByRole('button', 'Edit amount in USD')).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 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 }); + assert.equal(readHistory(dayPath).fetchedAt, savedDay.fetchedAt); + assert.deepEqual(readHistory(dayPath).points, savedDay.points); + await app.screenshot('live-phone-relaunch-preserves-day-chart'); +}); diff --git a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts index 430c8d7..55ac874 100644 --- a/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/provider-policy.e2e.ts @@ -36,7 +36,8 @@ test('crypto reference prices preserve ranges, provenance and credits after rest 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.'); - await expect(screen.getByText(/Coin Metrics.*minute reference prices.*approximate conversion/i)).toBeVisible(); + await expect(screen.getByText(/Coin Metrics.*minute reference prices.*approximate conversion/i)).toBeHidden(); + await expect(screen.getByText(/^Approximate conversion:/i)).toBeHidden(); await app.screenshot(capture); await agent.act('Close Details & history with the circular X at the top right and return to the converter.'); await expect(screen.getByTestId('currency.details.close')).toBeHidden(); @@ -53,8 +54,9 @@ test('crypto reference prices preserve ranges, provenance and credits after rest await screen.getByTestId('converter.settings').tap(); await screen.getByTestId('settings.rates').tap(); 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.*approximate conversion/i).first()).toBeVisible(); - await expect(screen.getByText(/Approximate conversion: 1 EUR = 2 USD \(Fawaz,/i).first()).toBeVisible(); + await expect(screen.getByText(/Coin Metrics.*minute reference prices/i).first()).toBeVisible(); + await expect(screen.getByText(/Coin Metrics.*approximate conversion/i)).toBeHidden(); + await expect(screen.getByText(/^Approximate conversion:/i)).toBeHidden(); await expect(screen.getByText(/Observed/i).first()).toBeVisible(); await app.screenshot('crypto-reference-rates-provenance'); await screen.getByRole('button', 'Settings', { exact: true }).tap(); @@ -65,3 +67,19 @@ test('crypto reference prices preserve ranges, provenance and credits after rest await expect(screen.getByText('Frankfurter', { exact: true })).toBeVisible(); await app.screenshot('crypto-reference-credits'); }); + + +test('non-USD crypto conversion keeps its dated daily FX notice', { 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 EUR, then select the Euro result and return to the converter without editing the amount.'); + await expect(screen.getByTestId('converter.source')).toHaveAccessibleName(/Euro/i); + await expect(screen.getByRole('button', 'Edit amount in EUR')).toHaveValue('1'); + await expect(screen.getByRole('button', /^Bitcoin,/i)).toHaveAccessibleName('Bitcoin, 0.00004'); + await screen.getByTestId('converter.options').tap(); + await screen.getByTestId('converter.settings').tap(); + await screen.getByTestId('settings.rates').tap(); + 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.*approximate conversion/i).first()).toBeVisible(); + await expect(screen.getByText(/Approximate conversion: 1 EUR = 2 USD \(Fawaz,/i).first()).toBeVisible(); + await app.screenshot('crypto-eur-actual-daily-fx-approximation'); +}); diff --git a/Documentation/Development.md b/Documentation/Development.md index 6db9f11..38d4fc7 100644 --- a/Documentation/Development.md +++ b/Documentation/Development.md @@ -26,7 +26,7 @@ Coin Metrics Community supplies current crypto reference prices and supported 1D The app uses the domain’s `.intradayEnhanced` policy. The reusable `.daily` policy remains available to package consumers and baseline/fallback tests. -Crypto prices converted from USD using a dated daily fiat reference are approximate estimates, not intraday FX. All uses bundled completed-month samples where available and omits the incomplete current month. Bundled Fawaz observations use March 2, 2024 and then each completed month’s first day; missing completed months may be fetched. These are reference observations, not exchange closes. Preserve source metadata, gaps and [data licence notices](DataSources.md) when refreshing bundled data with `Scripts/History/generate_archive.py`. +Crypto prices converted from USD using a dated daily fiat reference are approximate estimates, not intraday FX. All uses bundled completed-month samples where available and omits the incomplete current month. Bundled Fawaz observations use March 2, 2024 and then each completed month’s first day; missing completed months may be fetched. These are reference observations, not exchange closes. Preserve source metadata, gaps and [data licence notices](DataSources.md) when refreshing bundled data with `Scripts/History/generate_archive.py`. The [monthly refresh workflow](../Scripts/History/README.md) proposes meaningful archive changes through a review PR; unchanged data is skipped. ## Dependency cache diff --git a/Documentation/FeatureMap.md b/Documentation/FeatureMap.md index 46eed56..b59c477 100644 --- a/Documentation/FeatureMap.md +++ b/Documentation/FeatureMap.md @@ -39,7 +39,7 @@ Owners: ExchangeRates domain, [CurrencyDetails](../Features/CurrencyDetails/Sour | 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. Incompatible legacy caches 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 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-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-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/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift b/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift index 5a69a1b..82c96bc 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift @@ -135,13 +135,67 @@ extension CurrencyDisplay { _ snapshot: RateSnapshot, from: String, to: String, locale: Locale = .current ) -> String { let descriptions = [snapshot.quotes[from], snapshot.quotes[to]].compactMap { $0 } - .map { + .map { rate in + let source = presentationSource(rate.source, snapshot: snapshot, from: from, to: to) let detail = - RateMessages.providerDescription($0.source, locale: locale) + " · " - + publicationDate($0.published, locale: locale) - return [detail, RateMessages.conversionDescription($0.source, locale: locale)] + RateMessages.providerDescription(source, locale: locale) + " · " + + publicationDate(rate.published, locale: locale) + return [detail, RateMessages.conversionDescription(source, locale: locale)] .compactMap { $0 }.joined(separator: "\n") } return Set(descriptions).sorted().joined(separator: " / ") } + + /// Describes only the FX input that remains in the requested conversion. + public static func presentationSource( + _ source: RateSource, snapshot: RateSnapshot, from: String, to: String + ) -> RateSource { + guard source.provider == .coinMetrics, source.conversion != nil else { return source } + guard from == to || normalizationCancels(snapshot, from: from, to: to) else { return source } + return RateSource( + provider: source.provider, observation: source.observation, timeZone: source.timeZone, + latestObservation: source.latestObservation, + contributingProviders: source.contributingProviders) + } + + /// Presents a quote in the context of the selected base and destination currencies. + public static func quoteSource( + _ snapshot: RateSnapshot, code: String, base: String, destinations: [String] + ) -> RateSource? { + guard let source = snapshot.quotes[code]?.source else { return nil } + let comparisons = code == base ? destinations.filter { $0 != base } : [code] + let sources = comparisons.map { + presentationSource(source, snapshot: snapshot, from: base, to: $0) + } + return sources.first { $0.conversion != nil } ?? sources.first + ?? presentationSource(source, snapshot: snapshot, from: base, to: base) + } + + private static func normalizationCancels( + _ snapshot: RateSnapshot, from: String, to: String + ) -> Bool { + guard let first = snapshot.quotes[from], let second = snapshot.quotes[to] else { return false } + if first.source.provider == .coinMetrics, second.source.provider == .coinMetrics, + let anchor = first.source.conversion, anchor.base == "EUR", anchor.quote == "USD", + anchor.value > 0, !anchor.value.isNaN, anchor == second.source.conversion + { + return true + } + let crypto: ExchangeRate + let usd: ExchangeRate + if from == "USD" { + crypto = second; usd = first + } else if to == "USD" { + crypto = first; usd = second + } else { + return false + } + guard crypto.source.provider == .coinMetrics, let anchor = crypto.source.conversion else { + return false + } + return anchor.base == "EUR" && anchor.quote == "USD" && anchor.value > 0 + && !anchor.value.isNaN && anchor.value == usd.value + && anchor.provider == usd.source.provider && anchor.published == usd.published + } + } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesUITests/CurrencyPairProvenanceTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesUITests/CurrencyPairProvenanceTests.swift new file mode 100644 index 0000000..7cca70c --- /dev/null +++ b/Domain/ExchangeRates/Tests/ExchangeRatesUITests/CurrencyPairProvenanceTests.swift @@ -0,0 +1,106 @@ +import ExchangeRates +import ExchangeRatesUI +import Foundation +import Testing + +@Suite struct CurrencyPairProvenanceTests { + private let locale = Locale(identifier: "en_US") + private let day = "2026-10-14" + + private func snapshot(usd: Decimal = 2, ethAnchor: Decimal = 2) -> RateSnapshot { + func crypto(price: Decimal, anchor: Decimal) -> ExchangeRate { + ExchangeRate( + anchor / price, published: day, + source: .init( + provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt, + conversion: .init( + provider: .fawaz, published: day, value: anchor, base: "EUR", quote: "USD")), + observedAt: Date(timeIntervalSince1970: 1_792_000_000)) + } + return RateSnapshot(quotes: [ + "BTC": crypto(price: 100, anchor: 2), "ETH": crypto(price: 50, anchor: ethAnchor), + "USD": ExchangeRate( + usd, published: day, source: .init(provider: .fawaz, observation: .dailyRate)), + "EUR": ExchangeRate( + 1, published: day, source: .init(provider: .fawaz, observation: .dailyRate)), + "GBP": ExchangeRate( + 0.8, published: day, source: .init(provider: .fawaz, observation: .dailyRate)) + ]) + } + + @Test(arguments: [("BTC", "USD"), ("USD", "BTC"), ("BTC", "ETH"), ("ETH", "BTC")]) + func canceledAnchorsDescribeOriginalReferencesWithoutApproximation(from: String, to: String) { + let value = snapshot() + let text = CurrencyDisplay.details(value, from: from, to: to, locale: locale) + #expect(text.contains("Coin Metrics · minute reference prices")) + #expect(!text.contains("approximate conversion")) + #expect(!text.contains("Approximate conversion:")) + #expect(value.quotes["BTC"]?.source.conversion?.value == 2) + #expect(value.quotes["BTC"]?.observedAt != nil) + } + + @Test(arguments: [("BTC", "EUR"), ("EUR", "BTC")]) + func actualDailyFXConversionKeepsItsApproximationAndReference(from: String, to: String) { + let text = CurrencyDisplay.details(snapshot(), from: from, to: to, locale: locale) + #expect(text.contains("approximate conversion")) + #expect(text.contains("Approximate conversion: 1 EUR = 2 USD")) + } + + @Test(arguments: [("BTC", "ETH"), ("ETH", "BTC"), ("BTC", "USD"), ("USD", "BTC")]) + func mismatchedAnchorsRemainApproximate(from: String, to: String) { + let text = CurrencyDisplay.details( + snapshot(usd: 3, ethAnchor: 3), from: from, to: to, locale: locale) + #expect(text.contains("approximate conversion")) + #expect(text.contains("Approximate conversion:")) + } + + @Test func fiatDirectReferencesHaveNoApproximation() { + let text = CurrencyDisplay.details(snapshot(), from: "USD", to: "EUR", locale: locale) + #expect(text.contains("Fawaz · daily")) + #expect(!text.contains("approximate")) + } + + @Test(arguments: [("BTC", "GBP"), ("GBP", "BTC")]) + func sterlingPresentationRemainsUnchanged(from: String, to: String) { + let text = CurrencyDisplay.details(snapshot(), from: from, to: to, locale: locale) + #expect(text.contains("Coin Metrics · minute reference prices · approximate conversion · GMT")) + #expect(text.contains("Approximate conversion: 1 EUR = 2 USD")) + } + + @Test func quoteRowsUseExplicitBaseRatherThanDisplayOrder() throws { + let value = snapshot() + let source = try #require( + CurrencyDisplay.quoteSource( + value, code: "BTC", base: "USD", destinations: ["BTC", "USD"])) + #expect(source.conversion == nil) + #expect(source.observation == .minuteReference) + #expect(source.timeZone == .gmt) + let mixed = try #require( + CurrencyDisplay.quoteSource( + value, code: "BTC", base: "BTC", destinations: ["BTC", "USD", "EUR"])) + #expect(mixed.conversion?.value == 2) + let canceled = try #require( + CurrencyDisplay.quoteSource( + value, code: "BTC", base: "BTC", destinations: ["BTC", "USD", "ETH"])) + #expect(canceled.conversion == nil) + #expect(value.quotes["BTC"]?.source.conversion?.value == 2) + } + + @Test func equalNumbersFromDifferentFXPublicationsDoNotEraseApproximation() { + var quotes = snapshot().quotes + quotes["USD"] = ExchangeRate(2, published: "2026-10-13", source: .init(provider: .fawaz)) + let text = CurrencyDisplay.details( + RateSnapshot(quotes: quotes), from: "BTC", to: "USD", locale: locale) + #expect(text.contains("approximate conversion")) + } + + @Test func directFeedSourceDoesNotAcquireFXMetadata() { + let direct = RateSource(provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt) + let text = RateMessages.providerDescription( + CurrencyDisplay.presentationSource(direct, snapshot: snapshot(), from: "BTC", to: "USD"), + locale: locale) + #expect(text == "Coin Metrics · minute reference prices · GMT") + #expect(RateMessages.conversionDescription(direct, locale: locale) == nil) + } + +} diff --git a/Features/Settings/HarnessApp/SettingsHarnessApp.swift b/Features/Settings/HarnessApp/SettingsHarnessApp.swift index 0ab54b9..3902467 100644 --- a/Features/Settings/HarnessApp/SettingsHarnessApp.swift +++ b/Features/Settings/HarnessApp/SettingsHarnessApp.swift @@ -12,7 +12,7 @@ import SwiftUI var rates: SettingsRateState { let date = Date(timeIntervalSince1970: 1_791_050_760) let snapshot = dated ? RateSnapshot(fetchedAt: date, checkedAt: date) : RateSnapshot() - return SettingsRateState(snapshot: snapshot, codes: ["EUR", "USD"]) + return SettingsRateState(snapshot: snapshot, base: "EUR", codes: ["EUR", "USD"]) } init(failing: Bool, dated: Bool) { self.failing = failing diff --git a/Features/Settings/Sources/Settings/SettingsModel.swift b/Features/Settings/Sources/Settings/SettingsModel.swift index c6deeec..c4f8493 100644 --- a/Features/Settings/Sources/Settings/SettingsModel.swift +++ b/Features/Settings/Sources/Settings/SettingsModel.swift @@ -36,13 +36,17 @@ public struct SettingsPreferences: Sendable, Equatable { public struct SettingsRateState: Sendable { /// The latest supplied rate or location observation. public var snapshot: RateSnapshot + /// The converter source currency supplied by the host. + public var base: String /// Ordered canonical currency codes used by this presentation. public var codes: [String] /// Recoverable provider condition accompanying the current rates. public var warning: RefreshWarning? /// Creates the owned value or model with explicit host inputs and operations. - public init(snapshot: RateSnapshot, codes: [String], warning: RefreshWarning? = nil) { + public init(snapshot: RateSnapshot, base: String, codes: [String], warning: RefreshWarning? = nil) + { self.snapshot = snapshot + self.base = base self.codes = codes self.warning = warning } diff --git a/Features/Settings/Sources/SettingsUI/SettingsScreen.swift b/Features/Settings/Sources/SettingsUI/SettingsScreen.swift index 885a0b8..692466e 100644 --- a/Features/Settings/Sources/SettingsUI/SettingsScreen.swift +++ b/Features/Settings/Sources/SettingsUI/SettingsScreen.swift @@ -696,10 +696,13 @@ struct SettingsScreen: View { VStack(alignment: .leading, spacing: AppStyle.Space.xs) { Text(verbatim: code).font(AppStyle.font(.body, weight: .medium)) if let rate = snapshot.quotes[code] { - if let conversion = RateMessages.conversionDescription(rate.source, locale: locale) { + let source = + CurrencyDisplay.quoteSource( + snapshot, code: code, base: model.rates.base, destinations: codes) ?? rate.source + if let conversion = RateMessages.conversionDescription(source, locale: locale) { Text(conversion).font(AppStyle.font(.caption)).foregroundStyle(.secondary) } - Text(RateMessages.providerDescription(rate.source, locale: locale)) + Text(RateMessages.providerDescription(source, locale: locale)) .font(AppStyle.font(.subheadline)).foregroundStyle(.secondary) Group { if let observed = rate.observedAt { diff --git a/Features/Settings/Tests/SettingsTests/SettingsFlowLifetimeTests.swift b/Features/Settings/Tests/SettingsTests/SettingsFlowLifetimeTests.swift index 9d0eefd..a014756 100644 --- a/Features/Settings/Tests/SettingsTests/SettingsFlowLifetimeTests.swift +++ b/Features/Settings/Tests/SettingsTests/SettingsFlowLifetimeTests.swift @@ -5,7 +5,7 @@ import Testing @MainActor private final class SettingsLifetimeFixture { - var rates = SettingsRateState(snapshot: RateSnapshot(), codes: ["EUR", "USD"]) + var rates = SettingsRateState(snapshot: RateSnapshot(), base: "EUR", codes: ["EUR", "USD"]) var subscriptions = 0 var locationRequests = 0 var refreshRequests = 0 @@ -30,7 +30,8 @@ private final class SettingsLifetimeFixture { } func finishRefresh() { - pendingRefresh?.resume(returning: SettingsRateState(snapshot: RateSnapshot(), codes: ["JPY"])) + pendingRefresh? + .resume(returning: SettingsRateState(snapshot: RateSnapshot(), base: "JPY", codes: ["JPY"])) pendingRefresh = nil } } diff --git a/Features/Settings/Tests/SettingsTests/SettingsModelTests.swift b/Features/Settings/Tests/SettingsTests/SettingsModelTests.swift index 9eea074..094f63f 100644 --- a/Features/Settings/Tests/SettingsTests/SettingsModelTests.swift +++ b/Features/Settings/Tests/SettingsTests/SettingsModelTests.swift @@ -8,13 +8,15 @@ import Testing @MainActor @Suite struct SettingsModelTests { private func dependencies( refresh: @escaping () async throws -> SettingsRateState = { - SettingsRateState(snapshot: RateSnapshot(), codes: []) + SettingsRateState(snapshot: RateSnapshot(), base: "EUR", codes: []) }, replay: (() throws -> Void)? = nil, output: @escaping (SettingsOutput) -> Void = { _ in } ) -> SettingsDependencies { SettingsDependencies( - readState: { SettingsRateState(snapshot: RateSnapshot(), codes: ["EUR", "USD"]) }, + readState: { + SettingsRateState(snapshot: RateSnapshot(), base: "EUR", codes: ["EUR", "USD"]) + }, changes: { AsyncStream(bufferingPolicy: .unbounded) { _ in } }, readPreferences: { SettingsPreferences(theme: .system, accent: .primary) }, setTheme: { _ in }, setAccent: { _ in }, setMetalUnit: { _ in }, refresh: refresh, @@ -56,7 +58,8 @@ import Testing while continuation == nil { await Task.yield() } #expect(calls == 1) model.stop() - continuation?.resume(returning: SettingsRateState(snapshot: RateSnapshot(), codes: ["JPY"])) + continuation? + .resume(returning: SettingsRateState(snapshot: RateSnapshot(), base: "JPY", codes: ["JPY"])) await Task.yield() #expect(model.rates.codes == ["EUR", "USD"]) #expect(!model.isRefreshing) @@ -65,12 +68,13 @@ import Testing var preferences = SettingsPreferences(theme: .system, accent: .primary) let model = SettingsModel( dependencies: SettingsDependencies( - readState: { SettingsRateState(snapshot: RateSnapshot(), codes: []) }, + readState: { SettingsRateState(snapshot: RateSnapshot(), base: "EUR", codes: []) }, changes: { AsyncStream(bufferingPolicy: .unbounded) { _ in } }, readPreferences: { preferences }, setTheme: { preferences.theme = $0 }, setAccent: { preferences.accent = $0 }, setMetalUnit: { preferences.metalUnit = $0 }, - refresh: { SettingsRateState(snapshot: RateSnapshot(), codes: []) }, replay: nil, + refresh: { SettingsRateState(snapshot: RateSnapshot(), base: "EUR", codes: []) }, + replay: nil, output: { _ in })) model.setTheme(.dark) model.setAccent(.teal) @@ -78,7 +82,7 @@ import Testing #expect(model.preferences == SettingsPreferences(theme: .dark, accent: .teal, metalUnit: .gram)) } @Test func measurementSaveInvalidatesObservedPreferencesWithoutRateChanges() { - let state = SettingsRateState(snapshot: RateSnapshot(), codes: ["EUR", "USD"]) + let state = SettingsRateState(snapshot: RateSnapshot(), base: "EUR", codes: ["EUR", "USD"]) var preferences = SettingsPreferences(theme: .system, accent: .primary) let model = SettingsModel( dependencies: SettingsDependencies( @@ -102,7 +106,7 @@ import Testing } @Test func hostPreferenceCommitInvalidatesObservationWithoutRateChanges() async { - let state = SettingsRateState(snapshot: RateSnapshot(), codes: ["EUR", "USD"]) + let state = SettingsRateState(snapshot: RateSnapshot(), base: "EUR", codes: ["EUR", "USD"]) var preferences = SettingsPreferences(theme: .system, accent: .primary) let stream = AsyncStream.makeStream() var subscribed = false @@ -141,12 +145,12 @@ import Testing @Test func failedMeasurementSavePreservesAuthoritativePreference() { let model = SettingsModel( dependencies: SettingsDependencies( - readState: { SettingsRateState(snapshot: RateSnapshot(), codes: []) }, + readState: { SettingsRateState(snapshot: RateSnapshot(), base: "EUR", codes: []) }, changes: { AsyncStream { $0.finish() } }, readPreferences: { SettingsPreferences(theme: .system, accent: .primary) }, setTheme: { _ in }, setAccent: { _ in }, setMetalUnit: { _ in throw CocoaError(.fileWriteUnknown) }, - refresh: { SettingsRateState(snapshot: RateSnapshot(), codes: []) }, + refresh: { SettingsRateState(snapshot: RateSnapshot(), base: "EUR", codes: []) }, replay: nil, output: { _ in })) model.setMetalUnit(.gram) #expect(model.preferences.metalUnit == .troyOunce) @@ -154,7 +158,7 @@ import Testing } @Test func hostCommitsRefreshDisplayedStateUntilFlowTeardown() async { - var state = SettingsRateState(snapshot: RateSnapshot(), codes: ["EUR"]) + var state = SettingsRateState(snapshot: RateSnapshot(), base: "EUR", codes: ["EUR"]) let stream = AsyncStream.makeStream() let model = SettingsModel( dependencies: SettingsDependencies( @@ -163,16 +167,20 @@ import Testing setTheme: { _ in }, setAccent: { _ in }, setMetalUnit: { _ in }, refresh: { state }, replay: nil, output: { _ in })) let lifetime = Task { await model.run() } + state.base = "JPY" state.codes = ["EUR", "JPY"] stream.continuation.yield(()) for _ in 0..<100 where model.rates.codes != state.codes { await Task.yield() } #expect(model.rates.codes == ["EUR", "JPY"]) + #expect(model.rates.base == "JPY") lifetime.cancel() await lifetime.value + state.base = "USD" state.codes = ["USD"] stream.continuation.yield(()) await Task.yield() #expect(model.rates.codes == ["EUR", "JPY"]) + #expect(model.rates.base == "JPY") } } diff --git a/Scripts/History/.gitignore b/Scripts/History/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/Scripts/History/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/Scripts/History/README.md b/Scripts/History/README.md new file mode 100644 index 0000000..5fb569b --- /dev/null +++ b/Scripts/History/README.md @@ -0,0 +1,9 @@ +Run `python3 Scripts/History/generate_archive.py` from any directory to refresh the bundled archive. The generator fetches all 17 Coin Metrics CSV archives, the fixed pre-March-2024 Frankfurter period with the approved 66 providers, and dated Fawaz USD tables through the latest completed UTC month. March 2024 uses March 2; subsequent months use their first day. The current month is excluded. `--today YYYY-MM-DD` makes the cutoff deterministic; the three `--*-input` options accept saved source snapshots for reproduction. + +Run `python3 -m unittest discover -s Scripts/History -p 'test_*.py'` for generator checks. All sources must succeed and validate before an atomic output replacement. Loss of any previously bundled observation date fails for manual review, even if newer observations would keep the row count unchanged. Acquisition dates and input hashes alone never rewrite the archive; selected observations, provider selection, sampling, source links and licence changes do. Full licences and notices remain separate resources beside the JSON. An upstream HTTP denial stops the refresh; the generator does not change identity, use a proxy, or fall back to unapproved FX sources. + +`Refresh bundled history` runs at 06:00 UTC on the first day of each month and supports manual dispatch on `main`, after this workflow is merged. It reuses an open bot-authored PR under `chore/bundled-history-refresh-*`, skipping commits when its actual head already contains the same semantic content. With no open managed PR, it creates a fresh run-specific branch from current `main`; retained closed or squash-merged branches are ignored. It never pushes to `main`, force-pushes, approves or merges a PR. An unexpected change on the managed branch stops publication for manual review. Upstream failures leave the existing archive and update branch unchanged. If `main` advances during generation, rerun the refresh against the new base. + +Repository Actions settings must permit GitHub Actions to create pull requests (`Allow GitHub Actions to create and approve pull requests`); the workflow uses that setting only for creation. It needs no PAT or other private credential. Only the publishing job receives `contents: write` and `pull-requests: write`. + +[GitHub documents](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow) that `GITHUB_TOKEN` does not trigger ordinary push workflows; bot-created or updated pull requests can create approval-required workflow runs. A maintainer must choose **Approve workflows to run** when prompted and require the normal Tests checks before merging. This automation does not bypass that approval or the required app checks. If the installation suppresses those PR events entirely, close and reopen the PR as a maintainer to trigger the normal checks. diff --git a/Scripts/History/generate_archive.py b/Scripts/History/generate_archive.py index 5ab7b5f..cf68644 100644 --- a/Scripts/History/generate_archive.py +++ b/Scripts/History/generate_archive.py @@ -1,57 +1,208 @@ -import argparse, csv, datetime, hashlib, io, json, pathlib, re, urllib.request - -parser = argparse.ArgumentParser() -parser.add_argument('--crypto-input', type=pathlib.Path) -parser.add_argument('--fiat-input', type=pathlib.Path) -parser.add_argument('--fawaz-input', type=pathlib.Path) -parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('Domain/ExchangeRates/Sources/ExchangeRates/Resources/history-monthly-v1.json')) -args = parser.parse_args() -allowed = set(re.findall(r'case \w+ = "([A-Z]+)"', pathlib.Path('Domain/ExchangeRates/Sources/ExchangeRates/CurrencyCode.swift').read_text())) -codes = 'btc eth usdc usdt doge ltc xrp ada link dot bch xlm uni etc aave algo icp'.split() -providers = 'AMCM,BAM,BANREP,BCB,BCBO,BCC,BCCH,BCP,BCRA,BCT,BDI,BDP,BI,BIS,BNA,BNM,BNR,BOA,BOC,BOJA,BOM,BOT,BOTA,BOZ,BRB,CBA,CBAR,CBC,CBG,CBI,CBK,CBLLR,CBM,CBS,CBSL,CBU,DAB,DNB,ECB,HMRC,HNB,INFOREURO,JPC,LB,MAS,MMA,MNB,NBC,NBE,NBG,NBK,NBKR,NBM,NBP,NBRB,NBRM,NBT,NRB,NRBT,RB,RBF,RBM,RBV,SARB,SBI,UST' -def fetch(url): - with urllib.request.urlopen(url, timeout=120) as response: - return response.read() -if args.crypto_input: - crypto = json.loads(args.crypto_input.read_text()) -else: - crypto = {} - for code in codes: - rows = csv.DictReader(io.StringIO(fetch(f'https://raw.githubusercontent.com/coinmetrics/data/master/csv/{code}.csv').decode())) - crypto[code] = [(row['time'], row['PriceUSD']) for row in rows if row.get('PriceUSD') and '2010-07-18' <= row['time'] <= '2024-03-01' and float(row['PriceUSD']) > 0] -fiat = json.loads(args.fiat_input.read_text()) if args.fiat_input else json.loads(fetch(f'https://api.frankfurter.dev/v2/rates?base=USD&from=2010-07-01&to=2024-02-29&group=month&providers={providers}')) -fx = {} -for row in fiat: - if row['date'] < '2024-03-01' and row['quote'] in allowed: - fx.setdefault(row['quote'], {})[row['date']] = float(row['rate']) -fx['USD'] = {row['date']: 1.0 for row in fiat if row['date'] < '2024-03-01'} -crypto_monthly = {} -for code, rows in crypto.items(): - months = {} - for day, price in sorted(rows): - if day < '2024-03-01': - months[day[:7]] = [day, float(price)] - crypto_monthly[code.upper()] = list(months.values()) -crypto_fx = {} -for code, rows in crypto.items(): - by_date = dict(rows) - crypto_fx[code.upper()] = [[day, float(by_date[day])] for day in sorted(fx['USD']) if day in by_date] +import argparse +import csv +import datetime +import hashlib +import io +import json +import math +import os +from pathlib import Path +import re +import tempfile +import urllib.request + + +ROOT = Path(__file__).resolve().parents[2] +OUTPUT = ROOT / 'Domain/ExchangeRates/Sources/ExchangeRates/Resources/history-monthly-v1.json' +CATALOG = ROOT / 'Domain/ExchangeRates/Sources/ExchangeRates/CurrencyCode.swift' +CODES = 'btc eth usdc usdt doge ltc xrp ada link dot bch xlm uni etc aave algo icp'.split() +PROVIDERS = 'AMCM,BAM,BANREP,BCB,BCBO,BCC,BCCH,BCP,BCRA,BCT,BDI,BDP,BI,BIS,BNA,BNM,BNR,BOA,BOC,BOJA,BOM,BOT,BOTA,BOZ,BRB,CBA,CBAR,CBC,CBG,CBI,CBK,CBLLR,CBM,CBS,CBSL,CBU,DAB,DNB,ECB,HMRC,HNB,INFOREURO,JPC,LB,MAS,MMA,MNB,NBC,NBE,NBG,NBK,NBKR,NBM,NBP,NBRB,NBRM,NBT,NRB,NRBT,RB,RBF,RBM,RBV,SARB,SBI,UST' +STATIC_START = '2010-07-18' +STATIC_END = '2024-03-01' +EPOCH = datetime.date(1970, 1, 1) +SECTIONS = ('crypto', 'cryptoFX', 'fiat', 'fawaz') +VOLATILE_METADATA = {'acquired', 'cryptoInputSHA256', 'fiatInputSHA256', 'fawazInputSHA256'} + + +def publication_days(today): + current = today.replace(day=1) + cursor = datetime.date(2024, 3, 1) + days = [] + while cursor < current: + days.append('2024-03-02' if cursor == datetime.date(2024, 3, 1) else cursor.isoformat()) + cursor = (cursor.replace(day=28) + datetime.timedelta(days=4)).replace(day=1) + return days + + +def positive(value): + if isinstance(value, bool): + raise ValueError('Boolean exchange rate') + value = float(value) + if not math.isfinite(value) or value <= 0: + raise ValueError('Invalid exchange rate') + return value + + +def valid_day(value): + if datetime.date.fromisoformat(value).isoformat() != value: + raise ValueError('Invalid observation date') + return value + + +def canonical_hash(value): + return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest() + + def packed(rows): - epoch = datetime.date(1970, 1, 1) - return [[(datetime.date.fromisoformat(day)-epoch).days, value] for day,value in rows] -fawaz = {} -days = ['2024-03-02'] + [f'{year:04}-{month:02}-01' for year in range(2024, 2027) for month in range(1, 13) if '2024-04' <= f'{year:04}-{month:02}' <= '2026-09'] -for day in days: - if args.fawaz_input: - table = json.loads((args.fawaz_input / f'{day}.json').read_text()) + return [[(datetime.date.fromisoformat(day) - EPOCH).days, value] for day, value in rows] + + +def build_archive(crypto, fiat, tables, allowed, today): + days = publication_days(today) + if not days: + raise ValueError('No completed Fawaz months') + fx = {} + for row in fiat: + day = valid_day(row['date']) + if row['base'] != 'USD': + raise ValueError('Unexpected Frankfurter base') + rate = positive(row['rate']) + if '2010-07-01' <= day < STATIC_END and row['quote'] in allowed: + fx.setdefault(row['quote'], {})[day] = rate + if not fx.get('EUR'): + raise ValueError('Missing selected historical FX observations') + fx['USD'] = {day: 1.0 for rows in fx.values() for day in rows} + monthly = {} + aligned = {} + for code in CODES: + observations = {} + for day, value in crypto[code]: + day = valid_day(day) + value = positive(value) + if STATIC_START <= day < STATIC_END: + observations[day] = value + if not observations: + raise ValueError(f'Missing historical crypto observations: {code}') + months = {} + for day, value in sorted(observations.items()): + months[day[:7]] = [day, value] + monthly[code.upper()] = packed(months.values()) + aligned[code.upper()] = packed((day, observations[day]) for day in sorted(fx['USD']) if day in observations) + fawaz = {} + for day in days: + table = tables[day] + if table['date'] != day: + raise ValueError(f'Wrong publication date: {day}') + if positive(table['usd']['usd']) != 1: + raise ValueError(f'Unexpected USD identity: {day}') + positive(table['usd']['eur']) + positive(table['usd']['btc']) + for code, value in table['usd'].items(): + if code.upper() in allowed: + fawaz.setdefault(code.upper(), []).append([day, positive(value)]) + cutoff = (today.replace(day=1) - datetime.timedelta(days=1)).strftime('%B%Y') + return { + 'version': 1, + 'provenance': { + 'acquired': today.isoformat(), + 'cryptoSource': 'https://github.com/coinmetrics/data', + 'cryptoMetric': 'PriceUSD', + 'cryptoSampling': 'Last available positive daily observation per completed month; cryptoFX preserves observations matching FX monthly bucket dates.', + 'fiatSource': 'https://api.frankfurter.dev/v2/rates', + 'fiatSampling': 'group=month monthly reference buckets; cross-source joins are approximations.', + 'licenses': ['CC-BY-NC-4.0.txt', 'CC-BY-4.0.txt', 'Fawaz-CC0-1.0.txt'], + 'notice': 'DataSources.md', + 'fawazSource': 'https://github.com/fawazahmed0/exchange-api', + 'fawazSampling': f'First daily publication of each completed month March2024 through {cutoff}, March2024 uses earliest available March2; USD quote table; CC0-1.0.', + 'cryptoInputSHA256': canonical_hash(crypto), + 'fiatInputSHA256': canonical_hash(fiat), + 'fawazInputSHA256': canonical_hash(tables), + }, + 'providers': PROVIDERS.split(','), + 'crypto': monthly, + 'cryptoFX': aligned, + 'fiat': {code: packed(sorted(rows.items())) for code, rows in fx.items()}, + 'fawaz': {code: packed(rows) for code, rows in fawaz.items()}, + } + + +def semantic_content(archive): + result = dict(archive) + result['provenance'] = {key: value for key, value in archive['provenance'].items() if key not in VOLATILE_METADATA} + return result + + +def write_archive(output, archive): + if output.exists(): + existing = json.loads(output.read_text()) + if semantic_content(existing) == semantic_content(archive): + return False + for section in SECTIONS: + for code, rows in existing[section].items(): + new_rows = archive[section].get(code, []) + if not {row[0] for row in rows}.issubset({row[0] for row in new_rows}): + raise ValueError(f'Historical coverage decreased: {section}/{code}') + encoded = (json.dumps(archive, separators=(',', ':'), sort_keys=True, allow_nan=False) + '\n').encode() + output.parent.mkdir(parents=True, exist_ok=True) + temporary = None + try: + with tempfile.NamedTemporaryFile(dir=output.parent, prefix=f'.{output.name}.', delete=False) as stream: + temporary = Path(stream.name) + stream.write(encoded) + temporary.replace(output) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + return True + + +def fetch_json(url): + with urllib.request.urlopen(url, timeout=120) as response: + return json.load(response) + + +def load_sources(args, today): + if args.crypto_input: + crypto = json.loads(args.crypto_input.read_text()) else: - table = json.loads(fetch(f'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{day}/v1/currencies/usd.min.json')) - if table['date'] != day: - raise ValueError(f'Wrong publication date: {day}') - for code, value in table['usd'].items(): - if code.upper() in allowed and float(value) > 0: - fawaz.setdefault(code.upper(), []).append([day, float(value)]) -archive = {'version': 1, 'provenance': {'acquired': '2026-10-15', 'cryptoSource': 'https://github.com/coinmetrics/data', 'cryptoMetric': 'PriceUSD', 'cryptoSampling': 'Last available positive daily observation per completed month; cryptoFX preserves observations matching FX monthly bucket dates.', 'fiatSource': 'https://api.frankfurter.dev/v2/rates', 'fiatSampling': 'group=month monthly reference buckets; cross-source joins are approximations.', 'licenses': ['CC-BY-NC-4.0.txt', 'CC-BY-4.0.txt', 'Fawaz-CC0-1.0.txt'], 'notice': 'DataSources.md', 'fawazSource': 'https://github.com/fawazahmed0/exchange-api', 'fawazSampling': 'First daily publication of each completed month March2024 through September2026, March2024 uses earliest available March2; USD quote table; CC0-1.0.', 'cryptoInputSHA256': hashlib.sha256(json.dumps(crypto, sort_keys=True).encode()).hexdigest(), 'fiatInputSHA256': hashlib.sha256(json.dumps(fiat, sort_keys=True).encode()).hexdigest()}, 'fawaz': {code: packed(rows) for code,rows in fawaz.items()}, 'providers': providers.split(','), 'crypto': {code: packed(rows) for code, rows in crypto_monthly.items()}, 'cryptoFX': {code: packed(rows) for code,rows in crypto_fx.items()}, 'fiat': {code: packed(sorted(rows.items())) for code,rows in fx.items()}} -args.output.parent.mkdir(parents=True, exist_ok=True) -args.output.write_text(json.dumps(archive, separators=(',', ':'), sort_keys=True)+'\n') -print(f'{args.output}: {args.output.stat().st_size} bytes') + crypto = {} + for code in CODES: + url = f'https://raw.githubusercontent.com/coinmetrics/data/master/csv/{code}.csv' + with urllib.request.urlopen(url, timeout=120) as response: + rows = csv.DictReader(io.TextIOWrapper(response, encoding='utf-8')) + crypto[code] = [[row['time'], row['PriceUSD']] for row in rows if row.get('PriceUSD') and STATIC_START <= row['time'] <= STATIC_END and positive(row['PriceUSD'])] + fiat = json.loads(args.fiat_input.read_text()) if args.fiat_input else fetch_json( + f'https://api.frankfurter.dev/v2/rates?base=USD&from=2010-07-01&to=2024-02-29&group=month&providers={PROVIDERS}') + tables = {} + for day in publication_days(today): + if args.fawaz_input: + tables[day] = json.loads((args.fawaz_input / f'{day}.json').read_text()) + else: + try: + tables[day] = fetch_json(f'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{day}/v1/currencies/usd.min.json') + except (OSError, ValueError): + tables[day] = fetch_json(f'https://{day}.currency-api.pages.dev/v1/currencies/usd.min.json') + return crypto, fiat, tables + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument('--crypto-input', type=Path) + parser.add_argument('--fiat-input', type=Path) + parser.add_argument('--fawaz-input', type=Path) + parser.add_argument('--output', type=Path, default=OUTPUT) + parser.add_argument('--today', type=datetime.date.fromisoformat, default=datetime.datetime.now(datetime.timezone.utc).date()) + args = parser.parse_args(argv) + allowed = set(re.findall(r'case \w+ = "([A-Z]+)"', CATALOG.read_text())) + crypto, fiat, tables = load_sources(args, args.today) + archive = build_archive(crypto, fiat, tables, allowed, args.today) + changed = write_archive(args.output, archive) + print(f'{"Updated" if changed else "Unchanged"}: {args.output} ({args.output.stat().st_size} bytes)') + if os.environ.get('GITHUB_OUTPUT'): + with open(os.environ['GITHUB_OUTPUT'], 'a') as stream: + stream.write(f'changed={str(changed).lower()}\n') + return changed + + +if __name__ == '__main__': + main() diff --git a/Scripts/History/propose_refresh.py b/Scripts/History/propose_refresh.py new file mode 100644 index 0000000..e6245d3 --- /dev/null +++ b/Scripts/History/propose_refresh.py @@ -0,0 +1,102 @@ +import argparse +import json +import os +from pathlib import Path +import subprocess +import tempfile + +from generate_archive import OUTPUT, ROOT, semantic_content + + +PREFIX = 'chore/bundled-history-refresh' +ARCHIVE = OUTPUT.relative_to(ROOT).as_posix() +TITLE = '🔧 history: refresh bundled reference data' +BODY = '''## 🔗 Related issue + +No linked issue. + +## 📝 Summary + +Refresh bundled reference observations through the latest completed month, including upstream corrections to the older Coin Metrics and selected Frankfurter archives. Preserve the data licences, notices and provenance. + +## 🧪 Validation + +Archive generator tests passed. Generation completed and validated all selected sources before replacing the resource. No merge or self-approval is automated. + +Approve workflows to run on this bot-created PR if GitHub requests it, then require the normal Tests checks before merging. The refresh job is not a substitute for app CI. +''' + + +def select_review(pull_requests, run_id, attempt): + candidates = [pr for pr in pull_requests + if pr['state'] == 'OPEN' and not pr['isCrossRepository'] + and pr['author'].get('is_bot') is True + and pr['author']['login'] == 'app/github-actions' + and (pr['headRefName'] == PREFIX or pr['headRefName'].startswith(PREFIX + '-'))] + if len(candidates) > 1: + raise ValueError('Multiple open managed history PRs require manual review') + if candidates: + return candidates[0]['headRefName'], candidates[0]['url'] + if not run_id.isdecimal() or not attempt.isdecimal(): + raise ValueError('Invalid workflow run identity') + return f'{PREFIX}-{run_id}-{attempt}', None + + +def command(arguments): + return subprocess.run(arguments, check=True, text=True, capture_output=True).stdout.strip() + + +def propose(candidate_path, base_sha, run_id, attempt, run=command): + if run(['git', 'rev-parse', 'HEAD']) != base_sha: + raise ValueError('The default branch changed during generation; rerun the refresh') + candidate = json.loads(candidate_path.read_text()) + if semantic_content(json.loads(Path(ARCHIVE).read_text())) == semantic_content(candidate): + return None + pull_requests = json.loads(run([ + 'gh', 'pr', 'list', '--base', 'main', '--state', 'open', '--author', 'github-actions[bot]', + '--limit', '1000', '--json', 'headRefName,isCrossRepository,url,state,author', + ])) + branch, review_url = select_review(pull_requests, run_id, attempt) + run(['gh', 'auth', 'setup-git']) + if review_url: + remote = f'origin/{branch}' + run(['git', 'fetch', 'origin', f'{branch}:refs/remotes/origin/{branch}']) + changed_files = run(['git', 'diff', '--name-only', f'origin/main...{remote}']).splitlines() + if any(path != ARCHIVE for path in changed_files): + raise ValueError('The managed history PR contains unexpected changes; review it manually') + previous = json.loads(run(['git', 'show', f'{remote}:{ARCHIVE}'])) + if semantic_content(previous) == semantic_content(candidate): + return review_url + run(['git', 'switch', '--create', branch, '--track', remote]) + else: + run(['git', 'switch', '--create', branch, 'origin/main']) + Path(ARCHIVE).write_bytes(candidate_path.read_bytes()) + run(['git', 'add', '--', ARCHIVE]) + run(['git', 'config', 'user.name', 'github-actions[bot]']) + run(['git', 'config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com']) + run(['git', 'commit', '-m', TITLE, '-m', '* refresh selected provider observations and provenance']) + run(['git', 'push', '--set-upstream', 'origin', branch]) + if review_url: + return review_url + with tempfile.NamedTemporaryFile(mode='w', suffix='.md') as body: + body.write(BODY) + body.flush() + return run(['gh', 'pr', 'create', '--base', 'main', '--head', branch, + '--title', TITLE, '--body-file', body.name]) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--candidate', type=Path, required=True) + args = parser.parse_args() + review_url = propose(args.candidate, os.environ['BASE_SHA'], os.environ['GITHUB_RUN_ID'], + os.environ['GITHUB_RUN_ATTEMPT']) + message = f'History review: {review_url}' if review_url else 'The default branch already contains this history.' + print(message) + if os.environ.get('GITHUB_STEP_SUMMARY'): + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as summary: + summary.write(message + '\n') + + +if __name__ == '__main__': + main() diff --git a/Scripts/History/test_generate_archive.py b/Scripts/History/test_generate_archive.py new file mode 100644 index 0000000..81f3992 --- /dev/null +++ b/Scripts/History/test_generate_archive.py @@ -0,0 +1,150 @@ +import copy +import datetime +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import generate_archive as generator + + +class ArchiveGenerationTests(unittest.TestCase): + def setUp(self): + self.today = datetime.date(2024, 5, 15) + self.allowed = {'USD', 'EUR', 'GBP', *(code.upper() for code in generator.CODES)} + self.crypto = {code: [['2024-02-01', '2'], ['2024-02-29', '3'], ['2024-03-01', '9']] + for code in generator.CODES} + self.fiat = [ + {'date': '2024-02-01', 'base': 'USD', 'quote': 'EUR', 'rate': 0.8}, + {'date': '2024-02-01', 'base': 'USD', 'quote': 'GBP', 'rate': 0.5}, + ] + self.tables = {day: {'date': day, 'usd': {'usd': 1, 'eur': 0.8, 'btc': 2, 'unused': 999}} + for day in generator.publication_days(self.today)} + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.output = Path(self.temp.name) / 'history.json' + + def build(self, today=None): + return generator.build_archive(self.crypto, self.fiat, self.tables, self.allowed, today or self.today) + + def test_completed_month_boundary_including_year_rollover(self): + self.assertEqual(generator.publication_days(datetime.date(2024, 3, 31)), []) + self.assertEqual(generator.publication_days(datetime.date(2024, 4, 1)), ['2024-03-02']) + self.assertEqual(generator.publication_days(datetime.date(2026, 10, 1))[-1], '2026-09-01') + self.assertEqual(generator.publication_days(datetime.date(2026, 10, 31))[-1], '2026-09-01') + self.assertEqual(generator.publication_days(datetime.date(2027, 1, 1))[-1], '2026-12-01') + + def test_sampling_preserves_selected_observations_and_provider_notices(self): + archive = self.build() + february = (datetime.date(2024, 2, 29) - generator.EPOCH).days + self.assertEqual(archive['crypto']['BTC'], [[february, 3.0]]) + self.assertEqual(archive['cryptoFX']['BTC'][0][1], 2.0) + self.assertEqual(len(archive['fawaz']['BTC']), 2) + self.assertNotIn('UNUSED', archive['fawaz']) + self.assertEqual(len(archive['providers']), 66) + self.assertNotIn('BOE', archive['providers']) + self.assertEqual(archive['provenance']['licenses'], + ['CC-BY-NC-4.0.txt', 'CC-BY-4.0.txt', 'Fawaz-CC0-1.0.txt']) + self.assertEqual(archive['provenance']['notice'], 'DataSources.md') + + def test_acquisition_and_input_hash_churn_do_not_rewrite_output(self): + self.assertTrue(generator.write_archive(self.output, self.build())) + original = self.output.read_bytes() + timestamp = self.output.stat().st_mtime_ns + later = self.build(datetime.date(2024, 5, 30)) + for key in generator.VOLATILE_METADATA: + later['provenance'][key] = 'changed' + self.assertFalse(generator.write_archive(self.output, later)) + self.assertEqual(self.output.read_bytes(), original) + self.assertEqual(self.output.stat().st_mtime_ns, timestamp) + + def test_unselected_source_changes_are_noop(self): + generator.write_archive(self.output, self.build()) + self.crypto['btc'].append(['2026-01-01', '1000']) + self.tables['2024-04-01']['usd']['unused'] = 1001 + self.fiat.append({'date': '2024-02-01', 'base': 'USD', 'quote': 'UNUSED', 'rate': 1002}) + self.assertFalse(generator.write_archive(self.output, self.build())) + + def test_older_crypto_and_fiat_corrections_are_written(self): + generator.write_archive(self.output, self.build()) + self.crypto['btc'][1][1] = '3.5' + self.assertTrue(generator.write_archive(self.output, self.build())) + self.assertEqual(json.loads(self.output.read_text())['crypto']['BTC'][0][1], 3.5) + self.fiat[0]['rate'] = 0.9 + self.assertTrue(generator.write_archive(self.output, self.build())) + self.assertEqual(json.loads(self.output.read_text())['fiat']['EUR'][0][1], 0.9) + + def test_next_completed_month_adds_only_real_dated_table(self): + generator.write_archive(self.output, self.build()) + self.tables['2024-05-01'] = {'date': '2024-05-01', 'usd': {'usd': 1, 'eur': 0.9, 'btc': 3}} + self.assertTrue(generator.write_archive(self.output, self.build(datetime.date(2024, 6, 1)))) + saved = json.loads(self.output.read_text()) + self.assertEqual(len(saved['fawaz']['BTC']), 3) + self.assertEqual(saved['fawaz']['BTC'][-1][1], 3) + + def test_meaningful_provenance_change_is_written(self): + archive = self.build() + generator.write_archive(self.output, archive) + archive['provenance']['cryptoSource'] = 'https://example.invalid/corrected-source' + self.assertTrue(generator.write_archive(self.output, archive)) + + def test_upstream_failure_leaves_previous_output_untouched(self): + generator.write_archive(self.output, self.build()) + original = self.output.read_bytes() + with patch.object(generator, 'load_sources', side_effect=OSError('upstream unavailable')): + with self.assertRaises(OSError): + generator.main(['--output', str(self.output), '--today', '2024-05-15']) + self.assertEqual(self.output.read_bytes(), original) + self.assertEqual(list(self.output.parent.iterdir()), [self.output]) + + def test_wrong_publication_fails_before_output_replacement(self): + generator.write_archive(self.output, self.build()) + original = self.output.read_bytes() + self.tables['2024-04-01']['date'] = '2024-03-31' + with patch.object(generator, 'load_sources', return_value=(self.crypto, self.fiat, self.tables)): + with self.assertRaisesRegex(ValueError, 'Wrong publication'): + generator.main(['--output', str(self.output), '--today', '2024-05-15']) + self.assertEqual(self.output.read_bytes(), original) + + def test_malformed_rates_cannot_replace_valid_archive(self): + for value in [float('nan'), float('inf'), 0, -1, True]: + with self.subTest(value=value), self.assertRaises(ValueError): + generator.positive(value) + + def test_coverage_loss_requires_manual_review(self): + archive = self.build() + generator.write_archive(self.output, archive) + original = self.output.read_bytes() + candidate = copy.deepcopy(archive) + candidate['fiat']['GBP'] = [] + with self.assertRaisesRegex(ValueError, 'coverage decreased'): + generator.write_archive(self.output, candidate) + self.assertEqual(self.output.read_bytes(), original) + + def test_new_month_cannot_hide_loss_of_an_old_observation(self): + archive = self.build() + generator.write_archive(self.output, archive) + original = self.output.read_bytes() + candidate = copy.deepcopy(archive) + day, value = candidate['crypto']['ETH'][0] + candidate['crypto']['ETH'] = [[day + 31, value]] + self.assertEqual(len(candidate['crypto']['ETH']), len(archive['crypto']['ETH'])) + with self.assertRaisesRegex(ValueError, 'coverage decreased: crypto/ETH'): + generator.write_archive(self.output, candidate) + self.assertEqual(self.output.read_bytes(), original) + + def test_failed_atomic_replace_preserves_previous_output_and_removes_temporary_file(self): + archive = self.build() + generator.write_archive(self.output, archive) + original = self.output.read_bytes() + archive['fawaz']['BTC'][-1][1] = 3 + with patch.object(Path, 'replace', side_effect=OSError('replace failed')): + with self.assertRaises(OSError): + generator.write_archive(self.output, archive) + self.assertEqual(self.output.read_bytes(), original) + self.assertEqual(list(self.output.parent.iterdir()), [self.output]) + + +if __name__ == '__main__': + unittest.main() diff --git a/Scripts/History/test_propose_refresh.py b/Scripts/History/test_propose_refresh.py new file mode 100644 index 0000000..0f8ddbb --- /dev/null +++ b/Scripts/History/test_propose_refresh.py @@ -0,0 +1,136 @@ +import copy +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import propose_refresh as publisher + + +class ReviewLifecycleTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.output = Path(self.temp.name) / 'history.json' + self.candidate = Path(self.temp.name) / 'candidate.json' + self.old = {'version': 1, 'provenance': {'acquired': '2026-10-01'}, 'fiat': {'EUR': [[1, 2]]}} + self.new = copy.deepcopy(self.old) + self.new['fiat']['EUR'].append([2, 3]) + self.output.write_text(json.dumps(self.old)) + self.candidate.write_text(json.dumps(self.new)) + self.archive_patch = patch.object(publisher, 'ARCHIVE', str(self.output)) + self.archive_patch.start() + self.addCleanup(self.archive_patch.stop) + self.calls = [] + self.prs = [] + self.previous = self.old + self.changed_files = str(self.output) + + def review(self, branch=None, state='OPEN', author='app/github-actions', cross_repo=False): + return {'headRefName': branch or publisher.PREFIX + '-123-1', 'state': state, + 'url': 'https://github.com/example/currency/pull/1', + 'author': {'login': author, 'is_bot': author == 'app/github-actions'}, 'isCrossRepository': cross_repo} + + def execute(self, arguments): + self.calls.append(arguments) + if arguments[:3] == ['git', 'rev-parse', 'HEAD']: + return 'base' + if arguments[:3] == ['gh', 'pr', 'list']: + return json.dumps(self.prs) + if arguments[:3] == ['git', 'diff', '--name-only']: + return self.changed_files + if arguments[:2] == ['git', 'show']: + return json.dumps(self.previous) + if arguments[:3] == ['gh', 'pr', 'create']: + body = Path(arguments[arguments.index('--body-file') + 1]).read_text() + self.assertIn('## 🔗 Related issue', body) + self.assertIn('## 📝 Summary', body) + self.assertIn('## 🧪 Validation', body) + return 'https://github.com/example/currency/pull/2' + return '' + + def propose(self): + return publisher.propose(self.candidate, 'base', '456', '2', run=self.execute) + + def mutations(self): + return [call for call in self.calls if call[:2] in (['git', 'commit'], ['git', 'push'])] + + def test_actual_gh_cli_actions_author_reuses_open_review(self): + review = self.review() + review['author'] = {'is_bot': True, 'login': 'app/github-actions'} + branch, url = publisher.select_review([review], '456', '2') + self.assertEqual(branch, review['headRefName']) + self.assertEqual(url, review['url']) + + def test_retained_squash_merged_branch_is_ignored_even_if_its_content_matches(self): + self.prs = [self.review(state='MERGED')] + self.previous = self.new + result = self.propose() + branch = publisher.PREFIX + '-456-2' + self.assertEqual(result, 'https://github.com/example/currency/pull/2') + self.assertIn(['git', 'switch', '--create', branch, 'origin/main'], self.calls) + self.assertIn(['git', 'push', '--set-upstream', 'origin', branch], self.calls) + self.assertFalse(any(call[:2] in (['git', 'show'], ['git', 'fetch']) for call in self.calls)) + self.assertEqual(json.loads(self.output.read_text()), self.new) + + def test_closed_unmerged_branch_does_not_reuse_its_stale_base(self): + self.prs = [self.review(state='CLOSED')] + self.propose() + self.assertIn(['git', 'switch', '--create', publisher.PREFIX + '-456-2', 'origin/main'], self.calls) + + def test_open_managed_review_is_updated_with_regular_push(self): + review = self.review() + self.prs = [review] + result = self.propose() + branch = review['headRefName'] + self.assertEqual(result, review['url']) + self.assertIn(['git', 'switch', '--create', branch, '--track', 'origin/' + branch], self.calls) + self.assertIn(['git', 'push', '--set-upstream', 'origin', branch], self.calls) + self.assertFalse(any('--force' in argument for call in self.calls for argument in call)) + self.assertFalse(any(call[:3] == ['gh', 'pr', 'create'] for call in self.calls)) + + def test_open_review_with_same_semantics_has_no_commit_or_pr_update(self): + review = self.review() + self.prs = [review] + self.previous = copy.deepcopy(self.new) + self.previous['provenance']['acquired'] = '2026-09-01' + result = self.propose() + self.assertEqual(result, review['url']) + self.assertEqual(self.mutations(), []) + self.assertFalse(any(call[:2] == ['git', 'switch'] for call in self.calls)) + self.assertFalse(any(call[:3] == ['gh', 'pr', 'create'] for call in self.calls)) + + def test_main_already_contains_semantics_without_any_pr_lookup(self): + self.output.write_text(json.dumps(self.new)) + self.assertIsNone(self.propose()) + self.assertEqual(self.calls, [['git', 'rev-parse', 'HEAD']]) + + def test_unexpected_managed_branch_changes_fail_before_writing_or_pushing(self): + self.prs = [self.review()] + self.changed_files += '\nApp/private-change.swift' + original = self.output.read_bytes() + with self.assertRaisesRegex(ValueError, 'unexpected changes'): + self.propose() + self.assertEqual(self.output.read_bytes(), original) + self.assertEqual(self.mutations(), []) + + def test_multiple_open_managed_reviews_require_manual_resolution(self): + self.prs = [self.review(), self.review(branch=publisher.PREFIX + '-222-1')] + with self.assertRaisesRegex(ValueError, 'Multiple open'): + self.propose() + self.assertEqual(self.mutations(), []) + + def test_human_and_fork_reviews_are_not_reused(self): + self.prs = [self.review(author='a-person'), self.review(cross_repo=True)] + self.propose() + self.assertIn(['git', 'switch', '--create', publisher.PREFIX + '-456-2', 'origin/main'], self.calls) + + def test_stale_generation_base_fails_before_mutation(self): + with self.assertRaisesRegex(ValueError, 'default branch changed'): + publisher.propose(self.candidate, 'older-base', '456', '2', run=self.execute) + self.assertEqual(self.calls, [['git', 'rev-parse', 'HEAD']]) + + +if __name__ == '__main__': + unittest.main() From 80a1194e79c77d780b8154bc75963965a4aaa525 Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Thu, 15 Oct 2026 14:12:24 +0200 Subject: [PATCH 13/17] =?UTF-8?q?=E2=9C=85=20history:=20verify=20live=20ge?= =?UTF-8?q?neration=20before=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * run history-source refresh as a read-only PR dry run * reserve artifact publication and update PRs for main schedules --- .github/workflows/history-refresh.yml | 21 ++++++++++++++++----- Scripts/History/README.md | 2 ++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/history-refresh.yml b/.github/workflows/history-refresh.yml index 60c3cb2..c0cc6a3 100644 --- a/.github/workflows/history-refresh.yml +++ b/.github/workflows/history-refresh.yml @@ -1,6 +1,10 @@ name: Refresh bundled history on: + pull_request: + paths: + - 'Scripts/History/**' + - '.github/workflows/history-refresh.yml' schedule: - cron: '0 6 1 * *' workflow_dispatch: @@ -9,12 +13,15 @@ permissions: contents: read concurrency: - group: currency-history-refresh + group: currency-history-refresh-${{ github.event.pull_request.number || 'main' }} cancel-in-progress: false jobs: generate: - if: github.ref == 'refs/heads/main' + if: >- + github.event_name == 'pull_request' || + (github.ref == 'refs/heads/main' && + (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) runs-on: ubuntu-latest timeout-minutes: 30 outputs: @@ -23,7 +30,7 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: main + ref: ${{ github.event_name == 'pull_request' && github.sha || 'main' }} persist-credentials: false - name: Test archive generation run: python3 -m unittest discover -s Scripts/History -p 'test_*.py' @@ -33,7 +40,7 @@ jobs: echo "base_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" python3 Scripts/History/generate_archive.py - uses: actions/upload-artifact@v7 - if: steps.generate.outputs.changed == 'true' + if: github.event_name != 'pull_request' && steps.generate.outputs.changed == 'true' with: name: currency-history-refresh path: Domain/ExchangeRates/Sources/ExchangeRates/Resources/history-monthly-v1.json @@ -41,7 +48,11 @@ jobs: retention-days: 7 propose: needs: generate - if: needs.generate.outputs.changed == 'true' + if: >- + github.ref == 'refs/heads/main' && + github.event_name != 'pull_request' && + (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && + needs.generate.outputs.changed == 'true' runs-on: ubuntu-latest timeout-minutes: 10 permissions: diff --git a/Scripts/History/README.md b/Scripts/History/README.md index 5fb569b..540bd21 100644 --- a/Scripts/History/README.md +++ b/Scripts/History/README.md @@ -4,6 +4,8 @@ Run `python3 -m unittest discover -s Scripts/History -p 'test_*.py'` for generat `Refresh bundled history` runs at 06:00 UTC on the first day of each month and supports manual dispatch on `main`, after this workflow is merged. It reuses an open bot-authored PR under `chore/bundled-history-refresh-*`, skipping commits when its actual head already contains the same semantic content. With no open managed PR, it creates a fresh run-specific branch from current `main`; retained closed or squash-merged branches are ignored. It never pushes to `main`, force-pushes, approves or merges a PR. An unexpected change on the managed branch stops publication for manual review. Upstream failures leave the existing archive and update branch unchanged. If `main` advances during generation, rerun the refresh against the new base. +Pull requests changing `Scripts/History/**` or this workflow run the same full-source generation on GitHub's Ubuntu runner using the PR merge SHA. This dry run has read-only repository permissions, no persisted checkout credentials or private secrets, no candidate artifact upload, and no publishing job. It uses a separate concurrency group from scheduled refreshes. Upstream denials still fail the check and leave publication disabled; a successful dry run validates live generation in the intended runner environment before merging. + Repository Actions settings must permit GitHub Actions to create pull requests (`Allow GitHub Actions to create and approve pull requests`); the workflow uses that setting only for creation. It needs no PAT or other private credential. Only the publishing job receives `contents: write` and `pull-requests: write`. [GitHub documents](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow) that `GITHUB_TOKEN` does not trigger ordinary push workflows; bot-created or updated pull requests can create approval-required workflow runs. A maintainer must choose **Approve workflows to run** when prompted and require the normal Tests checks before merging. This automation does not bypass that approval or the required app checks. If the installation suppresses those PR events entirely, close and reopen the PR as a maintainer to trigger the normal checks. From 5f679c393138399c3bd3cf02f697ef12a4afc797 Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Thu, 15 Oct 2026 14:18:34 +0200 Subject: [PATCH 14/17] =?UTF-8?q?=F0=9F=90=9B=20history:=20use=20documente?= =?UTF-8?q?d=20source=20client=20and=20fix=20navigator=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fetch Frankfurter with bounded ordinary curl and preserve archives on errors * expose history documentation in the generated project and ignore Python caches --- .gitignore | 2 ++ Project.swift | 2 +- Scripts/History/.gitignore | 1 - Scripts/History/README.md | 4 +-- Scripts/History/generate_archive.py | 15 ++++++++-- Scripts/History/test_generate_archive.py | 36 ++++++++++++++++++++++++ 6 files changed, 54 insertions(+), 6 deletions(-) delete mode 100644 Scripts/History/.gitignore diff --git a/.gitignore b/.gitignore index 7e35a46..4c64b16 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ xcuserdata/ Tuist/Dependencies/ .validation/ + +__pycache__/ diff --git a/Project.swift b/Project.swift index e66ef10..248af37 100644 --- a/Project.swift +++ b/Project.swift @@ -576,7 +576,7 @@ let project = Project( .folderReference(path: "LICENSES"), "README.md", "LICENSE", "Project.swift", "Tuist.swift", ".gitignore", ".mise.toml", ".swift-format", ".github/**/*.yml", ".github/**/*.md", "Documentation/**/*.md", - "Documentation/**/*.png", "Scripts/**/*.sh", "Scripts/**/*.py", + "Documentation/**/*.png", "Scripts/**/*.sh", "Scripts/**/*.py", "Scripts/**/*.md", "Tuist/Package.swift", "Apps/Currency/README.md", "Apps/Currency/App/Configuration/**", "Apps/Currency/Modules/*/README.md", "AppExtensions/CurrencyWidgets/README.md", "AppExtensions/CurrencyWidgets/Configuration/**", "Foundation/*/Package.swift", diff --git a/Scripts/History/.gitignore b/Scripts/History/.gitignore deleted file mode 100644 index c18dd8d..0000000 --- a/Scripts/History/.gitignore +++ /dev/null @@ -1 +0,0 @@ -__pycache__/ diff --git a/Scripts/History/README.md b/Scripts/History/README.md index 540bd21..63cfa79 100644 --- a/Scripts/History/README.md +++ b/Scripts/History/README.md @@ -1,6 +1,6 @@ -Run `python3 Scripts/History/generate_archive.py` from any directory to refresh the bundled archive. The generator fetches all 17 Coin Metrics CSV archives, the fixed pre-March-2024 Frankfurter period with the approved 66 providers, and dated Fawaz USD tables through the latest completed UTC month. March 2024 uses March 2; subsequent months use their first day. The current month is excluded. `--today YYYY-MM-DD` makes the cutoff deterministic; the three `--*-input` options accept saved source snapshots for reproduction. +Run `python3 Scripts/History/generate_archive.py` from any directory to refresh the bundled archive. Python 3 and ordinary `curl` are required; hosted Ubuntu runners and macOS include curl. The generator fetches all 17 Coin Metrics CSV archives, the fixed pre-March-2024 Frankfurter period with the approved 66 providers, and dated Fawaz USD tables through the latest completed UTC month. March 2024 uses March 2; subsequent months use their first day. The current month is excluded. `--today YYYY-MM-DD` makes the cutoff deterministic; the three `--*-input` options accept saved source snapshots for reproduction. -Run `python3 -m unittest discover -s Scripts/History -p 'test_*.py'` for generator checks. All sources must succeed and validate before an atomic output replacement. Loss of any previously bundled observation date fails for manual review, even if newer observations would keep the row count unchanged. Acquisition dates and input hashes alone never rewrite the archive; selected observations, provider selection, sampling, source links and licence changes do. Full licences and notices remain separate resources beside the JSON. An upstream HTTP denial stops the refresh; the generator does not change identity, use a proxy, or fall back to unapproved FX sources. +Run `python3 -m unittest discover -s Scripts/History -p 'test_*.py'` for generator checks. All sources must succeed and validate before an atomic output replacement. Loss of any previously bundled observation date fails for manual review, even if newer observations would keep the row count unchanged. Acquisition dates and input hashes alone never rewrite the archive; selected observations, provider selection, sampling, source links and licence changes do. Full licences and notices remain separate resources beside the JSON. Frankfurter uses the documented ordinary curl client with a 120-second limit, without overridden user agents, spoofed headers, proxies, cookies or authentication. Coin Metrics and Fawaz use Python HTTPS. An upstream HTTP denial or malformed response stops the refresh without falling back to unapproved FX sources. `Refresh bundled history` runs at 06:00 UTC on the first day of each month and supports manual dispatch on `main`, after this workflow is merged. It reuses an open bot-authored PR under `chore/bundled-history-refresh-*`, skipping commits when its actual head already contains the same semantic content. With no open managed PR, it creates a fresh run-specific branch from current `main`; retained closed or squash-merged branches are ignored. It never pushes to `main`, force-pushes, approves or merges a PR. An unexpected change on the managed branch stops publication for manual review. Upstream failures leave the existing archive and update branch unchanged. If `main` advances during generation, rerun the refresh against the new base. diff --git a/Scripts/History/generate_archive.py b/Scripts/History/generate_archive.py index cf68644..9a5e727 100644 --- a/Scripts/History/generate_archive.py +++ b/Scripts/History/generate_archive.py @@ -8,6 +8,7 @@ import os from pathlib import Path import re +import subprocess import tempfile import urllib.request @@ -161,6 +162,17 @@ def fetch_json(url): return json.load(response) +def fetch_frankfurter(): + url = f'https://api.frankfurter.dev/v2/rates?base=USD&from=2010-07-01&to=2024-02-29&group=month&providers={PROVIDERS}' + response = subprocess.run( + ['curl', '--fail', '--silent', '--show-error', '--max-time', '120', url], + check=True, capture_output=True, text=True, timeout=125) + rows = json.loads(response.stdout) + if not isinstance(rows, list) or not rows or not all(isinstance(row, dict) for row in rows): + raise ValueError('Frankfurter must return a nonempty array of rate rows') + return rows + + def load_sources(args, today): if args.crypto_input: crypto = json.loads(args.crypto_input.read_text()) @@ -171,8 +183,7 @@ def load_sources(args, today): with urllib.request.urlopen(url, timeout=120) as response: rows = csv.DictReader(io.TextIOWrapper(response, encoding='utf-8')) crypto[code] = [[row['time'], row['PriceUSD']] for row in rows if row.get('PriceUSD') and STATIC_START <= row['time'] <= STATIC_END and positive(row['PriceUSD'])] - fiat = json.loads(args.fiat_input.read_text()) if args.fiat_input else fetch_json( - f'https://api.frankfurter.dev/v2/rates?base=USD&from=2010-07-01&to=2024-02-29&group=month&providers={PROVIDERS}') + fiat = json.loads(args.fiat_input.read_text()) if args.fiat_input else fetch_frankfurter() tables = {} for day in publication_days(today): if args.fawaz_input: diff --git a/Scripts/History/test_generate_archive.py b/Scripts/History/test_generate_archive.py index 81f3992..435dfe5 100644 --- a/Scripts/History/test_generate_archive.py +++ b/Scripts/History/test_generate_archive.py @@ -3,6 +3,7 @@ import json from pathlib import Path import tempfile +import subprocess import unittest from unittest.mock import patch @@ -28,6 +29,41 @@ def setUp(self): def build(self, today=None): return generator.build_archive(self.crypto, self.fiat, self.tables, self.allowed, today or self.today) + def test_documented_curl_client_preserves_exact_provider_scope(self): + response = subprocess.CompletedProcess([], 0, stdout=json.dumps(self.fiat)) + with patch.object(generator.subprocess, 'run', return_value=response) as execute: + self.assertEqual(generator.fetch_frankfurter(), self.fiat) + arguments = execute.call_args.args[0] + self.assertEqual(arguments[:-1], ['curl', '--fail', '--silent', '--show-error', '--max-time', '120']) + self.assertIn('base=USD&from=2010-07-01&to=2024-02-29&group=month', arguments[-1]) + self.assertTrue(arguments[-1].endswith('providers=' + generator.PROVIDERS)) + self.assertEqual(execute.call_args.kwargs['timeout'], 125) + self.assertTrue(execute.call_args.kwargs['check']) + + def test_curl_failure_and_invalid_json_leave_archive_untouched(self): + generator.write_archive(self.output, self.build()) + original = self.output.read_bytes() + crypto_file = self.output.parent / 'crypto.json' + crypto_file.write_text(json.dumps(self.crypto)) + failures = [ + subprocess.CalledProcessError(22, ['curl']), + subprocess.TimeoutExpired(['curl'], 125), + subprocess.CompletedProcess([], 0, stdout='upstream error'), + subprocess.CompletedProcess([], 0, stdout='{"error":"unavailable"}'), + subprocess.CompletedProcess([], 0, stdout='[]'), + subprocess.CompletedProcess([], 0, stdout='[null]'), + ] + for failure in failures: + with self.subTest(failure=str(failure)): + context = (patch.object(generator.subprocess, 'run', side_effect=failure) + if isinstance(failure, Exception) + else patch.object(generator.subprocess, 'run', return_value=failure)) + with context, self.assertRaises((subprocess.SubprocessError, ValueError)): + generator.main(['--output', str(self.output), '--today', '2024-05-15', + '--crypto-input', str(crypto_file)]) + self.assertEqual(self.output.read_bytes(), original) + self.assertEqual(set(self.output.parent.iterdir()), {self.output, crypto_file}) + def test_completed_month_boundary_including_year_rollover(self): self.assertEqual(generator.publication_days(datetime.date(2024, 3, 31)), []) self.assertEqual(generator.publication_days(datetime.date(2024, 4, 1)), ['2024-03-02']) From 496b4cb13996bb29076a918a5a6150970987f9c7 Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Thu, 15 Oct 2026 15:33:29 +0200 Subject: [PATCH 15/17] =?UTF-8?q?=F0=9F=93=9D=20sources:=20simplify=20Fran?= =?UTF-8?q?kfurter=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * replace institution-specific terms and source directory with the Frankfurter link * keep bundled notice aligned with repository documentation --- Documentation/DataSources.md | 90 +------------------ .../ExchangeRates/Resources/DataSources.md | 90 +------------------ 2 files changed, 4 insertions(+), 176 deletions(-) diff --git a/Documentation/DataSources.md b/Documentation/DataSources.md index cfc6b26..43abae4 100644 --- a/Documentation/DataSources.md +++ b/Documentation/DataSources.md @@ -16,92 +16,6 @@ The source archives are publicly downloadable at the link above. Bundled data fi Daily rates, fallback history and bundled monthly references: [Fawaz Ahmed’s Exchange API](https://github.com/fawazahmed0/exchange-api), distributed through its documented CDN and Cloudflare endpoints. Its repository declares [CC0 1.0 Universal](https://github.com/fawazahmed0/exchange-api/blob/main/LICENSE) ([full text](../LICENSES/Fawaz-CC0-1.0.txt)). The bundled completed-month history samples March 2, 2024 (the archive boundary), then the first day of each completed month from April 2024. The incomplete current month is omitted. Later missing completed months may be fetched as month-end references. Currency selects dates/assets and calculates cross-rates and reference conversions. CC0 applies only to rights the affirmer can waive or license; it does not warrant third-party source rights or grant trademark rights. -## Frankfurter and original fiat sources +## Frankfurter -[Frankfurter](https://frankfurter.dev/) aggregates institutional reference rates. Its [provider and licensing inventory](https://frankfurter.dev/license/) identifies the original sources; its software licence does not replace their data terms. Currency uses an explicit selection of provider IDs. A displayed calculation or blend is produced by Currency, rather than an official quote from any contributing institution. Dates refer to the source observations; monthly samples are reference observations, not market closes. Bundled metadata records the selected source IDs. The pre-March-2024 fiat archive requests USD-based monthly reference buckets from July 2010 through February 2024; actual coverage differs by currency. GGP, IMP and JEP use an explicit GBP-parity assumption. These are Currency’s reference calculations. - -The following notices accompany data from these sources, where present: - -- **Banco Central de la República Argentina (BCRA)**: source [BCRA](https://www.bcra.gob.ar/); [informational reuse terms](https://www.bcra.gob.ar/aviso-legal/). Currency’s noncommercial estimates preserve the source context; conversions, selection and formatting are Currency’s work. -- **Bank for International Settlements (BIS)**: underlying statistics sourced from [BIS Data Portal](https://data.bis.org/), under its [statistical-data terms](https://data.bis.org/help/legal). Currency’s calculations are identified as such; no additional charge is made for the included statistics. -- **Bank of Canada**: underlying data sourced from [Bank of Canada](https://www.bankofcanada.ca/), under its [terms](https://www.bankofcanada.ca/terms/). Currency selects/reformats reference observations and calculates cross-rates; the Bank of Canada does not endorse these changes. -- **Bank of Jamaica**: source [Bank of Jamaica](https://boj.org.jm/), used for noncommercial general reference with source acknowledgment under its [notice](https://boj.org.jm/disclaimer/). -- **European Central Bank (ECB)**: underlying reference rates sourced from [ECB](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html), under its [copyright and reuse terms](https://www.ecb.europa.eu/services/using-our-site/disclaimer/html/index.en.html). Selection, reformatting, cross-rates and converted prices are modifications by Currency. -- **HM Revenue & Customs**: [monthly customs exchange rates](https://www.trade-tariff.service.gov.uk/exchange_rates/monthly). Contains public sector information licensed under the [Open Government Licence v3.0](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/). Currency’s calculated reference conversions are not HMRC customs assessments. -- **European Commission / InforEuro**: underlying data from [InforEuro](https://commission.europa.eu/funding-tenders/procedures-guidelines-tenders/information-contractors-and-beneficiaries/exchange-rate-inforeuro_en), © European Union, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) ([full text](../LICENSES/CC-BY-4.0.txt)) as described in the [Commission legal notice](https://commission.europa.eu/legal-notice_en), except separately identified third-party material. Currency selects/reformats observations and calculates reference cross-rates. Retain the source/licence/warranty notices and identify changes; no additional legal or effective technological restriction may obstruct the licensed rights. See the licence for its warranty disclaimer. -- **Japan Customs**: underlying [customs exchange rates](https://www.customs.go.jp/english/kawase/index_e.htm); [copyright notice](https://www.customs.go.jp/copyright_e.htm) and [Public Data Licence 1.0](https://www.digital.go.jp/en/resources/open_data/public_data_license_v1.0). Numerical data are freely usable as described there. Selection, formatting and calculated estimates are by Currency / Dmytro Mishchenko; they are not unedited government publications. -- **Sveriges riksbank**: original statistics and their observation dates are identified through the source metadata and [original series](https://www.riksbank.se/en-gb/statistics/search-interest--exchange-rates/). See [open-data reuse terms](https://www.riksbank.se/sv/om-riksbanken/om-webbplatsen/oppna-data--information-tillganglig-for-vidareutnyttjande/). Processed/blended statistics and cross-rates are Currency’s calculations; Sveriges riksbank is credited only for the underlying original statistics, not as the source of those processed results. -- **U.S. Department of the Treasury**: underlying [Treasury reporting rates](https://fiscaldata.treasury.gov/datasets/treasury-reporting-rates-exchange/treasury-reporting-rates-of-exchange), available for copying, adaptation and redistribution under its [data authorization](https://fiscaldata.treasury.gov/api-documentation/#license-and-authorization). Currency’s calculations do not imply Treasury endorsement. - -### Source directory - -The selected source directory below links each original dataset. Source-specific notices and third-party exclusions continue to apply. Inclusion here is attribution and provenance, not a claim that every source uses the same licence. No linked licence in an aggregator’s inventory is not a public-domain dedication. - -| ID | Original institution and dataset | -| --- | --- | -| AMCM | [Autoridade Monetária de Macau](https://www.amcm.gov.mo/en/financial-information/interbank-midpoint) | -| BAM | [Bank Al-Maghrib](https://apihelpdesk.centralbankofmorocco.ma/apis) | -| BANREP | [Banco de la República](https://www.datos.gov.co/Econom-a-y-Finanzas/Tasa-de-Cambio-Representativa-del-Mercado-TRM/32sa-8pi3/about_data) | -| BCB | [Banco Central do Brasil](https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/) | -| BCBO | [Banco Central de Bolivia](https://www.bcb.gob.bo/?q=cotizaciones_tc) | -| BCC | [Banco Central de Cuba](https://api.bc.gob.cu/v1/tasas-de-cambio/activas) | -| BCCH | [Banco Central de Chile](https://si3.bcentral.cl/Siete/es/Siete/API) | -| BCP | [Banco Central del Paraguay](https://www.bcp.gov.py/webapps/web/cotizacion/monedas) | -| BCRA | [Banco Central de la República Argentina](https://www.bcra.gob.ar/en/monetary-and-financial-statistics/exchange-statistics/) | -| BCT | [Banque Centrale de Tunisie](https://www.bct.gov.tn/bct/siteprod/cours_archive.jsp) | -| BDI | [Banca d'Italia](https://tassidicambio.bancaditalia.it/) | -| BDP | [Banco de Portugal](https://bpstat.bportugal.pt/) | -| BI | [Bank Indonesia](https://www.bi.go.id/en/statistik/informasi-kurs/transaksi-bi/default.aspx) | -| BIS | [Bank for International Settlements](https://data.bis.org/topics/XRU) | -| BNA | [Banco Nacional de Angola](https://www.bna.ao/Conteudos/Mercados/listaTaxasCambioReferencia.aspx) | -| BNM | [Bank Negara Malaysia](https://www.bnm.gov.my/exchange-rates) | -| BNR | [Banca Națională a României](https://www.bnr.ro/en/561-exchange-rates) | -| BOA | [Bank of Algeria](https://www.bank-of-algeria.dz/donnees-historiques/) | -| BOC | [Bank of Canada](https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/) | -| BOJA | [Bank of Jamaica](https://boj.org.jm/market/foreign-exchange/counter-rates/) | -| BOM | [Bank of Mongolia](https://www.mongolbank.mn/en/currency-rate-movement) | -| BOT | [Bank of Thailand](https://www.bot.or.th/en/statistics/exchange-rate.html) | -| BOTA | [Bank of Tanzania](https://www.bot.go.tz/exchangerate/excrates) | -| BOZ | [Bank of Zambia](https://www.boz.zm/average-exchange-rates) | -| BRB | [Banque de la Republique du Burundi](https://www.brb.bi/en/affichagetoustauxchange) | -| CBA | [Central Bank of Armenia](https://www.cba.am/en/SitePage/ExchangeArchive.aspx) | -| CBAR | [Central Bank of the Republic of Azerbaijan](https://www.cbar.az/currency/rates) | -| CBC | [Central Bank of the Republic of China (Taiwan)](https://www.cbc.gov.tw/en/lp-4237-2.html) | -| CBG | [Central Bank of The Gambia](https://www.cbg.gm/indicative-exchange-rates-latest) | -| CBI | [Central Bank of Iraq](https://cbi.iq/page/144) | -| CBK | [Central Bank of Kenya](https://www.centralbank.go.ke/forex/) | -| CBLLR | [Central Bank of Liberia](https://www.cbl.org.lr/research/buying-selling-rates) | -| CBM | [Central Bank of Myanmar](https://forex.cbm.gov.mm/api/latest) | -| CBS | [Central Bank of Samoa](https://cbs.gov.ws/daily-exchange-rates) | -| CBSL | [Central Bank of Sri Lanka](https://www.cbsl.gov.lk/en/rates-and-indicators/exchange-rates/daily-indicative-exchange-rates) | -| CBU | [Central Bank of Uzbekistan](https://cbu.uz/en/arkhiv-kursov-valyut/json/) | -| DAB | [Da Afghanistan Bank](https://www.dab.gov.af/exchange-rates) | -| DNB | [Danmarks Nationalbank](https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates) | -| ECB | [European Central Bank](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html) | -| HMRC | [HM Revenue & Customs](https://www.trade-tariff.service.gov.uk/exchange_rates/monthly) | -| HNB | [Hrvatska Narodna Banka](https://api.hnb.hr/) | -| INFOREURO | [European Commission](https://commission.europa.eu/funding-and-tenders/procedures-guidelines-tenders/information-contractors-and-beneficiaries/exchange-rate-inforeuro_en) | -| JPC | [Japan Customs](https://www.customs.go.jp/tetsuzuki/kawase/) | -| LB | [Lietuvos Bankas](https://www.lb.lt/webservices/FxRates/FxRates.asmx) | -| MAS | [Monetary Authority of Singapore](https://eservices.mas.gov.sg/statistics/msb/ExchangeRates.aspx) | -| MMA | [Maldives Monetary Authority](https://www.mma.gov.mv/JSON/referencerates.json) | -| MNB | [Magyar Nemzeti Bank](https://www.mnb.hu/en/monetary-policy/the-mpc/exchange-rates) | -| NBC | [National Bank of Cambodia](https://www.nbc.gov.kh/english/economic_research/exchange_rate.php) | -| NBE | [National Bank of Ethiopia](https://nbe.gov.et/exchange-rate/) | -| NBG | [National Bank of Georgia](https://nbg.gov.ge/en/monetary-policy/currency) | -| NBK | [National Bank of Kazakhstan](https://nationalbank.kz/en/exchangerates/ezhednevnye-oficialnye-rynochnye-kursy-valyut) | -| NBKR | [National Bank of the Kyrgyz Republic](https://www.nbkr.kg/index1.jsp?item=1562&lang=ENG) | -| NBM | [National Bank of Moldova](https://www.bnm.md/en/official_exchange_rates) | -| NBP | [Narodowy Bank Polski](https://nbp.pl/en/statistic-and-financial-reporting/rates/) | -| NBRB | [Natsyyanalny Bank Respubliki Belarus](https://www.nbrb.by/engl/statistics/rates/ratesdaily.asp) | -| NBRM | [Narodna Banka na Republika Severna Makedonija](https://www.nbrm.mk) | -| NBT | [National Bank of Tajikistan](https://www.nbt.tj/en/kurs/) | -| NRB | [Nepal Rastra Bank](https://www.nrb.org.np/api/forex/v1/rates) | -| NRBT | [National Reserve Bank of Tonga](https://www.reservebank.to/index.php/financial-system/financial-markets/exchange-rates) | -| RB | [Sveriges Riksbank](https://www.riksbank.se/en-gb/statistics/) | -| RBF | [Reserve Bank of Fiji](https://www.rbf.gov.fj/statistics/economic-and-financial-statistics/) | -| RBM | [Reserve Bank of Malawi](https://www.rbm.mw/Statistics/MajorRates) | -| RBV | [Reserve Bank of Vanuatu](https://www.rbv.gov.vu/index.php/en/exchange-rates) | -| SARB | [South African Reserve Bank](https://custom.resbank.co.za/SarbWebApi/WebIndicators/HistoricalExchangeRatesDaily) | -| SBI | [Seðlabanki Íslands](https://www.sedlabanki.is/hagtolur/opinbert-gengi/) | -| UST | [U.S. Department of the Treasury](https://fiscaldata.treasury.gov/datasets/treasury-reporting-rates-exchange/treasury-reporting-rates-of-exchange) | +[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. diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/Resources/DataSources.md b/Domain/ExchangeRates/Sources/ExchangeRates/Resources/DataSources.md index 89a2865..40f3ea1 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/Resources/DataSources.md +++ b/Domain/ExchangeRates/Sources/ExchangeRates/Resources/DataSources.md @@ -16,92 +16,6 @@ The source archives are publicly downloadable at the link above. Bundled data fi Daily rates, fallback history and bundled monthly references: [Fawaz Ahmed’s Exchange API](https://github.com/fawazahmed0/exchange-api), distributed through its documented CDN and Cloudflare endpoints. Its repository declares [CC0 1.0 Universal](https://github.com/fawazahmed0/exchange-api/blob/main/LICENSE) ([full text](https://github.com/DimaMishchenko/currency/blob/main/LICENSES/Fawaz-CC0-1.0.txt)). The bundled completed-month history samples March 2, 2024 (the archive boundary), then the first day of each completed month from April 2024. The incomplete current month is omitted. Later missing completed months may be fetched as month-end references. Currency selects dates/assets and calculates cross-rates and reference conversions. CC0 applies only to rights the affirmer can waive or license; it does not warrant third-party source rights or grant trademark rights. -## Frankfurter and original fiat sources +## Frankfurter -[Frankfurter](https://frankfurter.dev/) aggregates institutional reference rates. Its [provider and licensing inventory](https://frankfurter.dev/license/) identifies the original sources; its software licence does not replace their data terms. Currency uses an explicit selection of provider IDs. A displayed calculation or blend is produced by Currency, rather than an official quote from any contributing institution. Dates refer to the source observations; monthly samples are reference observations, not market closes. Bundled metadata records the selected source IDs. The pre-March-2024 fiat archive requests USD-based monthly reference buckets from July 2010 through February 2024; actual coverage differs by currency. GGP, IMP and JEP use an explicit GBP-parity assumption. These are Currency’s reference calculations. - -The following notices accompany data from these sources, where present: - -- **Banco Central de la República Argentina (BCRA)**: source [BCRA](https://www.bcra.gob.ar/); [informational reuse terms](https://www.bcra.gob.ar/aviso-legal/). Currency’s noncommercial estimates preserve the source context; conversions, selection and formatting are Currency’s work. -- **Bank for International Settlements (BIS)**: underlying statistics sourced from [BIS Data Portal](https://data.bis.org/), under its [statistical-data terms](https://data.bis.org/help/legal). Currency’s calculations are identified as such; no additional charge is made for the included statistics. -- **Bank of Canada**: underlying data sourced from [Bank of Canada](https://www.bankofcanada.ca/), under its [terms](https://www.bankofcanada.ca/terms/). Currency selects/reformats reference observations and calculates cross-rates; the Bank of Canada does not endorse these changes. -- **Bank of Jamaica**: source [Bank of Jamaica](https://boj.org.jm/), used for noncommercial general reference with source acknowledgment under its [notice](https://boj.org.jm/disclaimer/). -- **European Central Bank (ECB)**: underlying reference rates sourced from [ECB](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html), under its [copyright and reuse terms](https://www.ecb.europa.eu/services/using-our-site/disclaimer/html/index.en.html). Selection, reformatting, cross-rates and converted prices are modifications by Currency. -- **HM Revenue & Customs**: [monthly customs exchange rates](https://www.trade-tariff.service.gov.uk/exchange_rates/monthly). Contains public sector information licensed under the [Open Government Licence v3.0](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/). Currency’s calculated reference conversions are not HMRC customs assessments. -- **European Commission / InforEuro**: underlying data from [InforEuro](https://commission.europa.eu/funding-tenders/procedures-guidelines-tenders/information-contractors-and-beneficiaries/exchange-rate-inforeuro_en), © European Union, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) ([full text](https://github.com/DimaMishchenko/currency/blob/main/LICENSES/CC-BY-4.0.txt)) as described in the [Commission legal notice](https://commission.europa.eu/legal-notice_en), except separately identified third-party material. Currency selects/reformats observations and calculates reference cross-rates. Retain the source/licence/warranty notices and identify changes; no additional legal or effective technological restriction may obstruct the licensed rights. See the licence for its warranty disclaimer. -- **Japan Customs**: underlying [customs exchange rates](https://www.customs.go.jp/english/kawase/index_e.htm); [copyright notice](https://www.customs.go.jp/copyright_e.htm) and [Public Data Licence 1.0](https://www.digital.go.jp/en/resources/open_data/public_data_license_v1.0). Numerical data are freely usable as described there. Selection, formatting and calculated estimates are by Currency / Dmytro Mishchenko; they are not unedited government publications. -- **Sveriges riksbank**: original statistics and their observation dates are identified through the source metadata and [original series](https://www.riksbank.se/en-gb/statistics/search-interest--exchange-rates/). See [open-data reuse terms](https://www.riksbank.se/sv/om-riksbanken/om-webbplatsen/oppna-data--information-tillganglig-for-vidareutnyttjande/). Processed/blended statistics and cross-rates are Currency’s calculations; Sveriges riksbank is credited only for the underlying original statistics, not as the source of those processed results. -- **U.S. Department of the Treasury**: underlying [Treasury reporting rates](https://fiscaldata.treasury.gov/datasets/treasury-reporting-rates-exchange/treasury-reporting-rates-of-exchange), available for copying, adaptation and redistribution under its [data authorization](https://fiscaldata.treasury.gov/api-documentation/#license-and-authorization). Currency’s calculations do not imply Treasury endorsement. - -### Source directory - -The selected source directory below links each original dataset. Source-specific notices and third-party exclusions continue to apply. Inclusion here is attribution and provenance, not a claim that every source uses the same licence. No linked licence in an aggregator’s inventory is not a public-domain dedication. - -| ID | Original institution and dataset | -| --- | --- | -| AMCM | [Autoridade Monetária de Macau](https://www.amcm.gov.mo/en/financial-information/interbank-midpoint) | -| BAM | [Bank Al-Maghrib](https://apihelpdesk.centralbankofmorocco.ma/apis) | -| BANREP | [Banco de la República](https://www.datos.gov.co/Econom-a-y-Finanzas/Tasa-de-Cambio-Representativa-del-Mercado-TRM/32sa-8pi3/about_data) | -| BCB | [Banco Central do Brasil](https://olinda.bcb.gov.br/olinda/servico/PTAX/versao/v1/odata/) | -| BCBO | [Banco Central de Bolivia](https://www.bcb.gob.bo/?q=cotizaciones_tc) | -| BCC | [Banco Central de Cuba](https://api.bc.gob.cu/v1/tasas-de-cambio/activas) | -| BCCH | [Banco Central de Chile](https://si3.bcentral.cl/Siete/es/Siete/API) | -| BCP | [Banco Central del Paraguay](https://www.bcp.gov.py/webapps/web/cotizacion/monedas) | -| BCRA | [Banco Central de la República Argentina](https://www.bcra.gob.ar/en/monetary-and-financial-statistics/exchange-statistics/) | -| BCT | [Banque Centrale de Tunisie](https://www.bct.gov.tn/bct/siteprod/cours_archive.jsp) | -| BDI | [Banca d'Italia](https://tassidicambio.bancaditalia.it/) | -| BDP | [Banco de Portugal](https://bpstat.bportugal.pt/) | -| BI | [Bank Indonesia](https://www.bi.go.id/en/statistik/informasi-kurs/transaksi-bi/default.aspx) | -| BIS | [Bank for International Settlements](https://data.bis.org/topics/XRU) | -| BNA | [Banco Nacional de Angola](https://www.bna.ao/Conteudos/Mercados/listaTaxasCambioReferencia.aspx) | -| BNM | [Bank Negara Malaysia](https://www.bnm.gov.my/exchange-rates) | -| BNR | [Banca Națională a României](https://www.bnr.ro/en/561-exchange-rates) | -| BOA | [Bank of Algeria](https://www.bank-of-algeria.dz/donnees-historiques/) | -| BOC | [Bank of Canada](https://www.bankofcanada.ca/rates/exchange/daily-exchange-rates/) | -| BOJA | [Bank of Jamaica](https://boj.org.jm/market/foreign-exchange/counter-rates/) | -| BOM | [Bank of Mongolia](https://www.mongolbank.mn/en/currency-rate-movement) | -| BOT | [Bank of Thailand](https://www.bot.or.th/en/statistics/exchange-rate.html) | -| BOTA | [Bank of Tanzania](https://www.bot.go.tz/exchangerate/excrates) | -| BOZ | [Bank of Zambia](https://www.boz.zm/average-exchange-rates) | -| BRB | [Banque de la Republique du Burundi](https://www.brb.bi/en/affichagetoustauxchange) | -| CBA | [Central Bank of Armenia](https://www.cba.am/en/SitePage/ExchangeArchive.aspx) | -| CBAR | [Central Bank of the Republic of Azerbaijan](https://www.cbar.az/currency/rates) | -| CBC | [Central Bank of the Republic of China (Taiwan)](https://www.cbc.gov.tw/en/lp-4237-2.html) | -| CBG | [Central Bank of The Gambia](https://www.cbg.gm/indicative-exchange-rates-latest) | -| CBI | [Central Bank of Iraq](https://cbi.iq/page/144) | -| CBK | [Central Bank of Kenya](https://www.centralbank.go.ke/forex/) | -| CBLLR | [Central Bank of Liberia](https://www.cbl.org.lr/research/buying-selling-rates) | -| CBM | [Central Bank of Myanmar](https://forex.cbm.gov.mm/api/latest) | -| CBS | [Central Bank of Samoa](https://cbs.gov.ws/daily-exchange-rates) | -| CBSL | [Central Bank of Sri Lanka](https://www.cbsl.gov.lk/en/rates-and-indicators/exchange-rates/daily-indicative-exchange-rates) | -| CBU | [Central Bank of Uzbekistan](https://cbu.uz/en/arkhiv-kursov-valyut/json/) | -| DAB | [Da Afghanistan Bank](https://www.dab.gov.af/exchange-rates) | -| DNB | [Danmarks Nationalbank](https://www.nationalbanken.dk/en/what-we-do/stable-prices-monetary-policy-and-the-danish-economy/exchange-rates) | -| ECB | [European Central Bank](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html) | -| HMRC | [HM Revenue & Customs](https://www.trade-tariff.service.gov.uk/exchange_rates/monthly) | -| HNB | [Hrvatska Narodna Banka](https://api.hnb.hr/) | -| INFOREURO | [European Commission](https://commission.europa.eu/funding-and-tenders/procedures-guidelines-tenders/information-contractors-and-beneficiaries/exchange-rate-inforeuro_en) | -| JPC | [Japan Customs](https://www.customs.go.jp/tetsuzuki/kawase/) | -| LB | [Lietuvos Bankas](https://www.lb.lt/webservices/FxRates/FxRates.asmx) | -| MAS | [Monetary Authority of Singapore](https://eservices.mas.gov.sg/statistics/msb/ExchangeRates.aspx) | -| MMA | [Maldives Monetary Authority](https://www.mma.gov.mv/JSON/referencerates.json) | -| MNB | [Magyar Nemzeti Bank](https://www.mnb.hu/en/monetary-policy/the-mpc/exchange-rates) | -| NBC | [National Bank of Cambodia](https://www.nbc.gov.kh/english/economic_research/exchange_rate.php) | -| NBE | [National Bank of Ethiopia](https://nbe.gov.et/exchange-rate/) | -| NBG | [National Bank of Georgia](https://nbg.gov.ge/en/monetary-policy/currency) | -| NBK | [National Bank of Kazakhstan](https://nationalbank.kz/en/exchangerates/ezhednevnye-oficialnye-rynochnye-kursy-valyut) | -| NBKR | [National Bank of the Kyrgyz Republic](https://www.nbkr.kg/index1.jsp?item=1562&lang=ENG) | -| NBM | [National Bank of Moldova](https://www.bnm.md/en/official_exchange_rates) | -| NBP | [Narodowy Bank Polski](https://nbp.pl/en/statistic-and-financial-reporting/rates/) | -| NBRB | [Natsyyanalny Bank Respubliki Belarus](https://www.nbrb.by/engl/statistics/rates/ratesdaily.asp) | -| NBRM | [Narodna Banka na Republika Severna Makedonija](https://www.nbrm.mk) | -| NBT | [National Bank of Tajikistan](https://www.nbt.tj/en/kurs/) | -| NRB | [Nepal Rastra Bank](https://www.nrb.org.np/api/forex/v1/rates) | -| NRBT | [National Reserve Bank of Tonga](https://www.reservebank.to/index.php/financial-system/financial-markets/exchange-rates) | -| RB | [Sveriges Riksbank](https://www.riksbank.se/en-gb/statistics/) | -| RBF | [Reserve Bank of Fiji](https://www.rbf.gov.fj/statistics/economic-and-financial-statistics/) | -| RBM | [Reserve Bank of Malawi](https://www.rbm.mw/Statistics/MajorRates) | -| RBV | [Reserve Bank of Vanuatu](https://www.rbv.gov.vu/index.php/en/exchange-rates) | -| SARB | [South African Reserve Bank](https://custom.resbank.co.za/SarbWebApi/WebIndicators/HistoricalExchangeRatesDaily) | -| SBI | [Seðlabanki Íslands](https://www.sedlabanki.is/hagtolur/opinbert-gengi/) | -| UST | [U.S. Department of the Treasury](https://fiscaldata.treasury.gov/datasets/treasury-reporting-rates-exchange/treasury-reporting-rates-of-exchange) | +[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. From a42576828cb65731942dffadae25e3f1b73cdd89 Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Thu, 15 Oct 2026 15:57:09 +0200 Subject: [PATCH 16/17] =?UTF-8?q?=F0=9F=94=A7=20history:=20run=20archive?= =?UTF-8?q?=20refresh=20only=20on=20schedule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * remove pull request and manual workflow triggers * simplify scheduled job guards and update maintenance documentation --- .github/workflows/history-refresh.yml | 19 +++++-------------- Scripts/History/README.md | 4 +--- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/.github/workflows/history-refresh.yml b/.github/workflows/history-refresh.yml index c0cc6a3..4ad013b 100644 --- a/.github/workflows/history-refresh.yml +++ b/.github/workflows/history-refresh.yml @@ -1,27 +1,19 @@ name: Refresh bundled history on: - pull_request: - paths: - - 'Scripts/History/**' - - '.github/workflows/history-refresh.yml' schedule: - cron: '0 6 1 * *' - workflow_dispatch: permissions: contents: read concurrency: - group: currency-history-refresh-${{ github.event.pull_request.number || 'main' }} + group: currency-history-refresh cancel-in-progress: false jobs: generate: - if: >- - github.event_name == 'pull_request' || - (github.ref == 'refs/heads/main' && - (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')) + if: github.ref == 'refs/heads/main' && github.event_name == 'schedule' runs-on: ubuntu-latest timeout-minutes: 30 outputs: @@ -30,7 +22,7 @@ jobs: steps: - uses: actions/checkout@v7 with: - ref: ${{ github.event_name == 'pull_request' && github.sha || 'main' }} + ref: main persist-credentials: false - name: Test archive generation run: python3 -m unittest discover -s Scripts/History -p 'test_*.py' @@ -40,7 +32,7 @@ jobs: echo "base_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" python3 Scripts/History/generate_archive.py - uses: actions/upload-artifact@v7 - if: github.event_name != 'pull_request' && steps.generate.outputs.changed == 'true' + if: steps.generate.outputs.changed == 'true' with: name: currency-history-refresh path: Domain/ExchangeRates/Sources/ExchangeRates/Resources/history-monthly-v1.json @@ -50,8 +42,7 @@ jobs: needs: generate if: >- github.ref == 'refs/heads/main' && - github.event_name != 'pull_request' && - (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && + github.event_name == 'schedule' && needs.generate.outputs.changed == 'true' runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/Scripts/History/README.md b/Scripts/History/README.md index 63cfa79..d3d9568 100644 --- a/Scripts/History/README.md +++ b/Scripts/History/README.md @@ -2,9 +2,7 @@ Run `python3 Scripts/History/generate_archive.py` from any directory to refresh Run `python3 -m unittest discover -s Scripts/History -p 'test_*.py'` for generator checks. All sources must succeed and validate before an atomic output replacement. Loss of any previously bundled observation date fails for manual review, even if newer observations would keep the row count unchanged. Acquisition dates and input hashes alone never rewrite the archive; selected observations, provider selection, sampling, source links and licence changes do. Full licences and notices remain separate resources beside the JSON. Frankfurter uses the documented ordinary curl client with a 120-second limit, without overridden user agents, spoofed headers, proxies, cookies or authentication. Coin Metrics and Fawaz use Python HTTPS. An upstream HTTP denial or malformed response stops the refresh without falling back to unapproved FX sources. -`Refresh bundled history` runs at 06:00 UTC on the first day of each month and supports manual dispatch on `main`, after this workflow is merged. It reuses an open bot-authored PR under `chore/bundled-history-refresh-*`, skipping commits when its actual head already contains the same semantic content. With no open managed PR, it creates a fresh run-specific branch from current `main`; retained closed or squash-merged branches are ignored. It never pushes to `main`, force-pushes, approves or merges a PR. An unexpected change on the managed branch stops publication for manual review. Upstream failures leave the existing archive and update branch unchanged. If `main` advances during generation, rerun the refresh against the new base. - -Pull requests changing `Scripts/History/**` or this workflow run the same full-source generation on GitHub's Ubuntu runner using the PR merge SHA. This dry run has read-only repository permissions, no persisted checkout credentials or private secrets, no candidate artifact upload, and no publishing job. It uses a separate concurrency group from scheduled refreshes. Upstream denials still fail the check and leave publication disabled; a successful dry run validates live generation in the intended runner environment before merging. +`Refresh bundled history` runs at 06:00 UTC on the first day of each month on `main`, after this workflow is merged. It does not run on pull requests, pushes or manual dispatch. It reuses an open bot-authored PR under `chore/bundled-history-refresh-*`, skipping commits when its actual head already contains the same semantic content. With no open managed PR, it creates a fresh run-specific branch from current `main`; retained closed or squash-merged branches are ignored. It never pushes to `main`, force-pushes, approves or merges a PR. An unexpected change on the managed branch stops publication for manual review. Upstream failures leave the existing archive and update branch unchanged. If `main` advances during generation, rerun the refresh against the new base. Repository Actions settings must permit GitHub Actions to create pull requests (`Allow GitHub Actions to create and approve pull requests`); the workflow uses that setting only for creation. It needs no PAT or other private credential. Only the publishing job receives `contents: write` and `pull-requests: write`. From 0abf227c22a53e4611ebd4cc4d9ca7a62154070e Mon Sep 17 00:00:00 2001 From: Dmytro Mishchenko Date: Thu, 15 Oct 2026 16:08:00 +0200 Subject: [PATCH 17/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rates:=20remove=20ob?= =?UTF-8?q?solete=20provider=20modes=20and=20cache=20migrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * use Coin Metrics and Fawaz without a provider-mode flag * remove Coinbase decoding, obsolete observation labels and duplicate fixtures * preserve fallback, FX normalization and current cache validation --- .../Resources/WatchWidgets.xcstrings | 267 ----------- .../Sources/WatchWidgetActions.swift | 2 +- .../Sources/WatchWidgetConfiguration.swift | 3 +- .../Sources/WatchWidgetTimeline.swift | 11 +- .../Sources/WatchWidgetViews.swift | 3 - .../Composition/WidgetComposition.swift | 9 +- .../Configuration/HistorySettings.swift | 3 +- .../HistoryWidgetAdapterTests.swift | 5 +- .../Sources/Composition/AppComposition.swift | 16 +- .../Composition/CurrencyE2EStartup.swift | 46 +- .../Composition/SystemActionComposition.swift | 8 +- .../Sources/CurrencyRateConfiguration.swift | 10 +- .../Tests/ConversionActionTests.swift | 8 +- .../LocalizationTests.swift | 13 +- ...s.swift => ProviderIntegrationTests.swift} | 15 +- Apps/Currency/Tests/E2E/README.md | 6 +- Apps/Currency/Tests/E2E/e2e.config.ts | 2 +- .../E2E/tests/converted-crypto-history.e2e.ts | 2 +- .../Tests/E2E/tests/history-archive.e2e.ts | 2 +- .../Tests/E2E/tests/live-app-provider.e2e.ts | 4 +- .../E2E/tests/widget-live-provider.e2e.ts | 2 +- .../App/Sources/WatchComposition.swift | 12 +- Apps/CurrencyWatch/README.md | 4 +- Documentation/Development.md | 4 +- Documentation/FeatureMap.md | 4 +- .../ExternalConversionTests.swift | 2 +- .../ExchangeRates.docc/ExchangeRates.md | 1 - .../ExchangeRates/HistoryService.swift | 21 +- .../Sources/ExchangeRates/README.md | 6 +- .../ExchangeRates/RateProviderPolicy.swift | 52 -- .../Sources/ExchangeRates/RateService.swift | 14 +- .../Sources/ExchangeRates/RateSnapshot.swift | 18 +- .../Sources/ExchangeRates/RateSource.swift | 92 +--- .../Sources/ExchangeRates/RateStore.swift | 19 +- .../ExchangeRatesUI/CurrencyDisplay.swift | 1 - .../ExchangeRatesUI/RateMessages.swift | 11 +- .../Resources/Support.xcstrings | 448 ------------------ .../BatchRefreshTests.swift | 6 +- .../CoinMetricsNormalizationTests.swift | 13 +- .../ExchangeRatesTests/CoinMetricsTests.swift | 15 - .../DefaultProvidersTests.swift | 104 ++++ .../HistoryCrossRateTests.swift | 6 +- .../HistoryFailureTests.swift | 41 +- .../HistoryIntervalTests.swift | 6 +- .../ExchangeRatesTests/HistoryTests.swift | 3 +- .../RateProviderPolicyTests.swift | 167 ------- .../ExchangeRatesTests/RateSourceTests.swift | 38 +- .../ExchangeRatesUITests/ResourceTests.swift | 31 +- .../CryptoHistoryWidgetHarness.swift | 2 +- .../Widgets/HistoryWidgetSnapshot.swift | 8 +- .../WidgetsTests/HistoryWidgetTests.swift | 6 +- .../CurrencyDetailsHarnessApp.swift | 3 +- .../CurrencyDetailsTests.swift | 2 +- 53 files changed, 268 insertions(+), 1329 deletions(-) rename Apps/Currency/Tests/ApplicationIntegrationTests/{ProviderPolicyIntegrationTests.swift => ProviderIntegrationTests.swift} (82%) delete mode 100644 Domain/ExchangeRates/Sources/ExchangeRates/RateProviderPolicy.swift create mode 100644 Domain/ExchangeRates/Tests/ExchangeRatesTests/DefaultProvidersTests.swift delete mode 100644 Domain/ExchangeRates/Tests/ExchangeRatesTests/RateProviderPolicyTests.swift diff --git a/AppExtensions/CurrencyWatchWidgets/Resources/WatchWidgets.xcstrings b/AppExtensions/CurrencyWatchWidgets/Resources/WatchWidgets.xcstrings index a2a8608..90fb207 100644 --- a/AppExtensions/CurrencyWatchWidgets/Resources/WatchWidgets.xcstrings +++ b/AppExtensions/CurrencyWatchWidgets/Resources/WatchWidgets.xcstrings @@ -2938,95 +2938,6 @@ } } }, - "hourlyCloses": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Completed hourly closes · UTC" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已完成的小时收盘价 · UTC" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "確定した時間足終値 · UTC" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Cierres horarios completados · UTC" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Abgeschlossene Stundenkurse · UTC" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Clôtures horaires terminées · UTC" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Fechamentos horários concluídos · UTC" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "완료된 시간별 종가 · UTC" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已完成的小時收盤價 · UTC" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Chiusure orarie completate · UTC" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Tamamlanan saatlik kapanışlar · UTC" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Завершённые часовые закрытия · UTC" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Завершені годинні закриття · UTC" - } - }, - "et": { - "stringUnit": { - "state": "translated", - "value": "Lõppenud tundide sulgemishinnad · UTC" - } - } - } - }, "referenceHistory": { "extractionState": "manual", "localizations": { @@ -4807,184 +4718,6 @@ } } }, - "dailyCloses": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Daily closes · UTC" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "每日收盘价 · UTC" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "日足終値 · UTC" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Cierres diarios · UTC" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Tagesschlusskurse · UTC" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Clôtures quotidiennes · UTC" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Fechamentos diários · UTC" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "일별 종가 · UTC" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "每日收盤價 · UTC" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Chiusure giornaliere · UTC" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Günlük kapanışlar · UTC" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Дневные закрытия · UTC" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Денні закриття · UTC" - } - }, - "et": { - "stringUnit": { - "state": "translated", - "value": "Päeva sulgemishinnad · UTC" - } - } - } - }, - "monthlyCloses": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Monthly closes · UTC" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "每月收盘价 · UTC" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "月足終値 · UTC" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Cierres mensuales · UTC" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Monatsschlusskurse · UTC" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Clôtures mensuelles · UTC" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Fechamentos mensais · UTC" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "월별 종가 · UTC" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "每月收盤價 · UTC" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Chiusure mensili · UTC" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Aylık kapanışlar · UTC" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Месячные закрытия · UTC" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Місячні закриття · UTC" - } - }, - "et": { - "stringUnit": { - "state": "translated", - "value": "Kuu sulgemishinnad · UTC" - } - } - } - }, "monthlyReference": { "extractionState": "manual", "localizations": { diff --git a/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetActions.swift b/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetActions.swift index ef74dbf..584d5fa 100644 --- a/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetActions.swift +++ b/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetActions.swift @@ -69,7 +69,7 @@ struct WatchWidgetSwapIntent: AppIntent { func perform() async throws -> some IntentResult { guard codes.count >= 2 else { return .result() } let directory = WatchWidgetAppGroup.directory() - let rates = RateStore(directory: directory, policy: CurrencyRateConfiguration.policy) + let rates = RateStore(directory: directory) .loadRates() try WidgetStore(directory: directory) .updateWidgetInput( diff --git a/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetConfiguration.swift b/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetConfiguration.swift index ec6a0b3..1884591 100644 --- a/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetConfiguration.swift +++ b/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetConfiguration.swift @@ -203,8 +203,7 @@ struct WatchHistoryRangeOptions: DynamicOptionsProvider { func results() async throws -> [WatchHistoryRange] { let pair = HistoryWidgetPair( - app: WatchWidgetAppGroup.input(), base: settings?.source.id, quote: settings?.quote.id, - policy: CurrencyRateConfiguration.policy) + app: WatchWidgetAppGroup.input(), base: settings?.source.id, quote: settings?.quote.id) return pair.supportsIntradayHistory ? WatchHistoryRange.allCases : WatchHistoryRange.allCases.filter { $0 != .day } diff --git a/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetTimeline.swift b/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetTimeline.swift index 1e4d3cd..bbc0307 100644 --- a/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetTimeline.swift +++ b/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetTimeline.swift @@ -110,7 +110,7 @@ enum WatchWidgetComposition { return WatchWidgetEntry( date: now, style: style, source: base, targets: selected, input: input, key: key, initialAmount: initial, - snapshot: RateStore(directory: directory, policy: CurrencyRateConfiguration.policy) + snapshot: RateStore(directory: directory) .loadRates(), configurationIsValid: WidgetMath.parseAmount(amount) != nil && CurrencyCatalog.codes.contains(base) @@ -153,10 +153,10 @@ enum WatchWidgetComposition { var refreshed = entry do { let result = try await RateStore( - directory: directory(), policy: CurrencyRateConfiguration.policy + directory: directory() ) .refreshRates( - using: RateService(policy: CurrencyRateConfiguration.policy), + using: RateService(), force: entry.snapshot.quotes.isEmpty, providerTimeout: .seconds(2)) refreshed = WatchWidgetEntry( date: .now, style: entry.style, source: entry.source, targets: entry.targets, @@ -274,13 +274,12 @@ struct WatchHistoryTimeline: AppIntentTimelineProvider { let now = Date() let pair = HistoryWidgetPair( app: WatchWidgetComposition.input(), base: configuration.source?.id, - quote: configuration.quote?.id, policy: CurrencyRateConfiguration.policy) + quote: configuration.quote?.id) let range = pair.normalizedRange(configuration.range.value) let result: HistoryResult if let request = pair.historyRequest { result = await HistoryService( - directory: WatchWidgetComposition.directory(), client: client, - policy: CurrencyRateConfiguration.policy + directory: WatchWidgetComposition.directory(), client: client ) .load( base: request.base, quote: request.quote, range: range, now: now, diff --git a/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetViews.swift b/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetViews.swift index b25d8e3..fa029fc 100644 --- a/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetViews.swift +++ b/AppExtensions/CurrencyWatchWidgets/Sources/WatchWidgetViews.swift @@ -448,9 +448,6 @@ struct WatchHistoryView: View { private var provenance: LocalizedStringResource { if snapshot.issue == .usingCachedSeries { return .WatchWidgets.savedHistory } switch snapshot.series?.source.observation { - case .hourlyClose: return .WatchWidgets.hourlyCloses - case .dailyClose: return .WatchWidgets.dailyCloses - case .monthlyLastClose: return .WatchWidgets.monthlyCloses case .monthlyReference: return .WatchWidgets.monthlyReference default: return .WatchWidgets.referenceHistory } diff --git a/AppExtensions/CurrencyWidgets/Sources/Composition/WidgetComposition.swift b/AppExtensions/CurrencyWidgets/Sources/Composition/WidgetComposition.swift index aba1901..1111631 100644 --- a/AppExtensions/CurrencyWidgets/Sources/Composition/WidgetComposition.swift +++ b/AppExtensions/CurrencyWidgets/Sources/Composition/WidgetComposition.swift @@ -35,7 +35,7 @@ enum WidgetComposition { static func timeline() -> WidgetTimelineDependencies { let directory = directory() let conversion = ConversionStore(directory: directory) - let rates = RateStore(directory: directory, policy: CurrencyRateConfiguration.policy) + let rates = RateStore(directory: directory) let local = LocalCurrencyStore(directory: directory) let widgets = WidgetStore(directory: directory) return WidgetTimelineDependencies( @@ -46,7 +46,7 @@ enum WidgetComposition { }, refreshRates: { force in try await rates.refreshRates( - using: RateService(policy: CurrencyRateConfiguration.policy), force: force, + using: RateService(), force: force, providerTimeout: .seconds(2)) }, refreshLocalCurrency: { @@ -62,8 +62,7 @@ enum WidgetComposition { let conversion = ConversionStore(directory: directory) let local = LocalCurrencyStore(directory: directory) let history = HistoryService( - directory: directory, client: NetworkClient(timeout: 10), - policy: CurrencyRateConfiguration.policy) + directory: directory, client: NetworkClient(timeout: 10)) return HistoryTimelineDependencies( input: { conversion.input() }, load: { base, quote, range, now in @@ -76,7 +75,7 @@ enum WidgetComposition { static func action() -> WidgetActionDependencies { let directory = directory() - let rates = RateStore(directory: directory, policy: CurrencyRateConfiguration.policy) + let rates = RateStore(directory: directory) let widgets = WidgetStore(directory: directory) return WidgetActionDependencies( rates: { rates.loadRates() }, diff --git a/AppExtensions/CurrencyWidgets/Sources/Configuration/HistorySettings.swift b/AppExtensions/CurrencyWidgets/Sources/Configuration/HistorySettings.swift index ff4db43..12d3e00 100644 --- a/AppExtensions/CurrencyWidgets/Sources/Configuration/HistorySettings.swift +++ b/AppExtensions/CurrencyWidgets/Sources/Configuration/HistorySettings.swift @@ -215,7 +215,6 @@ struct HistorySettings: WidgetConfigurationIntent { } } return HistoryWidgetPair( - app: input, base: resolve(base), quote: resolve(comparison), - policy: CurrencyRateConfiguration.policy) + app: input, base: resolve(base), quote: resolve(comparison)) } } diff --git a/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift b/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift index c464abd..8a1a7a8 100644 --- a/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift +++ b/AppExtensions/CurrencyWidgets/Tests/WidgetIntegrationTests/HistoryWidgetAdapterTests.swift @@ -28,7 +28,6 @@ import Widgets #expect(requests.withLock { $0 } == [.day]) #expect(settings.range == .day) #expect(settings.availableRanges(input: ConverterState()).contains(.day)) - #expect(timeline.entries.first?.snapshot.pair.policy == CurrencyRateConfiguration.policy) #expect(timeline.policy == .after(now.addingTimeInterval(3_600))) } @@ -193,9 +192,9 @@ import Widgets #expect(settings.availableRanges(input: input) == fiatRanges) } - @Test func dailyProviderHidesIntradayOptionsAndNormalizesSavedSelection() { + @Test func fiatPairHidesIntradayOptionsAndNormalizesSavedSelection() { let pair = HistoryWidgetPair( - app: ConverterState(), base: "BTC", quote: "USD", policy: .daily) + app: ConverterState(), base: "EUR", quote: "USD") let settings = HistorySettings() settings.range = .day #expect(!HistoryWidgetRange.available(for: pair).contains(.day)) diff --git a/Apps/Currency/App/Sources/Composition/AppComposition.swift b/Apps/Currency/App/Sources/Composition/AppComposition.swift index 4ba67ba..c276031 100644 --- a/Apps/Currency/App/Sources/Composition/AppComposition.swift +++ b/Apps/Currency/App/Sources/Composition/AppComposition.swift @@ -29,7 +29,6 @@ final class AppComposition { let discovery: HomeDiscoveryStore let history: HistoryService let service: RateService - let policy: RateProviderPolicy let appearance: AppearancePreferences let systemActions: SystemActionComposition lazy var searchIndex = CurrencySearchIndex.live(composition: systemActions) @@ -46,8 +45,8 @@ final class AppComposition { refreshRates: { [weak self] in _ = try? await self?.refresh(force: false) }, refreshLocalCurrency: { [weak self] in await self?.foregroundLocation.refreshIfNeeded() }, changed: { [weak self] in self?.changed() }, - sleepRates: { [policy] in - try await Task.sleep(for: .seconds(policy == .intradayEnhanced ? 60 : 21_600)) + sleepRates: { + try await Task.sleep(for: .seconds(CurrencyRateConfiguration.foregroundRefreshInterval)) }, sleepLocalCurrency: { try await Task.sleep(for: .seconds(300)) })) } @@ -67,20 +66,19 @@ final class AppComposition { init( directory: URL = AppGroup.directory, appearance: AppearancePreferences? = nil, discoveryDefaults: UserDefaults = .standard, - policy: RateProviderPolicy = CurrencyRateConfiguration.policy, service: RateService? = nil + service: RateService? = nil ) { syncDirectory = directory - self.policy = policy - self.service = service ?? RateService(policy: policy) + self.service = service ?? RateService() self.appearance = appearance ?? AppearancePreferences(defaults: .standard) - rates = RateStore(directory: directory, policy: policy) + rates = RateStore(directory: directory) conversion = ConversionStore(directory: directory) local = LocalCurrencyStore(directory: directory) progress = OnboardingProgressStore(directory: directory) discovery = HomeDiscoveryStore(defaults: discoveryDefaults) - history = HistoryService(directory: directory, policy: policy) + history = HistoryService(directory: directory) systemActions = SystemActionComposition( - directory: directory, policy: policy, service: self.service) + directory: directory, service: self.service) let actions = systemActions AppDependencyManager.shared.add(dependency: actions) let index = searchIndex diff --git a/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift b/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift index b05f03d..fc385ed 100644 --- a/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift +++ b/Apps/Currency/App/Sources/Composition/CurrencyE2EStartup.swift @@ -36,7 +36,7 @@ try seed(state, directory: directory(), defaults: defaults, now: now) WidgetCenter.shared.reloadAllTimelines() } - return RateService(policy: CurrencyRateConfiguration.policy, client: FixedRateClient()) + return RateService(client: FixedRateClient()) } private static func parse( @@ -73,8 +73,7 @@ private static func seed( _ state: InitialState, directory: URL, defaults: UserDefaults, now: Date ) throws { - let policy = CurrencyRateConfiguration.policy - let rates = RateStore(directory: directory, policy: policy) + let rates = RateStore(directory: directory) let daily = FixedRateClient.quotes(now: now) var effective = daily effective["BTC"] = FixedRateClient.coinMetricsQuote(now: now) @@ -85,17 +84,16 @@ quotes: effective, fetchedAt: now, dailyQuotes: daily, dailyFetchedAt: now, checkedAt: now, supplementalFetchedAt: now, supplementalQuotes: daily)) } - for mode in [RateProviderPolicy.daily, .intradayEnhanced] { - try seedHistory( - base: "EUR", quote: "USD", values: [1.8, 2], mode: mode, - directory: directory, now: now) - try seedHistory( - base: "EUR", quote: "XAU", values: [0.009, 0.01], mode: mode, - directory: directory, now: now) - try seedHistory( - base: "BTC", quote: "USD", values: [90_000, 100_000], mode: mode, - directory: directory, now: now) - } + + try seedHistory( + base: "EUR", quote: "USD", values: [1.8, 2], + directory: directory, now: now) + 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( points: (0...288) @@ -106,17 +104,7 @@ }, source: .init(provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt), fetchedAt: end) try writeHistory( - intraday, filename: "history-v2-intradayEnhanced-BTC-USD-1.json", - directory: directory) - let legacy = HistorySeries( - points: [ - HistoryPoint(date: now.addingTimeInterval(-3600), value: 1), - HistoryPoint(date: now, value: 2) - ], - source: .init(provider: .custom("Coinbase"), observation: .hourlyClose, timeZone: .gmt), - fetchedAt: now) - try writeHistory( - legacy, filename: "history-coinbaseEnhanced-coinbase-BTC-USD-1.json", + intraday, filename: "history-v3-BTC-USD-1.json", directory: directory) let files = [ "input.json", "onboarding.json", "widget-location-refresh.json", @@ -166,15 +154,15 @@ let seededRates = rates.loadRates() guard seededRates.quotes["EUR"]?.value == 1, seededRates.quotes["USD"]?.value == 2, seededRates.quotes["CHF"]?.value == 0.5, seededRates.quotes["CZK"]?.value == 25, - seededRates.quotes["BTC"]?.value == (policy == .intradayEnhanced ? 0.00004 : 0.00002), + seededRates.quotes["BTC"]?.value == 0.00004, seededRates.quotes["BTC"]?.source.provider - == (policy == .intradayEnhanced ? .coinMetrics : .fawaz), + == .coinMetrics, discoveryStore.load() == discovery else { throw StartupError.seedVerificationFailed } } private static func seedHistory( - base: String, quote: String, values: [Double], mode: RateProviderPolicy, + base: String, quote: String, values: [Double], directory: URL, now: Date ) throws { var calendar = Calendar(identifier: .gregorian) @@ -203,7 +191,7 @@ ? "\(range.rawValue)-\(calendar.component(.year, from: now))" : "\(range.rawValue)" try writeHistory( series, - filename: "history-v2-\(mode.rawValue)-\(base)-\(quote)-\(interval).json", + filename: "history-v3-\(base)-\(quote)-\(interval).json", directory: directory) } } diff --git a/Apps/Currency/App/Sources/Composition/SystemActionComposition.swift b/Apps/Currency/App/Sources/Composition/SystemActionComposition.swift index 9e98bb5..5aa049f 100644 --- a/Apps/Currency/App/Sources/Composition/SystemActionComposition.swift +++ b/Apps/Currency/App/Sources/Composition/SystemActionComposition.swift @@ -13,13 +13,13 @@ struct SystemActionComposition: Sendable { let readLocal: @Sendable () -> (LocalCurrency.WidgetLocation?, WidgetLocationStatus) init( - directory: URL, policy: RateProviderPolicy = CurrencyRateConfiguration.policy, + directory: URL, service: RateService? = nil ) { - let service = service ?? RateService(policy: policy) + let service = service ?? RateService() let conversion = ConversionStore(directory: directory) let local = LocalCurrencyStore(directory: directory) - let rates = RateStore(directory: directory, policy: policy) + let rates = RateStore(directory: directory) let readLocal: @Sendable () -> (LocalCurrency.WidgetLocation?, WidgetLocationStatus) = { let (observation, saved) = local.snapshot() switch CLLocationManager().authorizationStatus { @@ -40,6 +40,6 @@ struct SystemActionComposition: Sendable { try Task.checkCancellation() WidgetCenter.shared.reloadAllTimelines() return result - }, refreshInterval: policy.refreshInterval)) + }, refreshInterval: CurrencyRateConfiguration.widgetRefreshInterval)) } } diff --git a/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift b/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift index a352d7b..f904e98 100644 --- a/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift +++ b/Apps/Currency/Modules/CurrencyApplication/Sources/CurrencyRateConfiguration.swift @@ -1,16 +1,10 @@ -import ExchangeRates import Foundation -/// Shared provider configuration for the app, extensions and independent Watch app. +/// Shared refresh cadence for the app, extensions and independent Watch app. public enum CurrencyRateConfiguration { - /// Provider capabilities selected at each executable composition root. - public static let policy: RateProviderPolicy = .intradayEnhanced - /// Foreground polling respects the available provider update frequency. public static let foregroundRefreshInterval: TimeInterval = 60 /// Requested widget refresh cadence; the system decides actual execution times. - public static var widgetRefreshInterval: TimeInterval { - policy.refreshInterval - } + public static let widgetRefreshInterval: TimeInterval = 1800 } diff --git a/Apps/Currency/Modules/CurrencyApplication/Tests/ConversionActionTests.swift b/Apps/Currency/Modules/CurrencyApplication/Tests/ConversionActionTests.swift index e35fb8a..a0165d0 100644 --- a/Apps/Currency/Modules/CurrencyApplication/Tests/ConversionActionTests.swift +++ b/Apps/Currency/Modules/CurrencyApplication/Tests/ConversionActionTests.swift @@ -81,8 +81,8 @@ import Testing #expect(evaluation.results.first?.amount == "12.5") } - @Test(arguments: [RateProviderPolicy.daily, .intradayEnhanced]) - func headlessConversionsRespectProviderAttemptCadence(policy: RateProviderPolicy) async throws { + @Test + func headlessConversionsRespectProviderAttemptCadence() async throws { let now = Date.now let attempts = ProviderAttemptCounter() let snapshot = RateSnapshot( @@ -97,11 +97,11 @@ import Testing refresh: { _ in await attempts.record() return RefreshResult(snapshot: snapshot, warning: nil) - }, now: { now }, refreshInterval: policy.refreshInterval)) + }, now: { now }, refreshInterval: CurrencyRateConfiguration.widgetRefreshInterval)) let request = try action.request(amount: "10", source: "EUR", destination: "USD") let result = try await action.perform(request) #expect(result.results.first?.amount == "20") - #expect(await attempts.count == (policy == .intradayEnhanced ? 1 : 0)) + #expect(await attempts.count == 1) } private func make( diff --git a/Apps/Currency/Tests/ApplicationIntegrationTests/LocalizationTests.swift b/Apps/Currency/Tests/ApplicationIntegrationTests/LocalizationTests.swift index 4a75d26..445f826 100644 --- a/Apps/Currency/Tests/ApplicationIntegrationTests/LocalizationTests.swift +++ b/Apps/Currency/Tests/ApplicationIntegrationTests/LocalizationTests.swift @@ -77,8 +77,8 @@ import Testing == "24-hour history is unavailable for fiat currencies. Choose 1W or a longer range.") #expect( RateMessages.providerDescription( - .init(provider: .coinMetrics, observation: .hourlyClose, timeZone: .gmt), - locale: Locale(identifier: "en_US")) == "Coin Metrics · hourly closes · GMT") + .init(provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt), + locale: Locale(identifier: "en_US")) == "Coin Metrics · minute reference prices · GMT") } @Test func customProviderUsesLocalizedObservationTemplate() { @@ -88,13 +88,14 @@ import Testing RateMessages.providerDescription(source, locale: Locale(identifier: "en")) == "Example feed · monthly reference rates") #expect( - RateMessages.providerDescription(.init(provider: .coinMetrics, observation: .exchangeRate)) - == "Coin Metrics · retrieved") + RateMessages.providerDescription(.init(provider: .coinMetrics, observation: .minuteReference)) + == "Coin Metrics · minute reference prices") #expect( String(localized: .Details.rateRetrieved("today")) == "Retrieved today") - let daily = RateSource(provider: .coinMetrics, observation: .dailyClose, timeZone: .gmt) - #expect(RateMessages.providerDescription(daily).hasPrefix("Coin Metrics · daily closes · ")) + let daily = RateSource(provider: .coinMetrics, observation: .dailyReference, timeZone: .gmt) + #expect( + RateMessages.providerDescription(daily).hasPrefix("Coin Metrics · daily reference rates · ")) } @Test(arguments: [ diff --git a/Apps/Currency/Tests/ApplicationIntegrationTests/ProviderPolicyIntegrationTests.swift b/Apps/Currency/Tests/ApplicationIntegrationTests/ProviderIntegrationTests.swift similarity index 82% rename from Apps/Currency/Tests/ApplicationIntegrationTests/ProviderPolicyIntegrationTests.swift rename to Apps/Currency/Tests/ApplicationIntegrationTests/ProviderIntegrationTests.swift index b1aada1..70ec3bd 100644 --- a/Apps/Currency/Tests/ApplicationIntegrationTests/ProviderPolicyIntegrationTests.swift +++ b/Apps/Currency/Tests/ApplicationIntegrationTests/ProviderIntegrationTests.swift @@ -5,9 +5,9 @@ import Foundation import Testing @MainActor -struct ProviderPolicyIntegrationTests { - @Test(arguments: [RateProviderPolicy.daily, .intradayEnhanced]) - func everyPhoneEntryUsesTheSelectedPolicy(policy: RateProviderPolicy) throws { +struct ProviderIntegrationTests { + @Test + func everyPhoneEntryUsesCurrentCryptoRates() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let suite = UUID().uuidString let defaults = try #require(UserDefaults(suiteName: suite)) @@ -23,7 +23,7 @@ struct ProviderPolicyIntegrationTests { ] var quotes = daily quotes["BTC"] = .init( - 0.02, published: day, source: .init(provider: .coinMetrics, observation: .exchangeRate), + 0.02, published: day, source: .init(provider: .coinMetrics, observation: .minuteReference), retrievedAt: now) try RateCache(directory: directory) .save( @@ -32,17 +32,16 @@ struct ProviderPolicyIntegrationTests { checkedAt: now, supplementalFetchedAt: now, supplementalQuotes: daily)) let composition = AppComposition( directory: directory, appearance: AppearancePreferences(defaults: defaults), - discoveryDefaults: defaults, policy: policy) + discoveryDefaults: defaults) _ = try composition.edit { $0.setDestinations(["BTC"]) } - let expected: Decimal = policy == .daily ? 1 : 2 + let expected: Decimal = 2 #expect(composition.home.readRates().convert(100, from: "EUR", to: "BTC") == expected) #expect( composition.systemActions.action.dependencies.readRates() .convert(100, from: "EUR", to: "BTC") == expected) let settings = composition.settings(scene: composition.makeScene()) #expect(settings.readState().snapshot.convert(100, from: "EUR", to: "BTC") == expected) - #expect(composition.details.supportsIntraday("BTC", "USD") == (policy == .intradayEnhanced)) + #expect(composition.details.supportsIntraday("BTC", "USD") == true) #expect(!composition.details.supportsIntraday("EUR", "USD")) - #expect(composition.history.policy == policy) } } diff --git a/Apps/Currency/Tests/E2E/README.md b/Apps/Currency/Tests/E2E/README.md index 7525a8e..232adcf 100644 --- a/Apps/Currency/Tests/E2E/README.md +++ b/Apps/Currency/Tests/E2E/README.md @@ -79,15 +79,15 @@ 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. An old-provider chart cache is retained as a rejection fixture. 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 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. -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-v8-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-v9-CM`; rebuild after app or fixture changes. ```sh npm run test:e2e -- tests/provider-policy.e2e.ts ``` -The fixtures use the production provider decoders with a fixed HTTP client. Fawaz supplies daily rates; Coin Metrics supplies current crypto reference prices. Fresh v2 chart caches include a full day of five-minute sampled reference observations and monthly All provenance; they avoid live network timing. These fixture journeys do not prove live provider coverage or provider-shortfall handling. This journey verifies the standard provider configuration and same-build relaunch with compatible reference caches; the legacy chart fixture must remain excluded. +The fixtures use the production provider decoders with a fixed HTTP client. Fawaz supplies daily rates; Coin Metrics supplies current crypto reference prices. Fresh chart caches include a full day of five-minute sampled reference observations and monthly All provenance; they avoid live network timing. These fixture journeys do not prove live provider coverage or provider-shortfall handling. This journey verifies the standard provider configuration and same-build relaunch with compatible reference caches. Home Screen setup uses [widgetctl](https://github.com/DimaMishchenko/widgetctl), pinned as a GitHub dev dependency. It installs/configures the requested kind and size, preserves other apps’ widgets and unloads its helper after the module. Tests still validate rendered data and interactions. Set `CURRENCY_E2E_WIDGET_SETUP=gallery` to exercise system installation; private-configuration cases then skip. Lock Screen installation uses system UI. diff --git a/Apps/Currency/Tests/E2E/e2e.config.ts b/Apps/Currency/Tests/E2E/e2e.config.ts index 26aa378..21fe566 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-v8-CM', + identity: 'currency-fixtures-v9-CM', 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/converted-crypto-history.e2e.ts b/Apps/Currency/Tests/E2E/tests/converted-crypto-history.e2e.ts index 4018b6f..9ac7370 100644 --- a/Apps/Currency/Tests/E2E/tests/converted-crypto-history.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/converted-crypto-history.e2e.ts @@ -16,7 +16,7 @@ test('converted History widget fetches a live Bitcoin EUR day with dated USD to await expect(screen.getByRole('button', /^Bitcoin,/i)).toHaveAccessibleName('Bitcoin, 0.00002'); 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 cache = join(directory, 'history-v2-intradayEnhanced-BTC-EUR-1.json'); + const cache = join(directory, 'history-v3-BTC-EUR-1.json'); await device.closeApp(); if (existsSync(cache)) unlinkSync(cache); const rates = join(directory, 'rates.json'); diff --git a/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts b/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts index 6de5da4..386115b 100644 --- a/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/history-archive.e2e.ts @@ -9,7 +9,7 @@ test('All history renders the actual bundled Bitcoin archive from 2010', { tags: await start('ready-crypto'); 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 cache = join(directory, 'history-v2-intradayEnhanced-BTC-USD-0.json'); + const cache = join(directory, 'history-v3-BTC-USD-0.json'); if (existsSync(cache)) unlinkSync(cache); await agent.act('Open Details & history for the Bitcoin destination currency.'); await expect(screen.getByTestId('currency.details.BTC')).toBeVisible(); 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 ecc65a1..85b8c62 100644 --- a/Apps/Currency/Tests/E2E/tests/live-app-provider.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/live-app-provider.e2e.ts @@ -10,8 +10,8 @@ test('real provider refresh and phone history survive a live-network relaunch', 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-v2-intradayEnhanced-BTC-USD-1.json'); - const allPath = join(directory, 'history-v2-intradayEnhanced-BTC-USD-0.json'); + const dayPath = join(directory, 'history-v3-BTC-USD-1.json'); + const allPath = join(directory, 'history-v3-BTC-USD-0.json'); await device.closeApp(); for (const file of [ratesPath, dayPath, allPath]) { if (existsSync(file)) unlinkSync(file); diff --git a/Apps/Currency/Tests/E2E/tests/widget-live-provider.e2e.ts b/Apps/Currency/Tests/E2E/tests/widget-live-provider.e2e.ts index fab81c4..6a3c322 100644 --- a/Apps/Currency/Tests/E2E/tests/widget-live-provider.e2e.ts +++ b/Apps/Currency/Tests/E2E/tests/widget-live-provider.e2e.ts @@ -16,7 +16,7 @@ test('History widget fetches a complete live Coin Metrics day with the app termi await expect(screen.getByRole('button', /^Bitcoin,/i)).toHaveAccessibleName('Bitcoin, 0.00002'); 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 cache = join(directory, 'history-v2-intradayEnhanced-BTC-USD-1.json'); + const cache = join(directory, 'history-v3-BTC-USD-1.json'); await device.closeApp(); if (existsSync(cache)) unlinkSync(cache); assert.equal(existsSync(cache), false); diff --git a/Apps/CurrencyWatch/App/Sources/WatchComposition.swift b/Apps/CurrencyWatch/App/Sources/WatchComposition.swift index aa04f60..18e1fe1 100644 --- a/Apps/CurrencyWatch/App/Sources/WatchComposition.swift +++ b/Apps/CurrencyWatch/App/Sources/WatchComposition.swift @@ -17,7 +17,7 @@ final class WatchComposition { let conversion: ConversionStore let rates: RateStore let history: HistoryService - private let service = RateService(policy: CurrencyRateConfiguration.policy) + private let service = RateService() private let discovery = HomeDiscoveryStore(defaults: .standard) private var issue: HomeIssue? private var observers: [UUID: AsyncStream.Continuation] = [:] @@ -30,8 +30,8 @@ final class WatchComposition { else { preconditionFailure("Currency Watch requires its App Group entitlement") } self.directory = directory conversion = ConversionStore(directory: directory) - rates = RateStore(directory: directory, policy: CurrencyRateConfiguration.policy) - history = HistoryService(directory: directory, policy: CurrencyRateConfiguration.policy) + rates = RateStore(directory: directory) + history = HistoryService(directory: directory) systemActions = SystemActionComposition(directory: directory) let actions = systemActions AppDependencyManager.shared.add(dependency: actions) @@ -114,8 +114,8 @@ struct SystemActionComposition: Sendable { init(directory: URL) { let conversion = ConversionStore(directory: directory) - let rates = RateStore(directory: directory, policy: CurrencyRateConfiguration.policy) - let service = RateService(policy: CurrencyRateConfiguration.policy) + let rates = RateStore(directory: directory) + let service = RateService() readSelected = { [conversion] in let input = conversion.input() return [input.source] + input.manualDestinations @@ -130,6 +130,6 @@ struct SystemActionComposition: Sendable { try Task.checkCancellation() WidgetCenter.shared.reloadAllTimelines() return result - }, refreshInterval: CurrencyRateConfiguration.policy.refreshInterval)) + }, refreshInterval: CurrencyRateConfiguration.widgetRefreshInterval)) } } diff --git a/Apps/CurrencyWatch/README.md b/Apps/CurrencyWatch/README.md index 80e9d52..b241c76 100644 --- a/Apps/CurrencyWatch/README.md +++ b/Apps/CurrencyWatch/README.md @@ -35,8 +35,8 @@ Use one explicitly owned Watch simulator and device-targeted controls. Keep the 1. Install CurrencyWatch. Launch from a fresh container, add BTC to My Currencies and open Bitcoin details. The History range picker must offer One day alongside the daily ranges. Select it and verify a rendered BTC/USD chart or an explicit network issue; availability alone does not prove downloaded reference observations. 2. Return, terminate and relaunch the app. Verify the selected pair and available One day range persist. Capture native accessibility state and screenshots with the owned UDID. -3. Verify a failed current Coin Metrics refresh retains a valid cached quote or uses the available Fawaz daily fallback with its original provenance. An incomplete 1D response must remain explicitly cached or unavailable; it must not become a daily fallback chart. An old provider cache must not appear as a Coin Metrics chart. -4. If checking deterministic amounts, seed a coherent real `rates.json` with distinct Coin Metrics/Fawaz values and daily fallbacks before launch; record whether direct network refresh replaced it. Seed chart data only in the matching `history-v2-intradayEnhanced-BTC-USD-.json` namespace. +3. Verify a failed current Coin Metrics refresh retains a valid cached quote or uses the available Fawaz daily fallback with its original provenance. An incomplete 1D response must remain explicitly cached or unavailable; it must not become a daily fallback chart. +4. If checking deterministic amounts, seed a coherent real `rates.json` with distinct Coin Metrics/Fawaz values and daily fallbacks before launch; record whether direct network refresh replaced it. Seed chart data only in the matching `history-v3-BTC-USD-.json` namespace. Use `serve-sim` with the Watch UDID for native controls and screenshots; the iPhone E2E runner does not support this surface. If the Watch accessibility tree contains only an application root, stop automated picker navigation and record that gap; `simctl openurl` may also be unsupported. Save local evidence outside tracked documentation. A compiled Watch target, fixture inspection or failed driver connection does not count as a completed native journey. Spoken Siri, physical companion transport and installed complication configuration remain separate acceptance checks. diff --git a/Documentation/Development.md b/Documentation/Development.md index 38d4fc7..bdabb0c 100644 --- a/Documentation/Development.md +++ b/Documentation/Development.md @@ -22,9 +22,9 @@ 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. Incompatible legacy provider caches are excluded. 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. 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 app uses the domain’s `.intradayEnhanced` policy. The reusable `.daily` policy remains available to package consumers and baseline/fallback tests. +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. Crypto prices converted from USD using a dated daily fiat reference are approximate estimates, not intraday FX. All uses bundled completed-month samples where available and omits the incomplete current month. Bundled Fawaz observations use March 2, 2024 and then each completed month’s first day; missing completed months may be fetched. These are reference observations, not exchange closes. Preserve source metadata, gaps and [data licence notices](DataSources.md) when refreshing bundled data with `Scripts/History/generate_archive.py`. The [monthly refresh workflow](../Scripts/History/README.md) proposes meaningful archive changes through a review PR; unchanged data is skipped. diff --git a/Documentation/FeatureMap.md b/Documentation/FeatureMap.md index b59c477..04d9183 100644 --- a/Documentation/FeatureMap.md +++ b/Documentation/FeatureMap.md @@ -35,10 +35,10 @@ Owners: ExchangeRates domain, [CurrencyDetails](../Features/CurrencyDetails/Sour | ID | Capability / entry | Observable outcome | | --- | --- | --- | -| 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. Legacy provider quotes are excluded from cached reads; observation dates, approximate daily FX conversion and failures remain explicit. | +| 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. Incompatible legacy caches are rejected; no synthetic history is substituted. | +| 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-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/Domain/Conversion/Tests/ConversionTests/ExternalConversionTests.swift b/Domain/Conversion/Tests/ConversionTests/ExternalConversionTests.swift index d71749b..d9cdf78 100644 --- a/Domain/Conversion/Tests/ConversionTests/ExternalConversionTests.swift +++ b/Domain/Conversion/Tests/ConversionTests/ExternalConversionTests.swift @@ -107,7 +107,7 @@ import Testing @Test func customLiveProviderDoesNotProduceDailyFallbackWarning() throws { let request = try ConversionRequest( amount: "1", source: "BTC", destinations: [.init(code: "ETH")]) - let source = RateSource(provider: .custom("market"), observation: .exchangeRate) + let source = RateSource(provider: .custom("market"), observation: .minuteReference) let rates = RateSnapshot( quotes: [ "BTC": .init(2, published: "2027-01-15", source: source, retrievedAt: now), diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/ExchangeRates.docc/ExchangeRates.md b/Domain/ExchangeRates/Sources/ExchangeRates/ExchangeRates.docc/ExchangeRates.md index 18e1732..45c518a 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/ExchangeRates.docc/ExchangeRates.md +++ b/Domain/ExchangeRates/Sources/ExchangeRates/ExchangeRates.docc/ExchangeRates.md @@ -21,7 +21,6 @@ The package does not know about UI, App Groups, widgets, or polling schedules. H ### Refreshing quotes -- ``RateProviderPolicy`` - ``RateService`` - ``RefreshResult`` - ``RefreshWarning`` diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift b/Domain/ExchangeRates/Sources/ExchangeRates/HistoryService.swift index 31442d8..8c7f289 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 optional intraday crypto references. +/// Loads dated daily publications, bundled monthly references, and intraday crypto references. public actor HistoryService { private let client: any HTTPClient private let directory: URL @@ -12,22 +12,15 @@ public actor HistoryService { && !CurrencyCatalog.metals.contains(base) && !CurrencyCatalog.metals.contains(quote) } - /// The provider capabilities configured by the host. - public nonisolated let policy: RateProviderPolicy - - /// Whether the configured providers can supply intraday references for this pair. + /// Whether providers can supply intraday references for this pair. public nonisolated func supportsIntraday(base: String, quote: String) -> Bool { - policy == .intradayEnhanced && Self.supportsIntraday(base: base, quote: quote) + Self.supportsIntraday(base: base, quote: quote) } - /// Creates a history service, defaulting to Fawaz daily history. - public init( - directory: URL, client: any HTTPClient = NetworkClient(timeout: 30), - policy: RateProviderPolicy = .daily - ) { + /// Creates a history service using daily publications and intraday crypto references. + public init(directory: URL, client: any HTTPClient = NetworkClient(timeout: 30)) { self.directory = directory self.client = client - self.policy = policy } /// Loads provider observations or returns compatible saved observations with an explicit stale issue. @@ -45,7 +38,7 @@ public actor HistoryService { let year = calendar.component(.year, from: now) let interval = range == .yearToDate ? "\(range.rawValue)-\(year)" : "\(range.rawValue)" let file = directory.appendingPathComponent( - "history-v2-\(policy.rawValue)-\(base)-\(quote)-\(interval).json") + "history-v3-\(base)-\(quote)-\(interval).json") let cached = (try? Data(contentsOf: file)) .flatMap { try? JSONDecoder().decode(HistorySeries.self, from: $0) } .flatMap { series -> HistorySeries? in @@ -57,7 +50,7 @@ public actor HistoryService { (series.source.provider == expected || (range == .all && [.coinMetrics, .frankfurter].contains(series.source.provider))), series.source.observation == observation, - series.source.latestObservation == nil, series.points.count >= 2, + series.points.count >= 2, series.points.allSatisfy({ $0.value.isFinite && $0.value > 0 && $0.date <= now }), series.fetchedAt <= now, zip(series.points, series.points.dropFirst()).allSatisfy({ $0.date < $1.date }) diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/README.md b/Domain/ExchangeRates/Sources/ExchangeRates/README.md index c79847b..d669893 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/README.md +++ b/Domain/ExchangeRates/Sources/ExchangeRates/README.md @@ -14,13 +14,13 @@ let history = HistoryService(directory: cacheDirectory) let month = await history.load(base: "EUR", quote: "USD", range: .month) ``` -Quotes are currency units per EUR. Conversion returns an unrounded `Decimal?`; callers choose display precision. Missing/invalid rates return nil. Snapshots preserve publication dates and intraday timestamps. Refresh warnings, history issues, and provenance are typed so hosts can choose their own presentation. `RateSource` contains a `RateProviderID`, `RateObservation`, optional time zone, and optional `latestObservation` for a mixed historical endpoint; switch on these values to select your own strings or localization. Custom providers use `.custom("Provider name")`. Older string-based cache provenance decodes automatically. +Quotes are currency units per EUR. Conversion returns an unrounded `Decimal?`; callers choose display precision. Missing/invalid rates return nil. Snapshots preserve publication dates and intraday timestamps. Refresh warnings, history issues, and provenance are typed so hosts can choose their own presentation. `RateSource` contains a `RateProviderID`, `RateObservation`, optional time zone and conversion metadata; switch on these values to select your own strings or localization. Custom providers use `.custom("Provider name")`. -`RateService` and `HistoryService` default to `RateProviderPolicy.daily`. Hosts select `.intradayEnhanced` to add Coin Metrics Community crypto reference prices and supported 24-hour charts. Pass the same policy to `RateService`, `RateStore`, and `HistoryService`. `RateStore` filters persisted data for an explicit policy, keeping incompatible provider caches out of current reads. Explicitly injected providers remain available for tests and custom consumers. The package does not schedule background work. +`RateService` uses Fawaz daily data and Coin Metrics Community crypto reference prices. `HistoryService` supplies supported 24-hour crypto charts and longer daily/monthly ranges. `RateStore` spaces refresh attempts by thirty minutes and realigns cached Coin Metrics quotes with the selected daily EUR/USD reference. Explicitly injected providers remain available for tests and custom consumers. The package does not schedule background work. 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. Cache compatibility includes the provider policy and source provenance. +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. 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/RateProviderPolicy.swift b/Domain/ExchangeRates/Sources/ExchangeRates/RateProviderPolicy.swift deleted file mode 100644 index 055823b..0000000 --- a/Domain/ExchangeRates/Sources/ExchangeRates/RateProviderPolicy.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation - -/// Provider capabilities selected by the host application. -public enum RateProviderPolicy: String, Sendable { - /// Fawaz daily rates and daily historical observations for every supported asset. - case daily - /// Fawaz daily data with Coin Metrics current crypto reference rates and minute reference-rate charts. - case intradayEnhanced - - /// Minimum spacing between background rate refresh attempts. - public var refreshInterval: TimeInterval { self == .daily ? 21600 : 1800 } - - func filter(_ snapshot: RateSnapshot) -> RateSnapshot { - let dictionaries = [ - snapshot.quotes, snapshot.dailyQuotes ?? [:], snapshot.fiatQuotes ?? [:], - snapshot.supplementalQuotes ?? [:] - ] - let daily = dictionaries.reduce(into: [String: ExchangeRate]()) { result, quotes in - result = RateSnapshot.mergeDaily( - result, - quotes.filter { - $0.value.source.provider == .fawaz && !$0.value.isLive - }) - } - var effective = daily - if self == .intradayEnhanced { - for (code, quote) in snapshot.quotes - where CurrencyCatalog.crypto.contains(code) && quote.source.provider == .coinMetrics - && quote.isLive && quote.published >= (daily[code]?.published ?? "") - { - if let aligned = quote.alignedToDailyUSD(daily["USD"]) { effective[code] = aligned } - } - } - let excluded = dictionaries.contains { quotes in - quotes.contains { code, quote in - quote.source.provider != .fawaz - && !(self == .intradayEnhanced && CurrencyCatalog.crypto.contains(code) - && quote.source.provider == .coinMetrics && quote.isLive) - } - } - let dailyTime = daily.isEmpty ? nil : snapshot.supplementalFetchedAt ?? snapshot.dailyFetchedAt - let allowedRetrieval = - ([dailyTime] + effective.values.flatMap { [$0.cachedAt, $0.overlayTimestamp] }) - .compactMap { $0 }.max() - let fetchedAt = excluded ? allowedRetrieval ?? .distantPast : snapshot.fetchedAt - return RateSnapshot( - quotes: effective, fetchedAt: effective.isEmpty ? .distantPast : fetchedAt, - dailyQuotes: daily, dailyFetchedAt: dailyTime, - checkedAt: excluded ? nil : snapshot.checkedAt, - supplementalFetchedAt: dailyTime, fiatQuotes: [:], supplementalQuotes: daily) - } -} diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/RateService.swift b/Domain/ExchangeRates/Sources/ExchangeRates/RateService.swift index 56d0574..0388bf6 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/RateService.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/RateService.swift @@ -19,14 +19,12 @@ public actor RateService { private let daily: any RateProvider private let crypto: (any RateProvider)? - private let policy: RateProviderPolicy? private let sleep: @Sendable (Duration) async throws -> Void - /// Creates the provider set selected by the application, defaulting to daily Fawaz data. - public init(policy: RateProviderPolicy = .daily, client: any HTTPClient = NetworkClient()) { - self.policy = policy + /// Creates a service using Fawaz daily rates and Coin Metrics crypto reference rates. + public init(client: any HTTPClient = NetworkClient()) { self.fiat = nil self.daily = FawazProvider(client: client) - self.crypto = policy == .intradayEnhanced ? CoinMetricsProvider(client: client) : nil + self.crypto = CoinMetricsProvider(client: client) self.sleep = { try await Task.sleep(for: $0) } } @@ -35,7 +33,6 @@ public actor RateService { fiat: any RateProvider, daily: any RateProvider = FawazProvider(), crypto: (any RateProvider)? = nil ) { - self.policy = nil self.fiat = fiat self.daily = daily self.crypto = crypto @@ -46,7 +43,6 @@ public actor RateService { fiat: any RateProvider, daily: any RateProvider, crypto: (any RateProvider)?, sleep: @escaping @Sendable (Duration) async throws -> Void ) { - self.policy = nil self.fiat = fiat self.daily = daily self.crypto = crypto @@ -69,7 +65,7 @@ public actor RateService { previous: RateSnapshot, force: Bool = false, now: Date = .now, providerTimeout: Duration? = nil ) async -> RefreshResult { - let previous = policy?.filter(previous) ?? previous + let previous = previous.aligningCoinMetricsQuotes() guard !Task.isCancelled else { return RefreshResult(snapshot: previous, warning: nil) } let refreshFiat = fiat != nil @@ -132,7 +128,7 @@ public actor RateService { /// own the foreground deadline, so an uncooperative provider cannot keep recovery pending. /// The stream preserves daily fallbacks and the primary provider's freshness precedence. public func bootstrap(previous: RateSnapshot, now: Date = .now) -> AsyncStream { - let previous = policy?.filter(previous) ?? previous + let previous = previous.aligningCoinMetricsQuotes() let providers: [(Int, any RateProvider)] = [(0, daily)] + (fiat.map { [(1, $0)] } ?? []) + (crypto.map { [(2, $0)] } ?? []) diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/RateSnapshot.swift b/Domain/ExchangeRates/Sources/ExchangeRates/RateSnapshot.swift index f01c4fc..e4deb46 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/RateSnapshot.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/RateSnapshot.swift @@ -30,8 +30,7 @@ public struct ExchangeRate: Codable, Sendable, Equatable { /// Whether the quote represents a current intraday observation. public var isLive: Bool { - observedAt != nil || retrievedAt != nil || source.observation == .trade - || source.observation == .exchangeRate + observedAt != nil || retrievedAt != nil } var overlayTimestamp: Date? { observedAt ?? retrievedAt } @@ -135,6 +134,21 @@ extension RateSnapshot { fiatQuotes: primary, supplementalQuotes: supplement) } + func aligningCoinMetricsQuotes() -> RateSnapshot { + let daily = Self.selectDaily( + primary: primaryDailyQuotes, supplemental: supplementalDailyQuotes, + primaryFetchedAt: fiatFetchedAt ?? dailyFetchedAt, now: checkedAt ?? fetchedAt) + var aligned = quotes + for (code, quote) in quotes where quote.source.provider == .coinMetrics && quote.isLive { + aligned[code] = quote.alignedToDailyUSD(daily["USD"]) ?? daily[code] + } + return RateSnapshot( + quotes: aligned, fetchedAt: fetchedAt, dailyQuotes: dailyQuotes, + dailyFetchedAt: dailyFetchedAt, checkedAt: checkedAt, + fiatFetchedAt: fiatFetchedAt, supplementalFetchedAt: supplementalFetchedAt, + fiatQuotes: fiatQuotes, supplementalQuotes: supplementalQuotes) + } + func recordingRetrieval( _ incoming: [String: ExchangeRate], at now: Date ) -> [String: ExchangeRate] { diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/RateSource.swift b/Domain/ExchangeRates/Sources/ExchangeRates/RateSource.swift index 0368144..c3b6f05 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/RateSource.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/RateSource.swift @@ -13,47 +13,6 @@ public enum RateProviderID: Codable, Sendable, Equatable { /// A consumer-supplied provider identity or display name. case custom(String) - private enum CodingKeys: String, CodingKey { - case ecb, frankfurter, fawaz, coinMetrics, custom, coinbase - } - private enum ValueKeys: String, CodingKey { case value = "_0" } - - /// Decodes provider identity, retaining legacy identities for cache rejection. - public init(from decoder: any Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - guard values.allKeys.count == 1, let key = values.allKeys.first else { - throw RateError.invalidData - } - switch key { - case .ecb: self = .ecb - case .frankfurter: self = .frankfurter - case .fawaz: self = .fawaz - case .coinMetrics: self = .coinMetrics - case .coinbase: self = .custom("Coinbase") - case .custom: - self = .custom( - try values.nestedContainer(keyedBy: ValueKeys.self, forKey: key) - .decode(String.self, forKey: .value)) - } - } - - /// Preserves the existing provider cache representation. - public func encode(to encoder: any Encoder) throws { - var values = encoder.container(keyedBy: CodingKeys.self) - let key: CodingKeys - switch self { - case .ecb: key = .ecb - case .frankfurter: key = .frankfurter - case .fawaz: key = .fawaz - case .coinMetrics: key = .coinMetrics - case .custom(let name): - var nested = values.nestedContainer(keyedBy: ValueKeys.self, forKey: .custom) - try nested.encode(name, forKey: .value) - return - } - _ = values.nestedContainer(keyedBy: ValueKeys.self, forKey: key) - } - } /// How a provider's observation was sampled or aggregated. @@ -62,22 +21,12 @@ public enum RateObservation: String, Codable, Sendable { case unspecified /// A daily current-rate publication. case dailyRate - /// A current exchange rate without a provider observation timestamp. - case exchangeRate - /// An individual market trade. - case trade /// A historical daily reference rate. case dailyReference /// A minute reference-rate observation. case minuteReference /// A historical monthly reference rate. case monthlyReference - /// A completed hourly closing price. - case hourlyClose - /// A completed daily closing price. - case dailyClose - /// The last available daily close in each month. - case monthlyLastClose } /// Daily FX input used to normalize reference rates. @@ -111,8 +60,6 @@ public struct RateSource: Codable, Sendable, Equatable { public let provider: RateProviderID /// The observation's sampling or aggregation semantics. public let observation: RateObservation - /// A distinct sampling interval used for a newer endpoint in a historical series. - public let latestObservation: RateObservation? /// The time zone used for date buckets, when specified by the provider. public let timeZone: TimeZone? @@ -125,52 +72,15 @@ public struct RateSource: Codable, Sendable, Equatable { public init( provider: RateProviderID, observation: RateObservation = .unspecified, timeZone: TimeZone? = nil, - latestObservation: RateObservation? = nil, conversion: RateConversion? = nil, contributingProviders: [RateProviderID]? = nil ) { self.provider = provider self.observation = observation - self.latestObservation = latestObservation self.timeZone = timeZone self.conversion = conversion self.contributingProviders = contributingProviders } - private enum CodingKeys: String, CodingKey { - case provider, observation, timeZone, latestObservation, conversion, contributingProviders - } - - /// Decodes structured provenance or migrates the former source-string cache format. - public init(from decoder: any Decoder) throws { - if let legacy = try? decoder.singleValueContainer().decode(String.self) { - switch legacy { - case "ECB": self.init(provider: .ecb, observation: .dailyRate) - case "Frankfurter": self.init(provider: .frankfurter, observation: .dailyRate) - case "Fawaz · daily": self.init(provider: .fawaz, observation: .dailyRate) - case "Coinbase": self.init(provider: .custom("Coinbase"), observation: .trade) - case "Coinbase · daily closes · UTC": - self.init(provider: .custom("Coinbase"), observation: .dailyClose, timeZone: .gmt) - case "Coinbase · monthly last available close · UTC": - self.init(provider: .custom("Coinbase"), observation: .monthlyLastClose, timeZone: .gmt) - case "Frankfurter · daily reference rates": - self.init(provider: .frankfurter, observation: .dailyReference) - case "Frankfurter · monthly reference rates": - self.init(provider: .frankfurter, observation: .monthlyReference) - default: self.init(provider: .custom(legacy)) - } - return - } - let values = try decoder.container(keyedBy: CodingKeys.self) - self.init( - provider: try values.decode(RateProviderID.self, forKey: .provider), - observation: try values.decode(RateObservation.self, forKey: .observation), - timeZone: try values.decodeIfPresent(TimeZone.self, forKey: .timeZone), - latestObservation: try values.decodeIfPresent( - RateObservation.self, forKey: .latestObservation), - conversion: try values.decodeIfPresent(RateConversion.self, forKey: .conversion), - contributingProviders: try values.decodeIfPresent( - [RateProviderID].self, forKey: .contributingProviders)) - } } extension ExchangeRate { @@ -190,7 +100,7 @@ extension ExchangeRate { aligned, published: published, source: RateSource( provider: source.provider, observation: source.observation, timeZone: source.timeZone, - latestObservation: source.latestObservation, conversion: selected, + conversion: selected, contributingProviders: source.contributingProviders), observedAt: observedAt, retrievedAt: retrievedAt, cachedAt: cachedAt) } diff --git a/Domain/ExchangeRates/Sources/ExchangeRates/RateStore.swift b/Domain/ExchangeRates/Sources/ExchangeRates/RateStore.swift index 477deb7..792b843 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRates/RateStore.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRates/RateStore.swift @@ -5,20 +5,17 @@ import Foundation public struct RateStore: Sendable { /// Directory shared by the hosts that exchange rate snapshots. public let directory: URL - private let policy: RateProviderPolicy? /// Creates a coordinated rate store in a host-supplied directory. - /// A nil policy preserves injected provider provenance and a thirty-minute refresh cadence. - public init(directory: URL, policy: RateProviderPolicy? = nil) { + public init(directory: URL) { self.directory = directory - self.policy = policy } private var rates: RateCache { RateCache(directory: directory) } /// Loads the latest valid snapshot from coordinated shared storage. - public func loadRates() -> RateSnapshot { filter(rates.load()) } + public func loadRates() -> RateSnapshot { rates.load().aligningCoinMetricsQuotes() } /// Refreshes shared rates, preserving newer results committed by another host. /// /// Network requests run outside file coordination. Unless forced, attempts are spaced - /// according to the selected provider cadence. The final commit merges publication and observation metadata. + /// by thirty minutes. The final commit merges publication and observation metadata. public func refreshRates( using service: RateService, force: Bool = false, now: Date = .now, providerTimeout: Duration? = nil @@ -27,7 +24,7 @@ public struct RateStore: Sendable { guard force || now < (previous.checkedAt ?? .distantPast) || now.timeIntervalSince(previous.checkedAt ?? .distantPast) - >= (policy?.refreshInterval ?? 1800) + >= 1800 else { return RefreshResult(snapshot: previous, warning: nil) } @@ -36,7 +33,7 @@ public struct RateStore: Sendable { try Task.checkCancellation() return try coordinate("rates.json") { let current = loadRates() - let merged = filter(current.merging(filter(result.snapshot))) + let merged = current.merging(result.snapshot).aligningCoinMetricsQuotes() try rates.save(merged) return RefreshResult( snapshot: merged, @@ -44,10 +41,6 @@ public struct RateStore: Sendable { } } - private func filter(_ snapshot: RateSnapshot) -> RateSnapshot { - policy?.filter(snapshot) ?? snapshot - } - func coordinate(_ filename: String, action: () throws -> Value) throws -> Value { try FileCoordination.write(at: directory.appendingPathComponent(filename), action) } @@ -57,7 +50,7 @@ public struct RateStore: Sendable { try coordinate("rates.json") { let saved = loadRates() let current = saved.hasValidFetchTimestamp(now: now) ? saved : RateSnapshot() - let merged = filter(filter(snapshot).merging(current)) + let merged = snapshot.merging(current).aligningCoinMetricsQuotes() try RateCache(directory: directory).save(merged) return merged } diff --git a/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift b/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift index 82c96bc..ee93dc9 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRatesUI/CurrencyDisplay.swift @@ -154,7 +154,6 @@ extension CurrencyDisplay { guard from == to || normalizationCancels(snapshot, from: from, to: to) else { return source } return RateSource( provider: source.provider, observation: source.observation, timeZone: source.timeZone, - latestObservation: source.latestObservation, contributingProviders: source.contributingProviders) } diff --git a/Domain/ExchangeRates/Sources/ExchangeRatesUI/RateMessages.swift b/Domain/ExchangeRates/Sources/ExchangeRatesUI/RateMessages.swift index ea2c143..2b1dfec 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRatesUI/RateMessages.swift +++ b/Domain/ExchangeRates/Sources/ExchangeRatesUI/RateMessages.swift @@ -47,11 +47,6 @@ public enum RateMessages { approximate.locale = locale description = String(localized: approximate) } - if source.latestObservation == .hourlyClose { - var endpoint = LocalizedStringResource.Support.sourceWithLatestHourlyClose(description) - endpoint.locale = locale - description = String(localized: endpoint) - } guard let timeZone = source.timeZone else { return description } var withZone = LocalizedStringResource.Support.sourceWithTimeZone( description, timeZone.identifier) @@ -87,15 +82,11 @@ public enum RateMessages { ) -> String { var resource: LocalizedStringResource switch observation { - case .unspecified, .trade: return provider - case .exchangeRate: resource = .Support.retrievedRates(provider) + case .unspecified: return provider case .dailyRate: resource = .Support.dailyRates(provider) case .minuteReference: resource = .Support.minuteReference(provider) case .dailyReference: resource = .Support.dailyReference(provider) case .monthlyReference: resource = .Support.monthlyReference(provider) - case .dailyClose: resource = .Support.dailyCloses(provider) - case .hourlyClose: resource = .Support.hourlyCloses(provider) - case .monthlyLastClose: resource = .Support.monthlyLastClose(provider) } resource.locale = locale return String(localized: resource) diff --git a/Domain/ExchangeRates/Sources/ExchangeRatesUI/Resources/Support.xcstrings b/Domain/ExchangeRates/Sources/ExchangeRatesUI/Resources/Support.xcstrings index d18590a..b62ba32 100644 --- a/Domain/ExchangeRates/Sources/ExchangeRatesUI/Resources/Support.xcstrings +++ b/Domain/ExchangeRates/Sources/ExchangeRatesUI/Resources/Support.xcstrings @@ -2049,96 +2049,6 @@ } } }, - "dailyCloses": { - "extractionState": "manual", - "comment": "Provider provenance template; arguments contain provider or timezone data.", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ · daily closes" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ · 每日收盘价" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ · 日次終値" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ · cierres diarios" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ · tägliche Schlusskurse" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ · clôtures quotidiennes" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "%@ · fechamentos diários" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ · 일일 종가" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ · 每日收盤價" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%@ · chiusure giornaliere" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ · günlük kapanışlar" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%@ · дневные цены закрытия" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "%@ · денні ціни закриття" - } - }, - "et": { - "stringUnit": { - "state": "translated", - "value": "%@ · päeva sulgemishinnad" - } - } - } - }, "dailyRates": { "extractionState": "manual", "comment": "Provider provenance template; arguments contain provider or timezone data.", @@ -2858,185 +2768,6 @@ } } }, - "hourlyCloses": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ · hourly closes" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ · 每小时收盘价" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ · 毎時終値" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ · cierres horarios" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ · stündliche Schlusskurse" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ · clôtures horaires" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "%@ · fechamentos por hora" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ · 시간별 종가" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ · 每小時收盤價" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%@ · chiusure orarie" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ · saatlik kapanışlar" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%@ · часовые цены закрытия" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "%@ · годинні ціни закриття" - } - }, - "et": { - "stringUnit": { - "state": "translated", - "value": "%@ · tunni sulgemishinnad" - } - } - } - }, - "monthlyLastClose": { - "extractionState": "manual", - "comment": "Provider provenance template; arguments contain provider or timezone data.", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ · monthly last available close" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ · 每月最后可用收盘价" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ · 各月の最後に取得可能な終値" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ · último cierre disponible de cada mes" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ · letzter verfügbarer Schlusskurs je Monat" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ · dernière clôture disponible de chaque mois" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "%@ · último fechamento disponível de cada mês" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ · 각 월의 마지막 이용 가능한 종가" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ · 每月最後可用收盤價" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%@ · ultima chiusura mensile disponibile" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ · ayın son kullanılabilir kapanışı" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%@ · последняя доступная цена закрытия за месяц" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "%@ · остання доступна ціна закриття за місяць" - } - }, - "et": { - "stringUnit": { - "state": "translated", - "value": "%@ · kuu viimane saadaolev sulgemishind" - } - } - } - }, "monthlyReference": { "extractionState": "manual", "comment": "Provider provenance template; arguments contain provider or timezone data.", @@ -3127,185 +2858,6 @@ } } }, - "retrievedRates": { - "extractionState": "manual", - "comment": "Provider name followed by an indication that the accompanying date is the retrieval date, not a trade timestamp.", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ · retrieved" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ · 已获取" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ · 取得済み" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ · obtenido" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ · abgerufen" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ · récupéré" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "%@ · obtido" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ · 가져옴" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ · 已取得" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%@ · recuperati" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ · alındı" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%@ · получено" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "%@ · отримано" - } - }, - "et": { - "stringUnit": { - "state": "translated", - "value": "%@ · hangitud" - } - } - } - }, - "sourceWithLatestHourlyClose": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ · latest hourly close" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ · 最新每小时收盘价" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@ · 最新の毎時終値" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ · último cierre horario" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ · letzter Stundenschlusskurs" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ · dernière clôture horaire" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "%@ · último fechamento por hora" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@ · 최신 시간별 종가" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ · 最新每小時收盤價" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%@ · ultima chiusura oraria" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ · son saatlik kapanış" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%@ · последняя часовая цена закрытия" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "%@ · остання годинна ціна закриття" - } - }, - "et": { - "stringUnit": { - "state": "translated", - "value": "%@ · viimane tunni sulgemishind" - } - } - } - }, "sourceWithTimeZone": { "extractionState": "manual", "comment": "Provider provenance template; arguments contain provider or timezone data.", diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/BatchRefreshTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/BatchRefreshTests.swift index c446b49..35ef4db 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/BatchRefreshTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/BatchRefreshTests.swift @@ -36,7 +36,8 @@ private actor BatchHTTP: HTTPClient { let time = Date(timeIntervalSince1970: 1_767_355_200) let daily = ExchangeRate(1, published: "2026-01-02", source: .init(provider: .fawaz)) let live = ExchangeRate( - 2, published: "2026-01-02", source: .init(provider: .coinMetrics, observation: .exchangeRate), + 2, published: "2026-01-02", + source: .init(provider: .coinMetrics, observation: .minuteReference), retrievedAt: time) let previous = RateSnapshot(quotes: ["BTC": live], checkedAt: time) let service = RateService( @@ -65,7 +66,8 @@ private actor BatchHTTP: HTTPClient { let now = Date() let daily = ExchangeRate(1, published: "2026-01-02", source: .init(provider: .fawaz)) let live = ExchangeRate( - 2, published: "2026-01-02", source: .init(provider: .coinMetrics, observation: .exchangeRate), + 2, published: "2026-01-02", + source: .init(provider: .coinMetrics, observation: .minuteReference), retrievedAt: now) let result = await RateService( fiat: StubRateProvider(quotes: nil), diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsNormalizationTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsNormalizationTests.swift index 11752c2..940438e 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsNormalizationTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsNormalizationTests.swift @@ -100,14 +100,21 @@ private actor NormalizationGate: RateProvider { #expect(sawUpdatedBeforeCompletion) } - @Test func cachedPolicyReadRealignsLaterDailyPublicationWithoutChangingMarketTime() { + @Test func cachedStoreReadAndBootstrapSaveRealignWithoutChangingMarketTime() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } let newUSD = normalizationUSD(4, day: "2026-01-02") let staleAnchor = normalizationCrypto(anchor: 2, day: "2026-01-01") let snapshot = RateSnapshot( quotes: ["USD": newUSD, "BTC": staleAnchor], fetchedAt: normalizationNow, dailyQuotes: ["USD": newUSD], dailyFetchedAt: normalizationNow) - let aligned = RateProviderPolicy.intradayEnhanced.filter(snapshot) + try RateCache(directory: directory).save(snapshot) + let store = RateStore(directory: directory) + let aligned = store.loadRates() expectUSD100(aligned, anchor: 4, day: "2026-01-02") - #expect(RateProviderPolicy.intradayEnhanced.filter(aligned).quotes == aligned.quotes) + #expect(store.loadRates().quotes == aligned.quotes) + let saved = try store.saveBootstrapRates(snapshot, now: normalizationNow) + expectUSD100(saved, anchor: 4, day: "2026-01-02") + #expect(RateCache(directory: directory).load().quotes == saved.quotes) } } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsTests.swift index af8e018..a15dda3 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/CoinMetricsTests.swift @@ -191,19 +191,4 @@ private func metricsClient(_ http: MetricsHTTP) -> CoinMetricsClient { } } - @Test func oldProviderCacheDecodesButCannotReenterEitherPolicy() throws { - let source = try JSONDecoder() - .decode( - RateSource.self, - from: Data(#"{"provider":{"coinbase":{}},"observation":"exchangeRate"}"#.utf8)) - #expect(source.provider == .custom("Coinbase")) - let saved = RateSnapshot( - quotes: [ - "BTC": ExchangeRate(1, published: "2026-01-02", source: source, retrievedAt: metricsNow) - ], fetchedAt: metricsNow) - for policy in [RateProviderPolicy.daily, .intradayEnhanced] { - #expect(policy.filter(saved).quotes.isEmpty) - #expect(policy.filter(saved).checkedAt == nil) - } - } } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/DefaultProvidersTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/DefaultProvidersTests.swift new file mode 100644 index 0000000..086b959 --- /dev/null +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/DefaultProvidersTests.swift @@ -0,0 +1,104 @@ +import Foundation +import Testing + +@testable import ExchangeRates + +private actor DefaultProvidersHTTP: HTTPClient { + private(set) var urls: [URL] = [] + var failing = false + func failRequests() { failing = true } + func get(_ url: URL) async throws -> Data { + urls.append(url) + if failing { throw RateError.http(503) } + if url.host == "community-api.coinmetrics.io" { + return try JSONSerialization.data(withJSONObject: [ + "data": [ + [ + "asset": "btc", "time": Date().addingTimeInterval(-60).ISO8601Format(), + "ReferenceRateUSD": "50000" + ], + [ + "asset": "eth", "time": Date().addingTimeInterval(-60).ISO8601Format(), + "ReferenceRateUSD": "2500" + ] + ] + ]) + } + guard url.host == "cdn.jsdelivr.net" || url.host == "latest.currency-api.pages.dev" else { + throw RateError.invalidData + } + let day = String(Date().ISO8601Format().prefix(10)) + return Data("{\"date\":\"\(day)\",\"eur\":{\"usd\":2,\"btc\":0.00002,\"eth\":0.0005}}".utf8) + } +} + +@Suite struct DefaultProvidersTests { + @Test func refreshOverlaysCryptoAndFallsBackToUnmodifiedDailyOnFailure() async throws { + let client = DefaultProvidersHTTP() + let service = RateService(client: client) + let now = Date() + let first = await service.refresh(previous: RateSnapshot(), force: true, now: now) + #expect(first.snapshot.quotes["BTC"]?.value == Decimal(string: "0.00004")) + #expect(first.snapshot.quotes["BTC"]?.source.provider == .coinMetrics) + #expect(first.snapshot.dailyQuotes?["BTC"]?.value == Decimal(string: "0.00002")) + #expect(first.snapshot.quotes["USD"]?.source.provider == .fawaz) + await client.failRequests() + let fallback = await service.refresh( + previous: first.snapshot, now: now.addingTimeInterval(1800)) + #expect(fallback.snapshot.quotes["BTC"]?.value == Decimal(string: "0.00002")) + #expect(fallback.snapshot.quotes["BTC"]?.source.provider == .fawaz) + #expect(fallback.warning == .partialCryptoFallback) + #expect(await client.urls.filter { $0.host == "community-api.coinmetrics.io" }.count == 2) + #expect(await client.urls.filter { $0.host == "cdn.jsdelivr.net" }.count == 3) + } + + @Test func refreshAttemptsAreSpacedThirtyMinutes() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let client = DefaultProvidersHTTP() + let now = Date() + let store = RateStore(directory: directory) + let service = RateService(client: client) + _ = try await store.refreshRates(using: service, now: now) + let initialRequests = await client.urls.count + _ = try await store.refreshRates(using: service, now: now.addingTimeInterval(1799)) + #expect(await client.urls.count == initialRequests) + _ = try await store.refreshRates(using: service, now: now.addingTimeInterval(1800)) + #expect(await client.urls.count > initialRequests) + } + + @Test func storePreservesInjectedProviderQuotes() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let now = Date() + let daily = ExchangeRate(2, published: "2026-01-02", source: .init(provider: .custom("Daily"))) + let live = ExchangeRate( + 3, published: "2026-01-02", source: .init(provider: .custom("Crypto")), observedAt: now) + let store = RateStore(directory: directory) + let result = try await store.refreshRates( + using: RateService( + fiat: StubRateProvider(quotes: ["USD": daily]), daily: StubRateProvider(quotes: [:]), + crypto: StubRateProvider(quotes: ["BTC": live])), now: now) + #expect(result.snapshot.quotes["USD"]?.source.provider == .custom("Daily")) + #expect(result.snapshot.quotes["BTC"] == live) + #expect(store.loadRates().quotes == result.snapshot.quotes) + } + + @Test func bootstrapUsesDailyAndCryptoProviders() async { + let client = DefaultProvidersHTTP() + var deliveries = 0 + for await update in await RateService(client: client).bootstrap(previous: RateSnapshot()) { + deliveries += 1 + if update.isFinal { + #expect(update.snapshot.quotes["BTC"]?.source.provider == .coinMetrics) + #expect(update.snapshot.quotes["USD"]?.source.provider == .fawaz) + } + } + #expect(deliveries == 2) + #expect(await client.urls.contains { $0.host == "community-api.coinmetrics.io" }) + #expect( + await client.urls.allSatisfy { + $0.host == "community-api.coinmetrics.io" || $0.host == "cdn.jsdelivr.net" + }) + } +} diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryCrossRateTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryCrossRateTests.swift index 06a97cc..e1a1afa 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryCrossRateTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryCrossRateTests.swift @@ -46,7 +46,7 @@ actor MinuteHistoryHTTP: HTTPClient { let client = MinuteHistoryHTTP() await client.omitQuoteMinute() let result = await HistoryService( - directory: directory, client: client, policy: .intradayEnhanced + directory: directory, client: client ) .load(base: "BTC", quote: "ETH", range: .day, now: now) #expect(result.issue == nil) @@ -64,7 +64,7 @@ actor MinuteHistoryHTTP: HTTPClient { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let client = MinuteHistoryHTTP() - let service = HistoryService(directory: directory, client: client, policy: .intradayEnhanced) + let service = HistoryService(directory: directory, client: client) let direct = await service.load(base: "BTC", quote: "CZK", range: .day, now: now) let inverse = await service.load(base: "CZK", quote: "BTC", range: .day, now: now) #expect(direct.issue == nil) @@ -81,7 +81,7 @@ actor MinuteHistoryHTTP: HTTPClient { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let client = MinuteHistoryHTTP() - let service = HistoryService(directory: directory, client: client, policy: .intradayEnhanced) + let service = HistoryService(directory: directory, client: client) await client.shorten() let missing = await service.load(base: "BTC", quote: "USD", range: .day, now: now) #expect(missing.issue == .unavailable) diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryFailureTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryFailureTests.swift index 0623ddb..517deb4 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryFailureTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryFailureTests.swift @@ -11,7 +11,7 @@ import Testing let now = try historyDate("2026-01-08T12:00:00Z") let service = HistoryService(directory: directory, client: client) let first = await service.load(base: "BTC", quote: "USD", range: .week, now: now) - let file = directory.appendingPathComponent("history-v2-daily-BTC-USD-7.json") + let file = directory.appendingPathComponent("history-v3-BTC-USD-7.json") let original = try Data(contentsOf: file) await client.failRequests() let failed = await service.load( @@ -23,7 +23,7 @@ import Testing } @Test(arguments: [ - RateProviderID.custom("Coinbase"), .frankfurter, .custom("Coinbase + Frankfurter") + RateProviderID.frankfurter, .custom("Example feed") ]) func incompatibleCacheNeverReturnsFreshOrOffline(provider: RateProviderID) async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -35,8 +35,7 @@ import Testing .init(date: now.addingTimeInterval(-86400), value: 2), .init(date: now, value: 3) ], source: .init(provider: provider, observation: .dailyReference), fetchedAt: now) let data = try JSONEncoder().encode(saved) - try data.write(to: directory.appendingPathComponent("history-v2-daily-BTC-USD-7.json")) - try data.write(to: directory.appendingPathComponent("history-BTC-USD-7.json")) + try data.write(to: directory.appendingPathComponent("history-v3-BTC-USD-7.json")) let client = DailyHistoryHTTP() await client.failRequests() let result = await HistoryService(directory: directory, client: client) @@ -46,38 +45,4 @@ import Testing #expect(await !client.urls.isEmpty) } - @Test func policyModeSeparatesSeriesCachesAndDisabledHourlyNeverReadsSavedCoinbase() async throws - { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let now = try historyDate("2026-01-08T12:00:00Z") - let client = DailyHistoryHTTP() - let enhanced = HistoryService(directory: directory, client: client, policy: .intradayEnhanced) - #expect(enhanced.supportsIntraday(base: "BTC", quote: "USD")) - _ = await enhanced.load(base: "BTC", quote: "USD", range: .week, now: now) - let daily = HistoryService(directory: directory, client: client) - #expect(!daily.supportsIntraday(base: "BTC", quote: "USD")) - _ = await daily.load(base: "BTC", quote: "USD", range: .week, now: now) - let before = await client.urls.count - let saved = HistorySeries( - points: [ - .init(date: now.addingTimeInterval(-7200), value: 2), - .init(date: now.addingTimeInterval(-3600), value: 3) - ], - source: .init(provider: .custom("Coinbase"), observation: .hourlyClose, timeZone: .gmt), - fetchedAt: now) - try JSONEncoder().encode(saved) - .write( - to: directory.appendingPathComponent("history-coinbaseEnhanced-coinbase-BTC-USD-1.json")) - let result = await daily.load(base: "BTC", quote: "USD", range: .day, now: now) - #expect(result.series == nil) - #expect(result.issue == .intradayUnavailable) - #expect(await client.urls.count == before) - for mode in ["daily", "intradayEnhanced"] { - #expect( - FileManager.default.fileExists( - atPath: - directory.appendingPathComponent("history-v2-\(mode)-BTC-USD-7.json").path)) - } - } } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift index c2dbf37..2a0f2ba 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryIntervalTests.swift @@ -16,7 +16,7 @@ import Testing let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let client = MinuteHistoryHTTP() let result = await HistoryService( - directory: directory, client: client, policy: .intradayEnhanced + directory: directory, client: client ) .load(base: base, quote: quote, range: .day) #expect(result.series == nil) @@ -29,7 +29,7 @@ import Testing defer { try? FileManager.default.removeItem(at: directory) } let now = try historyDate("2026-01-05T00:00:00Z") let client = MinuteHistoryHTTP() - let service = HistoryService(directory: directory, client: client, policy: .intradayEnhanced) + let service = HistoryService(directory: directory, client: client) let first = await service.load(base: "BTC", quote: "USD", range: .day, now: now) #expect(first.issue == nil) await client.fail() @@ -50,7 +50,7 @@ import Testing let now = try historyDate("2026-01-05T00:05:17Z") let endpoint = try historyDate("2026-01-05T00:05:00Z") let client = MinuteHistoryHTTP() - let service = HistoryService(directory: directory, client: client, policy: .intradayEnhanced) + let service = HistoryService(directory: directory, client: client) let first = await service.load(base: "BTC", quote: "USD", range: .day, now: now) let series = try #require(first.series) #expect(first.issue == nil) diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift index 68afd80..1cf735c 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/HistoryTests.swift @@ -62,9 +62,8 @@ func historyDate(_ string: String) throws -> Date { defer { try? FileManager.default.removeItem(at: directory) } let client = DailyHistoryHTTP() let now = try historyDate("2026-01-08T12:00:00Z") - let service = HistoryService(directory: directory, client: client, policy: .intradayEnhanced) + let service = HistoryService(directory: directory, client: client) let first = await service.load(base: "BTC", quote: "EUR", range: .week, now: now) - #expect(first.series?.source.latestObservation == nil) _ = await service.load(base: "USD", quote: "XAU", range: .week, now: now) _ = await service.load( base: "BTC", quote: "EUR", range: .week, now: now.addingTimeInterval(21600)) diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateProviderPolicyTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateProviderPolicyTests.swift deleted file mode 100644 index bff2b22..0000000 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateProviderPolicyTests.swift +++ /dev/null @@ -1,167 +0,0 @@ -import Foundation -import Testing - -@testable import ExchangeRates - -private actor PolicyHTTP: HTTPClient { - private(set) var urls: [URL] = [] - var failing = false - func failRequests() { failing = true } - func get(_ url: URL) async throws -> Data { - urls.append(url) - if failing { throw RateError.http(503) } - if url.host == "community-api.coinmetrics.io" { - return try JSONSerialization.data(withJSONObject: [ - "data": [ - [ - "asset": "btc", "time": Date().addingTimeInterval(-60).ISO8601Format(), - "ReferenceRateUSD": "50000" - ], - [ - "asset": "eth", "time": Date().addingTimeInterval(-60).ISO8601Format(), - "ReferenceRateUSD": "2500" - ] - ] - ]) - } - guard url.host == "cdn.jsdelivr.net" || url.host == "latest.currency-api.pages.dev" else { - throw RateError.invalidData - } - let day = String(Date().ISO8601Format().prefix(10)) - return Data("{\"date\":\"\(day)\",\"eur\":{\"usd\":2,\"btc\":0.00002,\"eth\":0.0005}}".utf8) - } -} - -@Suite struct RateProviderPolicyTests { - @Test func dailyRefreshAndBootstrapNeverContactIntradayOrFrankfurter() async throws { - let client = PolicyHTTP() - let service = RateService(client: client) - let first = await service.refresh(previous: RateSnapshot(), force: true) - #expect(first.snapshot.quotes["BTC"]?.source.provider == .fawaz) - #expect(first.snapshot.quotes["USD"]?.value == 2) - #expect(first.warning == nil) - var deliveries = 0 - for await update in await service.bootstrap(previous: first.snapshot) { - deliveries += 1 - #expect(update.isFinal) - #expect(update.snapshot.quotes.values.allSatisfy { $0.source.provider == .fawaz }) - } - #expect(deliveries == 1) - #expect(await client.urls.count == 2) - #expect(await client.urls.allSatisfy { $0.host == "cdn.jsdelivr.net" }) - } - - @Test func enabledRefreshOverlaysCryptoAndFallsBackToUnmodifiedDailyOnFailure() async throws { - let client = PolicyHTTP() - let service = RateService(policy: .intradayEnhanced, client: client) - let now = Date() - let first = await service.refresh(previous: RateSnapshot(), force: true, now: now) - #expect(first.snapshot.quotes["BTC"]?.value == Decimal(string: "0.00004")) - #expect(first.snapshot.quotes["BTC"]?.source.provider == .coinMetrics) - #expect(first.snapshot.dailyQuotes?["BTC"]?.value == Decimal(string: "0.00002")) - #expect(first.snapshot.quotes["USD"]?.source.provider == .fawaz) - await client.failRequests() - let fallback = await service.refresh( - previous: first.snapshot, now: now.addingTimeInterval(1800)) - #expect(fallback.snapshot.quotes["BTC"]?.value == Decimal(string: "0.00002")) - #expect(fallback.snapshot.quotes["BTC"]?.source.provider == .fawaz) - #expect(fallback.warning == .partialCryptoFallback) - #expect(await client.urls.filter { $0.host == "community-api.coinmetrics.io" }.count == 2) - #expect(await client.urls.filter { $0.host == "cdn.jsdelivr.net" }.count == 3) - } - - @Test(arguments: [false, true]) - func disabledStoreFiltersAllCacheDictionariesAndBootstrap(legacy: Bool) throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let now = Date() - let daily = ExchangeRate(2, published: "2026-01-01", source: .init(provider: .fawaz)) - let live = ExchangeRate( - 3, published: "2026-01-02", - source: .init( - provider: .coinMetrics, - observation: .exchangeRate), retrievedAt: now) - let foreign = ExchangeRate(4, published: "2026-01-02", source: .init(provider: .frankfurter)) - let old = RateSnapshot( - quotes: ["USD": daily, "BTC": live, "GBP": foreign], fetchedAt: now, - dailyQuotes: legacy ? nil : ["USD": daily, "BTC": live, "GBP": foreign], - dailyFetchedAt: now, checkedAt: now, fiatFetchedAt: now, - supplementalFetchedAt: now, fiatQuotes: legacy ? nil : ["GBP": foreign], - supplementalQuotes: legacy ? nil : ["USD": daily, "BTC": live]) - try RateCache(directory: directory).save(old) - let store = RateStore(directory: directory, policy: .daily) - let loaded = store.loadRates() - #expect(loaded.quotes == ["USD": daily]) - #expect(loaded.dailyQuotes == ["USD": daily]) - #expect(loaded.fiatQuotes?.isEmpty == true) - #expect(loaded.supplementalQuotes == ["USD": daily]) - #expect(loaded.checkedAt == nil) - let saved = try store.saveBootstrapRates(old, now: now) - #expect(saved.quotes == ["USD": daily]) - #expect(RateCache(directory: directory).load().quotes == ["USD": daily]) - } - - @Test func disablingRestoresExistingDailyCryptoAndCannotMergeLiveBackIn() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let now = Date() - let daily = ExchangeRate(2, published: "2026-01-01", source: .init(provider: .fawaz)) - let live = ExchangeRate( - 3, published: "2026-01-02", - source: .init( - provider: .coinMetrics, - observation: .exchangeRate), retrievedAt: now) - let old = RateSnapshot( - quotes: ["BTC": live], fetchedAt: now, dailyQuotes: ["BTC": daily], - dailyFetchedAt: now, checkedAt: now) - try RateCache(directory: directory).save(old) - let store = RateStore(directory: directory, policy: .daily) - #expect(store.loadRates().quotes["BTC"] == daily) - let result = try await store.refreshRates( - using: RateService( - fiat: StubRateProvider(quotes: [:]), daily: StubRateProvider(quotes: ["BTC": daily]), - crypto: StubRateProvider(quotes: ["BTC": live])), force: true, now: now) - #expect(result.snapshot.quotes["BTC"]?.source.provider == .fawaz) - #expect(store.loadRates().quotes["BTC"]?.source.provider == .fawaz) - } - - @Test func disablingDoesNotAssignIntradayRetrievalTimeToDailyFallback() throws { - let now = Date() - let dailyTime = now.addingTimeInterval(-12 * 3600) - let daily = ExchangeRate( - 2, published: "2026-01-01", source: .init(provider: .fawaz), - cachedAt: dailyTime) - let live = ExchangeRate( - 3, published: "2026-01-02", - source: .init( - provider: .coinMetrics, - observation: .exchangeRate), retrievedAt: now) - let saved = RateSnapshot( - quotes: ["BTC": live], fetchedAt: now, dailyQuotes: ["BTC": daily], - dailyFetchedAt: dailyTime, checkedAt: now, supplementalFetchedAt: dailyTime) - let filtered = RateProviderPolicy.daily.filter(saved) - #expect(filtered.quotes["BTC"] == daily) - #expect(filtered.fetchedAt == dailyTime) - #expect(filtered.dailyFetchedAt == dailyTime) - #expect(filtered.checkedAt == nil) - #expect(RateProviderPolicy.intradayEnhanced.filter(saved).fetchedAt == now) - let unknown = ExchangeRate(2, published: "2026-01-01", source: .init(provider: .fawaz)) - let legacy = RateSnapshot(quotes: ["BTC": live, "USD": unknown], fetchedAt: now) - #expect(RateProviderPolicy.daily.filter(legacy).fetchedAt == .distantPast) - } - - @Test func dailyRefreshEarlyReturnUsesPolicyCadenceAndSanitizedSnapshot() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let client = PolicyHTTP() - let now = Date() - let store = RateStore(directory: directory, policy: .daily) - let service = RateService(client: client) - _ = try await store.refreshRates(using: service, now: now) - _ = try await store.refreshRates(using: service, now: now.addingTimeInterval(1800)) - #expect(await client.urls.count == 1) - _ = try await store.refreshRates(using: service, now: now.addingTimeInterval(21600)) - #expect(await client.urls.count == 2) - #expect(RateProviderPolicy.intradayEnhanced.refreshInterval == 1800) - } -} diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateSourceTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateSourceTests.swift index af57880..29dad5f 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateSourceTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesTests/RateSourceTests.swift @@ -3,33 +3,7 @@ import Foundation import Testing @Suite struct RateSourceTests { - @Test func legacyProvenanceDecodesIntoStructuredContext() throws { - let source = try JSONDecoder() - .decode( - RateSource.self, from: Data(#""Coinbase · monthly last available close · UTC""#.utf8)) - #expect(source.provider == .custom("Coinbase")) - #expect(source.observation == .monthlyLastClose) - #expect(source.timeZone == .gmt) - #expect(try JSONDecoder().decode(RateSource.self, from: JSONEncoder().encode(source)) == source) - } - - @Test(arguments: [RateObservation.dailyClose, .monthlyLastClose, .unspecified]) - func mixedHistoryProvenanceRoundTripsWithoutRelabelingHistoricalPoints( - observation: RateObservation - ) throws { - let source = RateSource( - provider: .coinMetrics, observation: observation, timeZone: .gmt, - latestObservation: .hourlyClose) - let restored = try JSONDecoder().decode(RateSource.self, from: JSONEncoder().encode(source)) - #expect(restored == source) - #expect(restored.observation == observation) - #expect(restored.latestObservation == .hourlyClose) - let original = RateSource(provider: .coinMetrics, observation: observation, timeZone: .gmt) - let old = try JSONDecoder().decode(RateSource.self, from: JSONEncoder().encode(original)) - #expect(old.latestObservation == nil) - } - - @Test func compositeConversionMetadataRoundTripsAndOldSourceHasNoAssumedFX() throws { + @Test func conversionMetadataRoundTripsAndPlainSourceHasNoAssumedFX() throws { let source = RateSource( provider: .coinMetrics, observation: .minuteReference, conversion: .init( @@ -37,19 +11,17 @@ import Testing contributingProviders: [.fawaz, .coinMetrics]) let restored = try JSONDecoder().decode(RateSource.self, from: JSONEncoder().encode(source)) #expect(restored == source) - let old = try JSONDecoder() + let plain = try JSONDecoder() .decode( RateSource.self, from: Data(#"{"provider":{"fawaz":{}},"observation":"dailyReference"}"#.utf8)) - #expect(old.conversion == nil) - #expect(old.contributingProviders == nil) + #expect(plain.conversion == nil) + #expect(plain.contributingProviders == nil) } @Test func customProviderRoundTripsWithoutInterpretingDisplayText() throws { let source = RateSource(provider: .custom("My reference feed"), observation: .dailyReference) #expect(try JSONDecoder().decode(RateSource.self, from: JSONEncoder().encode(source)) == source) - let legacy = try JSONDecoder().decode(RateSource.self, from: Data(#""My reference feed""#.utf8)) - #expect(legacy.provider == source.provider) - #expect(legacy.observation == .unspecified) + } } diff --git a/Domain/ExchangeRates/Tests/ExchangeRatesUITests/ResourceTests.swift b/Domain/ExchangeRates/Tests/ExchangeRatesUITests/ResourceTests.swift index 06f4438..08d6cdd 100644 --- a/Domain/ExchangeRates/Tests/ExchangeRatesUITests/ResourceTests.swift +++ b/Domain/ExchangeRates/Tests/ExchangeRatesUITests/ResourceTests.swift @@ -9,36 +9,13 @@ import UniformTypeIdentifiers @Test func provenanceInterpolationUsesTheOwningCatalog() { #expect( RateMessages.providerDescription( - RateSource(provider: .coinMetrics, observation: .dailyClose, timeZone: .gmt), - locale: Locale(identifier: "en_US")) == "Coin Metrics · daily closes · GMT") + RateSource(provider: .coinMetrics, observation: .dailyReference, timeZone: .gmt), + locale: Locale(identifier: "en_US")) == "Coin Metrics · daily reference rates · GMT") #expect( RateMessages.providerDescription( - RateSource(provider: .custom("Example"), observation: .exchangeRate), - locale: Locale(identifier: "en_US")) == "Example · retrieved") + RateSource(provider: .custom("Example"), observation: .unspecified), + locale: Locale(identifier: "en_US")) == "Example") } - @Test(arguments: [ - (RateObservation.dailyClose, "Coin Metrics · daily closes · latest hourly close · GMT"), - (.monthlyLastClose, "Coin Metrics · monthly last available close · latest hourly close · GMT") - ]) - func mixedHistoryDescribesBothAggregationAndHourlyEndpoint( - observation: RateObservation, expected: String - ) { - #expect( - RateMessages.providerDescription( - RateSource( - provider: .coinMetrics, observation: observation, timeZone: .gmt, - latestObservation: .hourlyClose), locale: Locale(identifier: "en_US")) == expected) - } - - @Test func convertedHistoryKeepsBothProvidersAndNamesItsHourlyEndpoint() { - #expect( - RateMessages.providerDescription( - RateSource( - provider: .custom("Coin Metrics + Frankfurter"), timeZone: .gmt, - latestObservation: .hourlyClose), locale: Locale(identifier: "en_US")) - == "Coin Metrics + Frankfurter · latest hourly close · GMT") - } - @Test func convertedReferenceNamesTheActualFXInputAndEverySource() throws { let source = RateSource( provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt, diff --git a/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift b/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift index 196f65d..10f1f76 100644 --- a/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift +++ b/Domain/Widgets/HarnessApp/CryptoHistoryWidgetHarness.swift @@ -43,7 +43,7 @@ struct CryptoHistoryWidgetHarness: View { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let history = HistoryService( - directory: directory, client: CryptoHistoryFixture(), policy: .intradayEnhanced) + directory: directory, client: CryptoHistoryFixture()) let pair = HistoryWidgetPair(app: ConverterState(), base: "BTC", quote: "USD") for range: HistoryRange in [.day, .week, .month] { let result = await history.load(base: "BTC", quote: "USD", range: range, now: date) diff --git a/Domain/Widgets/Sources/Widgets/HistoryWidgetSnapshot.swift b/Domain/Widgets/Sources/Widgets/HistoryWidgetSnapshot.swift index ddf15be..ee2415a 100644 --- a/Domain/Widgets/Sources/Widgets/HistoryWidgetSnapshot.swift +++ b/Domain/Widgets/Sources/Widgets/HistoryWidgetSnapshot.swift @@ -10,15 +10,11 @@ public struct HistoryWidgetPair: Sendable, Equatable { public let quote: String? /// The app-selected mass unit for both metal currencies. public let metalUnit: MetalUnit - /// Provider capabilities selected by the host for chart ranges and scheduling. - public let policy: RateProviderPolicy /// Nil overrides follow the app base and first displayed destination independently. public init( - app: ConverterState, base: String? = nil, quote: String? = nil, - policy: RateProviderPolicy = .intradayEnhanced + app: ConverterState, base: String? = nil, quote: String? = nil ) { - self.policy = policy metalUnit = app.metalUnit self.base = base ?? app.source self.quote = quote ?? app.destinationRows.first?.code @@ -48,7 +44,7 @@ public struct HistoryWidgetPair: Sendable, Equatable { /// Whether the supported pair can load intraday reference observations. public var supportsIntradayHistory: Bool { - guard policy == .intradayEnhanced, let request = historyRequest else { return false } + guard let request = historyRequest else { return false } return HistoryService.supportsIntraday(base: request.base, quote: request.quote) } /// Replaces an unavailable saved intraday range with daily one-month history. diff --git a/Domain/Widgets/Tests/WidgetsTests/HistoryWidgetTests.swift b/Domain/Widgets/Tests/WidgetsTests/HistoryWidgetTests.swift index 6158944..0bfee02 100644 --- a/Domain/Widgets/Tests/WidgetsTests/HistoryWidgetTests.swift +++ b/Domain/Widgets/Tests/WidgetsTests/HistoryWidgetTests.swift @@ -20,10 +20,10 @@ import Testing }, source: .init(provider: .frankfurter), fetchedAt: now), issue: issue)) } - @Test func dailyPolicyNormalizesSavedIntradaySelectionAndRefreshesDaily() { + @Test func fiatPairNormalizesIntradaySelectionAndRefreshesDaily() { let now = Date(timeIntervalSince1970: 1_700_000_000) let pair = HistoryWidgetPair( - app: ConverterState(), base: "BTC", quote: "USD", policy: .daily) + app: ConverterState(), base: "EUR", quote: "USD") #expect(pair.isSupported) #expect(!pair.supportsIntradayHistory) #expect(pair.normalizedRange(.day) == .month) @@ -218,7 +218,7 @@ import Testing ], source: .init( provider: intraday ? .coinMetrics : .fawaz, - observation: intraday ? .hourlyClose : .dailyReference, timeZone: .gmt), + observation: intraday ? .minuteReference : .dailyReference, timeZone: .gmt), fetchedAt: now) let pair = HistoryWidgetPair(app: ConverterState(), base: "BTC", quote: "USD") let fresh = HistoryWidgetSnapshot( diff --git a/Features/CurrencyDetails/HarnessApp/CurrencyDetailsHarnessApp.swift b/Features/CurrencyDetails/HarnessApp/CurrencyDetailsHarnessApp.swift index 6deb382..67997c1 100644 --- a/Features/CurrencyDetails/HarnessApp/CurrencyDetailsHarnessApp.swift +++ b/Features/CurrencyDetails/HarnessApp/CurrencyDetailsHarnessApp.swift @@ -77,8 +77,7 @@ struct CurrencyDetailsHarnessApp: App { points: points, source: .init( provider: isCrypto ? .coinMetrics : .frankfurter, - observation: isCrypto - ? (range == .day ? .hourlyClose : .dailyClose) : .dailyReference, + observation: isCrypto && range == .day ? .minuteReference : .dailyReference, timeZone: .gmt), fetchedAt: .now), issue: name == "cached" ? .usingCachedSeries : nil) } diff --git a/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift b/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift index 53cb85a..bd43fa1 100644 --- a/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift +++ b/Features/CurrencyDetails/Tests/CurrencyDetailsTests/CurrencyDetailsTests.swift @@ -62,7 +62,7 @@ struct CurrencyDetailsTests { @Test func requestedFiatCryptoPairInvertsProviderSeriesAndRetainsProvenance() async { let date = Date(timeIntervalSince1970: 1_700_000_000) - let source = RateSource(provider: .coinMetrics, observation: .hourlyClose, timeZone: .gmt) + let source = RateSource(provider: .coinMetrics, observation: .minuteReference, timeZone: .gmt) let received = HistorySeries( points: [ HistoryPoint(date: date, value: 40_000),