Skip to content

Commit 3926356

Browse files
authored
Adding transaction details endpoint (#8)
1 parent 6285b3e commit 3926356

17 files changed

+262
-41
lines changed

Sources/Grodt/Application/routes.swift

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,38 @@ func routes(_ app: Application) async throws {
99
let tickerDTOMapper = TickerDTOMapper()
1010
let loginResponseDTOMapper = LoginResponseDTOMapper()
1111
let transactionDTOMapper = TransactionDTOMapper(currencyDTOMapper: currencyDTOMapper)
12+
let tickerRepository = PostgresTickerRepository(database: app.db)
1213
let livePriceService = LivePriceService(alphavantage: alphavantage)
1314
let quoteCache = PostgresQuoteRepository(database: app.db)
1415
let priceService = CachedPriceService(priceService: livePriceService, cache: quoteCache)
16+
let investmentDTOMapper = InvestmentDTOMapper(currencyDTOMapper: currencyDTOMapper,
17+
transactionDTOMapper: transactionDTOMapper,
18+
tickerRepository: tickerRepository,
19+
priceService: priceService)
20+
let portfolioRepository = PostgresPortfolioRepository(database: app.db)
1521
let portfolioPerformanceCalculator = PortfolioPerformanceCalculator(priceService: priceService)
16-
let portfolioDTOMapper = PortfolioDTOMapper(transactionDTOMapper: transactionDTOMapper,
22+
let portfolioDTOMapper = PortfolioDTOMapper(investmentDTOMapper: investmentDTOMapper,
1723
currencyDTOMapper: currencyDTOMapper,
1824
performanceCalculator: portfolioPerformanceCalculator)
1925
let portfolioPerformanceUpdater = PortfolioPerformanceUpdater(
2026
userRepository: PostgresUserRepository(database: app.db),
21-
portfolioRepository: PostgresPortfolioRepository(database: app.db),
27+
portfolioRepository: portfolioRepository,
2228
tickerRepository: PostgresTickerRepository(database: app.db),
2329
quoteCache: quoteCache,
2430
priceService: priceService,
2531
performanceCalculator: portfolioPerformanceCalculator)
2632
let transactionChangedHandler = TransactionChangedHandler(portfolioRepository: PostgresPortfolioRepository(database: app.db),
2733
historicalPerformanceUpdater: portfolioPerformanceUpdater)
2834

35+
var tickersController = TickersController(tickerRepository: tickerRepository,
36+
dataMapper: tickerDTOMapper,
37+
tickerService: alphavantage)
38+
let tickerChangeHandler = TickerChangeHandler(priceService: priceService)
39+
tickersController.delegate = tickerChangeHandler
40+
41+
let investmentsController = InvestmentController(portfolioRepository: portfolioRepository,
42+
dataMapper: investmentDTOMapper)
43+
2944
let globalRateLimiter = RateLimiterMiddleware(maxRequests: 100, perSeconds: 60)
3045
let loginRateLimiter = RateLimiterMiddleware(maxRequests: 3, perSeconds: 60)
3146

@@ -58,10 +73,8 @@ func routes(_ app: Application) async throws {
5873
transactionController.delegate = transactionChangedHandler
5974
try routeBuilder.register(collection: transactionController)
6075

61-
try routeBuilder.register(collection: TickersController(tickerRepository: PostgresTickerRepository(database: app.db),
62-
dataMapper: tickerDTOMapper,
63-
tickerService: alphavantage)
64-
)
76+
try routeBuilder.register(collection: tickersController)
77+
try routeBuilder.register(collection: investmentsController)
6578
}
6679

6780

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Foundation
2+
3+
class TickerChangeHandler: TickersControllerDelegate {
4+
private let priceService: PriceService
5+
6+
init(
7+
priceService: PriceService) {
8+
self.priceService = priceService
9+
}
10+
11+
func tickerCreated(_ ticker: Ticker) {
12+
Task {
13+
_ = try await priceService.historicalPrice(for: ticker.symbol)
14+
_ = try await priceService.price(for: ticker.symbol)
15+
}
16+
}
17+
}
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/Controllers/TickersController.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@ import Vapor
22
import CollectionConcurrencyKit
33
import AlphaSwiftage
44

5+
protocol TickersControllerDelegate: AnyObject {
6+
func tickerCreated(_ ticker: Ticker)
7+
}
8+
59
struct TickersController: RouteCollection {
610
private let tickerRepository: TickerRepository
711
private let dataMapper: TickerDTOMapper
812
private let tickerService: AlphaVantageService
913

14+
var delegate: TickersControllerDelegate? // TODO: Weak
15+
1016
init(tickerRepository: TickerRepository,
1117
dataMapper: TickerDTOMapper,
1218
tickerService: AlphaVantageService) {
@@ -38,6 +44,7 @@ struct TickersController: RouteCollection {
3844
currency: postTicker.currency)
3945

4046
try await ticker.save(on: req.db)
47+
delegate?.tickerCreated(ticker)
4148
return postTicker
4249
}
4350

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import Foundation
2+
import CollectionConcurrencyKit
3+
4+
class InvestmentDTOMapper {
5+
enum InvestmentError: Error {
6+
case invalidPrice(for: String)
7+
}
8+
9+
private let currencyDTOMapper: CurrencyDTOMapper
10+
private let transactionDTOMapper: TransactionDTOMapper
11+
private let tickerRepository: TickerRepository
12+
private let priceService: PriceService
13+
14+
init(currencyDTOMapper: CurrencyDTOMapper,
15+
transactionDTOMapper: TransactionDTOMapper,
16+
tickerRepository: TickerRepository,
17+
priceService: PriceService) {
18+
self.currencyDTOMapper = currencyDTOMapper
19+
self.transactionDTOMapper = transactionDTOMapper
20+
self.priceService = priceService
21+
self.tickerRepository = tickerRepository
22+
}
23+
24+
func investments(from transactions: [Transaction]) async throws -> [InvestmentDTO] {
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)
37+
}
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+
}
55+
56+
return investments
57+
}
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+
}
96+
}

Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,34 @@ import Foundation
22
import CollectionConcurrencyKit
33

44
class PortfolioDTOMapper {
5-
private let transactionDTOMapper: TransactionDTOMapper
5+
private let investmentDTOMapper: InvestmentDTOMapper
66
private let currencyDTOMapper: CurrencyDTOMapper
77
private let performanceCalculator: PortfolioPerformanceCalculating
88

9-
init(transactionDTOMapper: TransactionDTOMapper,
9+
init(investmentDTOMapper: InvestmentDTOMapper,
1010
currencyDTOMapper: CurrencyDTOMapper,
1111
performanceCalculator: PortfolioPerformanceCalculating) {
12-
self.transactionDTOMapper = transactionDTOMapper
12+
self.investmentDTOMapper = investmentDTOMapper
1313
self.currencyDTOMapper = currencyDTOMapper
1414
self.performanceCalculator = performanceCalculator
1515
}
1616

1717
func portfolio(from portfolio: Portfolio) async throws -> PortfolioDTO {
1818

19+
let investments = try await investmentDTOMapper.investments(from: portfolio.transactions)
1920
return try await PortfolioDTO(id: portfolio.id?.uuidString ?? "",
2021
name: portfolio.name,
2122
currency: currencyDTOMapper.currency(from: portfolio.currency),
2223
performance: performance(for: portfolio),
23-
transactions: portfolio.transactions
24-
.sorted(by: { lhs, rhs in
25-
return lhs.purchaseDate > rhs.purchaseDate
26-
})
27-
.compactMap { transactionDTOMapper.transaction(from: $0) }
28-
)
24+
investments: investments)
2925
}
3026

3127
func portfolioInfo(from portfolio: Portfolio) async throws -> PortfolioInfoDTO {
3228

3329
return try await PortfolioInfoDTO(id: portfolio.id?.uuidString ?? "",
3430
name: portfolio.name,
3531
currency: currencyDTOMapper.currency(from: portfolio.currency),
36-
performance: performance(for: portfolio),
37-
transactions: portfolio.transactions.compactMap { $0.id?.uuidString }
32+
performance: performance(for: portfolio)
3833
)
3934
}
4035

@@ -45,10 +40,15 @@ class PortfolioDTOMapper {
4540
}
4641

4742
let financials = Financials()
48-
await financials.addMoneyIn(performance.moneyIn)
49-
await financials.addValue(performance.value)
50-
51-
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+
)
5252
}
5353

5454
func timeSeriesPerformance(from historicalPerformance: HistoricalPortfolioPerformance) async -> PortfolioPerformanceTimeSeriesDTO {
@@ -70,22 +70,34 @@ class PortfolioDTOMapper {
7070
}
7171

7272
actor Financials {
73-
var moneyIn: Decimal = 0
74-
var value: Decimal = 0
73+
private(set) var moneyIn: Decimal = 0
74+
private(set) var value: Decimal = 0
7575

76-
func addMoneyIn(_ amount: Decimal) {
76+
func addMoneyIn(_ amount: Decimal) async {
77+
guard amount > 0 else { return }
7778
moneyIn += amount
7879
}
7980

80-
func addValue(_ amount: Decimal) {
81+
func addValue(_ amount: Decimal) async {
82+
guard amount > 0 else { return }
8183
value += amount
8284
}
8385

8486
var profit: Decimal {
85-
return value - moneyIn
87+
value - moneyIn
8688
}
8789

8890
var totalReturn: Decimal {
89-
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
90102
}
91103
}

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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Foundation
2+
3+
struct InvestmentDTO: 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+
}
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/PortfolioDTO.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ struct PortfolioDTO: Codable, Equatable {
55
let name: String
66
let currency: CurrencyDTO
77
let performance: PortfolioPerformanceDTO
8-
let transactions: [TransactionDTO]
8+
let investments: [InvestmentDTO]
99
}

0 commit comments

Comments
 (0)