From e53beda09c8016208196b1670e51732043a41f66 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Fri, 29 Nov 2024 07:58:21 +0100 Subject: [PATCH 1/5] Working but slow --- .../PortfolioPerformance/PortfolioPerformanceUpdater.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift b/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift index 91d60ea..238bfbf 100644 --- a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift +++ b/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift @@ -63,7 +63,6 @@ class PortfolioPerformanceUpdater: PortfolioHistoricalPerformanceUpdater { var datedPerformance = [DatedPortfolioPerformance]() guard let earliestTransaction = portfolio.earliestTransaction else { return } let dates = dateRangeUntilToday(from: earliestTransaction.purchaseDate) - var priceCache = [String: Decimal]() for date in dates { let performanceForDate = try await performanceCalculator.performance(of: portfolio, on: date, priceCache: &priceCache) From 9e4cfdde0a068e99cc1f105473d6eeeb0bb2ec4f Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Fri, 29 Nov 2024 08:17:27 +0100 Subject: [PATCH 2/5] Cache prices --- .../PortfolioPerformance/PortfolioPerformanceUpdater.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift b/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift index 238bfbf..91d60ea 100644 --- a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift +++ b/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift @@ -63,6 +63,7 @@ class PortfolioPerformanceUpdater: PortfolioHistoricalPerformanceUpdater { var datedPerformance = [DatedPortfolioPerformance]() guard let earliestTransaction = portfolio.earliestTransaction else { return } let dates = dateRangeUntilToday(from: earliestTransaction.purchaseDate) + var priceCache = [String: Decimal]() for date in dates { let performanceForDate = try await performanceCalculator.performance(of: portfolio, on: date, priceCache: &priceCache) From 10eaa5fff4364227f09ca23a944c0e5b01ab59d5 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Fri, 29 Nov 2024 14:26:55 +0100 Subject: [PATCH 3/5] Works but slow --- .../PortfolioPerformanceCalculator.swift | 20 +++++++++--- .../PortfolioPerformanceUpdater.swift | 24 ++++++++++---- .../Grodt/BusinessLogic/PriceService.swift | 32 +++++++++++-------- .../TransactionChangedHandler.swift | 9 ++++-- 4 files changed, 59 insertions(+), 26 deletions(-) diff --git a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceCalculator.swift b/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceCalculator.swift index 8615abb..1c48389 100644 --- a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceCalculator.swift +++ b/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceCalculator.swift @@ -1,7 +1,10 @@ import Foundation protocol PortfolioPerformanceCalculating { - func performance(of portfolio: Portfolio, on date: YearMonthDayDate, priceCache: inout [String: Decimal]) async throws -> DatedPortfolioPerformance + func performance(of portfolio: Portfolio, + on date: YearMonthDayDate, + priceCache: inout [QuoteKey: Decimal], + quoteDictionary: [YearMonthDayDate : DatedQuote]) async throws -> DatedPortfolioPerformance } class PortfolioPerformanceCalculator: PortfolioPerformanceCalculating { @@ -14,7 +17,8 @@ class PortfolioPerformanceCalculator: PortfolioPerformanceCalculating { func performance( of portfolio: Portfolio, on date: YearMonthDayDate, - priceCache: inout [String: Decimal] + priceCache: inout [QuoteKey: Decimal], + quoteDictionary: [YearMonthDayDate : DatedQuote] ) async throws -> DatedPortfolioPerformance { let transactionsUntilDate = portfolio.transactions.filter { YearMonthDayDate($0.purchaseDate) <= date } @@ -24,12 +28,13 @@ class PortfolioPerformanceCalculator: PortfolioPerformanceCalculating { let inAmount = transaction.numberOfShares * transaction.pricePerShareAtPurchase + transaction.fees await financialsForDate.addMoneyIn(inAmount) + let quoteKey = QuoteKey(ticker: transaction.ticker, date: date) let price: Decimal - if let cachedPrice = priceCache[transaction.ticker] { + if let cachedPrice = priceCache[quoteKey] { price = cachedPrice } else { - price = try await self.priceService.price(for: transaction.ticker, on: date) - priceCache[transaction.ticker] = price + price = try await self.priceService.price(for: transaction.ticker, on: date, quoteDictionary: quoteDictionary) + priceCache[quoteKey] = price } let value = transaction.numberOfShares * price @@ -44,3 +49,8 @@ class PortfolioPerformanceCalculator: PortfolioPerformanceCalculating { return performanceForDate } } + +struct QuoteKey: Hashable { + let ticker: String + let date: YearMonthDayDate +} diff --git a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift b/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift index 91d60ea..9b263ec 100644 --- a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift +++ b/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift @@ -1,7 +1,7 @@ import Foundation protocol PortfolioHistoricalPerformanceUpdater { - func recalculateHistoricalPerformance(of portfolio: Portfolio) async throws + func recalculateHistoricalPerformance(of portfolio: Portfolio, since: Date?) async throws func updatePerformanceOfAllPortfolios() async throws } @@ -59,14 +59,26 @@ class PortfolioPerformanceUpdater: PortfolioHistoricalPerformanceUpdater { } } - func recalculateHistoricalPerformance(of portfolio: Portfolio) async throws { + func recalculateHistoricalPerformance(of portfolio: Portfolio, since: Date? = nil) async throws { var datedPerformance = [DatedPortfolioPerformance]() - guard let earliestTransaction = portfolio.earliestTransaction else { return } - let dates = dateRangeUntilToday(from: earliestTransaction.purchaseDate) - var priceCache = [String: Decimal]() + let startDate: Date = { + if let since { + return since + } + guard let earliestTransaction = portfolio.earliestTransaction else { return Date() } + return earliestTransaction.purchaseDate + }() + + let dates = dateRangeUntilToday(from: startDate) + var priceCache = [QuoteKey: Decimal]() + + var quoteDictionary: [YearMonthDayDate : DatedQuote] = [:] + if let storedQuotes = try await quoteRepository.historicalQuote(for: portfolio.earliestTransaction!.ticker) { + quoteDictionary = Dictionary(uniqueKeysWithValues: storedQuotes.datedQuotes.map { ($0.date, $0) }) + } for date in dates { - let performanceForDate = try await performanceCalculator.performance(of: portfolio, on: date, priceCache: &priceCache) + let performanceForDate = try await performanceCalculator.performance(of: portfolio, on: date, priceCache: &priceCache, quoteDictionary: quoteDictionary) datedPerformance.append(performanceForDate) } diff --git a/Sources/Grodt/BusinessLogic/PriceService.swift b/Sources/Grodt/BusinessLogic/PriceService.swift index 627c47d..8ffb88b 100644 --- a/Sources/Grodt/BusinessLogic/PriceService.swift +++ b/Sources/Grodt/BusinessLogic/PriceService.swift @@ -3,7 +3,7 @@ import AlphaSwiftage protocol PriceService { func price(for ticker: String) async throws -> Decimal - func price(for ticker: String, on date: YearMonthDayDate) async throws -> Decimal + func price(for ticker: String, on date: YearMonthDayDate, quoteDictionary: [YearMonthDayDate : DatedQuote]) async throws -> Decimal func fetchAndCreateHistoricalPrices(for ticker: String) async throws -> HistoricalQuote func fetchAndUpdatePrice(for outdatedQuote: Quote) async throws -> Decimal } @@ -35,20 +35,25 @@ class CachedPriceService: PriceService { return try await fetchAndCreatePrice(for: ticker) } - func price(for ticker: String, on date: YearMonthDayDate) async throws -> Decimal { + func price(for ticker: String, + on date: YearMonthDayDate, + quoteDictionary: [YearMonthDayDate : DatedQuote] + ) async throws -> Decimal { if date == YearMonthDayDate(Date()) { return try await price(for: ticker) } - - // Ensure the `await` is properly handled - let quotes: HistoricalQuote - if let storedQuotes = try await quoteRepository.historicalQuote(for: ticker) { - quotes = storedQuotes - } else { - quotes = try await fetchAndCreateHistoricalPrices(for: ticker) - } - - var quote = quotes.datedQuotes.first(where: { $0.date == date }) + +// let quotes: HistoricalQuote +// if let storedQuotes = try await quoteRepository.historicalQuote(for: ticker) { +// quotes = storedQuotes +// } else { +// quotes = try await fetchAndCreateHistoricalPrices(for: ticker) +// } + +// // Convert datedQuotes array to dictionary for fast lookup +// let quoteDictionary = Dictionary(uniqueKeysWithValues: quotes.datedQuotes.map { ($0.date, $0) }) + + var quote = quoteDictionary[date] var calendar = Calendar.current calendar.timeZone = TimeZone.universalGMT var dateToCheck = date @@ -58,8 +63,9 @@ class CachedPriceService: PriceService { break } dateToCheck = YearMonthDayDate(calendar.date(byAdding: .day, value: -1, to: dateToCheck.date)!) - quote = quotes.datedQuotes.first(where: { $0.date == dateToCheck }) + quote = quoteDictionary[dateToCheck] } + return quote!.price } diff --git a/Sources/Grodt/BusinessLogic/TransactionChangedHandler.swift b/Sources/Grodt/BusinessLogic/TransactionChangedHandler.swift index f9eeaa6..05771b5 100644 --- a/Sources/Grodt/BusinessLogic/TransactionChangedHandler.swift +++ b/Sources/Grodt/BusinessLogic/TransactionChangedHandler.swift @@ -12,11 +12,16 @@ class TransactionChangedHandler: TransactionsControllerDelegate { func transactionCreated(_ transaction: Transaction) async throws { let portfolio = try await portfolioRepository.expandPortfolio(on: transaction) - try await historicalPerformanceUpdater.recalculateHistoricalPerformance(of: portfolio) + try await historicalPerformanceUpdater.recalculateHistoricalPerformance(of: portfolio, since: startDateToUpdateTransactions(transaction)) } func transactionDeleted(_ transaction: Transaction) async throws { let portfolio = try await portfolioRepository.expandPortfolio(on: transaction) - try await historicalPerformanceUpdater.recalculateHistoricalPerformance(of: portfolio) + try await historicalPerformanceUpdater.recalculateHistoricalPerformance(of: portfolio, since: startDateToUpdateTransactions(transaction)) + } + + private func startDateToUpdateTransactions(_ transaction: Transaction) -> Date { + let calender = Calendar.current + return calender.date(byAdding: .day, value: -1, to: transaction.purchaseDate) ?? transaction.purchaseDate } } From a7ec14f12f970f50d44690f96989dd89010012c0 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Mon, 2 Dec 2024 14:34:26 +0100 Subject: [PATCH 4/5] Correct abstraction --- Sources/Grodt/Application/routes.swift | 10 +- .../DictInMemoryTickerPriceCache.swift | 23 +++ .../InMemoryTickerPriceCache.swift | 6 + .../PortfolioPerformanceCalculator.swift | 57 +++++--- .../PortfolioPerformanceUpdater.swift | 91 ++++++------ .../Grodt/BusinessLogic/PriceService.swift | 133 ------------------ .../PriceService/CachedPriceService.swift | 68 +++++++++ .../PriceService/LivePriceService.swift | 37 +++++ .../PriceService/PriceService.swift | 6 + .../PriceService/QuoteCache.swift | 11 ++ .../TransactionChangedHandler.swift | 9 +- .../Repositories/QuoteRepository.swift | 52 +++---- 12 files changed, 260 insertions(+), 243 deletions(-) create mode 100644 Sources/Grodt/BusinessLogic/PortfolioPerformance/InMemoryTickerPriceCache/DictInMemoryTickerPriceCache.swift create mode 100644 Sources/Grodt/BusinessLogic/PortfolioPerformance/InMemoryTickerPriceCache/InMemoryTickerPriceCache.swift delete mode 100644 Sources/Grodt/BusinessLogic/PriceService.swift create mode 100644 Sources/Grodt/BusinessLogic/PriceService/CachedPriceService.swift create mode 100644 Sources/Grodt/BusinessLogic/PriceService/LivePriceService.swift create mode 100644 Sources/Grodt/BusinessLogic/PriceService/PriceService.swift create mode 100644 Sources/Grodt/BusinessLogic/PriceService/QuoteCache.swift diff --git a/Sources/Grodt/Application/routes.swift b/Sources/Grodt/Application/routes.swift index ea2d7a7..fce035b 100644 --- a/Sources/Grodt/Application/routes.swift +++ b/Sources/Grodt/Application/routes.swift @@ -9,8 +9,9 @@ func routes(_ app: Application) async throws { let tickerDTOMapper = TickerDTOMapper() let loginResponseDTOMapper = LoginResponseDTOMapper() let transactionDTOMapper = TransactionDTOMapper(currencyDTOMapper: currencyDTOMapper) - let priceService = CachedPriceService(quoteRepository: PostgresQuoteRepository(database: app.db), - alphavantage: alphavantage) + let livePriceService = LivePriceService(alphavantage: alphavantage) + let quoteCache = PostgresQuoteRepository(database: app.db) + let priceService = CachedPriceService(priceService: livePriceService, cache: quoteCache) let portfolioPerformanceCalculator = PortfolioPerformanceCalculator(priceService: priceService) let portfolioDTOMapper = PortfolioDTOMapper(transactionDTOMapper: transactionDTOMapper, currencyDTOMapper: currencyDTOMapper, @@ -19,10 +20,9 @@ func routes(_ app: Application) async throws { userRepository: PostgresUserRepository(database: app.db), portfolioRepository: PostgresPortfolioRepository(database: app.db), tickerRepository: PostgresTickerRepository(database: app.db), - quoteRepository: PostgresQuoteRepository(database: app.db), + quoteCache: quoteCache, priceService: priceService, - performanceCalculator: portfolioPerformanceCalculator, - dataMapper: portfolioDTOMapper) + performanceCalculator: portfolioPerformanceCalculator) let transactionChangedHandler = TransactionChangedHandler(portfolioRepository: PostgresPortfolioRepository(database: app.db), historicalPerformanceUpdater: portfolioPerformanceUpdater) diff --git a/Sources/Grodt/BusinessLogic/PortfolioPerformance/InMemoryTickerPriceCache/DictInMemoryTickerPriceCache.swift b/Sources/Grodt/BusinessLogic/PortfolioPerformance/InMemoryTickerPriceCache/DictInMemoryTickerPriceCache.swift new file mode 100644 index 0000000..556f931 --- /dev/null +++ b/Sources/Grodt/BusinessLogic/PortfolioPerformance/InMemoryTickerPriceCache/DictInMemoryTickerPriceCache.swift @@ -0,0 +1,23 @@ +import Foundation + +class DictionaryInMemoryTickerPriceCache: InMemoryTickerPriceCache { + private var dictionary = [QuoteKey: Decimal]() + + func price(for ticker: String, on date: YearMonthDayDate) -> Decimal? { + return dictionary[QuoteKey(ticker, date)] + } + + func setPrice(_ price: Decimal, for ticker: String, on date: YearMonthDayDate) { + dictionary[QuoteKey(ticker, date)] = price + } +} + +fileprivate struct QuoteKey: Hashable { + let ticker: String + let date: YearMonthDayDate + + init(_ ticker: String, _ date: YearMonthDayDate) { + self.ticker = ticker + self.date = date + } +} diff --git a/Sources/Grodt/BusinessLogic/PortfolioPerformance/InMemoryTickerPriceCache/InMemoryTickerPriceCache.swift b/Sources/Grodt/BusinessLogic/PortfolioPerformance/InMemoryTickerPriceCache/InMemoryTickerPriceCache.swift new file mode 100644 index 0000000..6694dc4 --- /dev/null +++ b/Sources/Grodt/BusinessLogic/PortfolioPerformance/InMemoryTickerPriceCache/InMemoryTickerPriceCache.swift @@ -0,0 +1,6 @@ +import Foundation + +protocol InMemoryTickerPriceCache { + func price(for ticker: String, on date: YearMonthDayDate) -> Decimal? + func setPrice(_ price: Decimal, for ticker: String, on date: YearMonthDayDate) +} diff --git a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceCalculator.swift b/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceCalculator.swift index 1c48389..b26f491 100644 --- a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceCalculator.swift +++ b/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceCalculator.swift @@ -3,8 +3,7 @@ import Foundation protocol PortfolioPerformanceCalculating { func performance(of portfolio: Portfolio, on date: YearMonthDayDate, - priceCache: inout [QuoteKey: Decimal], - quoteDictionary: [YearMonthDayDate : DatedQuote]) async throws -> DatedPortfolioPerformance + using cache: InMemoryTickerPriceCache) async throws -> DatedPortfolioPerformance } class PortfolioPerformanceCalculator: PortfolioPerformanceCalculating { @@ -17,8 +16,7 @@ class PortfolioPerformanceCalculator: PortfolioPerformanceCalculating { func performance( of portfolio: Portfolio, on date: YearMonthDayDate, - priceCache: inout [QuoteKey: Decimal], - quoteDictionary: [YearMonthDayDate : DatedQuote] + using cache: InMemoryTickerPriceCache ) async throws -> DatedPortfolioPerformance { let transactionsUntilDate = portfolio.transactions.filter { YearMonthDayDate($0.purchaseDate) <= date } @@ -27,16 +25,7 @@ class PortfolioPerformanceCalculator: PortfolioPerformanceCalculating { for transaction in transactionsUntilDate { let inAmount = transaction.numberOfShares * transaction.pricePerShareAtPurchase + transaction.fees await financialsForDate.addMoneyIn(inAmount) - - let quoteKey = QuoteKey(ticker: transaction.ticker, date: date) - let price: Decimal - if let cachedPrice = priceCache[quoteKey] { - price = cachedPrice - } else { - price = try await self.priceService.price(for: transaction.ticker, on: date, quoteDictionary: quoteDictionary) - priceCache[quoteKey] = price - } - + let price = try await self.price(for: transaction.ticker, on: date, using: cache) let value = transaction.numberOfShares * price await financialsForDate.addValue(value) } @@ -48,9 +37,39 @@ class PortfolioPerformanceCalculator: PortfolioPerformanceCalculating { ) return performanceForDate } -} - -struct QuoteKey: Hashable { - let ticker: String - let date: YearMonthDayDate + + private func price(for ticker: String, + on date: YearMonthDayDate, + using cache: InMemoryTickerPriceCache) async throws -> Decimal { + if let cachedPrice = cache.price(for: ticker, on: date) { + return cachedPrice + } + let price = try await computePrice(for: ticker, on: date, using: cache) + cache.setPrice(price, for: ticker, on: date) + return price + } + + private func computePrice(for ticker: String, + on date: YearMonthDayDate, + using cache: InMemoryTickerPriceCache) async throws -> Decimal { + if date == YearMonthDayDate(Date()) { + return try await priceService.price(for: ticker) + } + + var calendar = Calendar.current + calendar.timeZone = TimeZone.universalGMT + var quote: Decimal? + var dateToCheck = date + + for _ in 0..<7 { + if quote != nil { + break + } + dateToCheck = YearMonthDayDate(calendar.date(byAdding: .day, value: -1, to: dateToCheck.date)!) + quote = cache.price(for: ticker, on: dateToCheck) + } + + // TODO: What to do if no price???? + return quote! + } } diff --git a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift b/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift index 9b263ec..d0b20e3 100644 --- a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift +++ b/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift @@ -1,7 +1,7 @@ import Foundation protocol PortfolioHistoricalPerformanceUpdater { - func recalculateHistoricalPerformance(of portfolio: Portfolio, since: Date?) async throws + func recalculatePerformance(of portfolio: Portfolio) async throws func updatePerformanceOfAllPortfolios() async throws } @@ -9,76 +9,52 @@ class PortfolioPerformanceUpdater: PortfolioHistoricalPerformanceUpdater { private let userRepository: UserRepository private let portfolioRepository: PortfolioRepository private let tickerRepository: TickerRepository - private let quoteRepository: QuoteRepository + private let quoteCache: QuoteCache private let priceService: PriceService private let performanceCalculator: PortfolioPerformanceCalculating - private let dataMapper: PortfolioDTOMapper private let rateLimiter = RateLimiter(maxRequestsPerMinute: 5) init(userRepository: UserRepository, portfolioRepository: PortfolioRepository, tickerRepository: TickerRepository, - quoteRepository: QuoteRepository, + quoteCache: QuoteCache, priceService: PriceService, - performanceCalculator: PortfolioPerformanceCalculating, - dataMapper: PortfolioDTOMapper) { + performanceCalculator: PortfolioPerformanceCalculating) { self.userRepository = userRepository self.portfolioRepository = portfolioRepository self.tickerRepository = tickerRepository - self.quoteRepository = quoteRepository + self.quoteCache = quoteCache self.priceService = priceService self.performanceCalculator = performanceCalculator - self.dataMapper = dataMapper } func updatePerformanceOfAllPortfolios() async throws { - // Remove all historrical prices - for quote in try await quoteRepository.allHistoricalQuote() { - try await quoteRepository.delete(quote) - } - - // Update historical prices and latest prices for all tickers - let allTickers = try await tickerRepository.allTickers() - for ticker in allTickers { - await rateLimiter.waitIfNeeded() - _ = try await priceService.fetchAndCreateHistoricalPrices(for: ticker.symbol) - await rateLimiter.waitIfNeeded() - if let quote = try await quoteRepository.quote(for: ticker.symbol) { - _ = try await priceService.fetchAndUpdatePrice(for: quote) - } - } - - // Update historical performance for all portfolios - let users = try await userRepository.allUsers() - for user in users { - let allPortfolios = try await portfolioRepository.allPortfolios(for: user.id!) - for portfolio in allPortfolios { - try await recalculateHistoricalPerformance(of: portfolio) - } - } + try await updateAllTickerPrices() + try await updateHistotrycalPerformances() } - func recalculateHistoricalPerformance(of portfolio: Portfolio, since: Date? = nil) async throws { + func recalculatePerformance(of portfolio: Portfolio) async throws { var datedPerformance = [DatedPortfolioPerformance]() let startDate: Date = { - if let since { - return since - } guard let earliestTransaction = portfolio.earliestTransaction else { return Date() } return earliestTransaction.purchaseDate }() - let dates = dateRangeUntilToday(from: startDate) - var priceCache = [QuoteKey: Decimal]() - - var quoteDictionary: [YearMonthDayDate : DatedQuote] = [:] - if let storedQuotes = try await quoteRepository.historicalQuote(for: portfolio.earliestTransaction!.ticker) { - quoteDictionary = Dictionary(uniqueKeysWithValues: storedQuotes.datedQuotes.map { ($0.date, $0) }) + let dictCache = DictionaryInMemoryTickerPriceCache() + for transaction in portfolio.transactions { + if let storedQuotes = try await quoteCache.historicalQuote(for: transaction.ticker) { + storedQuotes.datedQuotes.forEach { datedQuote in + dictCache.setPrice(datedQuote.price, for: transaction.ticker, on: datedQuote.date) + } + } } - + + let dates = dateRangeUntilToday(from: startDate) for date in dates { - let performanceForDate = try await performanceCalculator.performance(of: portfolio, on: date, priceCache: &priceCache, quoteDictionary: quoteDictionary) + let performanceForDate = try await performanceCalculator.performance(of: portfolio, + on: date, + using: dictCache) datedPerformance.append(performanceForDate) } @@ -93,6 +69,33 @@ class PortfolioPerformanceUpdater: PortfolioHistoricalPerformanceUpdater { try await portfolioRepository.createHistoricalPerformance(historicalPerformance) } } + + private func updateAllTickerPrices() async throws{ + let allTickers = try await tickerRepository.allTickers() + for ticker in allTickers { + try await clearCache(for: ticker.symbol) + + await rateLimiter.waitIfNeeded() + _ = try await priceService.historicalPrice(for: ticker.symbol) + await rateLimiter.waitIfNeeded() + _ = try await priceService.price(for: ticker.symbol) + } + } + + private func updateHistotrycalPerformances() async throws { + let users = try await userRepository.allUsers() + for user in users { + let allPortfolios = try await portfolioRepository.allPortfolios(for: user.id!) + for portfolio in allPortfolios { + try await recalculatePerformance(of: portfolio) + } + } + } + + private func clearCache(for ticker: String) async throws { + try await quoteCache.clearHistoricalQuote(for: ticker) + try await quoteCache.clearQuote(for: ticker) + } private func dateRangeUntilToday(from startDate: Date) -> [YearMonthDayDate] { var dates: [YearMonthDayDate] = [] diff --git a/Sources/Grodt/BusinessLogic/PriceService.swift b/Sources/Grodt/BusinessLogic/PriceService.swift deleted file mode 100644 index 8ffb88b..0000000 --- a/Sources/Grodt/BusinessLogic/PriceService.swift +++ /dev/null @@ -1,133 +0,0 @@ -import Foundation -import AlphaSwiftage - -protocol PriceService { - func price(for ticker: String) async throws -> Decimal - func price(for ticker: String, on date: YearMonthDayDate, quoteDictionary: [YearMonthDayDate : DatedQuote]) async throws -> Decimal - func fetchAndCreateHistoricalPrices(for ticker: String) async throws -> HistoricalQuote - func fetchAndUpdatePrice(for outdatedQuote: Quote) async throws -> Decimal -} - -class CachedPriceService: PriceService { - private enum Constant { - static let priceTTLInHours: Int = 24 - } - - private let quoteRepository: QuoteRepository - private let alphavantage: AlphaVantageService - - init(quoteRepository: QuoteRepository, - alphavantage: AlphaVantageService) { - self.quoteRepository = quoteRepository - self.alphavantage = alphavantage - } - - func price(for ticker: String) async throws -> Decimal { - let quote = try await quoteRepository.quote(for: ticker) - if let quote = quote { - if hasTTLPassed(for: quote) { - return try await fetchAndUpdatePrice(for: quote) - } else { - return quote.price - } - } - - return try await fetchAndCreatePrice(for: ticker) - } - - func price(for ticker: String, - on date: YearMonthDayDate, - quoteDictionary: [YearMonthDayDate : DatedQuote] - ) async throws -> Decimal { - if date == YearMonthDayDate(Date()) { - return try await price(for: ticker) - } - -// let quotes: HistoricalQuote -// if let storedQuotes = try await quoteRepository.historicalQuote(for: ticker) { -// quotes = storedQuotes -// } else { -// quotes = try await fetchAndCreateHistoricalPrices(for: ticker) -// } - -// // Convert datedQuotes array to dictionary for fast lookup -// let quoteDictionary = Dictionary(uniqueKeysWithValues: quotes.datedQuotes.map { ($0.date, $0) }) - - var quote = quoteDictionary[date] - var calendar = Calendar.current - calendar.timeZone = TimeZone.universalGMT - var dateToCheck = date - - for _ in 0..<7 { - if quote != nil { - break - } - dateToCheck = YearMonthDayDate(calendar.date(byAdding: .day, value: -1, to: dateToCheck.date)!) - quote = quoteDictionary[dateToCheck] - } - - return quote!.price - } - - private func liveHistoricalPrices(for ticker: String) async throws -> [DatedQuote] { - let dateFormatter = DateFormatter() - dateFormatter.timeZone = TimeZone.universalGMT - dateFormatter.dateFormat = "yyyy-MM-dd" - - let result = await alphavantage.dailyAdjustedTimeSeries(for: ticker, outputSize: .full) - switch result { - case .success(let quotes): - return quotes.compactMap { dateString, equityDailyData in - guard let date = dateFormatter.date(from: dateString) else { return nil} - return DatedQuote(price: equityDailyData.adjustedClose, date: YearMonthDayDate(date)) - } - case .failure(let error): - throw error - } - } - - private func fetchAndCreatePrice(for ticker: String) async throws -> Decimal { - let quote = try await latestQuote(for: ticker) - let newQuote = Quote(symbol: ticker, - price: quote.price, - lastUpdate: Date()) - - try await quoteRepository.create(newQuote) - return newQuote.price - } - - func fetchAndCreateHistoricalPrices(for ticker: String) async throws -> HistoricalQuote { - let quotes = try await liveHistoricalPrices(for: ticker) - let historicalQuote = HistoricalQuote(symbol: ticker, datedQuotes: quotes) - try await quoteRepository.create(historicalQuote) - return historicalQuote - } - - func fetchAndUpdatePrice(for outdatedQuote: Quote) async throws -> Decimal { - let quote = try await latestQuote(for: outdatedQuote.symbol) - outdatedQuote.lastUpdate = Date() - try await quoteRepository.update(outdatedQuote) - return quote.price - } - - private func hasTTLPassed(for quote: Quote) -> Bool { - let currentDate = Date() - let components = Calendar.current.dateComponents([.hour], from: quote.lastUpdate, to: currentDate) - - if let hours = components.hour, hours >= Constant.priceTTLInHours { - return true - } - - return false - } - - private func latestQuote(for ticker: String) async throws -> AlphaSwiftage.Quote { - let result = await alphavantage.quote(for: ticker) - switch result { - case .success(let quote): - return quote - case .failure(let error): - throw error - } - } -} diff --git a/Sources/Grodt/BusinessLogic/PriceService/CachedPriceService.swift b/Sources/Grodt/BusinessLogic/PriceService/CachedPriceService.swift new file mode 100644 index 0000000..22fdbba --- /dev/null +++ b/Sources/Grodt/BusinessLogic/PriceService/CachedPriceService.swift @@ -0,0 +1,68 @@ +import Foundation +import AlphaSwiftage + +class CachedPriceService: PriceService { + private enum Constant { + static let priceTTLInHours: Int = 24 + } + + private let cache: QuoteCache + private let priceService: PriceService + + init(priceService: PriceService, + cache: QuoteCache) { + self.priceService = priceService + self.cache = cache + } + + func price(for ticker: String) async throws -> Decimal { + let quoteFromCache = try await cache.quote(for: ticker) + if let quoteFromCache = quoteFromCache, !hasTTLPassed(for: quoteFromCache) { + return quoteFromCache.price + } + + let latestPrice = try await priceService.price(for: ticker) + try await storeCachedPrice(for: quoteFromCache, to: latestPrice, for: ticker) + return latestPrice + } + + func historicalPrice(for ticker: String) async throws -> [DatedQuote] { + let quotes: HistoricalQuote + if let storedQuotes = try await cache.historicalQuote(for: ticker) { + quotes = storedQuotes + } else { + let lastestPrices = try await priceService.historicalPrice(for: ticker) + let historicalQuote = HistoricalQuote(symbol: ticker, datedQuotes: lastestPrices) + try await cache.storeHistoricalQuote(historicalQuote) + quotes = historicalQuote + } + return quotes.datedQuotes + + } + + private func storeCachedPrice(for outdatedQuote: Quote?, + to newPrice: Decimal, + for ticker: String) async throws { + if let outdatedQuote { + outdatedQuote.lastUpdate = Date() + outdatedQuote.price = newPrice + try await cache.storeQuote(outdatedQuote) + } else { + let newQuote = Quote(symbol: ticker, + price: newPrice, + lastUpdate: Date()) + try await cache.storeQuote(newQuote) + } + } + + private func hasTTLPassed(for quote: Quote) -> Bool { + let currentDate = Date() + let components = Calendar.current.dateComponents([.hour], from: quote.lastUpdate, to: currentDate) + + if let hours = components.hour, hours >= Constant.priceTTLInHours { + return true + } + + return false + } +} diff --git a/Sources/Grodt/BusinessLogic/PriceService/LivePriceService.swift b/Sources/Grodt/BusinessLogic/PriceService/LivePriceService.swift new file mode 100644 index 0000000..8537919 --- /dev/null +++ b/Sources/Grodt/BusinessLogic/PriceService/LivePriceService.swift @@ -0,0 +1,37 @@ +import Foundation +import AlphaSwiftage + +class LivePriceService: PriceService { + private let alphavantage: AlphaVantageService + + init(alphavantage: AlphaVantageService) { + self.alphavantage = alphavantage + } + + func price(for ticker: String) async throws -> Decimal { + let result = await alphavantage.quote(for: ticker) + switch result { + case .success(let quote): + return quote.price + case .failure(let error): + throw error + } + } + + func historicalPrice(for ticker: String) async throws -> [DatedQuote] { + let dateFormatter = DateFormatter() + dateFormatter.timeZone = TimeZone.universalGMT + dateFormatter.dateFormat = "yyyy-MM-dd" + + let result = await alphavantage.dailyAdjustedTimeSeries(for: ticker, outputSize: .full) + switch result { + case .success(let quotes): + return quotes.compactMap { dateString, equityDailyData in + guard let date = dateFormatter.date(from: dateString) else { return nil} + return DatedQuote(price: equityDailyData.adjustedClose, date: YearMonthDayDate(date)) + } + case .failure(let error): + throw error + } + } +} diff --git a/Sources/Grodt/BusinessLogic/PriceService/PriceService.swift b/Sources/Grodt/BusinessLogic/PriceService/PriceService.swift new file mode 100644 index 0000000..7b46f27 --- /dev/null +++ b/Sources/Grodt/BusinessLogic/PriceService/PriceService.swift @@ -0,0 +1,6 @@ +import Foundation + +protocol PriceService { + func price(for ticker: String) async throws -> Decimal + func historicalPrice(for ticker: String) async throws -> [DatedQuote] +} diff --git a/Sources/Grodt/BusinessLogic/PriceService/QuoteCache.swift b/Sources/Grodt/BusinessLogic/PriceService/QuoteCache.swift new file mode 100644 index 0000000..677748f --- /dev/null +++ b/Sources/Grodt/BusinessLogic/PriceService/QuoteCache.swift @@ -0,0 +1,11 @@ +import Foundation + +protocol QuoteCache { + func quote(for ticker: String) async throws -> Quote? + func storeQuote(_ quote: Quote) async throws + func clearQuote(for ticker: String) async throws + + func historicalQuote(for ticker: String) async throws -> HistoricalQuote? + func storeHistoricalQuote(_ quote: HistoricalQuote) async throws + func clearHistoricalQuote(for ticker: String) async throws +} diff --git a/Sources/Grodt/BusinessLogic/TransactionChangedHandler.swift b/Sources/Grodt/BusinessLogic/TransactionChangedHandler.swift index 05771b5..3a3e047 100644 --- a/Sources/Grodt/BusinessLogic/TransactionChangedHandler.swift +++ b/Sources/Grodt/BusinessLogic/TransactionChangedHandler.swift @@ -12,16 +12,11 @@ class TransactionChangedHandler: TransactionsControllerDelegate { func transactionCreated(_ transaction: Transaction) async throws { let portfolio = try await portfolioRepository.expandPortfolio(on: transaction) - try await historicalPerformanceUpdater.recalculateHistoricalPerformance(of: portfolio, since: startDateToUpdateTransactions(transaction)) + try await historicalPerformanceUpdater.recalculatePerformance(of: portfolio) } func transactionDeleted(_ transaction: Transaction) async throws { let portfolio = try await portfolioRepository.expandPortfolio(on: transaction) - try await historicalPerformanceUpdater.recalculateHistoricalPerformance(of: portfolio, since: startDateToUpdateTransactions(transaction)) - } - - private func startDateToUpdateTransactions(_ transaction: Transaction) -> Date { - let calender = Calendar.current - return calender.date(byAdding: .day, value: -1, to: transaction.purchaseDate) ?? transaction.purchaseDate + try await historicalPerformanceUpdater.recalculatePerformance(of: portfolio) } } diff --git a/Sources/Grodt/Persistency/Repositories/QuoteRepository.swift b/Sources/Grodt/Persistency/Repositories/QuoteRepository.swift index cf16988..e7fa12e 100644 --- a/Sources/Grodt/Persistency/Repositories/QuoteRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/QuoteRepository.swift @@ -1,25 +1,7 @@ import Foundation import Fluent -protocol QuoteRepository { - func quote(for ticker: String) async throws -> Quote? - - func create(_ quote: Quote) async throws - - func update(_ quote: Quote) async throws - - func allHistoricalQuote() async throws -> [HistoricalQuote] - - func historicalQuote(for ticker: String) async throws -> HistoricalQuote? - - func create(_ historicalQuote: HistoricalQuote) async throws - - func delete(_ historicalQuote: HistoricalQuote) async throws - - func update(_ historicalQuote: HistoricalQuote) async throws -} - -class PostgresQuoteRepository: QuoteRepository { +class PostgresQuoteRepository: QuoteCache { private let database: Database init(database: Database) { @@ -32,18 +14,18 @@ class PostgresQuoteRepository: QuoteRepository { .first() } - func create(_ quote: Quote) async throws { - try await quote.save(on: database) + func storeQuote(_ quote: Quote) async throws { + if quote.$id.exists { + try await quote.update(on: database) + } else { + try await quote.create(on: database) + } } - func update(_ quote: Quote) async throws { - try await quote.update(on: database) + func clearQuote(for ticker: String) async throws { + try await quote(for: ticker)?.delete(on: database) } - func allHistoricalQuote() async throws -> [HistoricalQuote] { - return try await HistoricalQuote.query(on: database) - .all() - } func historicalQuote(for ticker: String) async throws -> HistoricalQuote? { return try await HistoricalQuote.query(on: database) @@ -51,15 +33,15 @@ class PostgresQuoteRepository: QuoteRepository { .first() } - func create(_ historicalQuote: HistoricalQuote) async throws { - try await historicalQuote.save(on: database) - } - - func update(_ historicalQuote: HistoricalQuote) async throws { - try await historicalQuote.update(on: database) + func storeHistoricalQuote(_ quote: HistoricalQuote) async throws { + if quote.$id.exists { + try await quote.update(on: database) + } else { + try await quote.create(on: database) + } } - func delete(_ historicalQuote: HistoricalQuote) async throws { - try await historicalQuote.delete(on: database) + func clearHistoricalQuote(for ticker: String) async throws { + try await historicalQuote(for: ticker)?.delete(on: database) } } From 9bd85e3eb3cf47ffd15c6781ae6655d9a34764a2 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Tue, 3 Dec 2024 20:50:48 +0100 Subject: [PATCH 5/5] Update schedule --- Sources/Grodt/Application/routes.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Grodt/Application/routes.swift b/Sources/Grodt/Application/routes.swift index fce035b..017f762 100644 --- a/Sources/Grodt/Application/routes.swift +++ b/Sources/Grodt/Application/routes.swift @@ -67,6 +67,6 @@ func routes(_ app: Application) async throws { app.queues.schedule(PortfolioPerformanceUpdaterJob(performanceUpdater: portfolioPerformanceUpdater)) .daily() - .at(9, 0) + .at(1, 0) app.queues.add(LoggingJobEventDelegate(logger: app.logger)) }