Skip to content

Commit a7540b7

Browse files
authored
Adding brokerage and brokerage account (#15)
1 parent ef3aa30 commit a7540b7

File tree

62 files changed

+2133
-453
lines changed

Some content is hidden

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

62 files changed

+2133
-453
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
steps:
2020
- uses: actions/checkout@v3
2121
- name: Set up Swift on Ubuntu
22-
uses: fwal/setup-swift@v1
22+
uses: swift-actions/setup-swift@v1
2323
with:
2424
swift-version: '5.9'
2525
- name: Build on Ubuntu
Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
import Vapor
22

33
func migrations(_ app: Application) throws {
4+
app.migrations.add(Brokerage.Migration())
5+
app.migrations.add(BrokerageAccount.Migration())
6+
7+
app.migrations.add(HistoricalPortfolioPerformanceDaily.Migration())
8+
app.migrations.add(HistoricalBrokerageAccountPerformanceDaily.Migration())
9+
app.migrations.add(HistoricalBrokeragePerformanceDaily.Migration())
10+
411
app.migrations.add(User.Migration(preconfigured: app.config.preconfiguredUser, logger: app.logger))
512
app.migrations.add(UserToken.Migration())
613
app.migrations.add(Portfolio.Migration())
14+
715
app.migrations.add(Transaction.Migration())
16+
app.migrations.add(Transaction.Migration_AddBrokerageAccountID())
17+
if app.environment != .testing {
18+
app.migrations.add(Transaction.Migration_DropPlatformAccountAndMakeBARequired())
19+
}
20+
821
app.migrations.add(Currency.Migration())
922
app.migrations.add(Ticker.Migration())
1023
app.migrations.add(Quote.Migration())
11-
app.migrations.add(HistoricalPortfolioPerformance.Migration())
1224
app.migrations.add(HistoricalQuote.Migration())
25+
26+
app.migrations.add(DropOldHistoricalPortfolioPerformance())
1327
}

Sources/Grodt/Application/routes.swift

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,37 @@ func routes(_ app: Application) async throws {
88
let currencyDTOMapper = CurrencyDTOMapper()
99
let tickerDTOMapper = TickerDTOMapper()
1010
let loginResponseDTOMapper = LoginResponseDTOMapper()
11-
let transactionDTOMapper = TransactionDTOMapper(currencyDTOMapper: currencyDTOMapper)
11+
let transactionDTOMapper = TransactionDTOMapper(currencyDTOMapper: currencyDTOMapper, database: app.db)
1212
let tickerRepository = PostgresTickerRepository(database: app.db)
1313
let livePriceService = LivePriceService(alphavantage: alphavantage)
1414
let quoteCache = PostgresQuoteRepository(database: app.db)
1515
let priceService = CachedPriceService(priceService: livePriceService, cache: quoteCache)
16+
let performanceCalculator = HoldingsPerformanceCalculator(priceService: priceService)
1617
let investmentDTOMapper = InvestmentDTOMapper(currencyDTOMapper: currencyDTOMapper,
1718
transactionDTOMapper: transactionDTOMapper,
1819
tickerRepository: tickerRepository,
1920
priceService: priceService)
21+
22+
let userRepository = PostgresUserRepository(database: app.db)
2023
let portfolioRepository = PostgresPortfolioRepository(database: app.db)
21-
let portfolioPerformanceCalculator = PortfolioPerformanceCalculator(priceService: priceService)
24+
let transactionRepository = PostgresTransactionRepository(database: app.db)
25+
let brokerageRepository = PostgresBrokerageRepository(database: app.db)
26+
let brokerageAccountRepository = PostgresBrokerageAccountRepository(database: app.db)
27+
let brokerageAccountDailyRepository = PostgresBrokerageAccountDailyPerformanceRepository(database: app.db)
28+
let brokerageDailyPerformanceRepository = PostgresBrokerageDailyPerformanceRepository(database: app.db)
29+
2230
let portfolioDTOMapper = PortfolioDTOMapper(investmentDTOMapper: investmentDTOMapper,
23-
currencyDTOMapper: currencyDTOMapper,
24-
performanceCalculator: portfolioPerformanceCalculator)
31+
transactionDTOMapper: transactionDTOMapper,
32+
currencyDTOMapper: currencyDTOMapper)
33+
let currencyRepository = PostgresCurrencyRepository(database: app.db)
2534
let portfolioPerformanceUpdater = PortfolioPerformanceUpdater(
26-
userRepository: PostgresUserRepository(database: app.db),
35+
userRepository: userRepository,
2736
portfolioRepository: portfolioRepository,
2837
tickerRepository: PostgresTickerRepository(database: app.db),
2938
quoteCache: quoteCache,
3039
priceService: priceService,
31-
performanceCalculator: portfolioPerformanceCalculator)
40+
performanceCalculator: performanceCalculator,
41+
portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository(db: app.db))
3242
let transactionChangedHandler = TransactionChangedHandler(portfolioRepository: PostgresPortfolioRepository(database: app.db),
3343
historicalPerformanceUpdater: portfolioPerformanceUpdater)
3444

@@ -41,7 +51,7 @@ func routes(_ app: Application) async throws {
4151
let investmentsController = InvestmentController(portfolioRepository: portfolioRepository,
4252
dataMapper: investmentDTOMapper)
4353

44-
let accountController = AccountController(userRepository: PostgresUserRepository(database: app.db), dataMapper: UserDTOMapper())
54+
let accountController = AccountController(userRepository: userRepository, dataMapper: UserDTOMapper())
4555

4656
let globalRateLimiter = RateLimiterMiddleware(maxRequests: 100, perSeconds: 60)
4757
let loginRateLimiter = RateLimiterMiddleware(maxRequests: 3, perSeconds: 60)
@@ -63,26 +73,54 @@ func routes(_ app: Application) async throws {
6373
try protected.register(collection:
6474
PortfoliosController(
6575
portfolioRepository: PostgresPortfolioRepository(database: app.db),
66-
currencyRepository: PostgresCurrencyRepository(database: app.db),
76+
currencyRepository: currencyRepository,
6777
historicalPortfolioPerformanceUpdater: portfolioPerformanceUpdater,
78+
portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository(db: app.db),
6879
dataMapper: portfolioDTOMapper)
6980
)
7081

71-
let transactionController = TransactionsController(transactionsRepository: PostgresTransactionRepository(database: app.db),
72-
currencyRepository: PostgresCurrencyRepository(database: app.db),
82+
let transactionController = TransactionsController(transactionsRepository: transactionRepository,
83+
currencyRepository: currencyRepository,
7384
dataMapper: transactionDTOMapper)
7485
transactionController.delegate = transactionChangedHandler
7586
try protected.register(collection: transactionController)
7687
try protected.register(collection: tickersController)
7788
try protected.register(collection: investmentsController)
7889
try protected.register(collection: accountController)
90+
try protected.register(collection: BrokerageController(brokerageRepository: brokerageRepository,
91+
dtoMapper: BrokerageDTOMapper(brokerageRepository: brokerageRepository,
92+
accountDTOMapper: BrokerageAccountDTOMapper(brokerageAccountRepository: brokerageAccountRepository,
93+
currencyMapper: currencyDTOMapper, database: app.db),
94+
database: app.db),
95+
accounts: brokerageAccountRepository,
96+
currencyMapper: currencyDTOMapper,
97+
performanceRepository: brokerageDailyPerformanceRepository,
98+
performancePointDTOMapper: PerformancePointDTOMapper()))
99+
try protected.register(collection: BrokerageAccountController(brokerageAccountRepository: brokerageAccountRepository,
100+
currencyMapper: currencyDTOMapper,
101+
currencyRepository: currencyRepository))
79102
}
80103

81104
if app.environment != .testing {
82-
let portfolioUpdaterJob = PortfolioPerformanceUpdaterJob(performanceUpdater: portfolioPerformanceUpdater)
83-
app.queues.schedule(portfolioUpdaterJob)
105+
let nightlyUpdaterJob = NightlyUpdaterJob(
106+
tickerPriceUpdater: TickerPriceUpdater(tickerRepository: tickerRepository,
107+
quoteCache: quoteCache,
108+
priceService: priceService),
109+
portfolioPerformanceUpdater: portfolioPerformanceUpdater,
110+
brokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdater(transactionRepository: transactionRepository,
111+
brokerageAccountRepository: brokerageAccountRepository,
112+
accountDailyRepository: brokerageAccountDailyRepository,
113+
userRepository: userRepository,
114+
calculator: performanceCalculator),
115+
brokeragePerformanceUpdater: BrokeragePerformanceUpdater(userRepository: userRepository,
116+
brokerageAccountRepository: brokerageAccountRepository,
117+
accountDailyRepository: brokerageAccountDailyRepository,
118+
brokerageDailyRepository: brokerageDailyPerformanceRepository)
119+
)
120+
app.queues.schedule(nightlyUpdaterJob)
84121
.daily()
85122
.at(3, 0)
123+
86124
app.queues.add(LoggingJobEventDelegate(logger: app.logger))
87125

88126
let userTokenCleanerJob = UserTokenClearUpJob(userTokenClearing: UserTokenClearer(database: app.db))
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import Vapor
2+
import Queues
3+
import Foundation
4+
5+
struct NightlyUpdaterJob: AsyncScheduledJob, @unchecked Sendable {
6+
private let tickerPriceUpdater: TickerPriceUpdating
7+
private let portfolioPerformanceUpdater: PortfolioPerformanceUpdating
8+
private let brokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdating
9+
private let brokeragePerformanceUpdater: BrokeragePerformanceUpdating
10+
11+
init(
12+
tickerPriceUpdater: TickerPriceUpdating,
13+
portfolioPerformanceUpdater: PortfolioPerformanceUpdating,
14+
brokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdating,
15+
brokeragePerformanceUpdater: BrokeragePerformanceUpdating
16+
) {
17+
self.tickerPriceUpdater = tickerPriceUpdater
18+
self.portfolioPerformanceUpdater = portfolioPerformanceUpdater
19+
self.brokerageAccountPerformanceUpdater = brokerageAccountPerformanceUpdater
20+
self.brokeragePerformanceUpdater = brokeragePerformanceUpdater
21+
}
22+
23+
func run(context: Queues.QueueContext) async throws {
24+
context.logger.info("NightlyUpdaterJob – Job started")
25+
try await logStep("Update all ticker prices", context: context) {
26+
try await tickerPriceUpdater.updateAllTickerPrices()
27+
}
28+
try await logStep("Update all portfolio performance", context: context) {
29+
try await portfolioPerformanceUpdater.updateAllPortfolioPerformance()
30+
}
31+
try await logStep("Update all brokerage account performance", context: context) {
32+
try await brokerageAccountPerformanceUpdater.updateAllBrokerageAccountPerformance()
33+
}
34+
try await logStep("Update all brokerage performance", context: context) {
35+
try await brokeragePerformanceUpdater.updateAllBrokeragePerformance()
36+
}
37+
context.logger.info("NightlyUpdaterJob – Job finished")
38+
}
39+
40+
@discardableResult
41+
private func logStep<T>(_ name: String, context: Queues.QueueContext, _ work: () async throws -> T) async throws -> T {
42+
context.logger.info("NightlyUpdaterJob – START: \(name)")
43+
let clock = ContinuousClock()
44+
let t0 = clock.now
45+
do {
46+
let value = try await work()
47+
let duration = t0.duration(to: clock.now)
48+
let comps = duration.components
49+
let seconds = Double(comps.seconds) + Double(comps.attoseconds) / 1_000_000_000_000_000_000.0
50+
context.logger.info("NightlyUpdaterJob – END: \(name) in \(String(format: "%.3f", seconds))s")
51+
return value
52+
} catch {
53+
let duration = t0.duration(to: clock.now)
54+
let comps = duration.components
55+
let seconds = Double(comps.seconds) + Double(comps.attoseconds) / 1_000_000_000_000_000_000.0
56+
context.logger.error("NightlyUpdaterJob – ERROR during \(name) after \(String(format: "%.3f", seconds))s: \(String(describing: error))")
57+
throw error
58+
}
59+
}
60+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import Queues
2+
import Fluent
3+
4+
protocol BrokerageAccountPerformanceUpdating {
5+
func updateAllBrokerageAccountPerformance() async throws
6+
}
7+
8+
class BrokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdating {
9+
private let brokerageAccountRepository: BrokerageAccountRepository
10+
private let transactionRepository: TransactionsRepository
11+
private let accountDailyRepository: PostgresBrokerageAccountDailyPerformanceRepository
12+
private let userRepository: UserRepository
13+
private let calculator: HoldingsPerformanceCalculating
14+
15+
init(transactionRepository: TransactionsRepository,
16+
brokerageAccountRepository: BrokerageAccountRepository,
17+
accountDailyRepository: PostgresBrokerageAccountDailyPerformanceRepository,
18+
userRepository: UserRepository,
19+
calculator: HoldingsPerformanceCalculating) {
20+
self.transactionRepository = transactionRepository
21+
self.brokerageAccountRepository = brokerageAccountRepository
22+
self.accountDailyRepository = accountDailyRepository
23+
self.userRepository = userRepository
24+
self.calculator = calculator
25+
}
26+
27+
func updateAllBrokerageAccountPerformance() async throws {
28+
let users = try await userRepository.allUsers()
29+
for user in users {
30+
try await updateAllAccounts(for: user)
31+
}
32+
}
33+
34+
private func updateAllAccounts(for user: User) async throws {
35+
guard let userID = user.id else { return }
36+
37+
let accounts = try await brokerageAccountRepository.all(for: userID)
38+
let userTransactions = try await transactionRepository.all(for: userID)
39+
40+
for account in accounts {
41+
try await updateSingleAccount(account, with: userTransactions)
42+
}
43+
}
44+
45+
private func updateSingleAccount(_ account: BrokerageAccount, with userTransactions: [Transaction]) async throws {
46+
let accountID = try account.requireID()
47+
48+
// Keep only transactions linked to this account (explicit loop avoids any Fluent `filter` ambiguity)
49+
var accountTransactions: [Transaction] = []
50+
accountTransactions.reserveCapacity(userTransactions.count)
51+
for transaction in userTransactions {
52+
let linkedID = transaction.$brokerageAccount.id ?? transaction.brokerageAccount?.id
53+
if linkedID == accountID {
54+
accountTransactions.append(transaction)
55+
}
56+
}
57+
58+
// No transactions → clear any stored series
59+
guard let earliest = accountTransactions.map(\.purchaseDate).min() else {
60+
try await accountDailyRepository.deleteAll(for: accountID)
61+
return
62+
}
63+
64+
let start = YearMonthDayDate(earliest)
65+
let end = YearMonthDayDate(Date())
66+
67+
let series = try await calculator.performanceSeries(
68+
for: accountTransactions,
69+
from: start,
70+
to: end
71+
)
72+
73+
try await accountDailyRepository.replaceSeries(for: accountID, with: series)
74+
}
75+
}

0 commit comments

Comments
 (0)