Skip to content

Commit efde4e2

Browse files
authored
Merge pull request #2 from adborbas/adborbas/historicalperf
Add historical performance counting
2 parents e6280b9 + 2301fed commit efde4e2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+782
-158
lines changed

Package.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ let package = Package(
1111
.package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"),
1212
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"),
1313
.package(url: "https://github.com/JohnSundell/CollectionConcurrencyKit.git", from: "0.1.0"),
14-
.package(url: "https://github.com/adborbas/alphaswiftage.git", from: "0.4.0"),
15-
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.0.0")
14+
.package(url: "https://github.com/adborbas/alphaswiftage.git", from: "0.5.0"),
15+
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.0.0"),
16+
.package(url: "https://github.com/vapor/queues-redis-driver.git", from: "1.0.0"),
1617
],
1718
targets: [
1819
.executableTarget(
@@ -24,6 +25,7 @@ let package = Package(
2425
.product(name: "Vapor", package: "vapor"),
2526
.product(name: "CollectionConcurrencyKit", package: "CollectionConcurrencyKit"),
2627
.product(name: "AlphaSwiftage", package: "alphaswiftage"),
28+
.product(name: "QueuesRedisDriver", package: "queues-redis-driver"),
2729
]
2830
),
2931
.testTarget(name: "GrodtTests", dependencies: [
File renamed without changes.

Sources/Grodt/entrypoint.swift renamed to Sources/Grodt/Application/entrypoint.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ enum Entrypoint {
77
var env = try Environment.detect()
88
try LoggingSystem.bootstrap(from: &env)
99

10-
let app = Application(env)
11-
defer { app.shutdown() }
12-
10+
let app = try await Application.make(env)
1311
do {
1412
try await configure(app)
1513
} catch {
1614
app.logger.report(error: error)
15+
try? await app.asyncShutdown()
1716
throw error
1817
}
18+
1919
try await app.execute()
20+
try await app.asyncShutdown()
2021
}
2122
}

Sources/Grodt/migrations.swift renamed to Sources/Grodt/Application/migrations.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ func migrations(_ app: Application) throws {
88
app.migrations.add(Currency.Migration())
99
app.migrations.add(Ticker.Migration())
1010
app.migrations.add(Quote.Migration())
11+
app.migrations.add(HistoricalPortfolioPerformance.Migration())
12+
app.migrations.add(HistoricalQuote.Migration())
1113
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import Vapor
2+
import AlphaSwiftage
3+
4+
func routes(_ app: Application) async throws {
5+
6+
let alphavantage = try await AlphaVantageService(serviceType: .rapidAPI(apiKey: app.config.alphavantageAPIKey()) )
7+
8+
let currencyDTOMapper = CurrencyDTOMapper()
9+
let tickerDTOMapper = TickerDTOMapper()
10+
let loginResponseDTOMapper = LoginResponseDTOMapper()
11+
let transactionDTOMapper = TransactionDTOMapper(currencyDTOMapper: currencyDTOMapper)
12+
let priceService = CachedPriceService(quoteRepository: PostgresQuoteRepository(database: app.db),
13+
alphavantage: alphavantage)
14+
let portfolioPerformanceCalculator = PortfolioPerformanceCalculator(priceService: priceService)
15+
let portfolioDTOMapper = PortfolioDTOMapper(transactionDTOMapper: transactionDTOMapper,
16+
currencyDTOMapper: currencyDTOMapper,
17+
performanceCalculator: portfolioPerformanceCalculator)
18+
let portfolioPerformanceUpdater = PortfolioPerformanceUpdater(
19+
userRepository: PostgresUserRepository(database: app.db),
20+
portfolioRepository: PostgresPortfolioRepository(database: app.db),
21+
tickerRepository: PostgresTickerRepository(database: app.db),
22+
quoteRepository: PostgresQuoteRepository(database: app.db),
23+
priceService: priceService,
24+
performanceCalculator: portfolioPerformanceCalculator,
25+
dataMapper: portfolioDTOMapper)
26+
let transactionChangedHandler = TransactionChangedHandler(portfolioRepository: PostgresPortfolioRepository(database: app.db),
27+
historicalPerformanceUpdater: portfolioPerformanceUpdater)
28+
29+
try app.group("") { routeBuilder in
30+
try routeBuilder.register(collection: UserController(dtoMapper: loginResponseDTOMapper))
31+
}
32+
33+
let tokenAuthMiddleware = UserToken.authenticator()
34+
let guardAuthMiddleware = User.guardMiddleware()
35+
36+
let protected = app.grouped([tokenAuthMiddleware, guardAuthMiddleware])
37+
try protected.group("api") { routeBuilder in
38+
try routeBuilder.register(collection:
39+
PortfoliosController(
40+
portfolioRepository: PostgresPortfolioRepository(database: app.db),
41+
currencyRepository: PostgresCurrencyRepository(database: app.db),
42+
historicalPortfolioPerformanceUpdater: portfolioPerformanceUpdater,
43+
dataMapper: portfolioDTOMapper)
44+
)
45+
46+
let transactionController = TransactionsController(transactionsRepository: PostgresTransactionRepository(database: app.db),
47+
currencyRepository: PostgresCurrencyRepository(database: app.db),
48+
dataMapper: transactionDTOMapper)
49+
50+
transactionController.delegate = transactionChangedHandler
51+
try routeBuilder.register(collection: transactionController)
52+
53+
try routeBuilder.register(collection: TickersController(tickerRepository: PostgresTickerRepository(database: app.db),
54+
dataMapper: tickerDTOMapper,
55+
tickerService: alphavantage)
56+
)
57+
}
58+
59+
60+
app.queues.schedule(PortfolioPerformanceUpdaterJob(performanceUpdater: portfolioPerformanceUpdater))
61+
.daily()
62+
.at(9, 0)
63+
app.queues.add(LoggingJobEventDelegate(logger: app.logger))
64+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import Queues
2+
3+
struct LoggingJobEventDelegate: AsyncJobEventDelegate {
4+
private let logger: Logger
5+
6+
init(logger: Logger) {
7+
self.logger = logger
8+
}
9+
10+
func dispatched(job: JobEventData) async throws {
11+
logger.info("job: \(job.id) dispacthed at \(job.queuedAt)")
12+
}
13+
14+
func didDequeue(jobId: String) async throws {
15+
logger.info("job: \(jobId) dequeued at \(Date())")
16+
}
17+
18+
func success(jobId: String) async throws {
19+
logger.info("job: \(jobId) successful at \(Date())")
20+
}
21+
22+
func error(jobId: String, error: any Error) async throws {
23+
logger.error("job \(jobId) failed with error: \(error)")
24+
}
25+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import Foundation
2+
3+
protocol PortfolioPerformanceCalculating {
4+
func performance(of portfolio: Portfolio, on date: YearMonthDayDate) async throws -> DatedPortfolioPerformance
5+
}
6+
7+
class PortfolioPerformanceCalculator: PortfolioPerformanceCalculating {
8+
private let priceService: PriceService
9+
10+
init(priceService: PriceService) {
11+
self.priceService = priceService
12+
}
13+
14+
func performance(of portfolio: Portfolio, on date: YearMonthDayDate) async throws -> DatedPortfolioPerformance {
15+
let transactionsUntilDate = portfolio.transactions.filter { YearMonthDayDate($0.purchaseDate) <= date }
16+
17+
let financialsForDate = Financials()
18+
for transaction in transactionsUntilDate {
19+
let inAmount = transaction.numberOfShares * transaction.pricePerShareAtPurchase + transaction.fees
20+
await financialsForDate.addMoneyIn(inAmount)
21+
22+
let value = try await transaction.numberOfShares * self.priceService.price(for: transaction.ticker, on: date)
23+
await financialsForDate.addValue(value)
24+
}
25+
26+
let performanceForDate = DatedPortfolioPerformance(
27+
moneyIn: await financialsForDate.moneyIn,
28+
value: await financialsForDate.value,
29+
date: date
30+
)
31+
return performanceForDate
32+
}
33+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import Foundation
2+
3+
protocol PortfolioHistoricalPerformanceUpdater {
4+
func recalculateHistoricalPerformance(of portfolio: Portfolio) async throws
5+
func updatePerformanceOfAllPortfolios() async throws
6+
}
7+
8+
class PortfolioPerformanceUpdater: PortfolioHistoricalPerformanceUpdater {
9+
private let userRepository: UserRepository
10+
private let portfolioRepository: PortfolioRepository
11+
private let tickerRepository: TickerRepository
12+
private let quoteRepository: QuoteRepository
13+
private let priceService: PriceService
14+
private let performanceCalculator: PortfolioPerformanceCalculating
15+
private let dataMapper: PortfolioDTOMapper
16+
17+
private let rateLimiter = RateLimiter(maxRequestsPerMinute: 5)
18+
19+
init(userRepository: UserRepository,
20+
portfolioRepository: PortfolioRepository,
21+
tickerRepository: TickerRepository,
22+
quoteRepository: QuoteRepository,
23+
priceService: PriceService,
24+
performanceCalculator: PortfolioPerformanceCalculating,
25+
dataMapper: PortfolioDTOMapper) {
26+
self.userRepository = userRepository
27+
self.portfolioRepository = portfolioRepository
28+
self.tickerRepository = tickerRepository
29+
self.quoteRepository = quoteRepository
30+
self.priceService = priceService
31+
self.performanceCalculator = performanceCalculator
32+
self.dataMapper = dataMapper
33+
}
34+
35+
func updatePerformanceOfAllPortfolios() async throws {
36+
// Update historical prices for all tickers
37+
let allTickers = try await tickerRepository.allTickers()
38+
for ticker in allTickers {
39+
await rateLimiter.waitIfNeeded()
40+
_ = try await priceService.fetchAndCreateHistoricalPrices(for: ticker.symbol)
41+
}
42+
43+
// Update historical performance for all portfolios
44+
let users = try await userRepository.allUsers()
45+
for user in users {
46+
let allPortfolios = try await portfolioRepository.allPortfolios(for: user.id!)
47+
for portfolio in allPortfolios {
48+
try await recalculateHistoricalPerformance(of: portfolio)
49+
}
50+
}
51+
}
52+
53+
func recalculateHistoricalPerformance(of portfolio: Portfolio) async throws {
54+
var datedPerformance = [DatedPortfolioPerformance]()
55+
guard let earliestTransaction = portfolio.earliestTransaction else { return }
56+
let dates = dateRangeUntilToday(from: earliestTransaction.purchaseDate)
57+
58+
for date in dates {
59+
let performanceForDate = try await performanceCalculator.performance(of: portfolio, on: date)
60+
datedPerformance.append(performanceForDate)
61+
}
62+
63+
if let perf = portfolio.historicalPerformance {
64+
perf.datedPerformance = datedPerformance
65+
try await portfolioRepository.updateHistoricalPerformance(perf)
66+
} else {
67+
let historicalPerformance = HistoricalPortfolioPerformance(
68+
portfolioID: portfolio.id!,
69+
datedPerformance: datedPerformance
70+
)
71+
try await portfolioRepository.createHistoricalPerformance(historicalPerformance)
72+
}
73+
}
74+
75+
private func dateRangeUntilToday(from startDate: Date) -> [YearMonthDayDate] {
76+
var dates: [YearMonthDayDate] = []
77+
var currentDate = startDate
78+
let calendar = Calendar.current
79+
let today = Date()
80+
81+
while currentDate <= today {
82+
let ymdDate = YearMonthDayDate(currentDate)
83+
dates.append(ymdDate)
84+
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
85+
}
86+
87+
return dates
88+
}
89+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Vapor
2+
import Queues
3+
4+
struct PortfolioPerformanceUpdaterJob: AsyncScheduledJob, @unchecked Sendable {
5+
private let performanceUpdater: PortfolioHistoricalPerformanceUpdater
6+
7+
init(performanceUpdater: PortfolioHistoricalPerformanceUpdater) {
8+
self.performanceUpdater = performanceUpdater
9+
}
10+
11+
func run(context: Queues.QueueContext) async throws {
12+
try await performanceUpdater.updatePerformanceOfAllPortfolios()
13+
}
14+
}

0 commit comments

Comments
 (0)