diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bde7ba9..59a8f40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Set up Swift on Ubuntu - uses: fwal/setup-swift@v1 + uses: swift-actions/setup-swift@v1 with: swift-version: '5.9' - name: Build on Ubuntu diff --git a/Sources/Grodt/Application/migrations.swift b/Sources/Grodt/Application/migrations.swift index 81e6c32..b54a572 100644 --- a/Sources/Grodt/Application/migrations.swift +++ b/Sources/Grodt/Application/migrations.swift @@ -1,13 +1,27 @@ import Vapor func migrations(_ app: Application) throws { + app.migrations.add(Brokerage.Migration()) + app.migrations.add(BrokerageAccount.Migration()) + + app.migrations.add(HistoricalPortfolioPerformanceDaily.Migration()) + app.migrations.add(HistoricalBrokerageAccountPerformanceDaily.Migration()) + app.migrations.add(HistoricalBrokeragePerformanceDaily.Migration()) + app.migrations.add(User.Migration(preconfigured: app.config.preconfiguredUser, logger: app.logger)) app.migrations.add(UserToken.Migration()) app.migrations.add(Portfolio.Migration()) + app.migrations.add(Transaction.Migration()) + app.migrations.add(Transaction.Migration_AddBrokerageAccountID()) + if app.environment != .testing { + app.migrations.add(Transaction.Migration_DropPlatformAccountAndMakeBARequired()) + } + app.migrations.add(Currency.Migration()) app.migrations.add(Ticker.Migration()) app.migrations.add(Quote.Migration()) - app.migrations.add(HistoricalPortfolioPerformance.Migration()) app.migrations.add(HistoricalQuote.Migration()) + + app.migrations.add(DropOldHistoricalPortfolioPerformance()) } diff --git a/Sources/Grodt/Application/routes.swift b/Sources/Grodt/Application/routes.swift index ca58fc9..77bcd62 100644 --- a/Sources/Grodt/Application/routes.swift +++ b/Sources/Grodt/Application/routes.swift @@ -8,27 +8,37 @@ func routes(_ app: Application) async throws { let currencyDTOMapper = CurrencyDTOMapper() let tickerDTOMapper = TickerDTOMapper() let loginResponseDTOMapper = LoginResponseDTOMapper() - let transactionDTOMapper = TransactionDTOMapper(currencyDTOMapper: currencyDTOMapper) + let transactionDTOMapper = TransactionDTOMapper(currencyDTOMapper: currencyDTOMapper, database: app.db) let tickerRepository = PostgresTickerRepository(database: app.db) let livePriceService = LivePriceService(alphavantage: alphavantage) let quoteCache = PostgresQuoteRepository(database: app.db) let priceService = CachedPriceService(priceService: livePriceService, cache: quoteCache) + let performanceCalculator = HoldingsPerformanceCalculator(priceService: priceService) let investmentDTOMapper = InvestmentDTOMapper(currencyDTOMapper: currencyDTOMapper, transactionDTOMapper: transactionDTOMapper, tickerRepository: tickerRepository, priceService: priceService) + + let userRepository = PostgresUserRepository(database: app.db) let portfolioRepository = PostgresPortfolioRepository(database: app.db) - let portfolioPerformanceCalculator = PortfolioPerformanceCalculator(priceService: priceService) + let transactionRepository = PostgresTransactionRepository(database: app.db) + let brokerageRepository = PostgresBrokerageRepository(database: app.db) + let brokerageAccountRepository = PostgresBrokerageAccountRepository(database: app.db) + let brokerageAccountDailyRepository = PostgresBrokerageAccountDailyPerformanceRepository(database: app.db) + let brokerageDailyPerformanceRepository = PostgresBrokerageDailyPerformanceRepository(database: app.db) + let portfolioDTOMapper = PortfolioDTOMapper(investmentDTOMapper: investmentDTOMapper, - currencyDTOMapper: currencyDTOMapper, - performanceCalculator: portfolioPerformanceCalculator) + transactionDTOMapper: transactionDTOMapper, + currencyDTOMapper: currencyDTOMapper) + let currencyRepository = PostgresCurrencyRepository(database: app.db) let portfolioPerformanceUpdater = PortfolioPerformanceUpdater( - userRepository: PostgresUserRepository(database: app.db), + userRepository: userRepository, portfolioRepository: portfolioRepository, tickerRepository: PostgresTickerRepository(database: app.db), quoteCache: quoteCache, priceService: priceService, - performanceCalculator: portfolioPerformanceCalculator) + performanceCalculator: performanceCalculator, + portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository(db: app.db)) let transactionChangedHandler = TransactionChangedHandler(portfolioRepository: PostgresPortfolioRepository(database: app.db), historicalPerformanceUpdater: portfolioPerformanceUpdater) @@ -41,7 +51,7 @@ func routes(_ app: Application) async throws { let investmentsController = InvestmentController(portfolioRepository: portfolioRepository, dataMapper: investmentDTOMapper) - let accountController = AccountController(userRepository: PostgresUserRepository(database: app.db), dataMapper: UserDTOMapper()) + let accountController = AccountController(userRepository: userRepository, dataMapper: UserDTOMapper()) let globalRateLimiter = RateLimiterMiddleware(maxRequests: 100, perSeconds: 60) let loginRateLimiter = RateLimiterMiddleware(maxRequests: 3, perSeconds: 60) @@ -63,26 +73,54 @@ func routes(_ app: Application) async throws { try protected.register(collection: PortfoliosController( portfolioRepository: PostgresPortfolioRepository(database: app.db), - currencyRepository: PostgresCurrencyRepository(database: app.db), + currencyRepository: currencyRepository, historicalPortfolioPerformanceUpdater: portfolioPerformanceUpdater, + portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository(db: app.db), dataMapper: portfolioDTOMapper) ) - let transactionController = TransactionsController(transactionsRepository: PostgresTransactionRepository(database: app.db), - currencyRepository: PostgresCurrencyRepository(database: app.db), + let transactionController = TransactionsController(transactionsRepository: transactionRepository, + currencyRepository: currencyRepository, dataMapper: transactionDTOMapper) transactionController.delegate = transactionChangedHandler try protected.register(collection: transactionController) try protected.register(collection: tickersController) try protected.register(collection: investmentsController) try protected.register(collection: accountController) + try protected.register(collection: BrokerageController(brokerageRepository: brokerageRepository, + dtoMapper: BrokerageDTOMapper(brokerageRepository: brokerageRepository, + accountDTOMapper: BrokerageAccountDTOMapper(brokerageAccountRepository: brokerageAccountRepository, + currencyMapper: currencyDTOMapper, database: app.db), + database: app.db), + accounts: brokerageAccountRepository, + currencyMapper: currencyDTOMapper, + performanceRepository: brokerageDailyPerformanceRepository, + performancePointDTOMapper: PerformancePointDTOMapper())) + try protected.register(collection: BrokerageAccountController(brokerageAccountRepository: brokerageAccountRepository, + currencyMapper: currencyDTOMapper, + currencyRepository: currencyRepository)) } if app.environment != .testing { - let portfolioUpdaterJob = PortfolioPerformanceUpdaterJob(performanceUpdater: portfolioPerformanceUpdater) - app.queues.schedule(portfolioUpdaterJob) + let nightlyUpdaterJob = NightlyUpdaterJob( + tickerPriceUpdater: TickerPriceUpdater(tickerRepository: tickerRepository, + quoteCache: quoteCache, + priceService: priceService), + portfolioPerformanceUpdater: portfolioPerformanceUpdater, + brokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdater(transactionRepository: transactionRepository, + brokerageAccountRepository: brokerageAccountRepository, + accountDailyRepository: brokerageAccountDailyRepository, + userRepository: userRepository, + calculator: performanceCalculator), + brokeragePerformanceUpdater: BrokeragePerformanceUpdater(userRepository: userRepository, + brokerageAccountRepository: brokerageAccountRepository, + accountDailyRepository: brokerageAccountDailyRepository, + brokerageDailyRepository: brokerageDailyPerformanceRepository) + ) + app.queues.schedule(nightlyUpdaterJob) .daily() .at(3, 0) + app.queues.add(LoggingJobEventDelegate(logger: app.logger)) let userTokenCleanerJob = UserTokenClearUpJob(userTokenClearing: UserTokenClearer(database: app.db)) diff --git a/Sources/Grodt/BusinessLogic/NightlyUpdaterJob.swift b/Sources/Grodt/BusinessLogic/NightlyUpdaterJob.swift new file mode 100644 index 0000000..b60020f --- /dev/null +++ b/Sources/Grodt/BusinessLogic/NightlyUpdaterJob.swift @@ -0,0 +1,60 @@ +import Vapor +import Queues +import Foundation + +struct NightlyUpdaterJob: AsyncScheduledJob, @unchecked Sendable { + private let tickerPriceUpdater: TickerPriceUpdating + private let portfolioPerformanceUpdater: PortfolioPerformanceUpdating + private let brokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdating + private let brokeragePerformanceUpdater: BrokeragePerformanceUpdating + + init( + tickerPriceUpdater: TickerPriceUpdating, + portfolioPerformanceUpdater: PortfolioPerformanceUpdating, + brokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdating, + brokeragePerformanceUpdater: BrokeragePerformanceUpdating + ) { + self.tickerPriceUpdater = tickerPriceUpdater + self.portfolioPerformanceUpdater = portfolioPerformanceUpdater + self.brokerageAccountPerformanceUpdater = brokerageAccountPerformanceUpdater + self.brokeragePerformanceUpdater = brokeragePerformanceUpdater + } + + func run(context: Queues.QueueContext) async throws { + context.logger.info("NightlyUpdaterJob – Job started") + try await logStep("Update all ticker prices", context: context) { + try await tickerPriceUpdater.updateAllTickerPrices() + } + try await logStep("Update all portfolio performance", context: context) { + try await portfolioPerformanceUpdater.updateAllPortfolioPerformance() + } + try await logStep("Update all brokerage account performance", context: context) { + try await brokerageAccountPerformanceUpdater.updateAllBrokerageAccountPerformance() + } + try await logStep("Update all brokerage performance", context: context) { + try await brokeragePerformanceUpdater.updateAllBrokeragePerformance() + } + context.logger.info("NightlyUpdaterJob – Job finished") + } + + @discardableResult + private func logStep(_ name: String, context: Queues.QueueContext, _ work: () async throws -> T) async throws -> T { + context.logger.info("NightlyUpdaterJob – START: \(name)") + let clock = ContinuousClock() + let t0 = clock.now + do { + let value = try await work() + let duration = t0.duration(to: clock.now) + let comps = duration.components + let seconds = Double(comps.seconds) + Double(comps.attoseconds) / 1_000_000_000_000_000_000.0 + context.logger.info("NightlyUpdaterJob – END: \(name) in \(String(format: "%.3f", seconds))s") + return value + } catch { + let duration = t0.duration(to: clock.now) + let comps = duration.components + let seconds = Double(comps.seconds) + Double(comps.attoseconds) / 1_000_000_000_000_000_000.0 + context.logger.error("NightlyUpdaterJob – ERROR during \(name) after \(String(format: "%.3f", seconds))s: \(String(describing: error))") + throw error + } + } +} diff --git a/Sources/Grodt/BusinessLogic/Performance Calculating/BrokerageAccountPerformanceUpdater.swift b/Sources/Grodt/BusinessLogic/Performance Calculating/BrokerageAccountPerformanceUpdater.swift new file mode 100644 index 0000000..fb3cca2 --- /dev/null +++ b/Sources/Grodt/BusinessLogic/Performance Calculating/BrokerageAccountPerformanceUpdater.swift @@ -0,0 +1,75 @@ +import Queues +import Fluent + +protocol BrokerageAccountPerformanceUpdating { + func updateAllBrokerageAccountPerformance() async throws +} + +class BrokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdating { + private let brokerageAccountRepository: BrokerageAccountRepository + private let transactionRepository: TransactionsRepository + private let accountDailyRepository: PostgresBrokerageAccountDailyPerformanceRepository + private let userRepository: UserRepository + private let calculator: HoldingsPerformanceCalculating + + init(transactionRepository: TransactionsRepository, + brokerageAccountRepository: BrokerageAccountRepository, + accountDailyRepository: PostgresBrokerageAccountDailyPerformanceRepository, + userRepository: UserRepository, + calculator: HoldingsPerformanceCalculating) { + self.transactionRepository = transactionRepository + self.brokerageAccountRepository = brokerageAccountRepository + self.accountDailyRepository = accountDailyRepository + self.userRepository = userRepository + self.calculator = calculator + } + + func updateAllBrokerageAccountPerformance() async throws { + let users = try await userRepository.allUsers() + for user in users { + try await updateAllAccounts(for: user) + } + } + + private func updateAllAccounts(for user: User) async throws { + guard let userID = user.id else { return } + + let accounts = try await brokerageAccountRepository.all(for: userID) + let userTransactions = try await transactionRepository.all(for: userID) + + for account in accounts { + try await updateSingleAccount(account, with: userTransactions) + } + } + + private func updateSingleAccount(_ account: BrokerageAccount, with userTransactions: [Transaction]) async throws { + let accountID = try account.requireID() + + // Keep only transactions linked to this account (explicit loop avoids any Fluent `filter` ambiguity) + var accountTransactions: [Transaction] = [] + accountTransactions.reserveCapacity(userTransactions.count) + for transaction in userTransactions { + let linkedID = transaction.$brokerageAccount.id ?? transaction.brokerageAccount?.id + if linkedID == accountID { + accountTransactions.append(transaction) + } + } + + // No transactions → clear any stored series + guard let earliest = accountTransactions.map(\.purchaseDate).min() else { + try await accountDailyRepository.deleteAll(for: accountID) + return + } + + let start = YearMonthDayDate(earliest) + let end = YearMonthDayDate(Date()) + + let series = try await calculator.performanceSeries( + for: accountTransactions, + from: start, + to: end + ) + + try await accountDailyRepository.replaceSeries(for: accountID, with: series) + } +} diff --git a/Sources/Grodt/BusinessLogic/Performance Calculating/BrokeragePerformanceUpdater.swift b/Sources/Grodt/BusinessLogic/Performance Calculating/BrokeragePerformanceUpdater.swift new file mode 100644 index 0000000..9bbd2cc --- /dev/null +++ b/Sources/Grodt/BusinessLogic/Performance Calculating/BrokeragePerformanceUpdater.swift @@ -0,0 +1,107 @@ +import Foundation +import Fluent + +protocol BrokeragePerformanceUpdating { + func updateAllBrokeragePerformance() async throws +} + +final class BrokeragePerformanceUpdater: BrokeragePerformanceUpdating { + // MARK: - Dependencies + private let userRepository: UserRepository + private let brokerageAccountRepository: BrokerageAccountRepository + private let accountDailyRepository: PostgresBrokerageAccountDailyPerformanceRepository + private let brokerageDailyRepository: PostgresBrokerageDailyPerformanceRepository + + // MARK: - Init + init(userRepository: UserRepository, + brokerageAccountRepository: BrokerageAccountRepository, + accountDailyRepository: PostgresBrokerageAccountDailyPerformanceRepository, + brokerageDailyRepository: PostgresBrokerageDailyPerformanceRepository) { + self.userRepository = userRepository + self.brokerageAccountRepository = brokerageAccountRepository + self.accountDailyRepository = accountDailyRepository + self.brokerageDailyRepository = brokerageDailyRepository + } + + func updateAllBrokeragePerformance() async throws { + let users = try await userRepository.allUsers() + for user in users { + try await updateAllBrokerages(for: user) + } + } + + private func updateAllBrokerages(for user: User) async throws { + guard let userID = user.id else { return } + + // Load all accounts for the user once (avoid N+1) + let accounts = try await brokerageAccountRepository.all(for: userID) + + // Group accounts by brokerage id + var accountsByBrokerage: [UUID: [BrokerageAccount]] = [:] + accountsByBrokerage.reserveCapacity(accounts.count) + for account in accounts { + let brokerageID = account.$brokerage.id + accountsByBrokerage[brokerageID, default: []].append(account) + } + + // Update each brokerage + for (brokerageID, accounts) in accountsByBrokerage { + try await updateSingleBrokerage(brokerageID, accounts: accounts) + } + } + + private func updateSingleBrokerage(_ brokerageID: UUID, accounts: [BrokerageAccount]) async throws { + guard !accounts.isEmpty else { + // No accounts → clear any stored series for this brokerage + try await brokerageDailyRepository.deleteAll(for: brokerageID) + return + } + + // Read each account's full series and track the global date window + var perAccountSeries: [[YearMonthDayDate: DatedPortfolioPerformance]] = [] + perAccountSeries.reserveCapacity(accounts.count) + + var earliestDate: Date? + for account in accounts { + let accountID = try account.requireID() + let series = try await accountDailyRepository.readSeries(for: accountID, from: nil, to: nil) + if let firstDate = series.first?.date.date { + earliestDate = min(earliestDate ?? firstDate, firstDate) + } + // Index by date for O(1) lookups during summation + var map: [YearMonthDayDate: DatedPortfolioPerformance] = [:] + map.reserveCapacity(series.count) + for point in series { map[point.date] = point } + perAccountSeries.append(map) + } + + // If no account has any data, clear and return + guard let startDate = earliestDate else { + try await brokerageDailyRepository.deleteAll(for: brokerageID) + return + } + + let start = YearMonthDayDate(startDate) + let end = YearMonthDayDate(Date()) + let days = YearMonthDayDate.days(from: start, to: end) + + // Sum across accounts for each day + var summed: [DatedPortfolioPerformance] = [] + summed.reserveCapacity(days.count) + + for day in days { + var moneyIn: Decimal = 0 + var value: Decimal = 0 + for seriesMap in perAccountSeries { + if let point = seriesMap[day] { + moneyIn += point.moneyIn + value += point.value + } + } + summed.append(DatedPortfolioPerformance(moneyIn: moneyIn, value: value, date: day)) + } + + // Replace the brokerage's series + try await brokerageDailyRepository.replaceSeries(for: brokerageID, with: summed) + } +} diff --git a/Sources/Grodt/BusinessLogic/Performance Calculating/HoldingsPerformanceCalculating.swift b/Sources/Grodt/BusinessLogic/Performance Calculating/HoldingsPerformanceCalculating.swift new file mode 100644 index 0000000..5f7fcfc --- /dev/null +++ b/Sources/Grodt/BusinessLogic/Performance Calculating/HoldingsPerformanceCalculating.swift @@ -0,0 +1,193 @@ +import Foundation + +protocol HoldingsPerformanceCalculating { + func performanceSeries(for transactions: [Transaction], from startDate: YearMonthDayDate, to endDate: YearMonthDayDate) async throws -> [DatedPortfolioPerformance] +} + +struct HoldingsPerformanceCalculator: HoldingsPerformanceCalculating { + let priceService: PriceService + + /// Computes an inclusive daily time series from `startDate` to `endDate`. + /// The implementation is **event-driven**: it updates state only when a transaction occurs + /// or when a quote changes, and carries prices forward across non-trading days. + func performanceSeries( + for transactions: [Transaction], + from startDate: YearMonthDayDate, + to endDate: YearMonthDayDate + ) async throws -> [DatedPortfolioPerformance] { + guard !transactions.isEmpty else { return [] } + guard endDate >= startDate else { return [] } + + // 1) Normalize inputs and build the day range. + let sortedTransactions = transactions.sorted { $0.purchaseDate < $1.purchaseDate } + let days = YearMonthDayDate.days(from: startDate, to: endDate) + let symbols = distinctTickers(from: sortedTransactions) + + // 2) Prefetch quotes and build price-change events per day + baselines at start. + var baselinePrices: [Symbol: Decimal] = [:] + let priceEventsByDay = try await buildPriceEvents( + for: symbols, + baseline: &baselinePrices, + startingAt: startDate, + endingAt: endDate + ) + + // 3) Establish initial running state at start date (moneyIn/qty/value and transaction cursor). + var state = Self.initialState(at: startDate, with: sortedTransactions, baselinePrices: baselinePrices) + + // 4) Sweep day-by-day, applying new transactions and price-change events. + var series: [DatedPortfolioPerformance] = [] + series.reserveCapacity(days.count) + + for day in days { + advanceTransactions(upToAndIncluding: day, transactions: sortedTransactions, state: &state) + applyPriceEvents(on: day, events: priceEventsByDay[day.date] ?? [], state: &state) + series.append(.init(moneyIn: state.moneyIn, value: state.value, date: day)) + } + + return series + } + + // MARK: - Private helpers (domain types) + + private typealias Symbol = String + + /// Rolling state that evolves over time while we sweep days. + private struct RunningState { + var moneyIn: Decimal + var value: Decimal + var quantityByTicker: [Symbol: Decimal] + var lastPriceByTicker: [Symbol: Decimal] + var txCursor: Int + } + + // MARK: - Private helpers (pure functions) + + /// Prefetches quotes once per symbol and returns (a) a day-indexed map of price-change events and + /// (b) the baseline price for each symbol as of `startDate` (last quote on or before start). + private func buildPriceEvents( + for symbols: [Symbol], + baseline: inout [Symbol: Decimal], + startingAt startDate: YearMonthDayDate, + endingAt endDate: YearMonthDayDate + ) async throws -> [Date: [(Symbol, Decimal)]] { + var eventsByDay: [Date: [(Symbol, Decimal)]] = [:] + + for symbol in symbols { + var quotes = try await priceService.historicalPrice(for: symbol) + guard !quotes.isEmpty else { continue } + quotes.sort { $0.date < $1.date } + + // Establish baseline ≤ startDate + var i = 0 + var last: Decimal? + while i < quotes.count, quotes[i].date <= startDate { + last = quotes[i].price + i += 1 + } + if let last { baseline[symbol] = last } + + // Record future quote changes only within the window + while i < quotes.count, quotes[i].date <= endDate { + eventsByDay[quotes[i].date.date, default: []].append((symbol, quotes[i].price)) + i += 1 + } + } + + return eventsByDay + } + + /// Computes the initial running state at `startDate` by applying all transactions up to that day. + private static func initialState( + at startDate: YearMonthDayDate, + with sortedTransactions: [Transaction], + baselinePrices: [Symbol: Decimal] + ) -> RunningState { + var moneyIn: Decimal = 0 + var value: Decimal = 0 + var quantityByTicker: [Symbol: Decimal] = [:] + var cursor = 0 + + // Apply all transactions up to and including the start day + while cursor < sortedTransactions.count { + let tx = sortedTransactions[cursor] + let txDay = YearMonthDayDate(tx.purchaseDate) + if txDay > startDate { break } + + moneyIn += (tx.numberOfShares * tx.pricePerShareAtPurchase) + tx.fees + let newQty = (quantityByTicker[tx.ticker] ?? 0) + tx.numberOfShares + quantityByTicker[tx.ticker] = newQty + + if let px = baselinePrices[tx.ticker] { + // Adjust value by the contribution of this delta quantity at baseline price + value += tx.numberOfShares * px + } + + cursor += 1 + } + + // If we have quantities but no baseline prices yet, compute value from whatever baselines exist + if value == 0, !quantityByTicker.isEmpty { + var v: Decimal = 0 + for (symbol, qty) in quantityByTicker where qty != 0 { + if let px = baselinePrices[symbol] { v += qty * px } + } + value = v + } + + return RunningState( + moneyIn: moneyIn, + value: value, + quantityByTicker: quantityByTicker, + lastPriceByTicker: baselinePrices, + txCursor: cursor + ) + } + + /// Applies all transactions whose date is ≤ `day`, advancing the transaction cursor and updating state. + private func advanceTransactions( + upToAndIncluding day: YearMonthDayDate, + transactions: [Transaction], + state: inout RunningState + ) { + while state.txCursor < transactions.count { + let tx = transactions[state.txCursor] + let txDay = YearMonthDayDate(tx.purchaseDate) + if txDay > day { break } + + state.moneyIn += (tx.numberOfShares * tx.pricePerShareAtPurchase) + tx.fees + + let prevQty = state.quantityByTicker[tx.ticker] ?? 0 + let newQty = prevQty + tx.numberOfShares + state.quantityByTicker[tx.ticker] = newQty + + if let px = state.lastPriceByTicker[tx.ticker] { + // Adjust current value by the contribution of the delta quantity at current price + state.value += tx.numberOfShares * px + } + + state.txCursor += 1 + } + } + + /// Applies price changes for the given day (if any), adjusting portfolio value using the current quantities. + private func applyPriceEvents(on day: YearMonthDayDate, events: [(Symbol, Decimal)], state: inout RunningState) { + guard !events.isEmpty else { return } + + for (symbol, newPx) in events { + let oldPx = state.lastPriceByTicker[symbol] + state.lastPriceByTicker[symbol] = newPx + + guard let qty = state.quantityByTicker[symbol], qty != 0 else { continue } + if let oldPx { + state.value += qty * (newPx - oldPx) + } else { + state.value += qty * newPx + } + } + } + + private func distinctTickers(from array: [Transaction]) -> [Symbol] { + Array(Set(array.map { $0.ticker })) + } +} diff --git a/Sources/Grodt/BusinessLogic/Performance Calculating/PortfolioPerformanceUpdater.swift b/Sources/Grodt/BusinessLogic/Performance Calculating/PortfolioPerformanceUpdater.swift new file mode 100644 index 0000000..43b4f25 --- /dev/null +++ b/Sources/Grodt/BusinessLogic/Performance Calculating/PortfolioPerformanceUpdater.swift @@ -0,0 +1,83 @@ +import Foundation +import Fluent + +protocol PortfolioPerformanceUpdating { + func recalculatePerformance(of portfolio: Portfolio) async throws + func updateAllPortfolioPerformance() async throws +} + +class PortfolioPerformanceUpdater: PortfolioPerformanceUpdating { + private let userRepository: UserRepository + private let portfolioRepository: PortfolioRepository + private let tickerRepository: TickerRepository + private let quoteCache: QuoteCache + private let priceService: PriceService + private let performanceCalculator: HoldingsPerformanceCalculating + + private let rateLimiter = RateLimiter(maxRequestsPerMinute: 5) + private let portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository + + init(userRepository: UserRepository, + portfolioRepository: PortfolioRepository, + tickerRepository: TickerRepository, + quoteCache: QuoteCache, + priceService: PriceService, + performanceCalculator: HoldingsPerformanceCalculating, + portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository) { + self.userRepository = userRepository + self.portfolioRepository = portfolioRepository + self.tickerRepository = tickerRepository + self.quoteCache = quoteCache + self.priceService = priceService + self.performanceCalculator = performanceCalculator + self.portfolioDailyRepo = portfolioDailyRepo + } + + func updateAllPortfolioPerformance() 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) + } + } + } + + func recalculatePerformance(of portfolio: Portfolio) async throws { + let transactions = portfolio.transactions + + // No transactions: clear daily rows and return + guard !transactions.isEmpty else { + try await portfolioDailyRepo.deleteAll(for: try portfolio.requireID()) + return + } + + let earliestDate = transactions.min(by: { $0.purchaseDate < $1.purchaseDate })!.purchaseDate + let start = YearMonthDayDate(earliestDate) + let end = YearMonthDayDate(Date()) + + let datedPerformance = try await performanceCalculator.performanceSeries( + for: transactions, + from: start, + to: end + ) + + try await portfolioDailyRepo.replaceSeries(for: try portfolio.requireID(), with: datedPerformance) + } + + private func dateRangeUntilToday(from startDate: Date) -> [YearMonthDayDate] { + var dates: [YearMonthDayDate] = [] + var currentDate = startDate + let calendar = Calendar.current + let today = Date() + + + while currentDate <= today { + let ymdDate = YearMonthDayDate(currentDate) + dates.append(ymdDate) + currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)! + } + + return dates + } +} diff --git a/Sources/Grodt/BusinessLogic/Performance Calculating/TickerPriceUpdating.swift b/Sources/Grodt/BusinessLogic/Performance Calculating/TickerPriceUpdating.swift new file mode 100644 index 0000000..35ac601 --- /dev/null +++ b/Sources/Grodt/BusinessLogic/Performance Calculating/TickerPriceUpdating.swift @@ -0,0 +1,38 @@ +protocol TickerPriceUpdating { + func updateAllTickerPrices() async throws +} + +class TickerPriceUpdater: TickerPriceUpdating { + private let tickerRepository: TickerRepository + private let priceService: PriceService + private let quoteCache: QuoteCache + + private let rateLimiter = RateLimiter(maxRequestsPerMinute: 5) + + init(tickerRepository: TickerRepository, + quoteCache: QuoteCache, + priceService: PriceService) { + self.tickerRepository = tickerRepository + self.quoteCache = quoteCache + self.priceService = priceService + } + + func updateAllTickerPrices() async throws { + print("\(#function)") + let allTickers = try await tickerRepository.allTickers() + for ticker in allTickers { + try await clearCache(for: ticker.symbol) + + print("\(#function) \(ticker.symbol)") + await rateLimiter.waitIfNeeded() + _ = try await priceService.historicalPrice(for: ticker.symbol) + await rateLimiter.waitIfNeeded() + _ = try await priceService.price(for: ticker.symbol) + } + } + + private func clearCache(for ticker: String) async throws { + try await quoteCache.clearHistoricalQuote(for: ticker) + try await quoteCache.clearQuote(for: ticker) + } +} diff --git a/Sources/Grodt/BusinessLogic/PortfolioPerformance/InMemoryTickerPriceCache/DictInMemoryTickerPriceCache.swift b/Sources/Grodt/BusinessLogic/PortfolioPerformance/InMemoryTickerPriceCache/DictInMemoryTickerPriceCache.swift deleted file mode 100644 index 556f931..0000000 --- a/Sources/Grodt/BusinessLogic/PortfolioPerformance/InMemoryTickerPriceCache/DictInMemoryTickerPriceCache.swift +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 6694dc4..0000000 --- a/Sources/Grodt/BusinessLogic/PortfolioPerformance/InMemoryTickerPriceCache/InMemoryTickerPriceCache.swift +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index b26f491..0000000 --- a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceCalculator.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Foundation - -protocol PortfolioPerformanceCalculating { - func performance(of portfolio: Portfolio, - on date: YearMonthDayDate, - using cache: InMemoryTickerPriceCache) async throws -> DatedPortfolioPerformance -} - -class PortfolioPerformanceCalculator: PortfolioPerformanceCalculating { - private let priceService: PriceService - - init(priceService: PriceService) { - self.priceService = priceService - } - - func performance( - of portfolio: Portfolio, - on date: YearMonthDayDate, - using cache: InMemoryTickerPriceCache - ) async throws -> DatedPortfolioPerformance { - let transactionsUntilDate = portfolio.transactions.filter { YearMonthDayDate($0.purchaseDate) <= date } - - let financialsForDate = Financials() - - for transaction in transactionsUntilDate { - let inAmount = transaction.numberOfShares * transaction.pricePerShareAtPurchase + transaction.fees - await financialsForDate.addMoneyIn(inAmount) - let price = try await self.price(for: transaction.ticker, on: date, using: cache) - let value = transaction.numberOfShares * price - await financialsForDate.addValue(value) - } - - let performanceForDate = DatedPortfolioPerformance( - moneyIn: await financialsForDate.moneyIn, - value: await financialsForDate.value, - date: date - ) - return performanceForDate - } - - 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 deleted file mode 100644 index d0b20e3..0000000 --- a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdater.swift +++ /dev/null @@ -1,115 +0,0 @@ -import Foundation - -protocol PortfolioHistoricalPerformanceUpdater { - func recalculatePerformance(of portfolio: Portfolio) async throws - func updatePerformanceOfAllPortfolios() async throws -} - -class PortfolioPerformanceUpdater: PortfolioHistoricalPerformanceUpdater { - private let userRepository: UserRepository - private let portfolioRepository: PortfolioRepository - private let tickerRepository: TickerRepository - private let quoteCache: QuoteCache - private let priceService: PriceService - private let performanceCalculator: PortfolioPerformanceCalculating - - private let rateLimiter = RateLimiter(maxRequestsPerMinute: 5) - - init(userRepository: UserRepository, - portfolioRepository: PortfolioRepository, - tickerRepository: TickerRepository, - quoteCache: QuoteCache, - priceService: PriceService, - performanceCalculator: PortfolioPerformanceCalculating) { - self.userRepository = userRepository - self.portfolioRepository = portfolioRepository - self.tickerRepository = tickerRepository - self.quoteCache = quoteCache - self.priceService = priceService - self.performanceCalculator = performanceCalculator - } - - func updatePerformanceOfAllPortfolios() async throws { - try await updateAllTickerPrices() - try await updateHistotrycalPerformances() - } - - func recalculatePerformance(of portfolio: Portfolio) async throws { - var datedPerformance = [DatedPortfolioPerformance]() - let startDate: Date = { - guard let earliestTransaction = portfolio.earliestTransaction else { return Date() } - return earliestTransaction.purchaseDate - }() - - 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, - using: dictCache) - datedPerformance.append(performanceForDate) - } - - if let perf = portfolio.historicalPerformance { - perf.datedPerformance = datedPerformance - try await portfolioRepository.updateHistoricalPerformance(perf) - } else { - let historicalPerformance = HistoricalPortfolioPerformance( - portfolioID: portfolio.id!, - datedPerformance: datedPerformance - ) - 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] = [] - var currentDate = startDate - let calendar = Calendar.current - let today = Date() - - - while currentDate <= today { - let ymdDate = YearMonthDayDate(currentDate) - dates.append(ymdDate) - currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)! - } - - return dates - } -} diff --git a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdaterJob.swift b/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdaterJob.swift deleted file mode 100644 index 9a81684..0000000 --- a/Sources/Grodt/BusinessLogic/PortfolioPerformance/PortfolioPerformanceUpdaterJob.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Vapor -import Queues - -struct PortfolioPerformanceUpdaterJob: AsyncScheduledJob, @unchecked Sendable { - private let performanceUpdater: PortfolioHistoricalPerformanceUpdater - - init(performanceUpdater: PortfolioHistoricalPerformanceUpdater) { - self.performanceUpdater = performanceUpdater - } - - func run(context: Queues.QueueContext) async throws { - try await performanceUpdater.updatePerformanceOfAllPortfolios() - } -} diff --git a/Sources/Grodt/BusinessLogic/PriceService/CachedPriceService.swift b/Sources/Grodt/BusinessLogic/PriceService/CachedPriceService.swift index 22fdbba..d8a6dd0 100644 --- a/Sources/Grodt/BusinessLogic/PriceService/CachedPriceService.swift +++ b/Sources/Grodt/BusinessLogic/PriceService/CachedPriceService.swift @@ -41,8 +41,8 @@ class CachedPriceService: PriceService { } private func storeCachedPrice(for outdatedQuote: Quote?, - to newPrice: Decimal, - for ticker: String) async throws { + to newPrice: Decimal, + for ticker: String) async throws { if let outdatedQuote { outdatedQuote.lastUpdate = Date() outdatedQuote.price = newPrice diff --git a/Sources/Grodt/BusinessLogic/Request+requiredParameter.swift b/Sources/Grodt/BusinessLogic/Request+requiredParameter.swift index 7498941..1fa7eee 100644 --- a/Sources/Grodt/BusinessLogic/Request+requiredParameter.swift +++ b/Sources/Grodt/BusinessLogic/Request+requiredParameter.swift @@ -13,4 +13,11 @@ extension Request { func requiredID() throws -> UUID { try requiredParameter(named: "id") } + + func requireUserID() throws -> UUID { + guard let userID = auth.get(User.self)?.id else { + throw Abort(.badRequest) + } + return userID + } } diff --git a/Sources/Grodt/BusinessLogic/TransactionChangedHandler.swift b/Sources/Grodt/BusinessLogic/TransactionChangedHandler.swift index 3a3e047..94b3a3a 100644 --- a/Sources/Grodt/BusinessLogic/TransactionChangedHandler.swift +++ b/Sources/Grodt/BusinessLogic/TransactionChangedHandler.swift @@ -2,10 +2,10 @@ import Foundation class TransactionChangedHandler: TransactionsControllerDelegate { private let portfolioRepository: PortfolioRepository - private let historicalPerformanceUpdater: PortfolioHistoricalPerformanceUpdater + private let historicalPerformanceUpdater: PortfolioPerformanceUpdating init(portfolioRepository: PortfolioRepository, - historicalPerformanceUpdater: PortfolioHistoricalPerformanceUpdater) { + historicalPerformanceUpdater: PortfolioPerformanceUpdating) { self.portfolioRepository = portfolioRepository self.historicalPerformanceUpdater = historicalPerformanceUpdater } diff --git a/Sources/Grodt/DTOs/BrokerageDTO.swift b/Sources/Grodt/DTOs/BrokerageDTO.swift new file mode 100644 index 0000000..59376d0 --- /dev/null +++ b/Sources/Grodt/DTOs/BrokerageDTO.swift @@ -0,0 +1,37 @@ +import Foundation + +struct BrokerageDTO: Codable { + let id: UUID + let name: String + let accounts: [BrokerageAccountDTO] + let totals: PerformanceTotalsDTO? +} + +struct BrokerageAccountDTO: Codable { + let id: UUID + let brokerageId: UUID + let brokerageName: String + let displayName: String + let baseCurrency: CurrencyDTO + let totals: PerformanceTotalsDTO? +} + +struct PerformanceTotalsDTO: Codable { + private let value: Decimal + private let moneyIn: Decimal + + init(value: Decimal, moneyIn: Decimal) { + self.value = value + self.moneyIn = moneyIn + } + + init() { + self.init(value: 0, moneyIn: 0) + } +} + +struct PerformancePointDTO: Codable { + let date: Date + let value: Decimal + let moneyIn: Decimal +} diff --git a/Sources/Grodt/DTOs/CreateTransactionRequestDTO.swift b/Sources/Grodt/DTOs/CreateTransactionRequestDTO.swift index cb3f60a..131647c 100644 --- a/Sources/Grodt/DTOs/CreateTransactionRequestDTO.swift +++ b/Sources/Grodt/DTOs/CreateTransactionRequestDTO.swift @@ -2,8 +2,7 @@ import Foundation struct CreateTransactionRequestDTO: Decodable { let portfolio: String - let platform: String - let account: String? + let brokerageAccountID: String? let purchaseDate: Date let ticker: String let currency: String @@ -12,14 +11,13 @@ struct CreateTransactionRequestDTO: Decodable { let pricePerShare: Decimal enum CodingKeys: String, CodingKey { - case portfolio, platform, account, purchaseDate, ticker, currency, fees, numberOfShares, pricePerShare + case portfolio, brokerageAccountID, platform, account, purchaseDate, ticker, currency, fees, numberOfShares, pricePerShare } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) portfolio = try container.decode(String.self, forKey: .portfolio) - platform = try container.decode(String.self, forKey: .platform) - account = try container.decodeIfPresent(String.self, forKey: .account) + brokerageAccountID = try container.decodeIfPresent(String.self, forKey: .brokerageAccountID) ticker = try container.decode(String.self, forKey: .ticker) currency = try container.decode(String.self, forKey: .currency) fees = try container.decode(Decimal.self, forKey: .fees) diff --git a/Sources/Grodt/DTOs/DTOMappers/InvestmentDTOMapper.swift b/Sources/Grodt/DTOs/DTOMappers/InvestmentDTOMapper.swift index a6e03b7..2c1a992 100644 --- a/Sources/Grodt/DTOs/DTOMappers/InvestmentDTOMapper.swift +++ b/Sources/Grodt/DTOs/DTOMappers/InvestmentDTOMapper.swift @@ -58,9 +58,9 @@ class InvestmentDTOMapper { } } - func investmentDetail(from transactions: [Transaction])async throws -> InvestmentDetailDTO { + func investmentDetail(from transactions: [Transaction]) async throws -> InvestmentDetailDTO { let investmentDTO = try await investments(from: transactions).first! - let transactions = transactions.compactMap { transactionDTOMapper.transaction(from: $0) } + let transactions = try await transactions.asyncCompactMap { try await transactionDTOMapper.transaction(from: $0) } return InvestmentDetailDTO(name: investmentDTO.name, shortName: investmentDTO.shortName, avgBuyPrice: investmentDTO.avgBuyPrice, diff --git a/Sources/Grodt/DTOs/DTOMappers/PerformancePointDTOMapper.swift b/Sources/Grodt/DTOs/DTOMappers/PerformancePointDTOMapper.swift new file mode 100644 index 0000000..3ebaa34 --- /dev/null +++ b/Sources/Grodt/DTOs/DTOMappers/PerformancePointDTOMapper.swift @@ -0,0 +1,7 @@ +struct PerformancePointDTOMapper { + func performancePoint(from enity: DatedPortfolioPerformance) -> PerformancePointDTO { + return PerformancePointDTO(date: enity.date.date, + value: enity.value, + moneyIn: enity.moneyIn) + } +} diff --git a/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift b/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift index b4b1bf5..3445a24 100644 --- a/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift +++ b/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift @@ -4,24 +4,26 @@ import CollectionConcurrencyKit class PortfolioDTOMapper { private let investmentDTOMapper: InvestmentDTOMapper private let currencyDTOMapper: CurrencyDTOMapper - private let performanceCalculator: PortfolioPerformanceCalculating + private let transactionDTOMapper: TransactionDTOMapper init(investmentDTOMapper: InvestmentDTOMapper, - currencyDTOMapper: CurrencyDTOMapper, - performanceCalculator: PortfolioPerformanceCalculating) { + transactionDTOMapper: TransactionDTOMapper, + currencyDTOMapper: CurrencyDTOMapper) { self.investmentDTOMapper = investmentDTOMapper + self.transactionDTOMapper = transactionDTOMapper self.currencyDTOMapper = currencyDTOMapper - self.performanceCalculator = performanceCalculator } func portfolio(from portfolio: Portfolio) async throws -> PortfolioDTO { let investments = try await investmentDTOMapper.investments(from: portfolio.transactions) + let transactions = try await portfolio.transactions.asyncMap { try await transactionDTOMapper.transaction(from: $0) } return try await PortfolioDTO(id: portfolio.id?.uuidString ?? "", name: portfolio.name, currency: currencyDTOMapper.currency(from: portfolio.currency), performance: performance(for: portfolio), - investments: investments) + investments: investments, + transactions: transactions) } func portfolioInfo(from portfolio: Portfolio) async throws -> PortfolioInfoDTO { @@ -34,37 +36,43 @@ class PortfolioDTOMapper { } func performance(for portfolio: Portfolio) async throws -> PortfolioPerformanceDTO { - guard portfolio.$historicalPerformance.value != nil, - let performance = portfolio.historicalPerformance?.datedPerformance.last else { + // Expect the daily series to be eager-loaded by the caller. If it's not loaded, fall back to zeros. + guard portfolio.$historicalDailyPerformance.value != nil, + let latest = portfolio.historicalDailyPerformance.max(by: { $0.date < $1.date }) + else { return PortfolioPerformanceDTO(moneyIn: 0, moneyOut: 0, profit: 0, totalReturn: 0) } - - let financials = Financials() - await financials.addMoneyIn(performance.moneyIn) - await financials.addValue(performance.value) - - return PortfolioPerformanceDTO( - moneyIn: await financials.moneyIn, - moneyOut: await financials.value, - profit: await financials.profit, - totalReturn: await financials.totalReturn - ) + + let moneyIn = latest.moneyIn + let moneyOut = latest.value + let profit = moneyOut - moneyIn + let totalReturn: Decimal = moneyIn > 0 ? (profit / moneyIn).rounded(to: 2) : 0 + + return PortfolioPerformanceDTO( + moneyIn: moneyIn, + moneyOut: moneyOut, + profit: profit, + totalReturn: totalReturn + ) } - func timeSeriesPerformance(from historicalPerformance: HistoricalPortfolioPerformance) async -> PortfolioPerformanceTimeSeriesDTO { - let values: [DatedPortfolioPerformanceDTO] = await historicalPerformance.$datedPerformance.wrappedValue.concurrentMap { datedPerformance in - let financials = Financials() - await financials.addMoneyIn(datedPerformance.moneyIn) - await financials.addValue(datedPerformance.value) - return await DatedPortfolioPerformanceDTO(date: datedPerformance.date.date, - moneyIn: financials.moneyIn, - moneyOut: financials.value, - profit: financials.profit, - totalReturn: financials.totalReturn) - - }.sorted { lhs, rhs in - lhs.date < lhs.date - } + func timeSeriesPerformance(from series: [DatedPortfolioPerformance]) async -> PortfolioPerformanceTimeSeriesDTO { + let values: [DatedPortfolioPerformanceDTO] = series + .map { point in + let moneyIn = point.moneyIn + let moneyOut = point.value + let profit = moneyOut - moneyIn + let totalReturn: Decimal = moneyIn > 0 ? (profit / moneyIn).rounded(to: 2) : 0 + return DatedPortfolioPerformanceDTO( + date: point.date.date, + moneyIn: moneyIn, + moneyOut: moneyOut, + profit: profit, + totalReturn: totalReturn + ) + } + .sorted { $0.date < $1.date } + return PortfolioPerformanceTimeSeriesDTO(values: values) } } diff --git a/Sources/Grodt/DTOs/DTOMappers/TransactionDTOMapper.swift b/Sources/Grodt/DTOs/DTOMappers/TransactionDTOMapper.swift deleted file mode 100644 index fc84c39..0000000 --- a/Sources/Grodt/DTOs/DTOMappers/TransactionDTOMapper.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -class TransactionDTOMapper { - private let currencyDTOMapper: CurrencyDTOMapper - - init(currencyDTOMapper: CurrencyDTOMapper) { - self.currencyDTOMapper = currencyDTOMapper - } - - func transaction(from transaction: Transaction) -> TransactionDTO { - return TransactionDTO(id: transaction.id?.uuidString ?? "", - portfolioName: transaction.portfolio.name, - platform: transaction.platform, - account: transaction.account, - purchaseDate: transaction.purchaseDate, - ticker: transaction.ticker, - currency: currencyDTOMapper.currency(from: transaction.currency), - fees: transaction.fees, - numberOfShares: transaction.numberOfShares, - pricePerShareAtPurchase: transaction.pricePerShareAtPurchase) - } -} diff --git a/Sources/Grodt/DTOs/PortfolioDTO.swift b/Sources/Grodt/DTOs/PortfolioDTO.swift index 2567060..3f2f8cf 100644 --- a/Sources/Grodt/DTOs/PortfolioDTO.swift +++ b/Sources/Grodt/DTOs/PortfolioDTO.swift @@ -6,4 +6,5 @@ struct PortfolioDTO: Codable, Equatable { let currency: CurrencyDTO let performance: PortfolioPerformanceDTO let investments: [InvestmentDTO] + let transactions: [TransactionDTO] } diff --git a/Sources/Grodt/Controllers/.gitkeep b/Sources/Grodt/Endpoints/.gitkeep similarity index 100% rename from Sources/Grodt/Controllers/.gitkeep rename to Sources/Grodt/Endpoints/.gitkeep diff --git a/Sources/Grodt/Controllers/AccountController.swift b/Sources/Grodt/Endpoints/AccountController.swift similarity index 100% rename from Sources/Grodt/Controllers/AccountController.swift rename to Sources/Grodt/Endpoints/AccountController.swift diff --git a/Sources/Grodt/Endpoints/BrokerageAccountController.swift b/Sources/Grodt/Endpoints/BrokerageAccountController.swift new file mode 100644 index 0000000..280f7f3 --- /dev/null +++ b/Sources/Grodt/Endpoints/BrokerageAccountController.swift @@ -0,0 +1,124 @@ +import Vapor +import Fluent + +struct BrokerageAccountController: RouteCollection { + private let brokerageAccountRepository: BrokerageAccountRepository + private let currencyMapper: CurrencyDTOMapper + private let currencyRepository: CurrencyRepository + + init(brokerageAccountRepository: BrokerageAccountRepository, currencyMapper: CurrencyDTOMapper, currencyRepository: CurrencyRepository) { + self.brokerageAccountRepository = brokerageAccountRepository + self.currencyMapper = currencyMapper + self.currencyRepository = currencyRepository + } + + func boot(routes: RoutesBuilder) throws { + let group = routes.grouped("brokerage-accounts") + group.get(use: list) + group.post(use: create) + group.group(":id") { item in + item.get(use: detail) + item.put(use: update) + item.delete(use: remove) + item.get("performance", use: performanceSeries) + } + } + + private func list(req: Request) async throws -> [BrokerageAccountDTO] { + let userID = try req.requireUserID() + let items = try await brokerageAccountRepository.all(for: userID) + return try await items.asyncMap { model in + let totals = try await brokerageAccountRepository.totals(for: model.requireID()) + let brokerage = try await model.$brokerage.get(on: req.db) + return BrokerageAccountDTO( + id: try model.requireID(), + brokerageId: try brokerage.requireID(), + brokerageName: brokerage.name, + displayName: model.displayName, + baseCurrency: currencyMapper.currency(from: model.baseCurrency), + totals: totals) + } + } + + private func create(req: Request) async throws -> BrokerageAccountDTO { + let userID = try req.requireUserID() + struct In: Content { + let brokerageID: UUID + let displayName: String + let currency: String + } + let input = try req.content.decode(In.self) + guard let currency = try await currencyRepository.currency(for: input.currency) else { + throw Abort(.badRequest) + } + + guard let brokerage = try await Brokerage.query(on: req.db) + .filter(\.$id == input.brokerageID) + .filter(\.$user.$id == userID) + .first() + else { throw Abort(.notFound, reason: "Brokerage not found") } + + let model = BrokerageAccount(brokerageID: try brokerage.requireID(), + displayName: input.displayName, + baseCurrency: currency) + + try await brokerageAccountRepository.create(model) + return BrokerageAccountDTO(id: try model.requireID(), + brokerageId: try brokerage.requireID(), + brokerageName: brokerage.name, + displayName: model.displayName, + baseCurrency: currencyMapper.currency(from: model.baseCurrency), + totals: nil) + } + + private func detail(req: Request) async throws -> BrokerageAccountDTO { + let userID = try req.requireUserID() + let model = try await requireAccount(req, userID: userID) + let brokerage = try await model.$brokerage.get(on: req.db) + let totals = try await brokerageAccountRepository.totals(for: model.requireID()) + return BrokerageAccountDTO(id: try model.requireID(), + brokerageId: try brokerage.requireID(), + brokerageName: brokerage.name, + displayName: model.displayName, + baseCurrency: currencyMapper.currency(from: model.baseCurrency), + totals: totals) + } + + private func update(req: Request) async throws -> HTTPStatus { + let userID = try req.requireUserID() + let model = try await requireAccount(req, userID: userID) + struct In: Content { let displayName: String; let accountNumberMasked: String? } + let input = try req.content.decode(In.self) + model.displayName = input.displayName + try await brokerageAccountRepository.update(model) + return .ok + } + + private func remove(req: Request) async throws -> HTTPStatus { + let userID = try req.requireUserID() + let model = try await requireAccount(req, userID: userID) + try await brokerageAccountRepository.delete(model) + return .noContent + } + + private func performanceSeries(req: Request) async throws -> [PerformancePointDTO] { + let userID = try req.requireUserID() + let account = try await requireAccount(req, userID: userID) + let rows = try await HistoricalBrokerageAccountPerformanceDaily.query(on: req.db) + .filter(\.$account.$id == account.requireID()) + .sort(\.$date, .ascending) + .all() + return rows.map { PerformancePointDTO(date: $0.date, value: $0.value, moneyIn: $0.moneyIn) } + } + + private func requireAccount(_ req: Request, userID: UUID) async throws -> BrokerageAccount { + let id = try req.parameters.require("id", as: UUID.self) + guard let model = try await brokerageAccountRepository.find(id, for: userID) else { + throw Abort(.notFound) + } + return model + } +} + +extension BrokerageAccountDTO: Content { } +extension PerformancePointDTO: Content { } diff --git a/Sources/Grodt/Controllers/InvestmentController.swift b/Sources/Grodt/Endpoints/InvestmentController.swift similarity index 100% rename from Sources/Grodt/Controllers/InvestmentController.swift rename to Sources/Grodt/Endpoints/InvestmentController.swift diff --git a/Sources/Grodt/Controllers/PortfoliosController.swift b/Sources/Grodt/Endpoints/PortfoliosController.swift similarity index 84% rename from Sources/Grodt/Controllers/PortfoliosController.swift rename to Sources/Grodt/Endpoints/PortfoliosController.swift index 314e2de..2cfa172 100644 --- a/Sources/Grodt/Controllers/PortfoliosController.swift +++ b/Sources/Grodt/Endpoints/PortfoliosController.swift @@ -5,17 +5,20 @@ import CollectionConcurrencyKit struct PortfoliosController: RouteCollection { private let portfolioRepository: PortfolioRepository + private let portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository private let currencyRepository: CurrencyRepository private let dataMapper: PortfolioDTOMapper - private let portfolioPerformanceUpdater: PortfolioHistoricalPerformanceUpdater + private let portfolioPerformanceUpdater: PortfolioPerformanceUpdating init(portfolioRepository: PortfolioRepository, currencyRepository: CurrencyRepository, - historicalPortfolioPerformanceUpdater: PortfolioHistoricalPerformanceUpdater, + historicalPortfolioPerformanceUpdater: PortfolioPerformanceUpdating, + portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository, dataMapper: PortfolioDTOMapper) { self.portfolioRepository = portfolioRepository self.currencyRepository = currencyRepository self.portfolioPerformanceUpdater = historicalPortfolioPerformanceUpdater + self.portfolioDailyRepo = portfolioDailyRepo self.dataMapper = dataMapper } @@ -86,7 +89,7 @@ struct PortfoliosController: RouteCollection { let updateDTO = try req.content.decode(UpdatePortfolioRequestDTO.self) - guard var portfolio = try await portfolioRepository.portfolio(for: userID, with: id) else { + guard let portfolio = try await portfolioRepository.portfolio(for: userID, with: id) else { throw Abort(.notFound) } @@ -120,12 +123,14 @@ struct PortfoliosController: RouteCollection { func historicalPerformance(req: Request) async throws -> PortfolioPerformanceTimeSeriesDTO { let id = try req.requiredID() - guard let _ = req.auth.get(User.self)?.id else { - throw Abort(.badRequest) + guard let userID = req.auth.get(User.self)?.id else { throw Abort(.badRequest) } + + guard let _ = try await portfolioRepository.portfolio(for: userID, with: id) else { + throw Abort(.notFound) } - - let historicalPerformance = try await portfolioRepository.historicalPerformance(with: id) - return await dataMapper.timeSeriesPerformance(from: historicalPerformance) + + let series = try await portfolioDailyRepo.readSeries(for: id, from: nil, to: nil) + return await dataMapper.timeSeriesPerformance(from: series) } } diff --git a/Sources/Grodt/Controllers/TickersController.swift b/Sources/Grodt/Endpoints/TickersController.swift similarity index 100% rename from Sources/Grodt/Controllers/TickersController.swift rename to Sources/Grodt/Endpoints/TickersController.swift diff --git a/Sources/Grodt/Controllers/UserController.swift b/Sources/Grodt/Endpoints/UserController.swift similarity index 100% rename from Sources/Grodt/Controllers/UserController.swift rename to Sources/Grodt/Endpoints/UserController.swift diff --git a/Sources/Grodt/Endpoints/brokerages/BrokerageAccountDTOMapper.swift b/Sources/Grodt/Endpoints/brokerages/BrokerageAccountDTOMapper.swift new file mode 100644 index 0000000..30a8d70 --- /dev/null +++ b/Sources/Grodt/Endpoints/brokerages/BrokerageAccountDTOMapper.swift @@ -0,0 +1,27 @@ +import Fluent + +struct BrokerageAccountDTOMapper { + private let brokerageAccountRepository: BrokerageAccountRepository + private let currencyMapper: CurrencyDTOMapper + private let database: Database + + init(brokerageAccountRepository: BrokerageAccountRepository, + currencyMapper: CurrencyDTOMapper, + database: Database) { + self.brokerageAccountRepository = brokerageAccountRepository + self.currencyMapper = currencyMapper + self.database = database + } + + func brokerageAccount(from brokerageAccount: BrokerageAccount) async throws -> BrokerageAccountDTO { + try await brokerageAccount.$brokerage.load(on: database) + let totals = try await brokerageAccountRepository.totals(for: brokerageAccount.requireID()) + + return try BrokerageAccountDTO(id: brokerageAccount.requireID(), + brokerageId: brokerageAccount.brokerage.requireID(), + brokerageName: brokerageAccount.brokerage.name, + displayName: brokerageAccount.displayName, + baseCurrency: currencyMapper.currency(from: brokerageAccount.baseCurrency), + totals: totals ?? PerformanceTotalsDTO()) + } +} diff --git a/Sources/Grodt/Endpoints/brokerages/BrokerageController.swift b/Sources/Grodt/Endpoints/brokerages/BrokerageController.swift new file mode 100644 index 0000000..3f34da0 --- /dev/null +++ b/Sources/Grodt/Endpoints/brokerages/BrokerageController.swift @@ -0,0 +1,95 @@ +import Vapor +import Fluent + +struct BrokerageController: RouteCollection { + private let brokerageRepository: BrokerageRepository + private let dtoMapper: BrokerageDTOMapper + private let accounts: BrokerageAccountRepository + private let currencyMapper: CurrencyDTOMapper + private let performanceRepository: PostgresBrokerageDailyPerformanceRepository + private let performancePointDTOMapper: PerformancePointDTOMapper + + init(brokerageRepository: BrokerageRepository, + dtoMapper: BrokerageDTOMapper, + accounts: BrokerageAccountRepository, + currencyMapper: CurrencyDTOMapper, + performanceRepository: PostgresBrokerageDailyPerformanceRepository, + performancePointDTOMapper: PerformancePointDTOMapper + ) { + self.brokerageRepository = brokerageRepository + self.dtoMapper = dtoMapper + self.accounts = accounts + self.currencyMapper = currencyMapper + self.performanceRepository = performanceRepository + self.performancePointDTOMapper = performancePointDTOMapper + } + + func boot(routes: RoutesBuilder) throws { + let group = routes.grouped("brokerages") + group.get(use: list) + group.post(use: create) + group.group(":id") { item in + item.get(use: detail) + item.put(use: update) + item.delete(use: remove) + item.get("performance", use: performanceSeries) + } + } + + private func list(req: Request) async throws -> [BrokerageDTO] { + let userID = try req.requireUserID() + let items = try await brokerageRepository.list(for: userID) + return try await items.asyncMap { try await dtoMapper.brokerage(from: $0) } + } + + private func create(req: Request) async throws -> BrokerageDTO { + let userID = try req.requireUserID() + let input = try req.content.decode(CreateUpdateBrokerageRequestDTO.self) + let item = Brokerage(userID: userID, name: input.name) + try await brokerageRepository.create(item) + return BrokerageDTO(id: try item.requireID(), + name: item.name, + accounts: [], + totals: nil) + } + + private func detail(req: Request) async throws -> BrokerageDTO { + let userID = try req.requireUserID() + let brokerage = try await requireBrokerage(req, userID: userID) + return try await dtoMapper.brokerage(from: brokerage) + } + + private func update(req: Request) async throws -> HTTPStatus { + let userID = try req.requireUserID() + let brokerage = try await requireBrokerage(req, userID: userID) + let input = try req.content.decode(CreateUpdateBrokerageRequestDTO.self) + brokerage.name = input.name + try await brokerageRepository.update(brokerage) + return .ok + } + + private func remove(req: Request) async throws -> HTTPStatus { + let userID = try req.requireUserID() + let brokerage = try await requireBrokerage(req, userID: userID) + try await brokerageRepository.delete(brokerage) + return .noContent + } + + private func performanceSeries(req: Request) async throws -> [PerformancePointDTO] { + let userID = try req.requireUserID() + _ = try await requireBrokerage(req, userID: userID) + let id = try req.parameters.require("id", as: UUID.self) + let rows = try await performanceRepository.readSeries(for: id, from: nil, to: nil) + return rows.map { performancePointDTOMapper.performancePoint(from: $0) } + } + + private func requireBrokerage(_ req: Request, userID: UUID) async throws -> Brokerage { + let id = try req.parameters.require("id", as: UUID.self) + guard let model = try await brokerageRepository.find(id, for: userID) else { + throw Abort(.notFound) + } + return model + } +} + +extension BrokerageDTO: Content { } diff --git a/Sources/Grodt/Endpoints/brokerages/BrokerageDTOMapper.swift b/Sources/Grodt/Endpoints/brokerages/BrokerageDTOMapper.swift new file mode 100644 index 0000000..506ccbc --- /dev/null +++ b/Sources/Grodt/Endpoints/brokerages/BrokerageDTOMapper.swift @@ -0,0 +1,31 @@ +import Fluent + +struct BrokerageDTOMapper { + private let brokerageRepository: BrokerageRepository + private let accountDTOMapper: BrokerageAccountDTOMapper + private let database: Database + + init(brokerageRepository: BrokerageRepository, + accountDTOMapper: BrokerageAccountDTOMapper, + database: Database) { + self.brokerageRepository = brokerageRepository + self.accountDTOMapper = accountDTOMapper + self.database = database + } + + func brokerage(from brokerage: Brokerage) async throws -> BrokerageDTO { + try await brokerage.$accounts.load(on: database) + let accountDTOs = try await brokerage.accounts.asyncMap { + try await accountDTOMapper.brokerageAccount(from: $0) + } + let totals = try await brokerageRepository.totals(for: brokerage.requireID()) + return try BrokerageDTO( + id: brokerage.requireID(), + name: brokerage.name, + accounts: accountDTOs, + totals: totals + ) + } +} + + diff --git a/Sources/Grodt/Endpoints/brokerages/CreateBrokerageRequestDTO.swift b/Sources/Grodt/Endpoints/brokerages/CreateBrokerageRequestDTO.swift new file mode 100644 index 0000000..2890e1e --- /dev/null +++ b/Sources/Grodt/Endpoints/brokerages/CreateBrokerageRequestDTO.swift @@ -0,0 +1,3 @@ +struct CreateUpdateBrokerageRequestDTO: Codable { + let name: String +} diff --git a/Sources/Grodt/Endpoints/transactions/BrokerageAccountInfoDTO.swift b/Sources/Grodt/Endpoints/transactions/BrokerageAccountInfoDTO.swift new file mode 100644 index 0000000..5492afd --- /dev/null +++ b/Sources/Grodt/Endpoints/transactions/BrokerageAccountInfoDTO.swift @@ -0,0 +1,8 @@ +import Foundation + +struct BrokerageAccountInfoDTO: Codable, Equatable { + let id: UUID + let brokerageId: UUID + let brokerageName: String + let displayName: String +} diff --git a/Sources/Grodt/DTOs/TransactionDTO.swift b/Sources/Grodt/Endpoints/transactions/TransactionDTO.swift similarity index 74% rename from Sources/Grodt/DTOs/TransactionDTO.swift rename to Sources/Grodt/Endpoints/transactions/TransactionDTO.swift index 49cba40..c3fe2b9 100644 --- a/Sources/Grodt/DTOs/TransactionDTO.swift +++ b/Sources/Grodt/Endpoints/transactions/TransactionDTO.swift @@ -3,43 +3,50 @@ import Foundation struct TransactionDTO: Encodable, Equatable { let id: String let portfolioName: String - let platform: String - let account: String? let purchaseDate: Date let ticker: String let currency: CurrencyDTO let fees: Decimal let numberOfShares: Decimal let pricePerShareAtPurchase: Decimal - + let brokerageAccount: BrokerageAccountInfoDTO? + enum CodingKeys: String, CodingKey { - case id, portfolioName, platform, account, ticker, currency, fees, numberOfShares, pricePerShareAtPurchase, purchaseDate + case id, portfolioName, ticker, currency, fees, numberOfShares, pricePerShareAtPurchase, purchaseDate, brokerageAccount } - init(id: String, portfolioName: String, platform: String, account: String? = nil, purchaseDate: Date, ticker: String, currency: CurrencyDTO, fees: Decimal, numberOfShares: Decimal, pricePerShareAtPurchase: Decimal) { + init( + id: String, + portfolioName: String, + purchaseDate: Date, + ticker: String, + currency: CurrencyDTO, + fees: Decimal, + numberOfShares: Decimal, + pricePerShareAtPurchase: Decimal, + brokerageAccount: BrokerageAccountInfoDTO? + ) { self.id = id self.portfolioName = portfolioName - self.platform = platform - self.account = account self.purchaseDate = purchaseDate self.ticker = ticker self.currency = currency self.fees = fees self.numberOfShares = numberOfShares self.pricePerShareAtPurchase = pricePerShareAtPurchase + self.brokerageAccount = brokerageAccount } - + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) portfolioName = try container.decode(String.self, forKey: .portfolioName) - platform = try container.decode(String.self, forKey: .platform) - account = try container.decodeIfPresent(String.self, forKey: .account) ticker = try container.decode(String.self, forKey: .ticker) currency = try container.decode(CurrencyDTO.self, forKey: .currency) fees = try container.decode(Decimal.self, forKey: .fees) numberOfShares = try container.decode(Decimal.self, forKey: .numberOfShares) pricePerShareAtPurchase = try container.decode(Decimal.self, forKey: .pricePerShareAtPurchase) + brokerageAccount = try container.decodeIfPresent(BrokerageAccountInfoDTO.self, forKey: .brokerageAccount) let dateString = try container.decode(String.self, forKey: .purchaseDate) if let date = ISO8601DateFormatter().date(from: dateString) { @@ -48,21 +55,17 @@ struct TransactionDTO: Encodable, Equatable { throw DecodingError.dataCorruptedError(forKey: .purchaseDate, in: container, debugDescription: "Date string does not match format expected by formatter.") } } - - // Custom function for encoding + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(portfolioName, forKey: .portfolioName) - try container.encode(platform, forKey: .platform) - if let account = account { - try container.encode(account, forKey: .account) - } try container.encode(ticker, forKey: .ticker) try container.encode(currency, forKey: .currency) try container.encode(fees, forKey: .fees) try container.encode(numberOfShares, forKey: .numberOfShares) try container.encode(pricePerShareAtPurchase, forKey: .pricePerShareAtPurchase) + try container.encodeIfPresent(brokerageAccount, forKey: .brokerageAccount) let dateString = ISO8601DateFormatter().string(from: purchaseDate) try container.encode(dateString, forKey: .purchaseDate) diff --git a/Sources/Grodt/Endpoints/transactions/TransactionDTOMapper.swift b/Sources/Grodt/Endpoints/transactions/TransactionDTOMapper.swift new file mode 100644 index 0000000..b331d7a --- /dev/null +++ b/Sources/Grodt/Endpoints/transactions/TransactionDTOMapper.swift @@ -0,0 +1,34 @@ +import Foundation +import Fluent + +class TransactionDTOMapper { + private let currencyDTOMapper: CurrencyDTOMapper + private let database: Database + + init(currencyDTOMapper: CurrencyDTOMapper, + database: Database) { + self.currencyDTOMapper = currencyDTOMapper + self.database = database + } + + func transaction(from transaction: Transaction) async throws -> TransactionDTO { + var brokerageAccount: BrokerageAccountInfoDTO? + if let brokerAcc = try await transaction.$brokerageAccount.get(on: database) { + let brokerage = try await brokerAcc.$brokerage.get(on: database) + brokerageAccount = BrokerageAccountInfoDTO(id: try brokerAcc.requireID(), + brokerageId: try brokerage.requireID(), + brokerageName: brokerAcc.brokerage.name, + displayName: brokerAcc.displayName) + } + return TransactionDTO(id: transaction.id?.uuidString ?? "", + portfolioName: transaction.portfolio.name, + purchaseDate: transaction.purchaseDate, + ticker: transaction.ticker, + currency: currencyDTOMapper.currency(from: transaction.currency), + fees: transaction.fees, + numberOfShares: transaction.numberOfShares, + pricePerShareAtPurchase: transaction.pricePerShareAtPurchase, + brokerageAccount: brokerageAccount + ) + } +} diff --git a/Sources/Grodt/Controllers/TransactionsController.swift b/Sources/Grodt/Endpoints/transactions/TransactionsController.swift similarity index 61% rename from Sources/Grodt/Controllers/TransactionsController.swift rename to Sources/Grodt/Endpoints/transactions/TransactionsController.swift index d96b4f4..f7225d9 100644 --- a/Sources/Grodt/Controllers/TransactionsController.swift +++ b/Sources/Grodt/Endpoints/transactions/TransactionsController.swift @@ -1,5 +1,9 @@ import Vapor +struct UpdateTransactionBrokerageAccountRequestDTO: Content { + let brokerageAccountId: String? +} + protocol TransactionsControllerDelegate: AnyObject { func transactionCreated(_ transaction: Transaction) async throws func transactionDeleted(_ transaction: Transaction) async throws @@ -26,18 +30,24 @@ class TransactionsController: RouteCollection { transactions.group(":id") { transaction in transaction.get(use: transactionDetail) transaction.delete(use: delete) + transaction.patch("brokerage-account", use: updateBrokerageAccount) } } - func create(req: Request) async throws -> TransactionDTO { + private func create(req: Request) async throws -> TransactionDTO { let transaction = try req.content.decode(CreateTransactionRequestDTO.self) guard let currency = try await currencyRepository.currency(for: transaction.currency) else { throw Abort(.badRequest) } + let brokerageAccountId: UUID? = { + guard let id = transaction.brokerageAccountID else { return nil } + return UUID(uuidString: id) + }() + + let newTransaction = Transaction(portfolioID: UUID(uuidString: transaction.portfolio)!, - platform: transaction.platform, - account: transaction.account, + brokerageAccountID: brokerageAccountId, purchaseDate: transaction.purchaseDate, ticker: transaction.ticker, currency: currency, @@ -47,20 +57,20 @@ class TransactionsController: RouteCollection { try await newTransaction.save(on: req.db) try await delegate?.transactionCreated(newTransaction) - return dataMapper.transaction(from: newTransaction) + return try await dataMapper.transaction(from: newTransaction) } - func transactionDetail(req: Request) async throws -> TransactionDTO { + private func transactionDetail(req: Request) async throws -> TransactionDTO { let id = try req.requiredID() guard let transaction = try await transactionsRepository.transaction(for: id) else { throw Abort(.notFound) } - return dataMapper.transaction(from: transaction) + return try await dataMapper.transaction(from: transaction) } - func delete(req: Request) async throws -> HTTPStatus { + private func delete(req: Request) async throws -> HTTPStatus { let id = try req.requiredID() guard let transaction = try await transactionsRepository.transaction(for: id) else { @@ -71,6 +81,26 @@ class TransactionsController: RouteCollection { try await delegate?.transactionDeleted(transaction) return .ok } + + private func updateBrokerageAccount(req: Request) async throws -> TransactionDTO { + let id = try req.requiredID() + guard let transaction = try await transactionsRepository.transaction(for: id) else { + throw Abort(.notFound) + } + + let body = try req.content.decode(UpdateTransactionBrokerageAccountRequestDTO.self) + + // Interpret empty string as nil (unlink) + let brokerageAccountID: UUID? = { + guard let raw = body.brokerageAccountId?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + return UUID(uuidString: raw) + }() + + transaction.$brokerageAccount.id = brokerageAccountID + try await transaction.save(on: req.db) + + return try await dataMapper.transaction(from: transaction) + } } extension TransactionDTO: Content { } diff --git a/Sources/Grodt/Persistency/Models/Brokerage.swift b/Sources/Grodt/Persistency/Models/Brokerage.swift new file mode 100644 index 0000000..9069bd6 --- /dev/null +++ b/Sources/Grodt/Persistency/Models/Brokerage.swift @@ -0,0 +1,63 @@ +import Foundation +import Fluent + +final class Brokerage: Model, @unchecked Sendable { + static let schema = "brokerages" + + @ID(key: .id) + var id: UUID? + + @Parent(key: Keys.userID) + var user: User + + @Field(key: Keys.name) + var name: String + + @Timestamp(key: Keys.createdAt, on: .create) + var createdAt: Date? + + @Timestamp(key: Keys.updatedAt, on: .update) + var updatedAt: Date? + + @Children(for: \.$brokerage) + var accounts: [BrokerageAccount] + + init() {} + + init(id: UUID? = nil, + userID: User.IDValue, + name: String) + { + self.id = id + self.$user.id = userID + self.name = name + } +} + +extension Brokerage { + enum Keys { + static let userID: FieldKey = "user_id" + static let name: FieldKey = "name" + static let createdAt: FieldKey = "created_at" + static let updatedAt: FieldKey = "updated_at" + } + + struct Migration: AsyncMigration { + let name = "CreateBrokerage" + + func prepare(on db: Database) async throws { + try await db.schema(Brokerage.schema) + .id() + .field(Keys.userID, .uuid, .required, .references(User.schema, "id", onDelete: .cascade)) + .field(Keys.name, .string, .required) + .field(Keys.createdAt, .datetime) + .field(Keys.updatedAt, .datetime) + .unique(on: Keys.userID, Keys.name) + .create() + } + + func revert(on db: Database) async throws { + try await db.schema(Brokerage.schema).delete() + } + } +} diff --git a/Sources/Grodt/Persistency/Models/BrokerageAccount.swift b/Sources/Grodt/Persistency/Models/BrokerageAccount.swift new file mode 100644 index 0000000..68792ed --- /dev/null +++ b/Sources/Grodt/Persistency/Models/BrokerageAccount.swift @@ -0,0 +1,70 @@ +import Foundation +import Fluent + +final class BrokerageAccount: Model, @unchecked Sendable { + static let schema = "brokerage_accounts" + + @ID(key: .id) + var id: UUID? + + @Parent(key: Keys.brokerageID) + var brokerage: Brokerage + + @Field(key: Keys.displayName) + var displayName: String + + @Field(key: Keys.baseCurrency) + var baseCurrency: Currency + + @Timestamp(key: Keys.createdAt, on: .create) + var createdAt: Date? + + @Timestamp(key: Keys.updatedAt, on: .update) + var updatedAt: Date? + + @Children(for: \.$brokerageAccount) + var transactions: [Transaction] + + init() {} + + init(id: UUID? = nil, + brokerageID: Brokerage.IDValue, + displayName: String, + baseCurrency: Currency) + { + self.id = id + self.$brokerage.id = brokerageID + self.displayName = displayName + self.baseCurrency = baseCurrency + } +} + +extension BrokerageAccount { + enum Keys { + static let brokerageID: FieldKey = "brokerage_id" + static let displayName: FieldKey = "display_name" + static let baseCurrency: FieldKey = "base_currency" + static let createdAt: FieldKey = "created_at" + static let updatedAt: FieldKey = "updated_at" + } + + struct Migration: AsyncMigration { + let name = "CreateBrokerageAccount" + + func prepare(on db: Database) async throws { + try await db.schema(BrokerageAccount.schema) + .id() + .field(Keys.brokerageID, .uuid, .required, .references(Brokerage.schema, "id", onDelete: .cascade)) + .field(Keys.displayName, .string, .required) + .field(Keys.baseCurrency, .dictionary, .required) + .field(Keys.createdAt, .datetime) + .field(Keys.updatedAt, .datetime) + .unique(on: Keys.brokerageID, Keys.displayName) + .create() + } + + func revert(on db: Database) async throws { + try await db.schema(BrokerageAccount.schema).delete() + } + } +} diff --git a/Sources/Grodt/Persistency/Models/DailyPerformance/DropOldHistoricalPortfolioPerformance.swift b/Sources/Grodt/Persistency/Models/DailyPerformance/DropOldHistoricalPortfolioPerformance.swift new file mode 100644 index 0000000..9fa994b --- /dev/null +++ b/Sources/Grodt/Persistency/Models/DailyPerformance/DropOldHistoricalPortfolioPerformance.swift @@ -0,0 +1,16 @@ +import Fluent +import SQLKit + +struct DropOldHistoricalPortfolioPerformance: AsyncMigration { + let name = "DropOldHistoricalPortfolioPerformance" + + func prepare(on db: Database) async throws { + if let sql = db as? SQLDatabase { + try await sql.raw(#"DROP TABLE IF EXISTS "historical_portfolio_performance""#).run() + } else { + // Best-effort (will throw if table doesn't exist) + try? await db.schema("historical_portfolio_performance").delete() + } + } + func revert(on db: Database) async throws { /* no-op */ } +} diff --git a/Sources/Grodt/Persistency/Models/DailyPerformance/HistoricalBrokerageAccountPerformanceDaily.swift b/Sources/Grodt/Persistency/Models/DailyPerformance/HistoricalBrokerageAccountPerformanceDaily.swift new file mode 100644 index 0000000..a456f10 --- /dev/null +++ b/Sources/Grodt/Persistency/Models/DailyPerformance/HistoricalBrokerageAccountPerformanceDaily.swift @@ -0,0 +1,64 @@ +import Foundation +import Fluent + +final class HistoricalBrokerageAccountPerformanceDaily: Model, @unchecked Sendable { + static let schema = "historical_brokerage_account_performance_daily" + + @ID(key: .id) + var id: UUID? + + @Parent(key: Keys.accountID) + var account: BrokerageAccount + + @Field(key: Keys.date) + var date: Date + + @Field(key: Keys.moneyIn) + var moneyIn: Decimal + + @Field(key: Keys.value) + var value: Decimal + + required init() {} + + init(id: UUID? = nil, + accountID: BrokerageAccount.IDValue, + date: Date, + moneyIn: Decimal, + value: Decimal) + { + self.id = id + self.$account.id = accountID + self.date = date + self.moneyIn = moneyIn + self.value = value + } +} + +extension HistoricalBrokerageAccountPerformanceDaily { + enum Keys { + static let accountID: FieldKey = "brokerage_account_id" + static let date: FieldKey = "date" + static let moneyIn: FieldKey = "money_in" + static let value: FieldKey = "value" + } + + struct Migration: AsyncMigration { + let name = "CreateHistoricalBrokerageAccountPerformanceDaily" + + func prepare(on db: Database) async throws { + try await db.schema(HistoricalBrokerageAccountPerformanceDaily.schema) + .id() + .field(Keys.accountID, .uuid, .required, .references(BrokerageAccount.schema, "id", onDelete: .cascade)) + .field(Keys.date, .date, .required) + .field(Keys.moneyIn, .sql(unsafeRaw: "NUMERIC(64,4)"), .required) + .field(Keys.value, .sql(unsafeRaw: "NUMERIC(64,4)"), .required) + .unique(on: Keys.accountID, Keys.date) + .create() + } + + func revert(on db: Database) async throws { + try await db.schema(HistoricalBrokerageAccountPerformanceDaily.schema).delete() + } + } +} diff --git a/Sources/Grodt/Persistency/Models/DailyPerformance/HistoricalBrokeragePerformanceDaily.swift b/Sources/Grodt/Persistency/Models/DailyPerformance/HistoricalBrokeragePerformanceDaily.swift new file mode 100644 index 0000000..29c6525 --- /dev/null +++ b/Sources/Grodt/Persistency/Models/DailyPerformance/HistoricalBrokeragePerformanceDaily.swift @@ -0,0 +1,64 @@ +import Foundation +import Fluent + +final class HistoricalBrokeragePerformanceDaily: Model, @unchecked Sendable { + static let schema = "historical_brokerage_performance_daily" + + @ID(key: .id) + var id: UUID? + + @Parent(key: Keys.brokerageID) + var brokerage: Brokerage + + @Field(key: Keys.date) + var date: Date + + @Field(key: Keys.moneyIn) + var moneyIn: Decimal + + @Field(key: Keys.value) + var value: Decimal + + required init() {} + + init(id: UUID? = nil, + brokerageID: Brokerage.IDValue, + date: Date, + moneyIn: Decimal, + value: Decimal) + { + self.id = id + self.$brokerage.id = brokerageID + self.date = date + self.moneyIn = moneyIn + self.value = value + } +} + +extension HistoricalBrokeragePerformanceDaily { + enum Keys { + static let brokerageID: FieldKey = "brokerage_id" + static let date: FieldKey = "date" + static let moneyIn: FieldKey = "money_in" + static let value: FieldKey = "value" + } + + struct Migration: AsyncMigration { + let name = "CreateHistoricalBrokeragePerformanceDaily" + + func prepare(on db: Database) async throws { + try await db.schema(HistoricalBrokeragePerformanceDaily.schema) + .id() + .field(Keys.brokerageID, .uuid, .required, .references(Brokerage.schema, "id", onDelete: .cascade)) + .field(Keys.date, .date, .required) + .field(Keys.moneyIn, .sql(unsafeRaw: "NUMERIC(64,4)"), .required) + .field(Keys.value, .sql(unsafeRaw: "NUMERIC(64,4)"), .required) + .unique(on: Keys.brokerageID, Keys.date) + .create() + } + + func revert(on db: Database) async throws { + try await db.schema(HistoricalBrokeragePerformanceDaily.schema).delete() + } + } +} diff --git a/Sources/Grodt/Persistency/Models/DailyPerformance/HistoricalPortfolioPerformanceDaily.swift b/Sources/Grodt/Persistency/Models/DailyPerformance/HistoricalPortfolioPerformanceDaily.swift new file mode 100644 index 0000000..97b151e --- /dev/null +++ b/Sources/Grodt/Persistency/Models/DailyPerformance/HistoricalPortfolioPerformanceDaily.swift @@ -0,0 +1,64 @@ +import Foundation +import Fluent + +final class HistoricalPortfolioPerformanceDaily: Model, @unchecked Sendable { + static let schema = "historical_portfolio_performance_daily" + + @ID(key: .id) + var id: UUID? + + @Parent(key: Keys.portfolioID) + var portfolio: Portfolio + + @Field(key: Keys.date) + var date: Date + + @Field(key: Keys.moneyIn) + var moneyIn: Decimal + + @Field(key: Keys.value) + var value: Decimal + + required init() {} + + init(id: UUID? = nil, + portfolioID: Portfolio.IDValue, + date: Date, + moneyIn: Decimal, + value: Decimal) + { + self.id = id + self.$portfolio.id = portfolioID + self.date = date + self.moneyIn = moneyIn + self.value = value + } +} + +extension HistoricalPortfolioPerformanceDaily { + enum Keys { + static let portfolioID: FieldKey = "portfolio_id" + static let date: FieldKey = "date" + static let moneyIn: FieldKey = "money_in" + static let value: FieldKey = "value" + } + + struct Migration: AsyncMigration { + let name = "CreateHistoricalPortfolioPerformanceDaily" + + func prepare(on db: Database) async throws { + try await db.schema(HistoricalPortfolioPerformanceDaily.schema) + .id() + .field(Keys.portfolioID, .uuid, .required, .references(Portfolio.schema, "id", onDelete: .cascade)) + .field(Keys.date, .date, .required) + .field(Keys.moneyIn, .sql(unsafeRaw: "NUMERIC(64,4)"), .required) + .field(Keys.value, .sql(unsafeRaw: "NUMERIC(64,4)"), .required) + .unique(on: Keys.portfolioID, Keys.date) + .create() + } + + func revert(on db: Database) async throws { + try await db.schema(HistoricalPortfolioPerformanceDaily.schema).delete() + } + } +} diff --git a/Sources/Grodt/Persistency/Models/DatedPortfolioPerformance.swift b/Sources/Grodt/Persistency/Models/DatedPortfolioPerformance.swift index abe1ba2..3d9a17c 100644 --- a/Sources/Grodt/Persistency/Models/DatedPortfolioPerformance.swift +++ b/Sources/Grodt/Persistency/Models/DatedPortfolioPerformance.swift @@ -1,6 +1,6 @@ import Foundation -struct DatedPortfolioPerformance: Codable { +struct DatedPortfolioPerformance: Codable, Equatable { let moneyIn: Decimal let value: Decimal let date: YearMonthDayDate diff --git a/Sources/Grodt/Persistency/Models/HistoricalPortfolioPerformance.swift b/Sources/Grodt/Persistency/Models/HistoricalPortfolioPerformance.swift deleted file mode 100644 index 3d0ac92..0000000 --- a/Sources/Grodt/Persistency/Models/HistoricalPortfolioPerformance.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Fluent -import Vapor - -final class HistoricalPortfolioPerformance: Model, @unchecked Sendable { - static let schema = "historical_portfolio_performance" - - @ID(key: .id) - var id: UUID? - - @Parent(key: Keys.portfolioID) - var portfolio: Portfolio - - @Field(key: Keys.datedPerformance) - var datedPerformance: [DatedPortfolioPerformance] - - required init() { } - - init(id: UUID? = nil, - portfolioID: Portfolio.IDValue, - datedPerformance: [DatedPortfolioPerformance]) { - self.id = id - self.$portfolio.id = portfolioID - self.datedPerformance = datedPerformance - } -} - -fileprivate extension HistoricalPortfolioPerformance { - enum Keys { - static let portfolioID: FieldKey = "portfolio_id" - static let datedPerformance: FieldKey = "dated_performance" - } -} - -extension HistoricalPortfolioPerformance { - struct Migration: AsyncMigration { - var name: String { "CreateHistoricalPortfolioPerformance" } - - func prepare(on database: Database) async throws { - try await database.schema(HistoricalPortfolioPerformance.schema) - .id() - .field(Keys.portfolioID, .uuid, .required, .references(Portfolio.schema, "id")) - .field(Keys.datedPerformance, .array(of: .json), .required) - .create() - } - - func revert(on database: Database) async throws { - try await database.schema(Portfolio.schema).delete() - } - } -} diff --git a/Sources/Grodt/Persistency/Models/Portfolio.swift b/Sources/Grodt/Persistency/Models/Portfolio.swift index e4388d3..1c356cf 100644 --- a/Sources/Grodt/Persistency/Models/Portfolio.swift +++ b/Sources/Grodt/Persistency/Models/Portfolio.swift @@ -19,8 +19,8 @@ final class Portfolio: Model, @unchecked Sendable { @Children(for: \.$portfolio) var transactions: [Transaction] - @OptionalChild(for: \.$portfolio) - var historicalPerformance: HistoricalPortfolioPerformance? + @Children(for: \.$portfolio) + var historicalDailyPerformance: [HistoricalPortfolioPerformanceDaily] required init() { } diff --git a/Sources/Grodt/Persistency/Models/Transaction.swift b/Sources/Grodt/Persistency/Models/Transaction.swift index 0d1f56b..d02fee7 100644 --- a/Sources/Grodt/Persistency/Models/Transaction.swift +++ b/Sources/Grodt/Persistency/Models/Transaction.swift @@ -1,5 +1,6 @@ import Foundation import Fluent +import FluentSQL class Transaction: Model, @unchecked Sendable { static let schema = "transactions" @@ -10,11 +11,8 @@ class Transaction: Model, @unchecked Sendable { @Parent(key: Keys.portfolioID) var portfolio: Portfolio - @Field(key: Keys.platform) - var platform: String - - @OptionalField(key: Keys.account) - var account: String? + @OptionalParent(key: Keys.brokerageAccountID) + var brokerageAccount: BrokerageAccount? @Field(key: Keys.purchaseDate) var purchaseDate: Date @@ -42,18 +40,17 @@ class Transaction: Model, @unchecked Sendable { init(id: UUID? = nil, portfolioID: Portfolio.IDValue, - platform: String, - account: String?, + brokerageAccountID: BrokerageAccount.IDValue?, purchaseDate: Date, ticker: String, currency: Currency, fees: Decimal, numberOfShares: Decimal, - pricePerShareAtPurchase: Decimal) { + pricePerShareAtPurchase: Decimal) + { self.id = id self.$portfolio.id = portfolioID - self.platform = platform - self.account = account + self.$brokerageAccount.id = brokerageAccountID self.purchaseDate = purchaseDate self.ticker = ticker self.currency = currency @@ -66,6 +63,7 @@ class Transaction: Model, @unchecked Sendable { fileprivate extension Transaction { enum Keys { static let portfolioID: FieldKey = "portfolio_id" + static let brokerageAccountID: FieldKey = "brokerage_account_id" static let platform: FieldKey = "platform" static let account: FieldKey = "account" static let purchaseDate: FieldKey = "purchase_date" @@ -79,26 +77,55 @@ fileprivate extension Transaction { extension Transaction { struct Migration: AsyncMigration { - let name: String = "CreateTransaction" - - func prepare(on database: Database) async throws { - try await database.schema(Transaction.schema) + var name: String { "CreateTransaction" } + + func prepare(on db: Database) async throws { + try await db.schema(Transaction.schema) .id() - .field(Keys.portfolioID, .uuid, .required, .references(Portfolio.schema, "id")) - .field(Keys.platform, .string, .required) - .field(Keys.account, .string) - .field(Keys.purchaseDate, .datetime, .required) + .field(Keys.portfolioID, .uuid, .required, .references(Portfolio.schema, "id", onDelete: .cascade)) + // brokerage_account_id will be added (optionally) by a later migration + .field(Keys.purchaseDate, .date, .required) .field(Keys.ticker, .string, .required) - .field(Keys.currency, .dictionary, .required) - .field(Keys.fees, .sql(unsafeRaw: "NUMERIC(7,2)"), .required) - .field(Keys.numberOfShares, .sql(unsafeRaw: "NUMERIC(64,6)"), .required) + .field(Keys.currency, .string, .required) + .field(Keys.fees, .sql(unsafeRaw: "NUMERIC(64,4)"), .required) + .field(Keys.numberOfShares, .sql(unsafeRaw: "NUMERIC(64,4)"), .required) .field(Keys.pricePerShareAtPurchase, .sql(unsafeRaw: "NUMERIC(64,4)"), .required) .create() } - - func revert(on database: Database) async throws { - try await database.schema(Transaction.schema).delete() + + func revert(on db: Database) async throws { + try await db.schema(Transaction.schema).delete() } } -} + + struct Migration_AddBrokerageAccountID: AsyncMigration { + let name = "AddBrokerageAccountIDToTransactions" + + func prepare(on db: Database) async throws { + try await db.schema(Transaction.schema) + .field(Keys.brokerageAccountID, .uuid, .references(BrokerageAccount.schema, "id")) + .update() + } + func revert(on db: Database) async throws { + try await db.schema(Transaction.schema) + .deleteField(Keys.brokerageAccountID) + .update() + } + } + + struct Migration_DropPlatformAccountAndMakeBARequired: AsyncMigration { + let name = "DropPlatformAccountAndMakeBrokerageAccountRequired" + + func prepare(on db: Database) async throws { + try await db.schema(Transaction.schema) + .deleteField("platform") + .deleteField("account") + .update() + } + + func revert(on db: Database) async throws { + // No-op (we intentionally don't recreate dropped columns) + } + } +} diff --git a/Sources/Grodt/Persistency/Repositories/BrokerageAccountRepository.swift b/Sources/Grodt/Persistency/Repositories/BrokerageAccountRepository.swift new file mode 100644 index 0000000..b8aa49d --- /dev/null +++ b/Sources/Grodt/Persistency/Repositories/BrokerageAccountRepository.swift @@ -0,0 +1,53 @@ +import Vapor +import Fluent + +protocol BrokerageAccountRepository { + func all(for userID: User.IDValue) async throws -> [BrokerageAccount] + func find(_ id: BrokerageAccount.IDValue, for userID: User.IDValue) async throws -> BrokerageAccount? + func create(_ account: BrokerageAccount) async throws + func update(_ account: BrokerageAccount) async throws + func delete(_ account: BrokerageAccount) async throws + func totals(for accountID: BrokerageAccount.IDValue) async throws -> PerformanceTotalsDTO? +} + +class PostgresBrokerageAccountRepository: BrokerageAccountRepository { + private let database: Database + + init(database: Database) { + self.database = database + } + + func all(for userID: User.IDValue) async throws -> [BrokerageAccount] { + let query = BrokerageAccount.query(on: database) + .join(Brokerage.self, on: \BrokerageAccount.$brokerage.$id == \Brokerage.$id) + .filter(Brokerage.self, \.$user.$id == userID) + return try await query.all() + } + + func find(_ id: BrokerageAccount.IDValue, for userID: User.IDValue) async throws -> BrokerageAccount? { + try await BrokerageAccount.query(on: database) + .filter(\.$id == id) + .join(Brokerage.self, on: \BrokerageAccount.$brokerage.$id == \Brokerage.$id) + .filter(Brokerage.self, \.$user.$id == userID) + .with(\.$brokerage) + .first() + } + + func create(_ account: BrokerageAccount) async throws { try await account.save(on: database) } + func update(_ account: BrokerageAccount) async throws { try await account.update(on: database) } + + func delete(_ account: BrokerageAccount) async throws { + let count = try await Transaction.query(on: database).filter(\.$brokerageAccount.$id == account.requireID()).count() + guard count == 0 else { throw Abort(.conflict, reason: "BrokerageAccount has transactions.") } + try await account.delete(on: database) + } + + func totals(for accountID: BrokerageAccount.IDValue) async throws -> PerformanceTotalsDTO? { + guard let last = try await HistoricalBrokerageAccountPerformanceDaily.query(on: database) + .filter(\.$account.$id == accountID) + .sort(\.$date, .descending) + .first() + else { return nil } + return .init(value: last.value, moneyIn: last.moneyIn) + } +} diff --git a/Sources/Grodt/Persistency/Repositories/BrokerageRepository.swift b/Sources/Grodt/Persistency/Repositories/BrokerageRepository.swift new file mode 100644 index 0000000..74db9a2 --- /dev/null +++ b/Sources/Grodt/Persistency/Repositories/BrokerageRepository.swift @@ -0,0 +1,62 @@ +import Vapor +import Fluent + +protocol BrokerageRepository: Sendable { + func list(for userID: User.IDValue) async throws -> [Brokerage] + func find(_ id: Brokerage.IDValue, for userID: User.IDValue) async throws -> Brokerage? + func create(_ brokerage: Brokerage) async throws + func update(_ brokerage: Brokerage) async throws + func delete(_ brokerage: Brokerage) async throws + func accountsCount(for brokerageID: Brokerage.IDValue) async throws -> Int + func totals(for brokerageID: Brokerage.IDValue) async throws -> PerformanceTotalsDTO? +} + +struct PostgresBrokerageRepository: BrokerageRepository { + private let database: Database + + init(database: Database) { + self.database = database + } + + func list(for userID: User.IDValue) async throws -> [Brokerage] { + try await Brokerage.query(on: database) + .filter(\.$user.$id == userID) + .with(\.$accounts) + .all() + } + + func find(_ id: Brokerage.IDValue, for userID: User.IDValue) async throws -> Brokerage? { + try await Brokerage.query(on: database) + .filter(\.$id == id) + .filter(\.$user.$id == userID) + .with(\.$accounts) + .first() + } + + func create(_ brokerage: Brokerage) async throws { + try await brokerage.save(on: database) + } + + func update(_ brokerage: Brokerage) async throws { + try await brokerage.update(on: database) + } + + func delete(_ brokerage: Brokerage) async throws { + let accounts = try await BrokerageAccount.query(on: database).filter(\.$brokerage.$id == brokerage.requireID()).count() + guard accounts == 0 else { throw Abort(.conflict, reason: "Brokerage has accounts.") } + try await brokerage.delete(on: database) + } + + func accountsCount(for brokerageID: Brokerage.IDValue) async throws -> Int { + try await BrokerageAccount.query(on: database).filter(\.$brokerage.$id == brokerageID).count() + } + + func totals(for brokerageID: Brokerage.IDValue) async throws -> PerformanceTotalsDTO? { + guard let last = try await HistoricalBrokeragePerformanceDaily.query(on: database) + .filter(\.$brokerage.$id == brokerageID) + .sort(\.$date, .descending) + .first() + else { return PerformanceTotalsDTO() } + return .init(value: last.value, moneyIn: last.moneyIn) + } +} diff --git a/Sources/Grodt/Persistency/Repositories/DailyPerformance/DailyPerformanceRepository.swift b/Sources/Grodt/Persistency/Repositories/DailyPerformance/DailyPerformanceRepository.swift new file mode 100644 index 0000000..db3f460 --- /dev/null +++ b/Sources/Grodt/Persistency/Repositories/DailyPerformance/DailyPerformanceRepository.swift @@ -0,0 +1,12 @@ +import Foundation +import Fluent +import SQLKit + +protocol DailyPerformanceRepository: Sendable { + associatedtype OwnerID: Sendable + + func replaceSeries(for ownerID: OwnerID, with points: [DatedPortfolioPerformance]) async throws + func upsert(points: [DatedPortfolioPerformance], for ownerID: OwnerID) async throws + func readSeries(for ownerID: OwnerID, from: YearMonthDayDate?, to: YearMonthDayDate?) async throws -> [DatedPortfolioPerformance] + func deleteAll(for ownerID: OwnerID) async throws +} diff --git a/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresBrokerageAccountDailyPerformanceRepository.swift b/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresBrokerageAccountDailyPerformanceRepository.swift new file mode 100644 index 0000000..a768b33 --- /dev/null +++ b/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresBrokerageAccountDailyPerformanceRepository.swift @@ -0,0 +1,74 @@ +import Foundation +import Fluent + +struct PostgresBrokerageAccountDailyPerformanceRepository: DailyPerformanceRepository { + typealias OwnerID = UUID + + let database: Database + + func replaceSeries(for ownerID: UUID, with points: [DatedPortfolioPerformance]) async throws { + try await deleteAll(for: ownerID) + guard !points.isEmpty else { return } + + for point in points { + let row = HistoricalBrokerageAccountPerformanceDaily( + accountID: ownerID, + date: point.date.date, + moneyIn: point.moneyIn, + value: point.value + ) + try await row.save(on: database) + } + } + + func upsert(points: [DatedPortfolioPerformance], for ownerID: UUID) async throws { + guard !points.isEmpty else { return } + + let minDate = points.map { $0.date.date }.min()! + let maxDate = points.map { $0.date.date }.max()! + + let existing = try await HistoricalBrokerageAccountPerformanceDaily.query(on: database) + .filter(\.$account.$id == ownerID) + .filter(\.$date >= minDate) + .filter(\.$date <= maxDate) + .all() + + var existingByDate: [Date: HistoricalBrokerageAccountPerformanceDaily] = [:] + existingByDate.reserveCapacity(existing.count) + for row in existing { existingByDate[row.date] = row } + + for point in points { + if let row = existingByDate[point.date.date] { + row.moneyIn = point.moneyIn + row.value = point.value + try await row.save(on: database) + } else { + let newRow = HistoricalBrokerageAccountPerformanceDaily( + accountID: ownerID, + date: point.date.date, + moneyIn: point.moneyIn, + value: point.value + ) + try await newRow.save(on: database) + } + } + } + + func readSeries(for ownerID: UUID, from: YearMonthDayDate?, to: YearMonthDayDate?) async throws -> [DatedPortfolioPerformance] { + var query = HistoricalBrokerageAccountPerformanceDaily.query(on: database) + .filter(\.$account.$id == ownerID) + .sort(HistoricalBrokerageAccountPerformanceDaily.Keys.date, .ascending) + + if let from { query = query.filter(\.$date >= from.date) } + if let to { query = query.filter(\.$date <= to.date) } + + let rows = try await query.all() + return rows.map { DatedPortfolioPerformance(moneyIn: $0.moneyIn, value: $0.value, date: YearMonthDayDate($0.date)) } + } + + func deleteAll(for ownerID: UUID) async throws { + try await HistoricalBrokerageAccountPerformanceDaily.query(on: database) + .filter(\.$account.$id == ownerID) + .delete() + } +} diff --git a/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresBrokerageDailyPerformanceRepository.swift b/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresBrokerageDailyPerformanceRepository.swift new file mode 100644 index 0000000..e9055ad --- /dev/null +++ b/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresBrokerageDailyPerformanceRepository.swift @@ -0,0 +1,76 @@ +import Foundation +import Fluent + +struct PostgresBrokerageDailyPerformanceRepository: DailyPerformanceRepository { + typealias OwnerID = UUID + + let database: Database + + func replaceSeries(for ownerID: UUID, with points: [DatedPortfolioPerformance]) async throws { + try await deleteAll(for: ownerID) + guard !points.isEmpty else { return } + + for point in points { + let row = HistoricalBrokeragePerformanceDaily( + brokerageID: ownerID, + date: point.date.date, + moneyIn: point.moneyIn, + value: point.value + ) + try await row.save(on: database) + } + } + + func upsert(points: [DatedPortfolioPerformance], for ownerID: UUID) async throws { + guard !points.isEmpty else { return } + + // Bound the lookup to a compact date range for efficiency + let minDate = points.map { $0.date.date }.min()! + let maxDate = points.map { $0.date.date }.max()! + + let existing = try await HistoricalBrokeragePerformanceDaily.query(on: database) + .filter(\.$brokerage.$id == ownerID) + .filter(\.$date >= minDate) + .filter(\.$date <= maxDate) + .all() + + var existingByDate: [Date: HistoricalBrokeragePerformanceDaily] = [:] + existingByDate.reserveCapacity(existing.count) + for row in existing { existingByDate[row.date] = row } + + for point in points { + if let row = existingByDate[point.date.date] { + row.moneyIn = point.moneyIn + row.value = point.value + try await row.save(on: database) + } else { + let newRow = HistoricalBrokeragePerformanceDaily( + brokerageID: ownerID, + date: point.date.date, + moneyIn: point.moneyIn, + value: point.value + ) + try await newRow.save(on: database) + } + } + } + + func readSeries(for ownerID: UUID, from: YearMonthDayDate?, to: YearMonthDayDate?) async throws -> [DatedPortfolioPerformance] { + var query = HistoricalBrokeragePerformanceDaily.query(on: database) + .filter(\.$brokerage.$id == ownerID) + .sort(HistoricalBrokeragePerformanceDaily.Keys.date, .ascending) + + if let from { query = query.filter(\.$date >= from.date) } + if let to { query = query.filter(\.$date <= to.date) } + + let rows = try await query.all() + return rows.map { DatedPortfolioPerformance(moneyIn: $0.moneyIn, value: $0.value, date: YearMonthDayDate($0.date)) } + } + + // Delete all rows for a brokerage. + func deleteAll(for ownerID: UUID) async throws { + try await HistoricalBrokeragePerformanceDaily.query(on: database) + .filter(\.$brokerage.$id == ownerID) + .delete() + } +} diff --git a/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresPortfolioDailyPerformanceRepository.swift b/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresPortfolioDailyPerformanceRepository.swift new file mode 100644 index 0000000..73ee4d2 --- /dev/null +++ b/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresPortfolioDailyPerformanceRepository.swift @@ -0,0 +1,76 @@ +import Foundation +import Fluent + +struct PostgresPortfolioDailyPerformanceRepository: DailyPerformanceRepository { + typealias OwnerID = UUID + + let db: Database + + func replaceSeries(for ownerID: UUID, with points: [DatedPortfolioPerformance]) async throws { + try await deleteAll(for: ownerID) + guard !points.isEmpty else { return } + + for point in points { + let row = HistoricalPortfolioPerformanceDaily( + portfolioID: ownerID, + date: point.date.date, + moneyIn: point.moneyIn, + value: point.value + ) + try await row.save(on: db) + } + } + + func upsert(points: [DatedPortfolioPerformance], for ownerID: UUID) async throws { + guard !points.isEmpty else { return } + + // Bound the lookup to a compact date range for efficiency + let minDate = points.map { $0.date.date }.min()! + let maxDate = points.map { $0.date.date }.max()! + + let existing = try await HistoricalPortfolioPerformanceDaily.query(on: db) + .filter(\.$portfolio.$id == ownerID) + .filter(\.$date >= minDate) + .filter(\.$date <= maxDate) + .all() + + var existingByDate: [Date: HistoricalPortfolioPerformanceDaily] = [:] + existingByDate.reserveCapacity(existing.count) + for row in existing { existingByDate[row.date] = row } + + for point in points { + if let row = existingByDate[point.date.date] { + row.moneyIn = point.moneyIn + row.value = point.value + try await row.save(on: db) + } else { + let newRow = HistoricalPortfolioPerformanceDaily( + portfolioID: ownerID, + date: point.date.date, + moneyIn: point.moneyIn, + value: point.value + ) + try await newRow.save(on: db) + } + } + } + + func readSeries(for ownerID: UUID, from: YearMonthDayDate?, to: YearMonthDayDate?) async throws -> [DatedPortfolioPerformance] { + var query = HistoricalPortfolioPerformanceDaily.query(on: db) + .filter(\.$portfolio.$id == ownerID) + .sort(HistoricalPortfolioPerformanceDaily.Keys.date, .ascending) + + if let from { query = query.filter(\.$date >= from.date) } + if let to { query = query.filter(\.$date <= to.date) } + + let rows = try await query.all() + return rows.map { DatedPortfolioPerformance(moneyIn: $0.moneyIn, value: $0.value, date: YearMonthDayDate($0.date)) } + } + + // Delete all rows for a portfolio. + func deleteAll(for ownerID: UUID) async throws { + try await HistoricalPortfolioPerformanceDaily.query(on: db) + .filter(\.$portfolio.$id == ownerID) + .delete() + } +} diff --git a/Sources/Grodt/Persistency/Repositories/PortfolioRepository.swift b/Sources/Grodt/Persistency/Repositories/PortfolioRepository.swift index 8a1cae2..a303bda 100644 --- a/Sources/Grodt/Persistency/Repositories/PortfolioRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/PortfolioRepository.swift @@ -8,10 +8,6 @@ protocol PortfolioRepository { func update(_ portfolio: Portfolio) async throws -> Portfolio func delete(for userID: User.IDValue, with id: Portfolio.IDValue) async throws - func historicalPerformance(with id: Portfolio.IDValue) async throws -> HistoricalPortfolioPerformance - func updateHistoricalPerformance(_ historicalPerformance: HistoricalPortfolioPerformance) async throws - func createHistoricalPerformance(_ historicalPerformance: HistoricalPortfolioPerformance) async throws - func expandPortfolio(on transaction: Transaction) async throws -> Portfolio } @@ -29,7 +25,7 @@ class PostgresPortfolioRepository: PortfolioRepository { portfolio.with(\.$transactions) { transaction in transaction.with(\.$portfolio) } - portfolio.with(\.$historicalPerformance) + portfolio.with(\.$historicalDailyPerformance) }.first() guard let user else { @@ -62,7 +58,7 @@ class PostgresPortfolioRepository: PortfolioRepository { guard let updatedPortfolio = try await Portfolio.query(on: database) .filter(\Portfolio.$id == portfolio.id!) .with(\.$transactions) - .with(\.$historicalPerformance) + .with(\.$historicalDailyPerformance) .first() else { throw FluentError.noResults } @@ -82,30 +78,14 @@ class PostgresPortfolioRepository: PortfolioRepository { try await portfolio.delete(on: database) } - func historicalPerformance(with id: Portfolio.IDValue) async throws -> HistoricalPortfolioPerformance { - guard let portfolioWithPerformance = try await Portfolio.query(on: database) - .filter(\Portfolio.$id == id) - .with(\.$historicalPerformance) - .first() else { - throw FluentError.noResults - } - - return portfolioWithPerformance.$historicalPerformance.wrappedValue ?? HistoricalPortfolioPerformance(portfolioID: id, datedPerformance: []) - } - - func updateHistoricalPerformance(_ historicalPerformance: HistoricalPortfolioPerformance) async throws { - try await historicalPerformance.update(on: database) - } - - func createHistoricalPerformance(_ historicalPerformance: HistoricalPortfolioPerformance) async throws { - try await historicalPerformance.save(on: database) - } - func expandPortfolio(on transaction: Transaction) async throws -> Portfolio { - return try await Portfolio.query(on: database) - .filter(\Portfolio.$id == transaction.$portfolio.get(on: database).id!) + let portfolioID = try await transaction.$portfolio.get(on: database).requireID() + guard let result = try await Portfolio.query(on: database) + .filter(\.$id == portfolioID) .with(\.$transactions) - .with(\.$historicalPerformance) - .first()! + .with(\.$historicalDailyPerformance) + .first() + else { throw FluentError.noResults } + return result } } diff --git a/Sources/Grodt/Persistency/Repositories/TransactionsRepository.swift b/Sources/Grodt/Persistency/Repositories/TransactionsRepository.swift index e79cf54..44d986d 100644 --- a/Sources/Grodt/Persistency/Repositories/TransactionsRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/TransactionsRepository.swift @@ -3,6 +3,7 @@ import Foundation protocol TransactionsRepository { func transaction(for id: UUID) async throws -> Transaction? + func all(for userID: User.IDValue) async throws -> [Transaction] } class PostgresTransactionRepository: TransactionsRepository { @@ -18,4 +19,14 @@ class PostgresTransactionRepository: TransactionsRepository { .with(\.$portfolio) .first() } + + func all(for userID: User.IDValue) async throws -> [Transaction] { + return try await Transaction.query(on: database) + .join(parent: \Transaction.$portfolio) + .filter(Portfolio.self, \.$user.$id == userID) + .with(\.$portfolio) + .with(\.$brokerageAccount) + .sort(\.$purchaseDate, .descending) + .all() + } } diff --git a/Sources/Grodt/Persistency/Repositories/UserRepository.swift b/Sources/Grodt/Persistency/Repositories/UserRepository.swift index d65086d..12cc42f 100644 --- a/Sources/Grodt/Persistency/Repositories/UserRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/UserRepository.swift @@ -18,7 +18,7 @@ class PostgresUserRepository: UserRepository { return User.query(on: database) .with(\.$portfolios) { portfolio in portfolio.with(\.$transactions) - portfolio.with(\.$historicalPerformance) + portfolio.with(\.$historicalDailyPerformance) } } diff --git a/Sources/Grodt/Persistency/YearMonthDayDate.swift b/Sources/Grodt/Persistency/YearMonthDayDate.swift index 5d8c0b6..ce4b2fc 100644 --- a/Sources/Grodt/Persistency/YearMonthDayDate.swift +++ b/Sources/Grodt/Persistency/YearMonthDayDate.swift @@ -3,6 +3,10 @@ import Foundation struct YearMonthDayDate: Codable, Equatable, Hashable, Comparable { private(set) var date: Date + init() { + self.init(Date()) + } + init(_ date: Date) { var calendar = Calendar.current calendar.timeZone = TimeZone.universalGMT @@ -23,4 +27,22 @@ struct YearMonthDayDate: Codable, Equatable, Hashable, Comparable { static func < (lhs: YearMonthDayDate, rhs: YearMonthDayDate) -> Bool { return lhs.date < rhs.date } + + static func days(from start: YearMonthDayDate, to end: YearMonthDayDate) -> [YearMonthDayDate] { + guard end >= start else { return [] } + + var result: [YearMonthDayDate] = [] + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone.universalGMT + + var cursor = start.date + let endDate = end.date + + while cursor <= endDate { + result.append(YearMonthDayDate(cursor)) + cursor = calendar.date(byAdding: .day, value: 1, to: cursor)! + } + + return result + } } diff --git a/Sources/Grodt/Utilities/RateLimiter.swift b/Sources/Grodt/Utilities/RateLimiter.swift index 2e7778c..b643c05 100644 --- a/Sources/Grodt/Utilities/RateLimiter.swift +++ b/Sources/Grodt/Utilities/RateLimiter.swift @@ -3,7 +3,7 @@ import Foundation actor RateLimiter { private enum Constants { static let nanosecondsPerSecond: UInt64 = 1_000_000_000 - static let timeIntervalInSeconds: TimeInterval = 60 + static let timeIntervalInSeconds: TimeInterval = 120 } private let maxRequestsPerMinute: Int diff --git a/Tests/GrodtTests/HoldingsPerformanceCalculatorTests.swift b/Tests/GrodtTests/HoldingsPerformanceCalculatorTests.swift new file mode 100644 index 0000000..8f03182 --- /dev/null +++ b/Tests/GrodtTests/HoldingsPerformanceCalculatorTests.swift @@ -0,0 +1,224 @@ +@testable import Grodt +import XCTest +import Foundation + +fileprivate final class MockPriceService: PriceService { + var pricesByTicker: [String: [DatedQuote]] = [:] + private(set) var historicalPriceCallCount: [String: Int] = [:] + private(set) var spotPriceCallCount: [String: Int] = [:] + + func price(for ticker: String) async throws -> Decimal { + spotPriceCallCount[ticker, default: 0] += 1 + return pricesByTicker[ticker]?.last?.price ?? 0 + } + + func historicalPrice(for ticker: String) async throws -> [DatedQuote] { + historicalPriceCallCount[ticker, default: 0] += 1 + return pricesByTicker[ticker] ?? [] + } +} + +final class HoldingsPerformanceCalculatorTests: XCTestCase { + private var mockPriceService = MockPriceService() + private lazy var calculator = HoldingsPerformanceCalculator(priceService: mockPriceService) + + func testSeries_SingleTicker_CarryForwardAndCumulativeMoneyIn() async throws { + // Given + let ticker = "AAPL" + let buy = givenTransaction(purchasedOn: YearMonthDayDate(2024, 1, 10), ticker: ticker, fees: 1, shares: 10, pricePerShare: 5) + let start = YearMonthDayDate(buy.purchaseDate) + let end = YearMonthDayDate(2024, 1, 15) + + mockPriceService.pricesByTicker = [ + ticker: [ + DatedQuote(price: 9, date: end), + DatedQuote(price: 6, date: start), + DatedQuote(price: 7, date: YearMonthDayDate(2024, 1, 12)) + ] + ] + + // When + let series = try await calculator.performanceSeries(for: [buy], from: start, to: end) + + // Then + let expected2: [DatedPortfolioPerformance] = [ + DatedPortfolioPerformance(moneyIn: 51, value: 60, date: YearMonthDayDate(2024, 1, 10)), + DatedPortfolioPerformance(moneyIn: 51, value: 60, date: YearMonthDayDate(2024, 1, 11)), + DatedPortfolioPerformance(moneyIn: 51, value: 70, date: YearMonthDayDate(2024, 1, 12)), + DatedPortfolioPerformance(moneyIn: 51, value: 70, date: YearMonthDayDate(2024, 1, 13)), + DatedPortfolioPerformance(moneyIn: 51, value: 70, date: YearMonthDayDate(2024, 1, 14)), + DatedPortfolioPerformance(moneyIn: 51, value: 90, date: YearMonthDayDate(2024, 1, 15)) + ] + XCTAssertEqual(series, expected2) + XCTAssertEqual(mockPriceService.historicalPriceCallCount[ticker] ?? 0, 1) + } + + func testSeries_MultiTicker_AggregationAndCarryForward() async throws { + // Given: MSFT buys on 10 (3 sh @10) and 12 (2 sh @12), AAPL buy on 14 (1 sh @100 + 2 fees) + let msft1 = givenTransaction(purchasedOn: YearMonthDayDate(2024, 1, 10), ticker: "MSFT", shares: 3, pricePerShare: 10) + let msft2 = givenTransaction(purchasedOn: YearMonthDayDate(2024, 1, 12), ticker: "MSFT", shares: 2, pricePerShare: 12) + let aapl = givenTransaction(purchasedOn: YearMonthDayDate(2024, 1, 14), ticker: "AAPL", fees: 2, shares: 1, pricePerShare: 100) + let start = YearMonthDayDate(2024, 1, 10) + let end = YearMonthDayDate(2024, 1, 15) + + let d10 = start + let d14 = YearMonthDayDate(2024, 1, 14) + let d15 = end + + // MSFT quotes only on 10 and 15; AAPL quote only on 14 to test carry-forward + mockPriceService.pricesByTicker = [ + "MSFT": [ DatedQuote(price: 20, date: d10), DatedQuote(price: 25, date: d15) ], + "AAPL": [ DatedQuote(price: 7, date: d14) ] + ] + + // When + let series = try await calculator.performanceSeries(for: [msft1, msft2, aapl], from: start, to: end) + + // Then + let expected: [DatedPortfolioPerformance] = [ + DatedPortfolioPerformance(moneyIn: 30, value: 60, date: YearMonthDayDate(2024, 1, 10)), + DatedPortfolioPerformance(moneyIn: 30, value: 60, date: YearMonthDayDate(2024, 1, 11)), + DatedPortfolioPerformance(moneyIn: 54, value: 100, date: YearMonthDayDate(2024, 1, 12)), + DatedPortfolioPerformance(moneyIn: 54, value: 100, date: YearMonthDayDate(2024, 1, 13)), + DatedPortfolioPerformance(moneyIn: 156, value: 107, date: YearMonthDayDate(2024, 1, 14)), + DatedPortfolioPerformance(moneyIn: 156, value: 132, date: YearMonthDayDate(2024, 1, 15)) + ] + XCTAssertEqual(series, expected) + // One historical fetch per ticker during prefetch + XCTAssertEqual(mockPriceService.historicalPriceCallCount["MSFT"] ?? 0, 1) + XCTAssertEqual(mockPriceService.historicalPriceCallCount["AAPL"] ?? 0, 1) + } + + func testSeries_EmptyTransactions_ReturnsEmpty() async throws { + let start = YearMonthDayDate(2024, 1, 10) + let end = YearMonthDayDate(2024, 1, 15) + let series = try await calculator.performanceSeries(for: [], from: start, to: end) + // Then + let expected: [DatedPortfolioPerformance] = [] + XCTAssertEqual(series, expected) + } + + func testSeries_TransactionsInFuture_BeforeStartIgnored() async throws { + // Given a purchase after the range end – should have no effect + let future = givenTransaction(purchasedOn: YearMonthDayDate(2024, 1, 20), ticker: "NVDA", shares: 1, pricePerShare: 100) + let start = YearMonthDayDate(2024, 1, 10) + let end = YearMonthDayDate(2024, 1, 15) + + mockPriceService.pricesByTicker = [ "NVDA": [ DatedQuote(price: 500, date: end) ] ] + + // When + let series = try await calculator.performanceSeries(for: [future], from: start, to: end) + + // Then: no contribution + let expected: [DatedPortfolioPerformance] = [ + DatedPortfolioPerformance(moneyIn: 0, value: 0, date: YearMonthDayDate(2024, 1, 10)), + DatedPortfolioPerformance(moneyIn: 0, value: 0, date: YearMonthDayDate(2024, 1, 11)), + DatedPortfolioPerformance(moneyIn: 0, value: 0, date: YearMonthDayDate(2024, 1, 12)), + DatedPortfolioPerformance(moneyIn: 0, value: 0, date: YearMonthDayDate(2024, 1, 13)), + DatedPortfolioPerformance(moneyIn: 0, value: 0, date: YearMonthDayDate(2024, 1, 14)), + DatedPortfolioPerformance(moneyIn: 0, value: 0, date: YearMonthDayDate(2024, 1, 15)) + ] + XCTAssertEqual(series, expected) + // Prefetch may still fetch NVDA once + XCTAssertEqual(mockPriceService.historicalPriceCallCount["NVDA"] ?? 0, 1) + } + + func testPerformance_40Years_10Tickers() async throws { + // Local helper to add years to a YearMonthDayDate + func addYears(_ years: Int, to day: YearMonthDayDate) -> YearMonthDayDate { + var calendar = Calendar.current + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let newDate = calendar.date(byAdding: .year, value: years, to: day.date)! + return YearMonthDayDate(newDate) + } + + // 40-year inclusive span + let start = YearMonthDayDate(1985, 1, 1) + let end = YearMonthDayDate(2024, 12, 31) + let allDays = YearMonthDayDate.days(from: start, to: end) + + // 10 synthetic tickers + let tickers = (0..<10).map { "T\($0)" } + + // Build deterministic quotes: one quote per day per ticker + mockPriceService.pricesByTicker.removeAll(keepingCapacity: true) + for (idx, ticker) in tickers.enumerated() { + var quotes: [DatedQuote] = [] + quotes.reserveCapacity(allDays.count) + let base = Decimal(100 + idx * 3) + for (dayIndex, day) in allDays.enumerated() { + // Sawtooth pattern ensures variation while staying deterministic + let bump = Decimal(dayIndex % 200) / 10 // 0.0 ... 19.9 then repeat + quotes.append(DatedQuote(price: base + bump, date: day)) + } + mockPriceService.pricesByTicker[ticker] = quotes + } + + // Transactions: 4 buys per ticker at 0, 10, 20, 30 years from start + var transactions: [Transaction] = [] + transactions.reserveCapacity(tickers.count * 4) + for (idx, ticker) in tickers.enumerated() { + for offset in [0, 10, 20, 30] { + let buyDay = addYears(offset, to: start) + let shares = Decimal(5 + (idx % 5)) // 5...9 shares + let purchasePrice = Decimal(100 + idx * 3 + offset) + transactions.append( + givenTransaction( + purchasedOn: buyDay, + ticker: ticker, + fees: 1, // small fee to exercise moneyIn + shares: shares, + pricePerShare: purchasePrice + ) + ) + } + } + + // Measure using a monotonic clock + let clock = ContinuousClock() + let t0 = clock.now + let series = try await calculator.performanceSeries(for: transactions, from: start, to: end) + let duration = t0.duration(to: clock.now) + + // Sanity + XCTAssertEqual(series.count, allDays.count) + XCTAssertFalse(series.isEmpty) + + // Convert Duration to seconds (lenient threshold; tune for CI hardware) + let comps = duration.components + let seconds = Double(comps.seconds) + Double(comps.attoseconds) / 1_000_000_000_000_000_000.0 + XCTAssertLessThan(seconds, 10.0) + } + + + private func givenTransaction( + portfolioID: UUID = UUID(), + brokerageAccountID: UUID? = UUID(), + purchasedOn: YearMonthDayDate, + ticker: String, + currency: Currency = TestConstant.Currencies.eur, + fees: Decimal = 0, + shares: Decimal, + pricePerShare: Decimal + ) -> Transaction { + Transaction( + portfolioID: portfolioID, + brokerageAccountID: brokerageAccountID, + purchaseDate: purchasedOn.date, + ticker: ticker, + currency: currency, + fees: fees, + numberOfShares: shares, + pricePerShareAtPurchase: pricePerShare + ) + } +} + +extension YearMonthDayDate { + init(_ y: Int, _ m: Int, _ d: Int) { + var calendar = Calendar.current + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let date = calendar.date(from: DateComponents(timeZone: calendar.timeZone, year: y, month: m, day: d))! + self.init(date) + } +} diff --git a/Tests/GrodtTests/TestConstant.swift b/Tests/GrodtTests/TestConstant.swift index 4d5b6b6..b8d9a1a 100644 --- a/Tests/GrodtTests/TestConstant.swift +++ b/Tests/GrodtTests/TestConstant.swift @@ -14,7 +14,8 @@ enum TestConstant { name: "New", currency: Currencies.eur.dto, performance: PerformanceDTOs.zero, - investments: []) + investments: [], + transactions: []) } enum PerformanceDTOs {