Skip to content

Commit 0d94abc

Browse files
committed
Add transactionDetail endpoint
1 parent be8d29b commit 0d94abc

File tree

11 files changed

+178
-52
lines changed

11 files changed

+178
-52
lines changed

Sources/Grodt/Application/routes.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,17 @@ func routes(_ app: Application) async throws {
1414
let quoteCache = PostgresQuoteRepository(database: app.db)
1515
let priceService = CachedPriceService(priceService: livePriceService, cache: quoteCache)
1616
let investmentDTOMapper = InvestmentDTOMapper(currencyDTOMapper: currencyDTOMapper,
17+
transactionDTOMapper: transactionDTOMapper,
1718
tickerRepository: tickerRepository,
1819
priceService: priceService)
20+
let portfolioRepository = PostgresPortfolioRepository(database: app.db)
1921
let portfolioPerformanceCalculator = PortfolioPerformanceCalculator(priceService: priceService)
2022
let portfolioDTOMapper = PortfolioDTOMapper(investmentDTOMapper: investmentDTOMapper,
2123
currencyDTOMapper: currencyDTOMapper,
2224
performanceCalculator: portfolioPerformanceCalculator)
2325
let portfolioPerformanceUpdater = PortfolioPerformanceUpdater(
2426
userRepository: PostgresUserRepository(database: app.db),
25-
portfolioRepository: PostgresPortfolioRepository(database: app.db),
27+
portfolioRepository: portfolioRepository,
2628
tickerRepository: PostgresTickerRepository(database: app.db),
2729
quoteCache: quoteCache,
2830
priceService: priceService,
@@ -36,6 +38,9 @@ func routes(_ app: Application) async throws {
3638
let tickerChangeHandler = TickerChangeHandler(priceService: priceService)
3739
tickersController.delegate = tickerChangeHandler
3840

41+
let investmentsController = InvestmentController(portfolioRepository: portfolioRepository,
42+
dataMapper: investmentDTOMapper)
43+
3944
let globalRateLimiter = RateLimiterMiddleware(maxRequests: 100, perSeconds: 60)
4045
let loginRateLimiter = RateLimiterMiddleware(maxRequests: 3, perSeconds: 60)
4146

@@ -68,8 +73,8 @@ func routes(_ app: Application) async throws {
6873
transactionController.delegate = transactionChangedHandler
6974
try routeBuilder.register(collection: transactionController)
7075

71-
try routeBuilder.register(collection: tickersController
72-
)
76+
try routeBuilder.register(collection: tickersController)
77+
try routeBuilder.register(collection: investmentsController)
7378
}
7479

7580

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Vapor
2+
import Fluent
3+
4+
struct InvestmentController: RouteCollection {
5+
private let portfolioRepository: PortfolioRepository
6+
private let dataMapper: InvestmentDTOMapper
7+
8+
init(portfolioRepository: PortfolioRepository,
9+
dataMapper: InvestmentDTOMapper) {
10+
self.portfolioRepository = portfolioRepository
11+
self.dataMapper = dataMapper
12+
}
13+
14+
func boot(routes: Vapor.RoutesBuilder) throws {
15+
let investments = routes.grouped("investments")
16+
17+
investments.group(":ticker") { investment in
18+
investment.get(use: invesetmentDetail)
19+
}
20+
}
21+
22+
func invesetmentDetail(req: Request) async throws -> InvestmentDetailDTO {
23+
let ticker: String = try req.requiredParameter(named: "ticker")
24+
25+
guard let userID = req.auth.get(User.self)?.id else {
26+
throw Abort(.badRequest)
27+
}
28+
29+
let portfolios = try await portfolioRepository.allPortfolios(for: userID)
30+
let transactions = portfolios
31+
.flatMap { $0.transactions }
32+
.filter { $0.ticker == ticker }
33+
return try await dataMapper.investmentDetail(from: transactions)
34+
}
35+
}
36+
37+
extension InvestmentDetailDTO: Content { }

Sources/Grodt/DTOs/DTOMappers/InvestmentDTOMapper.swift

Lines changed: 74 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,95 @@ import Foundation
22
import CollectionConcurrencyKit
33

44
class InvestmentDTOMapper {
5+
enum InvestmentError: Error {
6+
case invalidPrice(for: String)
7+
}
8+
59
private let currencyDTOMapper: CurrencyDTOMapper
10+
private let transactionDTOMapper: TransactionDTOMapper
611
private let tickerRepository: TickerRepository
712
private let priceService: PriceService
813

914
init(currencyDTOMapper: CurrencyDTOMapper,
15+
transactionDTOMapper: TransactionDTOMapper,
1016
tickerRepository: TickerRepository,
1117
priceService: PriceService) {
1218
self.currencyDTOMapper = currencyDTOMapper
19+
self.transactionDTOMapper = transactionDTOMapper
1320
self.priceService = priceService
1421
self.tickerRepository = tickerRepository
1522
}
1623

1724
func investments(from transactions: [Transaction]) async throws -> [InvestmentDTO] {
18-
let investments: [InvestmentDTO] = try await transactions
19-
.grouped { $0.ticker }
20-
.asyncCompactMap { (ticker, transactions) in
21-
let name = try await tickerRepository.tickers(for: ticker)?.name ?? ""
22-
let latestPrice = try await priceService.price(for: ticker)
23-
var pricePerPurchase: [Decimal: Decimal] = [:]
24-
var numberOfShares: Decimal = 0
25-
var totalCost: Decimal = 0
26-
transactions.forEach { transaction in
27-
pricePerPurchase[transaction.pricePerShareAtPurchase] = transaction.numberOfShares
28-
numberOfShares += transaction.numberOfShares
29-
totalCost += (transaction.pricePerShareAtPurchase * transaction.numberOfShares) + transaction.fees
30-
}
31-
let currentValue = numberOfShares * latestPrice
32-
let avgBuyPrice = pricePerPurchase.keys.reduce(0) { $0 + $1 } / Decimal(pricePerPurchase.count)
33-
let profit = currentValue - totalCost
34-
let totalReturn = (totalCost == 0 ? 0 : profit / totalCost).ro
35-
36-
return InvestmentDTO(name: name,
37-
shortName: ticker,
38-
avgBuyPrice: avgBuyPrice,
39-
latestPrice: latestPrice,
40-
totalReturn: totalReturn,
41-
profit: profit,
42-
value: currentValue,
43-
numberOfShares: numberOfShares,
44-
currency: currencyDTOMapper.currency(from: transactions.first!.currency))
25+
let groupedTransactions = transactions.grouped { $0.ticker }
26+
27+
let investments: [InvestmentDTO] = try await groupedTransactions.asyncCompactMap { ticker, transactions in
28+
guard let firstTransaction = transactions.first else { return nil }
29+
30+
async let name = tickerRepository.tickers(for: ticker)?.name ?? ""
31+
async let latestPrice = priceService.price(for: ticker)
32+
let aggregates = calculateTransactionAggregates(transactions)
33+
34+
let fetchedLatestPrice = try await latestPrice
35+
guard fetchedLatestPrice > 0 else {
36+
throw InvestmentError.invalidPrice(for: ticker)
4537
}
38+
39+
let currentValue = aggregates.numberOfShares * fetchedLatestPrice
40+
let profit = currentValue - aggregates.totalCost
41+
let totalReturn = calculateTotalReturn(profit: profit, cost: aggregates.totalCost)
42+
43+
return InvestmentDTO(
44+
name: try await name,
45+
shortName: ticker,
46+
avgBuyPrice: aggregates.avgBuyPrice,
47+
latestPrice: fetchedLatestPrice,
48+
totalReturn: totalReturn,
49+
profit: profit,
50+
value: currentValue,
51+
numberOfShares: aggregates.numberOfShares,
52+
currency: currencyDTOMapper.currency(from: firstTransaction.currency)
53+
)
54+
}
4655

4756
return investments
4857
}
58+
59+
func investmentDetail(from transactions: [Transaction])async throws -> InvestmentDetailDTO {
60+
let investmentDTO = try await investments(from: transactions).first!
61+
let transactions = transactions.compactMap { transactionDTOMapper.transaction(from: $0) }
62+
return InvestmentDetailDTO(name: investmentDTO.name,
63+
shortName: investmentDTO.shortName,
64+
avgBuyPrice: investmentDTO.avgBuyPrice,
65+
latestPrice: investmentDTO.latestPrice,
66+
totalReturn: investmentDTO.totalReturn,
67+
profit: investmentDTO.profit,
68+
value: investmentDTO.value,
69+
numberOfShares: investmentDTO.numberOfShares,
70+
currency: investmentDTO.currency,
71+
transactions: transactions)
72+
}
73+
74+
private func calculateTransactionAggregates(_ transactions: [Transaction]) -> (avgBuyPrice: Decimal, totalCost: Decimal, numberOfShares: Decimal) {
75+
var totalCost: Decimal = 0
76+
var numberOfShares: Decimal = 0
77+
var pricePerPurchase: [Decimal: Decimal] = [:]
78+
79+
transactions.forEach { transaction in
80+
pricePerPurchase[transaction.pricePerShareAtPurchase] = transaction.numberOfShares
81+
totalCost += (transaction.pricePerShareAtPurchase * transaction.numberOfShares) + transaction.fees
82+
numberOfShares += transaction.numberOfShares
83+
}
84+
85+
let avgBuyPrice = pricePerPurchase.keys.reduce(0) { $0 + $1 } / Decimal(pricePerPurchase.count)
86+
return (avgBuyPrice: avgBuyPrice, totalCost: totalCost, numberOfShares: numberOfShares)
87+
}
88+
89+
private func calculateTotalReturn(profit: Decimal, cost: Decimal) -> Decimal {
90+
guard cost != 0 else { return 0 }
91+
var totalReturn = profit / cost
92+
var roundedReturn = Decimal()
93+
NSDecimalRound(&roundedReturn, &totalReturn, 2, .bankers)
94+
return roundedReturn
95+
}
4996
}

Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,15 @@ class PortfolioDTOMapper {
4040
}
4141

4242
let financials = Financials()
43-
await financials.addMoneyIn(performance.moneyIn)
44-
await financials.addValue(performance.value)
45-
46-
return await PortfolioPerformanceDTO(moneyIn: financials.moneyIn, moneyOut: financials.value, profit: financials.profit, totalReturn: financials.totalReturn)
43+
await financials.addMoneyIn(performance.moneyIn)
44+
await financials.addValue(performance.value)
45+
46+
return PortfolioPerformanceDTO(
47+
moneyIn: await financials.moneyIn,
48+
moneyOut: await financials.value,
49+
profit: await financials.profit,
50+
totalReturn: await financials.totalReturn
51+
)
4752
}
4853

4954
func timeSeriesPerformance(from historicalPerformance: HistoricalPortfolioPerformance) async -> PortfolioPerformanceTimeSeriesDTO {
@@ -65,22 +70,34 @@ class PortfolioDTOMapper {
6570
}
6671

6772
actor Financials {
68-
var moneyIn: Decimal = 0
69-
var value: Decimal = 0
73+
private(set) var moneyIn: Decimal = 0
74+
private(set) var value: Decimal = 0
7075

71-
func addMoneyIn(_ amount: Decimal) {
76+
func addMoneyIn(_ amount: Decimal) async {
77+
guard amount > 0 else { return }
7278
moneyIn += amount
7379
}
7480

75-
func addValue(_ amount: Decimal) {
81+
func addValue(_ amount: Decimal) async {
82+
guard amount > 0 else { return }
7683
value += amount
7784
}
7885

7986
var profit: Decimal {
80-
return value - moneyIn
87+
value - moneyIn
8188
}
8289

8390
var totalReturn: Decimal {
84-
return moneyIn == 0 ? 0 : profit / moneyIn
91+
guard moneyIn > 0 else { return 0 }
92+
return (profit / moneyIn).rounded(to: 2)
93+
}
94+
}
95+
96+
fileprivate extension Decimal {
97+
func rounded(to scale: Int, roundingMode: NSDecimalNumber.RoundingMode = .bankers) -> Decimal {
98+
var value = self
99+
var result = Decimal()
100+
NSDecimalRound(&result, &value, scale, roundingMode)
101+
return result
85102
}
86103
}

Sources/Grodt/DTOs/DTOMappers/TransactionDTOMapper.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class TransactionDTOMapper {
99

1010
func transaction(from transaction: Transaction) -> TransactionDTO {
1111
return TransactionDTO(id: transaction.id?.uuidString ?? "",
12+
portfolioName: transaction.portfolio.name,
1213
platform: transaction.platform,
1314
account: transaction.account,
1415
purchaseDate: transaction.purchaseDate,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Foundation
2+
3+
struct InvestmentDetailDTO: Codable, Equatable {
4+
let name: String
5+
let shortName: String
6+
let avgBuyPrice: Decimal
7+
let latestPrice: Decimal
8+
let totalReturn: Decimal
9+
let profit: Decimal
10+
let value: Decimal
11+
let numberOfShares: Decimal
12+
let currency: CurrencyDTO
13+
let transactions: [TransactionDTO]
14+
}

Sources/Grodt/DTOs/TransactionDTO.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Foundation
22

33
struct TransactionDTO: Encodable, Equatable {
44
let id: String
5+
let portfolioName: String
56
let platform: String
67
let account: String?
78
let purchaseDate: Date
@@ -12,11 +13,12 @@ struct TransactionDTO: Encodable, Equatable {
1213
let pricePerShareAtPurchase: Decimal
1314

1415
enum CodingKeys: String, CodingKey {
15-
case id, platform, account, ticker, currency, fees, numberOfShares, pricePerShareAtPurchase, purchaseDate
16+
case id, portfolioName, platform, account, ticker, currency, fees, numberOfShares, pricePerShareAtPurchase, purchaseDate
1617
}
1718

18-
init(id: String, platform: String, account: String? = nil, purchaseDate: Date, ticker: String, currency: CurrencyDTO, fees: Decimal, numberOfShares: Decimal, pricePerShareAtPurchase: Decimal) {
19+
init(id: String, portfolioName: String, platform: String, account: String? = nil, purchaseDate: Date, ticker: String, currency: CurrencyDTO, fees: Decimal, numberOfShares: Decimal, pricePerShareAtPurchase: Decimal) {
1920
self.id = id
21+
self.portfolioName = portfolioName
2022
self.platform = platform
2123
self.account = account
2224
self.purchaseDate = purchaseDate
@@ -30,6 +32,7 @@ struct TransactionDTO: Encodable, Equatable {
3032
init(from decoder: Decoder) throws {
3133
let container = try decoder.container(keyedBy: CodingKeys.self)
3234
id = try container.decode(String.self, forKey: .id)
35+
portfolioName = try container.decode(String.self, forKey: .portfolioName)
3336
platform = try container.decode(String.self, forKey: .platform)
3437
account = try container.decodeIfPresent(String.self, forKey: .account)
3538
ticker = try container.decode(String.self, forKey: .ticker)
@@ -50,6 +53,7 @@ struct TransactionDTO: Encodable, Equatable {
5053
func encode(to encoder: Encoder) throws {
5154
var container = encoder.container(keyedBy: CodingKeys.self)
5255
try container.encode(id, forKey: .id)
56+
try container.encode(portfolioName, forKey: .portfolioName)
5357
try container.encode(platform, forKey: .platform)
5458
if let account = account {
5559
try container.encode(account, forKey: .account)

Sources/Grodt/Persistency/Repositories/PortfolioRepository.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ class PostgresPortfolioRepository: PortfolioRepository {
2424
let user = try await User.query(on: database)
2525
.filter(\User.$id == userID)
2626
.with(\.$portfolios) { portfolio in
27-
portfolio.with(\.$transactions)
27+
portfolio.with(\.$transactions) { transaction in
28+
transaction.with(\.$portfolio)
29+
}
2830
portfolio.with(\.$historicalPerformance)
2931
}.first()
3032

Sources/Grodt/Persistency/Repositories/TransactionsRepository.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class PostgresTransactionRepository: TransactionsRepository {
1515
func transaction(for id: UUID) async throws -> Transaction? {
1616
return try await Transaction.query(on: database)
1717
.filter(\Transaction.$id == id)
18+
.with(\.$portfolio)
1819
.first()
1920
}
2021
}

Tests/GrodtTests/EquatableExceptID.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ extension PortfolioInfoDTO: EquatableExceptID {
99
func equalToExceptIDWith(_ other: PortfolioInfoDTO) -> Bool {
1010
return name == other.name &&
1111
currency == other.currency &&
12-
performance == other.performance &&
13-
transactions == other.transactions
12+
performance == other.performance
1413
}
1514
}
1615

@@ -19,7 +18,7 @@ extension PortfolioDTO: EquatableExceptID {
1918
return name == other.name &&
2019
currency == other.currency &&
2120
performance == other.performance &&
22-
transactions == other.transactions
21+
investments == other.investments
2322
}
2423
}
2524

0 commit comments

Comments
 (0)