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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
steps:
- uses: actions/checkout@v3
- name: Set up Swift on Ubuntu
uses: fwal/setup-swift@v1
uses: swift-actions/setup-swift@v1
with:
swift-version: '5.9'
- name: Build on Ubuntu
Expand Down
16 changes: 15 additions & 1 deletion Sources/Grodt/Application/migrations.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import Vapor

func migrations(_ app: Application) throws {
app.migrations.add(Brokerage.Migration())
app.migrations.add(BrokerageAccount.Migration())

app.migrations.add(HistoricalPortfolioPerformanceDaily.Migration())
app.migrations.add(HistoricalBrokerageAccountPerformanceDaily.Migration())
app.migrations.add(HistoricalBrokeragePerformanceDaily.Migration())

app.migrations.add(User.Migration(preconfigured: app.config.preconfiguredUser, logger: app.logger))
app.migrations.add(UserToken.Migration())
app.migrations.add(Portfolio.Migration())

app.migrations.add(Transaction.Migration())
app.migrations.add(Transaction.Migration_AddBrokerageAccountID())
if app.environment != .testing {
app.migrations.add(Transaction.Migration_DropPlatformAccountAndMakeBARequired())
}

app.migrations.add(Currency.Migration())
app.migrations.add(Ticker.Migration())
app.migrations.add(Quote.Migration())
app.migrations.add(HistoricalPortfolioPerformance.Migration())
app.migrations.add(HistoricalQuote.Migration())

app.migrations.add(DropOldHistoricalPortfolioPerformance())
}
62 changes: 50 additions & 12 deletions Sources/Grodt/Application/routes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,37 @@ func routes(_ app: Application) async throws {
let currencyDTOMapper = CurrencyDTOMapper()
let tickerDTOMapper = TickerDTOMapper()
let loginResponseDTOMapper = LoginResponseDTOMapper()
let transactionDTOMapper = TransactionDTOMapper(currencyDTOMapper: currencyDTOMapper)
let transactionDTOMapper = TransactionDTOMapper(currencyDTOMapper: currencyDTOMapper, database: app.db)
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 performanceCalculator = HoldingsPerformanceCalculator(priceService: priceService)
let investmentDTOMapper = InvestmentDTOMapper(currencyDTOMapper: currencyDTOMapper,
transactionDTOMapper: transactionDTOMapper,
tickerRepository: tickerRepository,
priceService: priceService)

let userRepository = PostgresUserRepository(database: app.db)
let portfolioRepository = PostgresPortfolioRepository(database: app.db)
let portfolioPerformanceCalculator = PortfolioPerformanceCalculator(priceService: priceService)
let transactionRepository = PostgresTransactionRepository(database: app.db)
let brokerageRepository = PostgresBrokerageRepository(database: app.db)
let brokerageAccountRepository = PostgresBrokerageAccountRepository(database: app.db)
let brokerageAccountDailyRepository = PostgresBrokerageAccountDailyPerformanceRepository(database: app.db)
let brokerageDailyPerformanceRepository = PostgresBrokerageDailyPerformanceRepository(database: app.db)

let portfolioDTOMapper = PortfolioDTOMapper(investmentDTOMapper: investmentDTOMapper,
currencyDTOMapper: currencyDTOMapper,
performanceCalculator: portfolioPerformanceCalculator)
transactionDTOMapper: transactionDTOMapper,
currencyDTOMapper: currencyDTOMapper)
let currencyRepository = PostgresCurrencyRepository(database: app.db)
let portfolioPerformanceUpdater = PortfolioPerformanceUpdater(
userRepository: PostgresUserRepository(database: app.db),
userRepository: userRepository,
portfolioRepository: portfolioRepository,
tickerRepository: PostgresTickerRepository(database: app.db),
quoteCache: quoteCache,
priceService: priceService,
performanceCalculator: portfolioPerformanceCalculator)
performanceCalculator: performanceCalculator,
portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository(db: app.db))
let transactionChangedHandler = TransactionChangedHandler(portfolioRepository: PostgresPortfolioRepository(database: app.db),
historicalPerformanceUpdater: portfolioPerformanceUpdater)

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

let accountController = AccountController(userRepository: PostgresUserRepository(database: app.db), dataMapper: UserDTOMapper())
let accountController = AccountController(userRepository: userRepository, dataMapper: UserDTOMapper())

let globalRateLimiter = RateLimiterMiddleware(maxRequests: 100, perSeconds: 60)
let loginRateLimiter = RateLimiterMiddleware(maxRequests: 3, perSeconds: 60)
Expand All @@ -63,26 +73,54 @@ func routes(_ app: Application) async throws {
try protected.register(collection:
PortfoliosController(
portfolioRepository: PostgresPortfolioRepository(database: app.db),
currencyRepository: PostgresCurrencyRepository(database: app.db),
currencyRepository: currencyRepository,
historicalPortfolioPerformanceUpdater: portfolioPerformanceUpdater,
portfolioDailyRepo: PostgresPortfolioDailyPerformanceRepository(db: app.db),
dataMapper: portfolioDTOMapper)
)

let transactionController = TransactionsController(transactionsRepository: PostgresTransactionRepository(database: app.db),
currencyRepository: PostgresCurrencyRepository(database: app.db),
let transactionController = TransactionsController(transactionsRepository: transactionRepository,
currencyRepository: currencyRepository,
dataMapper: transactionDTOMapper)
transactionController.delegate = transactionChangedHandler
try protected.register(collection: transactionController)
try protected.register(collection: tickersController)
try protected.register(collection: investmentsController)
try protected.register(collection: accountController)
try protected.register(collection: BrokerageController(brokerageRepository: brokerageRepository,
dtoMapper: BrokerageDTOMapper(brokerageRepository: brokerageRepository,
accountDTOMapper: BrokerageAccountDTOMapper(brokerageAccountRepository: brokerageAccountRepository,
currencyMapper: currencyDTOMapper, database: app.db),
database: app.db),
accounts: brokerageAccountRepository,
currencyMapper: currencyDTOMapper,
performanceRepository: brokerageDailyPerformanceRepository,
performancePointDTOMapper: PerformancePointDTOMapper()))
try protected.register(collection: BrokerageAccountController(brokerageAccountRepository: brokerageAccountRepository,
currencyMapper: currencyDTOMapper,
currencyRepository: currencyRepository))
}

if app.environment != .testing {
let portfolioUpdaterJob = PortfolioPerformanceUpdaterJob(performanceUpdater: portfolioPerformanceUpdater)
app.queues.schedule(portfolioUpdaterJob)
let nightlyUpdaterJob = NightlyUpdaterJob(
tickerPriceUpdater: TickerPriceUpdater(tickerRepository: tickerRepository,
quoteCache: quoteCache,
priceService: priceService),
portfolioPerformanceUpdater: portfolioPerformanceUpdater,
brokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdater(transactionRepository: transactionRepository,
brokerageAccountRepository: brokerageAccountRepository,
accountDailyRepository: brokerageAccountDailyRepository,
userRepository: userRepository,
calculator: performanceCalculator),
brokeragePerformanceUpdater: BrokeragePerformanceUpdater(userRepository: userRepository,
brokerageAccountRepository: brokerageAccountRepository,
accountDailyRepository: brokerageAccountDailyRepository,
brokerageDailyRepository: brokerageDailyPerformanceRepository)
)
app.queues.schedule(nightlyUpdaterJob)
.daily()
.at(3, 0)

app.queues.add(LoggingJobEventDelegate(logger: app.logger))

let userTokenCleanerJob = UserTokenClearUpJob(userTokenClearing: UserTokenClearer(database: app.db))
Expand Down
60 changes: 60 additions & 0 deletions Sources/Grodt/BusinessLogic/NightlyUpdaterJob.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Vapor
import Queues
import Foundation

struct NightlyUpdaterJob: AsyncScheduledJob, @unchecked Sendable {
private let tickerPriceUpdater: TickerPriceUpdating
private let portfolioPerformanceUpdater: PortfolioPerformanceUpdating
private let brokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdating
private let brokeragePerformanceUpdater: BrokeragePerformanceUpdating

init(
tickerPriceUpdater: TickerPriceUpdating,
portfolioPerformanceUpdater: PortfolioPerformanceUpdating,
brokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdating,
brokeragePerformanceUpdater: BrokeragePerformanceUpdating
) {
self.tickerPriceUpdater = tickerPriceUpdater
self.portfolioPerformanceUpdater = portfolioPerformanceUpdater
self.brokerageAccountPerformanceUpdater = brokerageAccountPerformanceUpdater
self.brokeragePerformanceUpdater = brokeragePerformanceUpdater
}

func run(context: Queues.QueueContext) async throws {
context.logger.info("NightlyUpdaterJob – Job started")
try await logStep("Update all ticker prices", context: context) {
try await tickerPriceUpdater.updateAllTickerPrices()
}
try await logStep("Update all portfolio performance", context: context) {
try await portfolioPerformanceUpdater.updateAllPortfolioPerformance()
}
try await logStep("Update all brokerage account performance", context: context) {
try await brokerageAccountPerformanceUpdater.updateAllBrokerageAccountPerformance()
}
try await logStep("Update all brokerage performance", context: context) {
try await brokeragePerformanceUpdater.updateAllBrokeragePerformance()
}
context.logger.info("NightlyUpdaterJob – Job finished")
}

@discardableResult
private func logStep<T>(_ name: String, context: Queues.QueueContext, _ work: () async throws -> T) async throws -> T {
context.logger.info("NightlyUpdaterJob – START: \(name)")
let clock = ContinuousClock()
let t0 = clock.now
do {
let value = try await work()
let duration = t0.duration(to: clock.now)
let comps = duration.components
let seconds = Double(comps.seconds) + Double(comps.attoseconds) / 1_000_000_000_000_000_000.0
context.logger.info("NightlyUpdaterJob – END: \(name) in \(String(format: "%.3f", seconds))s")
return value
} catch {
let duration = t0.duration(to: clock.now)
let comps = duration.components
let seconds = Double(comps.seconds) + Double(comps.attoseconds) / 1_000_000_000_000_000_000.0
context.logger.error("NightlyUpdaterJob – ERROR during \(name) after \(String(format: "%.3f", seconds))s: \(String(describing: error))")
throw error
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Queues
import Fluent

protocol BrokerageAccountPerformanceUpdating {
func updateAllBrokerageAccountPerformance() async throws
}

class BrokerageAccountPerformanceUpdater: BrokerageAccountPerformanceUpdating {
private let brokerageAccountRepository: BrokerageAccountRepository
private let transactionRepository: TransactionsRepository
private let accountDailyRepository: PostgresBrokerageAccountDailyPerformanceRepository
private let userRepository: UserRepository
private let calculator: HoldingsPerformanceCalculating

init(transactionRepository: TransactionsRepository,
brokerageAccountRepository: BrokerageAccountRepository,
accountDailyRepository: PostgresBrokerageAccountDailyPerformanceRepository,
userRepository: UserRepository,
calculator: HoldingsPerformanceCalculating) {
self.transactionRepository = transactionRepository
self.brokerageAccountRepository = brokerageAccountRepository
self.accountDailyRepository = accountDailyRepository
self.userRepository = userRepository
self.calculator = calculator
}

func updateAllBrokerageAccountPerformance() async throws {
let users = try await userRepository.allUsers()
for user in users {
try await updateAllAccounts(for: user)
}
}

private func updateAllAccounts(for user: User) async throws {
guard let userID = user.id else { return }

let accounts = try await brokerageAccountRepository.all(for: userID)
let userTransactions = try await transactionRepository.all(for: userID)

for account in accounts {
try await updateSingleAccount(account, with: userTransactions)
}
}

private func updateSingleAccount(_ account: BrokerageAccount, with userTransactions: [Transaction]) async throws {
let accountID = try account.requireID()

// Keep only transactions linked to this account (explicit loop avoids any Fluent `filter` ambiguity)
var accountTransactions: [Transaction] = []
accountTransactions.reserveCapacity(userTransactions.count)
for transaction in userTransactions {
let linkedID = transaction.$brokerageAccount.id ?? transaction.brokerageAccount?.id
if linkedID == accountID {
accountTransactions.append(transaction)
}
}

// No transactions → clear any stored series
guard let earliest = accountTransactions.map(\.purchaseDate).min() else {
try await accountDailyRepository.deleteAll(for: accountID)
return
}

let start = YearMonthDayDate(earliest)
let end = YearMonthDayDate(Date())

let series = try await calculator.performanceSeries(
for: accountTransactions,
from: start,
to: end
)

try await accountDailyRepository.replaceSeries(for: accountID, with: series)
}
}
Loading