diff --git a/Sources/Grodt/Application/routes.swift b/Sources/Grodt/Application/routes.swift index 77bcd62..3ee50c5 100644 --- a/Sources/Grodt/Application/routes.swift +++ b/Sources/Grodt/Application/routes.swift @@ -24,11 +24,12 @@ func routes(_ app: Application) async throws { 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 brokerageAccountDailyPerformanceRepository = PostgresBrokerageAccountDailyPerformanceRepository(database: app.db) let brokerageDailyPerformanceRepository = PostgresBrokerageDailyPerformanceRepository(database: app.db) let portfolioDTOMapper = PortfolioDTOMapper(investmentDTOMapper: investmentDTOMapper, transactionDTOMapper: transactionDTOMapper, + performanceDTOMapper: DatedPerformanceDTOMapper(), currencyDTOMapper: currencyDTOMapper) let currencyRepository = PostgresCurrencyRepository(database: app.db) let portfolioPerformanceUpdater = PortfolioPerformanceUpdater( @@ -95,8 +96,10 @@ func routes(_ app: Application) async throws { accounts: brokerageAccountRepository, currencyMapper: currencyDTOMapper, performanceRepository: brokerageDailyPerformanceRepository, - performancePointDTOMapper: PerformancePointDTOMapper())) + performanceDTOMapper: DatedPerformanceDTOMapper())) try protected.register(collection: BrokerageAccountController(brokerageAccountRepository: brokerageAccountRepository, + performanceRepository: brokerageAccountDailyPerformanceRepository, + performanceDTOMapper: DatedPerformanceDTOMapper(), currencyMapper: currencyDTOMapper, currencyRepository: currencyRepository)) } @@ -109,12 +112,12 @@ func routes(_ app: Application) async throws { portfolioPerformanceUpdater: portfolioPerformanceUpdater, brokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdater(transactionRepository: transactionRepository, brokerageAccountRepository: brokerageAccountRepository, - accountDailyRepository: brokerageAccountDailyRepository, + accountDailyRepository: brokerageAccountDailyPerformanceRepository, userRepository: userRepository, calculator: performanceCalculator), brokeragePerformanceUpdater: BrokeragePerformanceUpdater(userRepository: userRepository, brokerageAccountRepository: brokerageAccountRepository, - accountDailyRepository: brokerageAccountDailyRepository, + accountDailyRepository: brokerageAccountDailyPerformanceRepository, brokerageDailyRepository: brokerageDailyPerformanceRepository) ) app.queues.schedule(nightlyUpdaterJob) diff --git a/Sources/Grodt/BusinessLogic/Performance Calculating/BrokeragePerformanceUpdater.swift b/Sources/Grodt/BusinessLogic/Performance Calculating/BrokeragePerformanceUpdater.swift index 9bbd2cc..a740301 100644 --- a/Sources/Grodt/BusinessLogic/Performance Calculating/BrokeragePerformanceUpdater.swift +++ b/Sources/Grodt/BusinessLogic/Performance Calculating/BrokeragePerformanceUpdater.swift @@ -58,7 +58,7 @@ final class BrokeragePerformanceUpdater: BrokeragePerformanceUpdating { } // Read each account's full series and track the global date window - var perAccountSeries: [[YearMonthDayDate: DatedPortfolioPerformance]] = [] + var perAccountSeries: [[YearMonthDayDate: DatedPerformance]] = [] perAccountSeries.reserveCapacity(accounts.count) var earliestDate: Date? @@ -69,7 +69,7 @@ final class BrokeragePerformanceUpdater: BrokeragePerformanceUpdating { earliestDate = min(earliestDate ?? firstDate, firstDate) } // Index by date for O(1) lookups during summation - var map: [YearMonthDayDate: DatedPortfolioPerformance] = [:] + var map: [YearMonthDayDate: DatedPerformance] = [:] map.reserveCapacity(series.count) for point in series { map[point.date] = point } perAccountSeries.append(map) @@ -86,7 +86,7 @@ final class BrokeragePerformanceUpdater: BrokeragePerformanceUpdating { let days = YearMonthDayDate.days(from: start, to: end) // Sum across accounts for each day - var summed: [DatedPortfolioPerformance] = [] + var summed: [DatedPerformance] = [] summed.reserveCapacity(days.count) for day in days { @@ -98,7 +98,7 @@ final class BrokeragePerformanceUpdater: BrokeragePerformanceUpdating { value += point.value } } - summed.append(DatedPortfolioPerformance(moneyIn: moneyIn, value: value, date: day)) + summed.append(DatedPerformance(moneyIn: moneyIn, value: value, date: day)) } // Replace the brokerage's series diff --git a/Sources/Grodt/BusinessLogic/Performance Calculating/HoldingsPerformanceCalculating.swift b/Sources/Grodt/BusinessLogic/Performance Calculating/HoldingsPerformanceCalculating.swift index 5f7fcfc..8039fb7 100644 --- a/Sources/Grodt/BusinessLogic/Performance Calculating/HoldingsPerformanceCalculating.swift +++ b/Sources/Grodt/BusinessLogic/Performance Calculating/HoldingsPerformanceCalculating.swift @@ -1,7 +1,7 @@ import Foundation protocol HoldingsPerformanceCalculating { - func performanceSeries(for transactions: [Transaction], from startDate: YearMonthDayDate, to endDate: YearMonthDayDate) async throws -> [DatedPortfolioPerformance] + func performanceSeries(for transactions: [Transaction], from startDate: YearMonthDayDate, to endDate: YearMonthDayDate) async throws -> [DatedPerformance] } struct HoldingsPerformanceCalculator: HoldingsPerformanceCalculating { @@ -14,7 +14,7 @@ struct HoldingsPerformanceCalculator: HoldingsPerformanceCalculating { for transactions: [Transaction], from startDate: YearMonthDayDate, to endDate: YearMonthDayDate - ) async throws -> [DatedPortfolioPerformance] { + ) async throws -> [DatedPerformance] { guard !transactions.isEmpty else { return [] } guard endDate >= startDate else { return [] } @@ -36,7 +36,7 @@ struct HoldingsPerformanceCalculator: HoldingsPerformanceCalculating { 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] = [] + var series: [DatedPerformance] = [] series.reserveCapacity(days.count) for day in days { diff --git a/Sources/Grodt/DTOs/BrokerageDTO.swift b/Sources/Grodt/DTOs/BrokerageDTO.swift deleted file mode 100644 index 59376d0..0000000 --- a/Sources/Grodt/DTOs/BrokerageDTO.swift +++ /dev/null @@ -1,37 +0,0 @@ -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/DTOMappers/DatedPerformanceDTOMapper.swift b/Sources/Grodt/DTOs/DTOMappers/DatedPerformanceDTOMapper.swift new file mode 100644 index 0000000..bd16555 --- /dev/null +++ b/Sources/Grodt/DTOs/DTOMappers/DatedPerformanceDTOMapper.swift @@ -0,0 +1,16 @@ +import Foundation + +struct DatedPerformanceDTOMapper { + func performancePoint(from enity: DatedPerformance) -> DatedPerformanceDTO { + let moneyIn = enity.moneyIn + let moneyOut = enity.value + let profit = moneyOut - moneyIn + let totalReturn: Decimal = moneyIn > 0 ? (profit / moneyIn).rounded(to: 2) : 0 + + return DatedPerformanceDTO(date: enity.date.date, + moneyIn: moneyIn, + moneyOut: moneyOut, + profit: profit, + totalReturn: totalReturn) + } +} diff --git a/Sources/Grodt/DTOs/DTOMappers/PerformancePointDTOMapper.swift b/Sources/Grodt/DTOs/DTOMappers/PerformancePointDTOMapper.swift deleted file mode 100644 index 3ebaa34..0000000 --- a/Sources/Grodt/DTOs/DTOMappers/PerformancePointDTOMapper.swift +++ /dev/null @@ -1,7 +0,0 @@ -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 3445a24..57bf42b 100644 --- a/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift +++ b/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift @@ -5,12 +5,15 @@ class PortfolioDTOMapper { private let investmentDTOMapper: InvestmentDTOMapper private let currencyDTOMapper: CurrencyDTOMapper private let transactionDTOMapper: TransactionDTOMapper + private let performanceDTOMapper: DatedPerformanceDTOMapper init(investmentDTOMapper: InvestmentDTOMapper, transactionDTOMapper: TransactionDTOMapper, + performanceDTOMapper: DatedPerformanceDTOMapper, currencyDTOMapper: CurrencyDTOMapper) { self.investmentDTOMapper = investmentDTOMapper self.transactionDTOMapper = transactionDTOMapper + self.performanceDTOMapper = performanceDTOMapper self.currencyDTOMapper = currencyDTOMapper } @@ -35,12 +38,12 @@ class PortfolioDTOMapper { ) } - func performance(for portfolio: Portfolio) async throws -> PortfolioPerformanceDTO { + func performance(for portfolio: Portfolio) async throws -> PerformanceDTO { // 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) + return PerformanceDTO(moneyIn: 0, moneyOut: 0, profit: 0, totalReturn: 0) } let moneyIn = latest.moneyIn @@ -48,7 +51,7 @@ class PortfolioDTOMapper { let profit = moneyOut - moneyIn let totalReturn: Decimal = moneyIn > 0 ? (profit / moneyIn).rounded(to: 2) : 0 - return PortfolioPerformanceDTO( + return PerformanceDTO( moneyIn: moneyIn, moneyOut: moneyOut, profit: profit, @@ -56,24 +59,11 @@ class PortfolioDTOMapper { ) } - 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 - ) - } + func timeSeriesPerformance(from series: [DatedPerformance]) async -> PerformanceTimeSeriesDTO { + let values = series.map { performanceDTOMapper.performancePoint(from: $0) } .sorted { $0.date < $1.date } - - return PortfolioPerformanceTimeSeriesDTO(values: values) + + return PerformanceTimeSeriesDTO(values: values) } } diff --git a/Sources/Grodt/DTOs/PerformanceDTO.swift b/Sources/Grodt/DTOs/PerformanceDTO.swift new file mode 100644 index 0000000..1d48a5d --- /dev/null +++ b/Sources/Grodt/DTOs/PerformanceDTO.swift @@ -0,0 +1,17 @@ +import Foundation + +struct PerformanceDTO: Codable, Equatable { + let moneyIn: Decimal + let moneyOut: Decimal + let profit: Decimal + let totalReturn: Decimal + + static var zero: PerformanceDTO { + return .init( + moneyIn: 0, + moneyOut: 0, + profit: 0, + totalReturn: 0 + ) + } +} diff --git a/Sources/Grodt/DTOs/PerformanceTimeSeriesDTO.swift b/Sources/Grodt/DTOs/PerformanceTimeSeriesDTO.swift index e9c8cd6..884f98f 100644 --- a/Sources/Grodt/DTOs/PerformanceTimeSeriesDTO.swift +++ b/Sources/Grodt/DTOs/PerformanceTimeSeriesDTO.swift @@ -1,10 +1,10 @@ import Foundation -struct PortfolioPerformanceTimeSeriesDTO: Codable, Equatable { - let values: [DatedPortfolioPerformanceDTO] +struct PerformanceTimeSeriesDTO: Codable, Equatable { + let values: [DatedPerformanceDTO] } -struct DatedPortfolioPerformanceDTO: Codable, Equatable { +struct DatedPerformanceDTO: Codable, Equatable { let date: Date let moneyIn: Decimal let moneyOut: Decimal diff --git a/Sources/Grodt/DTOs/PortfolioDTO.swift b/Sources/Grodt/DTOs/PortfolioDTO.swift index 3f2f8cf..d7c396e 100644 --- a/Sources/Grodt/DTOs/PortfolioDTO.swift +++ b/Sources/Grodt/DTOs/PortfolioDTO.swift @@ -4,7 +4,7 @@ struct PortfolioDTO: Codable, Equatable { let id: String let name: String let currency: CurrencyDTO - let performance: PortfolioPerformanceDTO + let performance: PerformanceDTO let investments: [InvestmentDTO] let transactions: [TransactionDTO] } diff --git a/Sources/Grodt/DTOs/PortfolioInfoDTO.swift b/Sources/Grodt/DTOs/PortfolioInfoDTO.swift index b9c80f0..63e345f 100644 --- a/Sources/Grodt/DTOs/PortfolioInfoDTO.swift +++ b/Sources/Grodt/DTOs/PortfolioInfoDTO.swift @@ -4,5 +4,5 @@ struct PortfolioInfoDTO: Codable, Equatable { let id: String let name: String let currency: CurrencyDTO - let performance: PortfolioPerformanceDTO + let performance: PerformanceDTO } diff --git a/Sources/Grodt/DTOs/PortfolioPerformanceDTO.swift b/Sources/Grodt/DTOs/PortfolioPerformanceDTO.swift deleted file mode 100644 index 161d59e..0000000 --- a/Sources/Grodt/DTOs/PortfolioPerformanceDTO.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -struct PortfolioPerformanceDTO: Codable, Equatable { - let moneyIn: Decimal - let moneyOut: Decimal - let profit: Decimal - let totalReturn: Decimal -} diff --git a/Sources/Grodt/Endpoints/PortfoliosController.swift b/Sources/Grodt/Endpoints/PortfoliosController.swift index 2cfa172..b9499c5 100644 --- a/Sources/Grodt/Endpoints/PortfoliosController.swift +++ b/Sources/Grodt/Endpoints/PortfoliosController.swift @@ -32,7 +32,7 @@ struct PortfoliosController: RouteCollection { portfolio.put(use: update) portfolio.delete(use: delete) - portfolio.group("historicalPerformance") { pref in + portfolio.group("performance") { pref in pref.get(use: historicalPerformance) } } @@ -121,7 +121,7 @@ struct PortfoliosController: RouteCollection { return .ok } - func historicalPerformance(req: Request) async throws -> PortfolioPerformanceTimeSeriesDTO { + func historicalPerformance(req: Request) async throws -> PerformanceTimeSeriesDTO { let id = try req.requiredID() guard let userID = req.auth.get(User.self)?.id else { throw Abort(.badRequest) } @@ -136,4 +136,4 @@ struct PortfoliosController: RouteCollection { extension PortfolioDTO: Content { } extension PortfolioInfoDTO: Content { } -extension PortfolioPerformanceTimeSeriesDTO: Content { } +extension PerformanceTimeSeriesDTO: Content { } diff --git a/Sources/Grodt/Endpoints/BrokerageAccountController.swift b/Sources/Grodt/Endpoints/brokerages/BrokerageAccountController.swift similarity index 78% rename from Sources/Grodt/Endpoints/BrokerageAccountController.swift rename to Sources/Grodt/Endpoints/brokerages/BrokerageAccountController.swift index 280f7f3..eb0b2a5 100644 --- a/Sources/Grodt/Endpoints/BrokerageAccountController.swift +++ b/Sources/Grodt/Endpoints/brokerages/BrokerageAccountController.swift @@ -3,11 +3,19 @@ import Fluent struct BrokerageAccountController: RouteCollection { private let brokerageAccountRepository: BrokerageAccountRepository + private let performanceRepository: PostgresBrokerageAccountDailyPerformanceRepository private let currencyMapper: CurrencyDTOMapper + private let performanceDTOMapper: DatedPerformanceDTOMapper private let currencyRepository: CurrencyRepository - init(brokerageAccountRepository: BrokerageAccountRepository, currencyMapper: CurrencyDTOMapper, currencyRepository: CurrencyRepository) { + init(brokerageAccountRepository: BrokerageAccountRepository, + performanceRepository: PostgresBrokerageAccountDailyPerformanceRepository, + performanceDTOMapper: DatedPerformanceDTOMapper, + currencyMapper: CurrencyDTOMapper, + currencyRepository: CurrencyRepository) { self.brokerageAccountRepository = brokerageAccountRepository + self.performanceRepository = performanceRepository + self.performanceDTOMapper = performanceDTOMapper self.currencyMapper = currencyMapper self.currencyRepository = currencyRepository } @@ -28,7 +36,7 @@ struct BrokerageAccountController: RouteCollection { 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 performance = try await brokerageAccountRepository.performance(for: model.requireID()) let brokerage = try await model.$brokerage.get(on: req.db) return BrokerageAccountDTO( id: try model.requireID(), @@ -36,7 +44,7 @@ struct BrokerageAccountController: RouteCollection { brokerageName: brokerage.name, displayName: model.displayName, baseCurrency: currencyMapper.currency(from: model.baseCurrency), - totals: totals) + performance: performance) } } @@ -68,20 +76,20 @@ struct BrokerageAccountController: RouteCollection { brokerageName: brokerage.name, displayName: model.displayName, baseCurrency: currencyMapper.currency(from: model.baseCurrency), - totals: nil) + performance: PerformanceDTO.zero) } 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()) + let performance = try await brokerageAccountRepository.performance(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) + performance: performance) } private func update(req: Request) async throws -> HTTPStatus { @@ -101,14 +109,16 @@ struct BrokerageAccountController: RouteCollection { return .noContent } - private func performanceSeries(req: Request) async throws -> [PerformancePointDTO] { + private func performanceSeries(req: Request) async throws -> PerformanceTimeSeriesDTO { 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) } + let rows = try await performanceRepository.readSeries(for: account.requireID(), + from: nil, + to: nil) + + let values = rows.map { performanceDTOMapper.performancePoint(from: $0) } + .sorted { $0.date < $1.date } + return PerformanceTimeSeriesDTO(values: values) } private func requireAccount(_ req: Request, userID: UUID) async throws -> BrokerageAccount { @@ -121,4 +131,3 @@ struct BrokerageAccountController: RouteCollection { } extension BrokerageAccountDTO: Content { } -extension PerformancePointDTO: Content { } diff --git a/Sources/Grodt/Endpoints/brokerages/BrokerageController.swift b/Sources/Grodt/Endpoints/brokerages/BrokerageController.swift index 3f34da0..b88aa7e 100644 --- a/Sources/Grodt/Endpoints/brokerages/BrokerageController.swift +++ b/Sources/Grodt/Endpoints/brokerages/BrokerageController.swift @@ -7,21 +7,21 @@ struct BrokerageController: RouteCollection { private let accounts: BrokerageAccountRepository private let currencyMapper: CurrencyDTOMapper private let performanceRepository: PostgresBrokerageDailyPerformanceRepository - private let performancePointDTOMapper: PerformancePointDTOMapper + private let performanceDTOMapper: DatedPerformanceDTOMapper init(brokerageRepository: BrokerageRepository, dtoMapper: BrokerageDTOMapper, accounts: BrokerageAccountRepository, currencyMapper: CurrencyDTOMapper, performanceRepository: PostgresBrokerageDailyPerformanceRepository, - performancePointDTOMapper: PerformancePointDTOMapper + performanceDTOMapper: DatedPerformanceDTOMapper ) { self.brokerageRepository = brokerageRepository self.dtoMapper = dtoMapper self.accounts = accounts self.currencyMapper = currencyMapper self.performanceRepository = performanceRepository - self.performancePointDTOMapper = performancePointDTOMapper + self.performanceDTOMapper = performanceDTOMapper } func boot(routes: RoutesBuilder) throws { @@ -50,7 +50,7 @@ struct BrokerageController: RouteCollection { return BrokerageDTO(id: try item.requireID(), name: item.name, accounts: [], - totals: nil) + performance: PerformanceDTO.zero) } private func detail(req: Request) async throws -> BrokerageDTO { @@ -75,12 +75,15 @@ struct BrokerageController: RouteCollection { return .noContent } - private func performanceSeries(req: Request) async throws -> [PerformancePointDTO] { + private func performanceSeries(req: Request) async throws -> PerformanceTimeSeriesDTO { 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) } + + let values = rows.map { performanceDTOMapper.performancePoint(from: $0) } + .sorted { $0.date < $1.date } + return PerformanceTimeSeriesDTO(values: values) } private func requireBrokerage(_ req: Request, userID: UUID) async throws -> Brokerage { diff --git a/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageAccountDTO.swift b/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageAccountDTO.swift new file mode 100644 index 0000000..a3e5d4e --- /dev/null +++ b/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageAccountDTO.swift @@ -0,0 +1,10 @@ +import Foundation + +struct BrokerageAccountDTO: Codable { + let id: UUID + let brokerageId: UUID + let brokerageName: String + let displayName: String + let baseCurrency: CurrencyDTO + let performance: PerformanceDTO +} diff --git a/Sources/Grodt/Endpoints/brokerages/BrokerageAccountDTOMapper.swift b/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageAccountDTOMapper.swift similarity index 86% rename from Sources/Grodt/Endpoints/brokerages/BrokerageAccountDTOMapper.swift rename to Sources/Grodt/Endpoints/brokerages/DTO/BrokerageAccountDTOMapper.swift index 30a8d70..ca621f8 100644 --- a/Sources/Grodt/Endpoints/brokerages/BrokerageAccountDTOMapper.swift +++ b/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageAccountDTOMapper.swift @@ -15,13 +15,13 @@ struct BrokerageAccountDTOMapper { func brokerageAccount(from brokerageAccount: BrokerageAccount) async throws -> BrokerageAccountDTO { try await brokerageAccount.$brokerage.load(on: database) - let totals = try await brokerageAccountRepository.totals(for: brokerageAccount.requireID()) + let performance = try await brokerageAccountRepository.performance(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()) + performance: performance) } } diff --git a/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageDTO.swift b/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageDTO.swift new file mode 100644 index 0000000..13e3997 --- /dev/null +++ b/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageDTO.swift @@ -0,0 +1,8 @@ +import Foundation + +struct BrokerageDTO: Codable { + let id: UUID + let name: String + let accounts: [BrokerageAccountDTO] + let performance: PerformanceDTO +} diff --git a/Sources/Grodt/Endpoints/brokerages/BrokerageDTOMapper.swift b/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageDTOMapper.swift similarity index 87% rename from Sources/Grodt/Endpoints/brokerages/BrokerageDTOMapper.swift rename to Sources/Grodt/Endpoints/brokerages/DTO/BrokerageDTOMapper.swift index 506ccbc..be0adcc 100644 --- a/Sources/Grodt/Endpoints/brokerages/BrokerageDTOMapper.swift +++ b/Sources/Grodt/Endpoints/brokerages/DTO/BrokerageDTOMapper.swift @@ -18,12 +18,12 @@ struct BrokerageDTOMapper { let accountDTOs = try await brokerage.accounts.asyncMap { try await accountDTOMapper.brokerageAccount(from: $0) } - let totals = try await brokerageRepository.totals(for: brokerage.requireID()) + let performance = try await brokerageRepository.performance(for: brokerage.requireID()) return try BrokerageDTO( id: brokerage.requireID(), name: brokerage.name, accounts: accountDTOs, - totals: totals + performance: performance ) } } diff --git a/Sources/Grodt/Endpoints/brokerages/CreateBrokerageRequestDTO.swift b/Sources/Grodt/Endpoints/brokerages/DTO/CreateBrokerageRequestDTO.swift similarity index 100% rename from Sources/Grodt/Endpoints/brokerages/CreateBrokerageRequestDTO.swift rename to Sources/Grodt/Endpoints/brokerages/DTO/CreateBrokerageRequestDTO.swift diff --git a/Sources/Grodt/Persistency/Models/DatedPortfolioPerformance.swift b/Sources/Grodt/Persistency/Models/DatedPerformance.swift similarity index 64% rename from Sources/Grodt/Persistency/Models/DatedPortfolioPerformance.swift rename to Sources/Grodt/Persistency/Models/DatedPerformance.swift index 3d9a17c..fa9240b 100644 --- a/Sources/Grodt/Persistency/Models/DatedPortfolioPerformance.swift +++ b/Sources/Grodt/Persistency/Models/DatedPerformance.swift @@ -1,6 +1,6 @@ import Foundation -struct DatedPortfolioPerformance: Codable, Equatable { +struct DatedPerformance: Codable, Equatable { let moneyIn: Decimal let value: Decimal let date: YearMonthDayDate diff --git a/Sources/Grodt/Persistency/Repositories/BrokerageAccountRepository.swift b/Sources/Grodt/Persistency/Repositories/BrokerageAccountRepository.swift index b8aa49d..f780c7c 100644 --- a/Sources/Grodt/Persistency/Repositories/BrokerageAccountRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/BrokerageAccountRepository.swift @@ -7,7 +7,7 @@ protocol BrokerageAccountRepository { 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? + func performance(for accountID: BrokerageAccount.IDValue) async throws -> PerformanceDTO } class PostgresBrokerageAccountRepository: BrokerageAccountRepository { @@ -42,12 +42,21 @@ class PostgresBrokerageAccountRepository: BrokerageAccountRepository { try await account.delete(on: database) } - func totals(for accountID: BrokerageAccount.IDValue) async throws -> PerformanceTotalsDTO? { + func performance(for accountID: BrokerageAccount.IDValue) async throws -> PerformanceDTO { 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) + else { return PerformanceDTO.zero } + + let moneyIn = last.moneyIn + let moneyOut = last.value + let profit = moneyOut - moneyIn + let totalReturn: Decimal = moneyIn > 0 ? (profit / moneyIn).rounded(to: 2) : 0 + + return PerformanceDTO(moneyIn: moneyIn, + moneyOut: moneyOut, + profit: profit, + totalReturn: totalReturn) } } diff --git a/Sources/Grodt/Persistency/Repositories/BrokerageRepository.swift b/Sources/Grodt/Persistency/Repositories/BrokerageRepository.swift index 74db9a2..d1150c4 100644 --- a/Sources/Grodt/Persistency/Repositories/BrokerageRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/BrokerageRepository.swift @@ -8,7 +8,7 @@ protocol BrokerageRepository: Sendable { 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? + func performance(for brokerageID: Brokerage.IDValue) async throws -> PerformanceDTO } struct PostgresBrokerageRepository: BrokerageRepository { @@ -51,12 +51,21 @@ struct PostgresBrokerageRepository: BrokerageRepository { try await BrokerageAccount.query(on: database).filter(\.$brokerage.$id == brokerageID).count() } - func totals(for brokerageID: Brokerage.IDValue) async throws -> PerformanceTotalsDTO? { + func performance(for brokerageID: Brokerage.IDValue) async throws -> PerformanceDTO { 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) + else { return PerformanceDTO.zero } + + let moneyIn = last.moneyIn + let moneyOut = last.value + let profit = moneyOut - moneyIn + let totalReturn: Decimal = moneyIn > 0 ? (profit / moneyIn).rounded(to: 2) : 0 + + return PerformanceDTO(moneyIn: moneyIn, + moneyOut: moneyOut, + profit: profit, + totalReturn: totalReturn) } } diff --git a/Sources/Grodt/Persistency/Repositories/DailyPerformance/DailyPerformanceRepository.swift b/Sources/Grodt/Persistency/Repositories/DailyPerformance/DailyPerformanceRepository.swift index db3f460..0b61db6 100644 --- a/Sources/Grodt/Persistency/Repositories/DailyPerformance/DailyPerformanceRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/DailyPerformance/DailyPerformanceRepository.swift @@ -5,8 +5,8 @@ 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 replaceSeries(for ownerID: OwnerID, with points: [DatedPerformance]) async throws + func upsert(points: [DatedPerformance], for ownerID: OwnerID) async throws + func readSeries(for ownerID: OwnerID, from: YearMonthDayDate?, to: YearMonthDayDate?) async throws -> [DatedPerformance] 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 index a768b33..d3f9624 100644 --- a/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresBrokerageAccountDailyPerformanceRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresBrokerageAccountDailyPerformanceRepository.swift @@ -6,7 +6,7 @@ struct PostgresBrokerageAccountDailyPerformanceRepository: DailyPerformanceRepos let database: Database - func replaceSeries(for ownerID: UUID, with points: [DatedPortfolioPerformance]) async throws { + func replaceSeries(for ownerID: UUID, with points: [DatedPerformance]) async throws { try await deleteAll(for: ownerID) guard !points.isEmpty else { return } @@ -21,7 +21,7 @@ struct PostgresBrokerageAccountDailyPerformanceRepository: DailyPerformanceRepos } } - func upsert(points: [DatedPortfolioPerformance], for ownerID: UUID) async throws { + func upsert(points: [DatedPerformance], for ownerID: UUID) async throws { guard !points.isEmpty else { return } let minDate = points.map { $0.date.date }.min()! @@ -54,7 +54,7 @@ struct PostgresBrokerageAccountDailyPerformanceRepository: DailyPerformanceRepos } } - func readSeries(for ownerID: UUID, from: YearMonthDayDate?, to: YearMonthDayDate?) async throws -> [DatedPortfolioPerformance] { + func readSeries(for ownerID: UUID, from: YearMonthDayDate?, to: YearMonthDayDate?) async throws -> [DatedPerformance] { var query = HistoricalBrokerageAccountPerformanceDaily.query(on: database) .filter(\.$account.$id == ownerID) .sort(HistoricalBrokerageAccountPerformanceDaily.Keys.date, .ascending) @@ -63,7 +63,7 @@ struct PostgresBrokerageAccountDailyPerformanceRepository: DailyPerformanceRepos 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)) } + return rows.map { DatedPerformance(moneyIn: $0.moneyIn, value: $0.value, date: YearMonthDayDate($0.date)) } } func deleteAll(for ownerID: UUID) async throws { diff --git a/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresBrokerageDailyPerformanceRepository.swift b/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresBrokerageDailyPerformanceRepository.swift index e9055ad..95c2c7d 100644 --- a/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresBrokerageDailyPerformanceRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresBrokerageDailyPerformanceRepository.swift @@ -6,7 +6,7 @@ struct PostgresBrokerageDailyPerformanceRepository: DailyPerformanceRepository { let database: Database - func replaceSeries(for ownerID: UUID, with points: [DatedPortfolioPerformance]) async throws { + func replaceSeries(for ownerID: UUID, with points: [DatedPerformance]) async throws { try await deleteAll(for: ownerID) guard !points.isEmpty else { return } @@ -21,7 +21,7 @@ struct PostgresBrokerageDailyPerformanceRepository: DailyPerformanceRepository { } } - func upsert(points: [DatedPortfolioPerformance], for ownerID: UUID) async throws { + func upsert(points: [DatedPerformance], for ownerID: UUID) async throws { guard !points.isEmpty else { return } // Bound the lookup to a compact date range for efficiency @@ -55,7 +55,7 @@ struct PostgresBrokerageDailyPerformanceRepository: DailyPerformanceRepository { } } - func readSeries(for ownerID: UUID, from: YearMonthDayDate?, to: YearMonthDayDate?) async throws -> [DatedPortfolioPerformance] { + func readSeries(for ownerID: UUID, from: YearMonthDayDate?, to: YearMonthDayDate?) async throws -> [DatedPerformance] { var query = HistoricalBrokeragePerformanceDaily.query(on: database) .filter(\.$brokerage.$id == ownerID) .sort(HistoricalBrokeragePerformanceDaily.Keys.date, .ascending) @@ -64,7 +64,7 @@ struct PostgresBrokerageDailyPerformanceRepository: DailyPerformanceRepository { 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)) } + return rows.map { DatedPerformance(moneyIn: $0.moneyIn, value: $0.value, date: YearMonthDayDate($0.date)) } } // Delete all rows for a brokerage. diff --git a/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresPortfolioDailyPerformanceRepository.swift b/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresPortfolioDailyPerformanceRepository.swift index 73ee4d2..9c729af 100644 --- a/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresPortfolioDailyPerformanceRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/DailyPerformance/PostgresPortfolioDailyPerformanceRepository.swift @@ -6,7 +6,7 @@ struct PostgresPortfolioDailyPerformanceRepository: DailyPerformanceRepository { let db: Database - func replaceSeries(for ownerID: UUID, with points: [DatedPortfolioPerformance]) async throws { + func replaceSeries(for ownerID: UUID, with points: [DatedPerformance]) async throws { try await deleteAll(for: ownerID) guard !points.isEmpty else { return } @@ -21,7 +21,7 @@ struct PostgresPortfolioDailyPerformanceRepository: DailyPerformanceRepository { } } - func upsert(points: [DatedPortfolioPerformance], for ownerID: UUID) async throws { + func upsert(points: [DatedPerformance], for ownerID: UUID) async throws { guard !points.isEmpty else { return } // Bound the lookup to a compact date range for efficiency @@ -55,7 +55,7 @@ struct PostgresPortfolioDailyPerformanceRepository: DailyPerformanceRepository { } } - func readSeries(for ownerID: UUID, from: YearMonthDayDate?, to: YearMonthDayDate?) async throws -> [DatedPortfolioPerformance] { + func readSeries(for ownerID: UUID, from: YearMonthDayDate?, to: YearMonthDayDate?) async throws -> [DatedPerformance] { var query = HistoricalPortfolioPerformanceDaily.query(on: db) .filter(\.$portfolio.$id == ownerID) .sort(HistoricalPortfolioPerformanceDaily.Keys.date, .ascending) @@ -64,7 +64,7 @@ struct PostgresPortfolioDailyPerformanceRepository: DailyPerformanceRepository { 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)) } + return rows.map { DatedPerformance(moneyIn: $0.moneyIn, value: $0.value, date: YearMonthDayDate($0.date)) } } // Delete all rows for a portfolio. diff --git a/Tests/GrodtTests/HoldingsPerformanceCalculatorTests.swift b/Tests/GrodtTests/HoldingsPerformanceCalculatorTests.swift index 8f03182..1d8a7ee 100644 --- a/Tests/GrodtTests/HoldingsPerformanceCalculatorTests.swift +++ b/Tests/GrodtTests/HoldingsPerformanceCalculatorTests.swift @@ -41,13 +41,13 @@ final class HoldingsPerformanceCalculatorTests: XCTestCase { 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)) + let expected2: [DatedPerformance] = [ + DatedPerformance(moneyIn: 51, value: 60, date: YearMonthDayDate(2024, 1, 10)), + DatedPerformance(moneyIn: 51, value: 60, date: YearMonthDayDate(2024, 1, 11)), + DatedPerformance(moneyIn: 51, value: 70, date: YearMonthDayDate(2024, 1, 12)), + DatedPerformance(moneyIn: 51, value: 70, date: YearMonthDayDate(2024, 1, 13)), + DatedPerformance(moneyIn: 51, value: 70, date: YearMonthDayDate(2024, 1, 14)), + DatedPerformance(moneyIn: 51, value: 90, date: YearMonthDayDate(2024, 1, 15)) ] XCTAssertEqual(series, expected2) XCTAssertEqual(mockPriceService.historicalPriceCallCount[ticker] ?? 0, 1) @@ -75,13 +75,13 @@ final class HoldingsPerformanceCalculatorTests: XCTestCase { 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)) + let expected: [DatedPerformance] = [ + DatedPerformance(moneyIn: 30, value: 60, date: YearMonthDayDate(2024, 1, 10)), + DatedPerformance(moneyIn: 30, value: 60, date: YearMonthDayDate(2024, 1, 11)), + DatedPerformance(moneyIn: 54, value: 100, date: YearMonthDayDate(2024, 1, 12)), + DatedPerformance(moneyIn: 54, value: 100, date: YearMonthDayDate(2024, 1, 13)), + DatedPerformance(moneyIn: 156, value: 107, date: YearMonthDayDate(2024, 1, 14)), + DatedPerformance(moneyIn: 156, value: 132, date: YearMonthDayDate(2024, 1, 15)) ] XCTAssertEqual(series, expected) // One historical fetch per ticker during prefetch @@ -94,7 +94,7 @@ final class HoldingsPerformanceCalculatorTests: XCTestCase { let end = YearMonthDayDate(2024, 1, 15) let series = try await calculator.performanceSeries(for: [], from: start, to: end) // Then - let expected: [DatedPortfolioPerformance] = [] + let expected: [DatedPerformance] = [] XCTAssertEqual(series, expected) } @@ -110,13 +110,13 @@ final class HoldingsPerformanceCalculatorTests: XCTestCase { 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)) + let expected: [DatedPerformance] = [ + DatedPerformance(moneyIn: 0, value: 0, date: YearMonthDayDate(2024, 1, 10)), + DatedPerformance(moneyIn: 0, value: 0, date: YearMonthDayDate(2024, 1, 11)), + DatedPerformance(moneyIn: 0, value: 0, date: YearMonthDayDate(2024, 1, 12)), + DatedPerformance(moneyIn: 0, value: 0, date: YearMonthDayDate(2024, 1, 13)), + DatedPerformance(moneyIn: 0, value: 0, date: YearMonthDayDate(2024, 1, 14)), + DatedPerformance(moneyIn: 0, value: 0, date: YearMonthDayDate(2024, 1, 15)) ] XCTAssertEqual(series, expected) // Prefetch may still fetch NVDA once diff --git a/Tests/GrodtTests/TestConstant.swift b/Tests/GrodtTests/TestConstant.swift index b8d9a1a..7aba2ea 100644 --- a/Tests/GrodtTests/TestConstant.swift +++ b/Tests/GrodtTests/TestConstant.swift @@ -19,7 +19,7 @@ enum TestConstant { } enum PerformanceDTOs { - static let zero = PortfolioPerformanceDTO(moneyIn: 0, moneyOut: 0, profit: 0, totalReturn: 0) + static let zero = PerformanceDTO(moneyIn: 0, moneyOut: 0, profit: 0, totalReturn: 0) } enum Currencies {