diff --git a/Sources/Grodt/Application/routes.swift b/Sources/Grodt/Application/routes.swift index 017f762..79986ad 100644 --- a/Sources/Grodt/Application/routes.swift +++ b/Sources/Grodt/Application/routes.swift @@ -9,16 +9,22 @@ func routes(_ app: Application) async throws { let tickerDTOMapper = TickerDTOMapper() let loginResponseDTOMapper = LoginResponseDTOMapper() let transactionDTOMapper = TransactionDTOMapper(currencyDTOMapper: currencyDTOMapper) + 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 investmentDTOMapper = InvestmentDTOMapper(currencyDTOMapper: currencyDTOMapper, + transactionDTOMapper: transactionDTOMapper, + tickerRepository: tickerRepository, + priceService: priceService) + let portfolioRepository = PostgresPortfolioRepository(database: app.db) let portfolioPerformanceCalculator = PortfolioPerformanceCalculator(priceService: priceService) - let portfolioDTOMapper = PortfolioDTOMapper(transactionDTOMapper: transactionDTOMapper, + let portfolioDTOMapper = PortfolioDTOMapper(investmentDTOMapper: investmentDTOMapper, currencyDTOMapper: currencyDTOMapper, performanceCalculator: portfolioPerformanceCalculator) let portfolioPerformanceUpdater = PortfolioPerformanceUpdater( userRepository: PostgresUserRepository(database: app.db), - portfolioRepository: PostgresPortfolioRepository(database: app.db), + portfolioRepository: portfolioRepository, tickerRepository: PostgresTickerRepository(database: app.db), quoteCache: quoteCache, priceService: priceService, @@ -26,6 +32,15 @@ func routes(_ app: Application) async throws { let transactionChangedHandler = TransactionChangedHandler(portfolioRepository: PostgresPortfolioRepository(database: app.db), historicalPerformanceUpdater: portfolioPerformanceUpdater) + var tickersController = TickersController(tickerRepository: tickerRepository, + dataMapper: tickerDTOMapper, + tickerService: alphavantage) + let tickerChangeHandler = TickerChangeHandler(priceService: priceService) + tickersController.delegate = tickerChangeHandler + + let investmentsController = InvestmentController(portfolioRepository: portfolioRepository, + dataMapper: investmentDTOMapper) + let globalRateLimiter = RateLimiterMiddleware(maxRequests: 100, perSeconds: 60) let loginRateLimiter = RateLimiterMiddleware(maxRequests: 3, perSeconds: 60) @@ -58,10 +73,8 @@ func routes(_ app: Application) async throws { transactionController.delegate = transactionChangedHandler try routeBuilder.register(collection: transactionController) - try routeBuilder.register(collection: TickersController(tickerRepository: PostgresTickerRepository(database: app.db), - dataMapper: tickerDTOMapper, - tickerService: alphavantage) - ) + try routeBuilder.register(collection: tickersController) + try routeBuilder.register(collection: investmentsController) } diff --git a/Sources/Grodt/BusinessLogic/TickerChangeHandler.swift b/Sources/Grodt/BusinessLogic/TickerChangeHandler.swift new file mode 100644 index 0000000..5fbdce2 --- /dev/null +++ b/Sources/Grodt/BusinessLogic/TickerChangeHandler.swift @@ -0,0 +1,17 @@ +import Foundation + +class TickerChangeHandler: TickersControllerDelegate { + private let priceService: PriceService + + init( + priceService: PriceService) { + self.priceService = priceService + } + + func tickerCreated(_ ticker: Ticker) { + Task { + _ = try await priceService.historicalPrice(for: ticker.symbol) + _ = try await priceService.price(for: ticker.symbol) + } + } +} diff --git a/Sources/Grodt/Controllers/InvestmentController.swift b/Sources/Grodt/Controllers/InvestmentController.swift new file mode 100644 index 0000000..5e2189f --- /dev/null +++ b/Sources/Grodt/Controllers/InvestmentController.swift @@ -0,0 +1,37 @@ +import Vapor +import Fluent + +struct InvestmentController: RouteCollection { + private let portfolioRepository: PortfolioRepository + private let dataMapper: InvestmentDTOMapper + + init(portfolioRepository: PortfolioRepository, + dataMapper: InvestmentDTOMapper) { + self.portfolioRepository = portfolioRepository + self.dataMapper = dataMapper + } + + func boot(routes: Vapor.RoutesBuilder) throws { + let investments = routes.grouped("investments") + + investments.group(":ticker") { investment in + investment.get(use: invesetmentDetail) + } + } + + func invesetmentDetail(req: Request) async throws -> InvestmentDetailDTO { + let ticker: String = try req.requiredParameter(named: "ticker") + + guard let userID = req.auth.get(User.self)?.id else { + throw Abort(.badRequest) + } + + let portfolios = try await portfolioRepository.allPortfolios(for: userID) + let transactions = portfolios + .flatMap { $0.transactions } + .filter { $0.ticker == ticker } + return try await dataMapper.investmentDetail(from: transactions) + } +} + +extension InvestmentDetailDTO: Content { } diff --git a/Sources/Grodt/Controllers/TickersController.swift b/Sources/Grodt/Controllers/TickersController.swift index dc46df0..f432524 100644 --- a/Sources/Grodt/Controllers/TickersController.swift +++ b/Sources/Grodt/Controllers/TickersController.swift @@ -2,11 +2,17 @@ import Vapor import CollectionConcurrencyKit import AlphaSwiftage +protocol TickersControllerDelegate: AnyObject { + func tickerCreated(_ ticker: Ticker) +} + struct TickersController: RouteCollection { private let tickerRepository: TickerRepository private let dataMapper: TickerDTOMapper private let tickerService: AlphaVantageService + var delegate: TickersControllerDelegate? // TODO: Weak + init(tickerRepository: TickerRepository, dataMapper: TickerDTOMapper, tickerService: AlphaVantageService) { @@ -38,6 +44,7 @@ struct TickersController: RouteCollection { currency: postTicker.currency) try await ticker.save(on: req.db) + delegate?.tickerCreated(ticker) return postTicker } diff --git a/Sources/Grodt/DTOs/DTOMappers/InvestmentDTOMapper.swift b/Sources/Grodt/DTOs/DTOMappers/InvestmentDTOMapper.swift new file mode 100644 index 0000000..1c71e68 --- /dev/null +++ b/Sources/Grodt/DTOs/DTOMappers/InvestmentDTOMapper.swift @@ -0,0 +1,96 @@ +import Foundation +import CollectionConcurrencyKit + +class InvestmentDTOMapper { + enum InvestmentError: Error { + case invalidPrice(for: String) + } + + private let currencyDTOMapper: CurrencyDTOMapper + private let transactionDTOMapper: TransactionDTOMapper + private let tickerRepository: TickerRepository + private let priceService: PriceService + + init(currencyDTOMapper: CurrencyDTOMapper, + transactionDTOMapper: TransactionDTOMapper, + tickerRepository: TickerRepository, + priceService: PriceService) { + self.currencyDTOMapper = currencyDTOMapper + self.transactionDTOMapper = transactionDTOMapper + self.priceService = priceService + self.tickerRepository = tickerRepository + } + + func investments(from transactions: [Transaction]) async throws -> [InvestmentDTO] { + let groupedTransactions = transactions.grouped { $0.ticker } + + let investments: [InvestmentDTO] = try await groupedTransactions.asyncCompactMap { ticker, transactions in + guard let firstTransaction = transactions.first else { return nil } + + async let name = tickerRepository.tickers(for: ticker)?.name ?? "" + async let latestPrice = priceService.price(for: ticker) + let aggregates = calculateTransactionAggregates(transactions) + + let fetchedLatestPrice = try await latestPrice + guard fetchedLatestPrice > 0 else { + throw InvestmentError.invalidPrice(for: ticker) + } + + let currentValue = aggregates.numberOfShares * fetchedLatestPrice + let profit = currentValue - aggregates.totalCost + let totalReturn = calculateTotalReturn(profit: profit, cost: aggregates.totalCost) + + return InvestmentDTO( + name: try await name, + shortName: ticker, + avgBuyPrice: aggregates.avgBuyPrice, + latestPrice: fetchedLatestPrice, + totalReturn: totalReturn, + profit: profit, + value: currentValue, + numberOfShares: aggregates.numberOfShares, + currency: currencyDTOMapper.currency(from: firstTransaction.currency) + ) + } + + return investments + } + + func investmentDetail(from transactions: [Transaction])async throws -> InvestmentDetailDTO { + let investmentDTO = try await investments(from: transactions).first! + let transactions = transactions.compactMap { transactionDTOMapper.transaction(from: $0) } + return InvestmentDetailDTO(name: investmentDTO.name, + shortName: investmentDTO.shortName, + avgBuyPrice: investmentDTO.avgBuyPrice, + latestPrice: investmentDTO.latestPrice, + totalReturn: investmentDTO.totalReturn, + profit: investmentDTO.profit, + value: investmentDTO.value, + numberOfShares: investmentDTO.numberOfShares, + currency: investmentDTO.currency, + transactions: transactions) + } + + private func calculateTransactionAggregates(_ transactions: [Transaction]) -> (avgBuyPrice: Decimal, totalCost: Decimal, numberOfShares: Decimal) { + var totalCost: Decimal = 0 + var numberOfShares: Decimal = 0 + var pricePerPurchase: [Decimal: Decimal] = [:] + + transactions.forEach { transaction in + pricePerPurchase[transaction.pricePerShareAtPurchase] = transaction.numberOfShares + totalCost += (transaction.pricePerShareAtPurchase * transaction.numberOfShares) + transaction.fees + numberOfShares += transaction.numberOfShares + } + + let avgBuyPrice = pricePerPurchase.keys.reduce(0) { $0 + $1 } / Decimal(pricePerPurchase.count) + return (avgBuyPrice: avgBuyPrice, totalCost: totalCost, numberOfShares: numberOfShares) + } + + private func calculateTotalReturn(profit: Decimal, cost: Decimal) -> Decimal { + guard cost != 0 else { return 0 } + var totalReturn = profit / cost + var roundedReturn = Decimal() + NSDecimalRound(&roundedReturn, &totalReturn, 2, .bankers) + return roundedReturn + } +} diff --git a/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift b/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift index c327870..05a53ec 100644 --- a/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift +++ b/Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift @@ -2,30 +2,26 @@ import Foundation import CollectionConcurrencyKit class PortfolioDTOMapper { - private let transactionDTOMapper: TransactionDTOMapper + private let investmentDTOMapper: InvestmentDTOMapper private let currencyDTOMapper: CurrencyDTOMapper private let performanceCalculator: PortfolioPerformanceCalculating - init(transactionDTOMapper: TransactionDTOMapper, + init(investmentDTOMapper: InvestmentDTOMapper, currencyDTOMapper: CurrencyDTOMapper, performanceCalculator: PortfolioPerformanceCalculating) { - self.transactionDTOMapper = transactionDTOMapper + self.investmentDTOMapper = investmentDTOMapper self.currencyDTOMapper = currencyDTOMapper self.performanceCalculator = performanceCalculator } func portfolio(from portfolio: Portfolio) async throws -> PortfolioDTO { + let investments = try await investmentDTOMapper.investments(from: portfolio.transactions) return try await PortfolioDTO(id: portfolio.id?.uuidString ?? "", name: portfolio.name, currency: currencyDTOMapper.currency(from: portfolio.currency), performance: performance(for: portfolio), - transactions: portfolio.transactions - .sorted(by: { lhs, rhs in - return lhs.purchaseDate > rhs.purchaseDate - }) - .compactMap { transactionDTOMapper.transaction(from: $0) } - ) + investments: investments) } func portfolioInfo(from portfolio: Portfolio) async throws -> PortfolioInfoDTO { @@ -33,8 +29,7 @@ class PortfolioDTOMapper { return try await PortfolioInfoDTO(id: portfolio.id?.uuidString ?? "", name: portfolio.name, currency: currencyDTOMapper.currency(from: portfolio.currency), - performance: performance(for: portfolio), - transactions: portfolio.transactions.compactMap { $0.id?.uuidString } + performance: performance(for: portfolio) ) } @@ -45,10 +40,15 @@ class PortfolioDTOMapper { } let financials = Financials() - await financials.addMoneyIn(performance.moneyIn) - await financials.addValue(performance.value) - - return await PortfolioPerformanceDTO(moneyIn: financials.moneyIn, moneyOut: financials.value, profit: financials.profit, totalReturn: financials.totalReturn) + 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 + ) } func timeSeriesPerformance(from historicalPerformance: HistoricalPortfolioPerformance) async -> PortfolioPerformanceTimeSeriesDTO { @@ -70,22 +70,34 @@ class PortfolioDTOMapper { } actor Financials { - var moneyIn: Decimal = 0 - var value: Decimal = 0 + private(set) var moneyIn: Decimal = 0 + private(set) var value: Decimal = 0 - func addMoneyIn(_ amount: Decimal) { + func addMoneyIn(_ amount: Decimal) async { + guard amount > 0 else { return } moneyIn += amount } - func addValue(_ amount: Decimal) { + func addValue(_ amount: Decimal) async { + guard amount > 0 else { return } value += amount } var profit: Decimal { - return value - moneyIn + value - moneyIn } var totalReturn: Decimal { - return moneyIn == 0 ? 0 : profit / moneyIn + guard moneyIn > 0 else { return 0 } + return (profit / moneyIn).rounded(to: 2) + } +} + +fileprivate extension Decimal { + func rounded(to scale: Int, roundingMode: NSDecimalNumber.RoundingMode = .bankers) -> Decimal { + var value = self + var result = Decimal() + NSDecimalRound(&result, &value, scale, roundingMode) + return result } } diff --git a/Sources/Grodt/DTOs/DTOMappers/TransactionDTOMapper.swift b/Sources/Grodt/DTOs/DTOMappers/TransactionDTOMapper.swift index 06839dc..fc84c39 100644 --- a/Sources/Grodt/DTOs/DTOMappers/TransactionDTOMapper.swift +++ b/Sources/Grodt/DTOs/DTOMappers/TransactionDTOMapper.swift @@ -9,6 +9,7 @@ class TransactionDTOMapper { 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, diff --git a/Sources/Grodt/DTOs/InvestmentDTO.swift b/Sources/Grodt/DTOs/InvestmentDTO.swift new file mode 100644 index 0000000..387e79e --- /dev/null +++ b/Sources/Grodt/DTOs/InvestmentDTO.swift @@ -0,0 +1,13 @@ +import Foundation + +struct InvestmentDTO: Codable, Equatable { + let name: String + let shortName: String + let avgBuyPrice: Decimal + let latestPrice: Decimal + let totalReturn: Decimal + let profit: Decimal + let value: Decimal + let numberOfShares: Decimal + let currency: CurrencyDTO +} diff --git a/Sources/Grodt/DTOs/InvestmentDetailDTO.swift b/Sources/Grodt/DTOs/InvestmentDetailDTO.swift new file mode 100644 index 0000000..bab78dc --- /dev/null +++ b/Sources/Grodt/DTOs/InvestmentDetailDTO.swift @@ -0,0 +1,14 @@ +import Foundation + +struct InvestmentDetailDTO: Codable, Equatable { + let name: String + let shortName: String + let avgBuyPrice: Decimal + let latestPrice: Decimal + let totalReturn: Decimal + let profit: Decimal + let value: Decimal + let numberOfShares: Decimal + let currency: CurrencyDTO + let transactions: [TransactionDTO] +} diff --git a/Sources/Grodt/DTOs/PortfolioDTO.swift b/Sources/Grodt/DTOs/PortfolioDTO.swift index 5d3ea14..2567060 100644 --- a/Sources/Grodt/DTOs/PortfolioDTO.swift +++ b/Sources/Grodt/DTOs/PortfolioDTO.swift @@ -5,5 +5,5 @@ struct PortfolioDTO: Codable, Equatable { let name: String let currency: CurrencyDTO let performance: PortfolioPerformanceDTO - let transactions: [TransactionDTO] + let investments: [InvestmentDTO] } diff --git a/Sources/Grodt/DTOs/PortfolioInfoDTO.swift b/Sources/Grodt/DTOs/PortfolioInfoDTO.swift index c573c32..b9c80f0 100644 --- a/Sources/Grodt/DTOs/PortfolioInfoDTO.swift +++ b/Sources/Grodt/DTOs/PortfolioInfoDTO.swift @@ -5,5 +5,4 @@ struct PortfolioInfoDTO: Codable, Equatable { let name: String let currency: CurrencyDTO let performance: PortfolioPerformanceDTO - let transactions: [String] } diff --git a/Sources/Grodt/DTOs/TransactionDTO.swift b/Sources/Grodt/DTOs/TransactionDTO.swift index 7b7e9e0..49cba40 100644 --- a/Sources/Grodt/DTOs/TransactionDTO.swift +++ b/Sources/Grodt/DTOs/TransactionDTO.swift @@ -2,6 +2,7 @@ import Foundation struct TransactionDTO: Encodable, Equatable { let id: String + let portfolioName: String let platform: String let account: String? let purchaseDate: Date @@ -12,11 +13,12 @@ struct TransactionDTO: Encodable, Equatable { let pricePerShareAtPurchase: Decimal enum CodingKeys: String, CodingKey { - case id, platform, account, ticker, currency, fees, numberOfShares, pricePerShareAtPurchase, purchaseDate + case id, portfolioName, platform, account, ticker, currency, fees, numberOfShares, pricePerShareAtPurchase, purchaseDate } - init(id: String, platform: String, account: String? = nil, purchaseDate: Date, ticker: String, currency: CurrencyDTO, fees: Decimal, numberOfShares: Decimal, pricePerShareAtPurchase: Decimal) { + init(id: String, portfolioName: String, platform: String, account: String? = nil, purchaseDate: Date, ticker: String, currency: CurrencyDTO, fees: Decimal, numberOfShares: Decimal, pricePerShareAtPurchase: Decimal) { self.id = id + self.portfolioName = portfolioName self.platform = platform self.account = account self.purchaseDate = purchaseDate @@ -30,6 +32,7 @@ struct TransactionDTO: Encodable, Equatable { 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) @@ -50,6 +53,7 @@ struct TransactionDTO: Encodable, Equatable { 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) diff --git a/Sources/Grodt/Persistency/Repositories/PortfolioRepository.swift b/Sources/Grodt/Persistency/Repositories/PortfolioRepository.swift index 1e08de0..3ba5e4a 100644 --- a/Sources/Grodt/Persistency/Repositories/PortfolioRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/PortfolioRepository.swift @@ -24,7 +24,9 @@ class PostgresPortfolioRepository: PortfolioRepository { let user = try await User.query(on: database) .filter(\User.$id == userID) .with(\.$portfolios) { portfolio in - portfolio.with(\.$transactions) + portfolio.with(\.$transactions) { transaction in + transaction.with(\.$portfolio) + } portfolio.with(\.$historicalPerformance) }.first() diff --git a/Sources/Grodt/Persistency/Repositories/TickerRepository.swift b/Sources/Grodt/Persistency/Repositories/TickerRepository.swift index 8f45fc8..c178905 100644 --- a/Sources/Grodt/Persistency/Repositories/TickerRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/TickerRepository.swift @@ -2,6 +2,7 @@ import Fluent protocol TickerRepository { func allTickers() async throws -> [Ticker] + func tickers(for symbol: String) async throws -> Ticker? } class PostgresTickerRepository: TickerRepository { @@ -15,4 +16,10 @@ class PostgresTickerRepository: TickerRepository { return try await Ticker.query(on: database) .all() } + + func tickers(for symbol: String) async throws -> Ticker? { + return try await Ticker.query(on: database) + .filter(\Ticker.$symbol == symbol) + .first() + } } diff --git a/Sources/Grodt/Persistency/Repositories/TransactionsRepository.swift b/Sources/Grodt/Persistency/Repositories/TransactionsRepository.swift index e824412..e79cf54 100644 --- a/Sources/Grodt/Persistency/Repositories/TransactionsRepository.swift +++ b/Sources/Grodt/Persistency/Repositories/TransactionsRepository.swift @@ -15,6 +15,7 @@ class PostgresTransactionRepository: TransactionsRepository { func transaction(for id: UUID) async throws -> Transaction? { return try await Transaction.query(on: database) .filter(\Transaction.$id == id) + .with(\.$portfolio) .first() } } diff --git a/Tests/GrodtTests/EquatableExceptID.swift b/Tests/GrodtTests/EquatableExceptID.swift index cf9e149..0e51f66 100644 --- a/Tests/GrodtTests/EquatableExceptID.swift +++ b/Tests/GrodtTests/EquatableExceptID.swift @@ -9,8 +9,7 @@ extension PortfolioInfoDTO: EquatableExceptID { func equalToExceptIDWith(_ other: PortfolioInfoDTO) -> Bool { return name == other.name && currency == other.currency && - performance == other.performance && - transactions == other.transactions + performance == other.performance } } @@ -19,7 +18,7 @@ extension PortfolioDTO: EquatableExceptID { return name == other.name && currency == other.currency && performance == other.performance && - transactions == other.transactions + investments == other.investments } } diff --git a/Tests/GrodtTests/TestConstant.swift b/Tests/GrodtTests/TestConstant.swift index d3b1f2a..4d5b6b6 100644 --- a/Tests/GrodtTests/TestConstant.swift +++ b/Tests/GrodtTests/TestConstant.swift @@ -6,16 +6,15 @@ enum TestConstant { static let new = PortfolioInfoDTO(id: UUID().uuidString, name: "New", currency: Currencies.eur.dto, - performance: PerformanceDTOs.zero, - transactions: []) + performance: PerformanceDTOs.zero) } enum PortfolioDTOs { static let new = PortfolioDTO(id: UUID().uuidString, - name: "New", - currency: Currencies.eur.dto, - performance: PerformanceDTOs.zero, - transactions: []) + name: "New", + currency: Currencies.eur.dto, + performance: PerformanceDTOs.zero, + investments: []) } enum PerformanceDTOs {