Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions Sources/Grodt/Application/routes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,38 @@ 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,
performanceCalculator: portfolioPerformanceCalculator)
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)

Expand Down Expand Up @@ -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)
}


Expand Down
17 changes: 17 additions & 0 deletions Sources/Grodt/BusinessLogic/TickerChangeHandler.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
37 changes: 37 additions & 0 deletions Sources/Grodt/Controllers/InvestmentController.swift
Original file line number Diff line number Diff line change
@@ -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 { }
7 changes: 7 additions & 0 deletions Sources/Grodt/Controllers/TickersController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -38,6 +44,7 @@ struct TickersController: RouteCollection {
currency: postTicker.currency)

try await ticker.save(on: req.db)
delegate?.tickerCreated(ticker)
return postTicker
}

Expand Down
96 changes: 96 additions & 0 deletions Sources/Grodt/DTOs/DTOMappers/InvestmentDTOMapper.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
54 changes: 33 additions & 21 deletions Sources/Grodt/DTOs/DTOMappers/PortfolioDTOMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,34 @@ 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 {

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)
)
}

Expand All @@ -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 {
Expand All @@ -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
}
}
1 change: 1 addition & 0 deletions Sources/Grodt/DTOs/DTOMappers/TransactionDTOMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions Sources/Grodt/DTOs/InvestmentDTO.swift
Original file line number Diff line number Diff line change
@@ -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
}
14 changes: 14 additions & 0 deletions Sources/Grodt/DTOs/InvestmentDetailDTO.swift
Original file line number Diff line number Diff line change
@@ -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]
}
2 changes: 1 addition & 1 deletion Sources/Grodt/DTOs/PortfolioDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ struct PortfolioDTO: Codable, Equatable {
let name: String
let currency: CurrencyDTO
let performance: PortfolioPerformanceDTO
let transactions: [TransactionDTO]
let investments: [InvestmentDTO]
}
1 change: 0 additions & 1 deletion Sources/Grodt/DTOs/PortfolioInfoDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,4 @@ struct PortfolioInfoDTO: Codable, Equatable {
let name: String
let currency: CurrencyDTO
let performance: PortfolioPerformanceDTO
let transactions: [String]
}
Loading
Loading