-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathPriceService.swift
More file actions
302 lines (247 loc) · 9.86 KB
/
PriceService.swift
File metadata and controls
302 lines (247 loc) · 9.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
import Foundation
// MARK: - Data Models
public struct TradingPair {
public let name: String
public let base: String
public let quote: String
public let symbol: String
}
struct PriceResponse: Codable {
let price: Double
let timestamp: Double
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
timestamp = try container.decode(Double.self, forKey: .timestamp)
// Handle price as either String or Double
if let priceString = try? container.decode(String.self, forKey: .price) {
guard let priceValue = Double(priceString) else {
throw DecodingError.dataCorruptedError(forKey: .price, in: container, debugDescription: "Price string is not a valid number")
}
price = priceValue
} else {
price = try container.decode(Double.self, forKey: .price)
}
}
}
struct CandleResponse: Codable {
let timestamp: Double
let open: Double
let close: Double
let high: Double
let low: Double
let volume: Double
}
struct PriceChange {
let isPositive: Bool
let formatted: String
}
struct PriceData {
let name: String
let change: PriceChange
let price: String
let pastValues: [Double]
}
enum GraphPeriod: String, CaseIterable, Codable {
case oneDay = "1D"
case oneWeek = "1W"
case oneMonth = "1M"
case oneYear = "1Y"
}
enum PriceServiceError: Error {
case invalidURL
case invalidPair
case networkError
case decodingError
case noPriceDataAvailable
}
// MARK: - Trading Pairs Constants
public let tradingPairs: [TradingPair] = [
TradingPair(name: "BTC/USD", base: "BTC", quote: "USD", symbol: "$"),
TradingPair(name: "BTC/EUR", base: "BTC", quote: "EUR", symbol: "€"),
TradingPair(name: "BTC/GBP", base: "BTC", quote: "GBP", symbol: "£"),
TradingPair(name: "BTC/JPY", base: "BTC", quote: "JPY", symbol: "¥"),
]
// Convenience array for just the pair names
public let tradingPairNames: [String] = tradingPairs.map(\.name)
// MARK: - Helper Models
private struct CachedPriceData: Codable {
let name: String
let changeIsPositive: Bool
let changeFormatted: String
let price: String
let pastValues: [Double]
}
// MARK: - Caching System
class PriceWidgetCache {
static let shared = PriceWidgetCache()
private let userDefaults = UserDefaults.standard
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private init() {}
func set(_ value: some Codable, forKey key: String) {
do {
let data = try encoder.encode(value)
userDefaults.set(data, forKey: "price_widget_cache_\(key)")
} catch {
print("Failed to cache price data for key \(key): \(error)")
}
}
func get<T: Codable>(_ type: T.Type, forKey key: String) -> T? {
guard let data = userDefaults.data(forKey: "price_widget_cache_\(key)") else {
return nil
}
do {
return try decoder.decode(type, from: data)
} catch {
print("Failed to decode cached price data for key \(key): \(error)")
return nil
}
}
}
// MARK: - Price Service
class PriceService {
static let shared = PriceService()
private let baseURL = "https://feeds.synonym.to/price-feed/api"
private init() {}
/// Fetches price data for given pairs and period using stale-while-revalidate strategy
/// - Parameters:
/// - pairs: Array of trading pair names (e.g., ["BTC/USD"])
/// - period: Time period for historical data
/// - returnCachedImmediately: If true, returns cached data immediately if available
/// - Returns: Array of PriceData
/// - Throws: PriceServiceError
func fetchPriceData(pairs: [String], period: GraphPeriod, returnCachedImmediately: Bool = true) async throws -> [PriceData] {
// If we want cached data and it exists, return it immediately
if returnCachedImmediately, let cachedData = getCachedData(pairs: pairs, period: period) {
// Start fresh fetch in background to update cache (don't await)
Task {
do {
try await fetchFreshData(pairs: pairs, period: period)
// Cache will be updated automatically in fetchFreshData
} catch {
// Silent failure for background updates
print("Background price data update failed: \(error)")
}
}
return cachedData
}
// No cache available or cache not requested - fetch fresh data
return try await fetchFreshData(pairs: pairs, period: period)
}
/// Fetches fresh data from API (always hits the network)
/// Individual pair failures are logged but don't fail the entire request - only fails if ALL pairs fail
@discardableResult
private func fetchFreshData(pairs: [String], period: GraphPeriod) async throws -> [PriceData] {
let priceDataArray = await withTaskGroup(of: PriceData?.self) { group in
var results: [PriceData] = []
for pairName in pairs {
group.addTask {
do {
return try await self.fetchPairData(pairName: pairName, period: period)
} catch {
Logger.warn("Failed to fetch price data for \(pairName): \(error.localizedDescription)")
return nil
}
}
}
for await priceData in group {
if let data = priceData {
results.append(data)
}
}
return results
}
guard !priceDataArray.isEmpty else {
throw PriceServiceError.noPriceDataAvailable
}
return priceDataArray
}
private func getCachedData(pairs: [String], period: GraphPeriod) -> [PriceData]? {
let cache = PriceWidgetCache.shared
let cachedItems = pairs.compactMap { pairName in
cache.get(CachedPriceData.self, forKey: "\(pairName)_\(period.rawValue)")
}
guard cachedItems.count == pairs.count else { return nil }
return cachedItems.map { cached in
PriceData(
name: cached.name,
change: PriceChange(isPositive: cached.changeIsPositive, formatted: cached.changeFormatted),
price: cached.price,
pastValues: cached.pastValues
)
}
}
private func fetchPairData(pairName: String, period: GraphPeriod) async throws -> PriceData {
guard let pair = tradingPairs.first(where: { $0.name == pairName }) else {
throw PriceServiceError.invalidPair
}
let ticker = "\(pair.base)\(pair.quote)"
// Fetch historical data
let candles = try await fetchCandles(ticker: ticker, period: period)
let sortedCandles = candles.sorted { $0.timestamp < $1.timestamp }
let pastValues = sortedCandles.map(\.close)
// Fetch latest price
let latestPrice = try await fetchLatestPrice(ticker: ticker)
// Replace last historical value with latest price
let updatedPastValues = Array(pastValues.dropLast()) + [latestPrice]
// Calculate change
let change = calculateChange(values: updatedPastValues)
// Format price
let formattedPrice = formatPrice(pair: pair, price: latestPrice)
let priceData = PriceData(
name: pairName,
change: change,
price: formattedPrice,
pastValues: updatedPastValues
)
// Cache the data
cacheData(pairName: pairName, period: period, data: priceData)
return priceData
}
private func fetchLatestPrice(ticker: String) async throws -> Double {
guard let url = URL(string: "\(baseURL)/price/\(ticker)/latest") else {
throw PriceServiceError.invalidURL
}
let (data, _) = try await URLSession.shared.data(from: url)
let response = try JSONDecoder().decode(PriceResponse.self, from: data)
return response.price
}
private func fetchCandles(ticker: String, period: GraphPeriod) async throws -> [CandleResponse] {
guard let url = URL(string: "\(baseURL)/price/\(ticker)/history/\(period.rawValue)") else {
throw PriceServiceError.invalidURL
}
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([CandleResponse].self, from: data)
}
private func calculateChange(values: [Double]) -> PriceChange {
guard values.count >= 2 else {
return PriceChange(isPositive: true, formatted: "+0%")
}
let change = values.last! / values.first! - 1
let sign = change >= 0 ? "+" : ""
let percentage = change * 100
return PriceChange(
isPositive: change >= 0,
formatted: "\(sign)\(String(format: "%.2f", percentage))%"
)
}
private func formatPrice(pair: TradingPair, price: Double) -> String {
// Format with localized thousands separator, no decimals
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 0
let formatted = formatter.string(from: NSNumber(value: price)) ?? String(format: "%.0f", price)
return "\(pair.symbol) \(formatted)"
}
private func cacheData(pairName: String, period: GraphPeriod, data: PriceData) {
let cacheKey = "\(pairName)_\(period.rawValue)"
let cachedData = CachedPriceData(
name: data.name,
changeIsPositive: data.change.isPositive,
changeFormatted: data.change.formatted,
price: data.price,
pastValues: data.pastValues
)
PriceWidgetCache.shared.set(cachedData, forKey: cacheKey)
}
}