From 465815fe877c09cd75ace384e2ef5a09b25a2904 Mon Sep 17 00:00:00 2001 From: Lidizz Date: Wed, 25 Feb 2026 23:54:14 +0100 Subject: [PATCH 1/5] refactor: trim verbose Javadoc in InvestmentSimulationService (428330 lines) - Remove 'Learning Notes' and historical comments not appropriate for production - Condense method documentation to concise single-line or 3-4 line Javadocs - Remove redundant inline comments and excessive log statements - No logic changes all code paths identical --- .../service/InvestmentSimulationService.java | 142 +++--------------- 1 file changed, 22 insertions(+), 120 deletions(-) diff --git a/backend/src/main/java/com/moshimo/backend/domain/service/InvestmentSimulationService.java b/backend/src/main/java/com/moshimo/backend/domain/service/InvestmentSimulationService.java index e7a9704..2bbcb2b 100644 --- a/backend/src/main/java/com/moshimo/backend/domain/service/InvestmentSimulationService.java +++ b/backend/src/main/java/com/moshimo/backend/domain/service/InvestmentSimulationService.java @@ -21,19 +21,11 @@ import java.util.stream.Collectors; /** - * Investment Simulation Service - Core business logic for "What If" calculations. - * - * Learning Notes: - * - BigDecimal arithmetic for financial precision - * - Stream API for data transformation and aggregation - * - Algorithm complexity: O(n × m) where n = investments, m = days - * - CAGR formula: (endValue / beginValue)^(1/years) - 1 - * - * Design Pattern: Service Layer, Strategy Pattern (calculation algorithms) + * Core business logic for "What If" investment simulations. * * Collaborators: - * - TimelineAggregator: handles daily → weekly/monthly/yearly/smart downsampling - * - BenchmarkService: handles SPY benchmark calculation (with @Cacheable) + * - TimelineAggregator: daily → weekly/monthly/yearly/smart downsampling + * - BenchmarkService: SPY benchmark calculation (with @Cacheable) */ @Service @Transactional(readOnly = true) @@ -47,15 +39,8 @@ public class InvestmentSimulationService { private final BenchmarkService benchmarkService; /** - * Calculate investment simulation results. - * - * Algorithm: - * 1. For each investment, calculate shares purchased (amount / price on purchase date) - * 2. Build timeline: for each date, calculate total portfolio value - * 3. Calculate metrics: CAGR, returns, gains - * - * @param request simulation parameters - * @return simulation results with timeline and holdings + * Run a full simulation: process each investment, build portfolio timeline, + * calculate metrics (CAGR, returns, gains), and attach benchmark data. */ public SimulationResponse simulate(SimulationRequest request) { LocalDate endDate = request.getEndDate() != null ? request.getEndDate() : LocalDate.now(); @@ -126,47 +111,33 @@ public SimulationResponse simulate(SimulationRequest request) { .build(); } - /** - * Process a single investment and calculate current value. - */ + /** Process a single investment: resolve purchase/current prices and compute shares & value. */ private InvestmentHolding processInvestment(InvestmentItemRequest item, LocalDate endDate) { Asset asset = assetService.getAssetEntityBySymbol(item.getSymbol()); - // Get purchase price (handle holidays by finding next available trading day) + // Get purchase price (falls back to next available trading day for holidays) AssetPrice purchasePrice = assetPriceRepository .findByAssetIdAndDate(asset.getId(), item.getPurchaseDate()) - .or(() -> { - log.info("Exact date {} not available for {}, finding next trading day", - item.getPurchaseDate(), item.getSymbol()); - return assetPriceRepository.findNextAvailableDate(asset.getId(), item.getPurchaseDate()); - }) + .or(() -> assetPriceRepository.findNextAvailableDate(asset.getId(), item.getPurchaseDate())) .orElseThrow(() -> new IllegalArgumentException( "No price data available for " + item.getSymbol() + " on or after " + item.getPurchaseDate() )); - // Calculate shares purchased (use adjusted close for accuracy) BigDecimal priceOnPurchase = purchasePrice.getAdjustedClose() != null ? purchasePrice.getAdjustedClose() : purchasePrice.getClose(); BigDecimal shares = item.getAmountUsd().divide(priceOnPurchase, 8, RoundingMode.HALF_UP); - // Get current price (on end date) AssetPrice currentPrice = assetPriceRepository .findByAssetIdAndDate(asset.getId(), endDate) - .orElseGet(() -> { - log.warn("No price data for {} on {}, using latest available", - item.getSymbol(), endDate); - return assetPriceRepository.findLatestByAssetId(asset.getId()) + .orElseGet(() -> assetPriceRepository.findLatestByAssetId(asset.getId()) .orElseThrow(() -> new IllegalArgumentException( "No price data available for " + item.getSymbol() - )); - }); + ))); BigDecimal priceOnEnd = currentPrice.getAdjustedClose() != null ? currentPrice.getAdjustedClose() : currentPrice.getClose(); - - // Calculate current value BigDecimal currentValue = shares.multiply(priceOnEnd).setScale(2, RoundingMode.HALF_UP); return new InvestmentHolding( @@ -183,31 +154,9 @@ private InvestmentHolding processInvestment(InvestmentItemRequest item, LocalDat /** * Build complete portfolio value timeline with daily granularity. - * - * NEW IMPLEMENTATION (replaces 2-point stub): - * This method now generates a COMPLETE historical performance timeline, - * showing actual portfolio value on every trading day from first purchase - * to end date. This reveals real market volatility, crashes, and recoveries. - * - * Algorithm: - * 1. Determine date range (earliest purchase → end date) - * 2. Batch-fetch ALL stock prices for entire period (1 efficient query!) - * 3. For each trading day: - * - Calculate each holding's value on that day (shares × price) - * - Sum to get total portfolio value - * 4. Return chronological list of daily values - * - * Performance Optimization: - * - OLD: N holdings × D days = N×D individual queries (SLOW!) - * - NEW: 1 batch query fetching all data, then in-memory aggregation (FAST!) - * - * Time Complexity: O(D × H) where D = trading days, H = holdings count - * Space Complexity: O(D) for timeline list - * Database Queries: 1 (batch query for all stocks) - * - * @param holdings list of processed investments with purchase info - * @param endDate simulation end date - * @return complete daily timeline (unsampled, to be aggregated by caller) + * Batch-fetches all prices in one query, then aggregates in memory. + * O(D × H) where D = trading days, H = holdings. Returns unsampled data + * for the caller (TimelineAggregator) to downsample. */ private List buildTimeline( List holdings, LocalDate endDate) { @@ -222,11 +171,7 @@ private List buildTimeline( .min(LocalDate::compareTo) .orElse(endDate); - long totalDays = ChronoUnit.DAYS.between(startDate, endDate); - log.info("Building timeline from {} to {} ({} days)", - startDate, endDate, totalDays); - - // Batch-fetch all prices for all stocks in one query + // Batch-fetch all prices for all assets in one query List assetIds = holdings.stream() .map(h -> h.asset.getId()) .distinct() @@ -235,8 +180,6 @@ private List buildTimeline( List allPrices = assetPriceRepository .findByAssetIdsAndDateBetween(assetIds, startDate, endDate); - log.info("Fetched {} price records for {} assets", allPrices.size(), assetIds.size()); - // Group prices by date for O(1) lookup: Map> Map> pricesByDate = allPrices.stream() .collect(Collectors.groupingBy( @@ -255,11 +198,9 @@ private List buildTimeline( Map pricesOnDate = pricesByDate.get(currentDate); if (pricesOnDate != null && !pricesOnDate.isEmpty()) { - // Trading day - calculate portfolio value BigDecimal portfolioValue = BigDecimal.ZERO; for (InvestmentHolding holding : holdings) { - // Only count holdings purchased on or before this date if (!holding.purchaseDate.isAfter(currentDate)) { AssetPrice price = pricesOnDate.get(holding.asset.getId()); @@ -280,25 +221,12 @@ private List buildTimeline( currentDate = currentDate.plusDays(1); } - log.info("Generated {} daily timeline points", timeline.size()); return timeline; } /** - * Build individual timelines for each holding. - * - * This enables the frontend to display per-stock performance charts, - * allowing users to compare how each investment performed over time. - * - * Algorithm: - * 1. For each holding, get all price data from purchase date to end date - * 2. Calculate daily value (shares × price) for each trading day - * 3. Apply same timeframe aggregation as main timeline - * - * @param holdings list of processed investments - * @param endDate simulation end date - * @param timeframe aggregation timeframe - * @return Map of symbol → aggregated timeline + * Build individual timelines for each holding (symbol → aggregated timeline). + * Enables per-asset performance comparison on the frontend. */ private Map> buildHoldingsTimelines( List holdings, LocalDate endDate, Timeframe timeframe) { @@ -306,14 +234,12 @@ private Map> buildHoldingsTimelin Map> result = new LinkedHashMap<>(); for (InvestmentHolding holding : holdings) { - // Get all prices for this asset from purchase date to end date List prices = assetPriceRepository .findByAssetIdAndDateBetween(holding.asset.getId(), holding.purchaseDate, endDate) .stream() .sorted((a, b) -> a.getDate().compareTo(b.getDate())) .collect(Collectors.toList()); - // Build daily timeline for this holding List dailyTimeline = prices.stream() .map(price -> { BigDecimal priceValue = price.getAdjustedClose() != null @@ -325,24 +251,16 @@ private Map> buildHoldingsTimelin }) .collect(Collectors.toList()); - // Apply same timeframe aggregation List aggregated = timelineAggregator.aggregateTimeline(dailyTimeline, timeframe); result.put(holding.asset.getSymbol(), aggregated); - - log.debug("Built timeline for {}: {} daily points → {} aggregated points", - holding.asset.getSymbol(), dailyTimeline.size(), aggregated.size()); } - log.info("Built individual timelines for {} holdings", result.size()); return result; } - /** - * Calculate percentage return. - * Formula: ((currentValue - invested) / invested) × 100 - */ + /** Calculate percentage return: ((currentValue - invested) / invested) × 100. */ private BigDecimal calculatePercentReturn(BigDecimal invested, BigDecimal currentValue) { if (invested.compareTo(BigDecimal.ZERO) == 0) { return BigDecimal.ZERO; @@ -353,38 +271,27 @@ private BigDecimal calculatePercentReturn(BigDecimal invested, BigDecimal curren .setScale(2, RoundingMode.HALF_UP); } - /** - * Calculate Compound Annual Growth Rate (CAGR). - * - * Formula: CAGR = (endValue / beginValue)^(1/years) - 1 - * - * Learning: Financial algorithm for annualized return rate. - * Example: $1000 → $2000 over 5 years = 14.87% CAGR - */ + /** Calculate CAGR: (endValue / beginValue)^(1/years) - 1. */ private BigDecimal calculateCAGR(BigDecimal invested, BigDecimal currentValue, LocalDate startDate, LocalDate endDate) { if (invested.compareTo(BigDecimal.ZERO) == 0) { return BigDecimal.ZERO; } - // Calculate years (as decimal) long days = ChronoUnit.DAYS.between(startDate, endDate); - double years = days / 365.25; // Account for leap years + double years = days / 365.25; - if (years < 0.01) { // Less than ~4 days + if (years < 0.01) { return BigDecimal.ZERO; } - // CAGR = (endValue / beginValue)^(1/years) - 1 double ratio = currentValue.doubleValue() / invested.doubleValue(); double cagr = (Math.pow(ratio, 1.0 / years) - 1.0) * 100.0; return BigDecimal.valueOf(cagr).setScale(2, RoundingMode.HALF_UP); } - /** - * Get earliest investment date. - */ + /** Get earliest investment date. */ private LocalDate getEarliestDate(List investments) { return investments.stream() .map(InvestmentItemRequest::getPurchaseDate) @@ -392,9 +299,7 @@ private LocalDate getEarliestDate(List investments) { .orElse(LocalDate.now()); } - /** - * Convert internal holding to response DTO. - */ + /** Convert internal holding to response DTO. */ private SimulationResponse.HoldingInfo toHoldingInfo(InvestmentHolding holding) { BigDecimal absoluteGain = holding.currentValue.subtract(holding.invested); BigDecimal percentReturn = calculatePercentReturn(holding.invested, holding.currentValue); @@ -412,9 +317,6 @@ private SimulationResponse.HoldingInfo toHoldingInfo(InvestmentHolding holding) ); } - /** - * Internal holding data structure. - */ private record InvestmentHolding( Asset asset, LocalDate purchaseDate, From a117481e7196cab8d44c014de7d363842c42b79c Mon Sep 17 00:00:00 2001 From: Lidizz Date: Wed, 25 Feb 2026 23:57:01 +0100 Subject: [PATCH 2/5] perf: fix N+1 query in buildHoldingsTimelines - Extract batchFetchPrices() to share one DB query between buildTimeline() and buildHoldingsTimelines() - buildHoldingsTimelines() now filters the pre-fetched price map in memory instead of issuing per-holding findByAssetIdAndDateBetween queries - Update test mocks to use findByAssetIdsAndDateBetween (batch) --- .../service/InvestmentSimulationService.java | 69 ++++++++++--------- .../InvestmentSimulationServiceTest.java | 28 ++++---- 2 files changed, 50 insertions(+), 47 deletions(-) diff --git a/backend/src/main/java/com/moshimo/backend/domain/service/InvestmentSimulationService.java b/backend/src/main/java/com/moshimo/backend/domain/service/InvestmentSimulationService.java index 2bbcb2b..4a2cd6f 100644 --- a/backend/src/main/java/com/moshimo/backend/domain/service/InvestmentSimulationService.java +++ b/backend/src/main/java/com/moshimo/backend/domain/service/InvestmentSimulationService.java @@ -61,8 +61,12 @@ public SimulationResponse simulate(SimulationRequest request) { totalInvested = totalInvested.add(item.getAmountUsd()); } + // Batch-fetch all prices for all assets in one query (shared by both timeline builders) + LocalDate earliestDate = getEarliestDate(request.getInvestments()); + Map> pricesByDate = batchFetchPrices(holdings, earliestDate, endDate); + // Build full daily timeline - List dailyTimeline = buildTimeline(holdings, endDate); + List dailyTimeline = buildTimeline(holdings, earliestDate, endDate, pricesByDate); // Apply timeframe aggregation List timeline = @@ -79,7 +83,6 @@ public SimulationResponse simulate(SimulationRequest request) { // Calculate metrics BigDecimal absoluteGain = currentValue.subtract(totalInvested); BigDecimal percentReturn = calculatePercentReturn(totalInvested, currentValue); - LocalDate earliestDate = getEarliestDate(request.getInvestments()); BigDecimal cagr = calculateCAGR(totalInvested, currentValue, earliestDate, endDate); // Build holdings response @@ -91,9 +94,9 @@ public SimulationResponse simulate(SimulationRequest request) { List benchmarkTimeline = benchmarkService.calculateBenchmarkTimeline(earliestDate, endDate, totalInvested, timeframe); - // Build individual holding timelines for per-stock visualization + // Build individual holding timelines (reuses batch-fetched prices — no extra queries) Map> holdingsTimelines = - buildHoldingsTimelines(holdings, endDate, timeframe); + buildHoldingsTimelines(holdings, endDate, timeframe, pricesByDate); log.info("Simulation complete - Invested: {}, Current: {}, Return: {}%", totalInvested, currentValue, percentReturn); @@ -153,25 +156,16 @@ private InvestmentHolding processInvestment(InvestmentItemRequest item, LocalDat } /** - * Build complete portfolio value timeline with daily granularity. - * Batch-fetches all prices in one query, then aggregates in memory. - * O(D × H) where D = trading days, H = holdings. Returns unsampled data - * for the caller (TimelineAggregator) to downsample. + * Batch-fetch all prices for the given holdings in one DB query, grouped for O(1) lookup. + * Returns Map<Date, Map<AssetId, AssetPrice>>. */ - private List buildTimeline( - List holdings, LocalDate endDate) { + private Map> batchFetchPrices( + List holdings, LocalDate startDate, LocalDate endDate) { if (holdings.isEmpty()) { - return List.of(); + return Map.of(); } - // Find overall date range - LocalDate startDate = holdings.stream() - .map(h -> h.purchaseDate) - .min(LocalDate::compareTo) - .orElse(endDate); - - // Batch-fetch all prices for all assets in one query List assetIds = holdings.stream() .map(h -> h.asset.getId()) .distinct() @@ -180,8 +174,7 @@ private List buildTimeline( List allPrices = assetPriceRepository .findByAssetIdsAndDateBetween(assetIds, startDate, endDate); - // Group prices by date for O(1) lookup: Map> - Map> pricesByDate = allPrices.stream() + return allPrices.stream() .collect(Collectors.groupingBy( AssetPrice::getDate, Collectors.toMap( @@ -189,8 +182,20 @@ private List buildTimeline( Function.identity() ) )); + } + + /** + * Build complete portfolio value timeline with daily granularity. + * Uses pre-fetched price map — no additional DB queries. + */ + private List buildTimeline( + List holdings, LocalDate startDate, LocalDate endDate, + Map> pricesByDate) { + + if (holdings.isEmpty()) { + return List.of(); + } - // Build timeline day-by-day List timeline = new ArrayList<>(); LocalDate currentDate = startDate; @@ -226,28 +231,30 @@ private List buildTimeline( /** * Build individual timelines for each holding (symbol → aggregated timeline). - * Enables per-asset performance comparison on the frontend. + * Reuses the batch-fetched price map — no additional DB queries. */ private Map> buildHoldingsTimelines( - List holdings, LocalDate endDate, Timeframe timeframe) { + List holdings, LocalDate endDate, Timeframe timeframe, + Map> pricesByDate) { Map> result = new LinkedHashMap<>(); for (InvestmentHolding holding : holdings) { - List prices = assetPriceRepository - .findByAssetIdAndDateBetween(holding.asset.getId(), holding.purchaseDate, endDate) - .stream() - .sorted((a, b) -> a.getDate().compareTo(b.getDate())) - .collect(Collectors.toList()); + Long assetId = holding.asset.getId(); - List dailyTimeline = prices.stream() - .map(price -> { + // Extract this holding's prices from the shared map, sorted by date + List dailyTimeline = pricesByDate.entrySet().stream() + .filter(e -> !e.getKey().isBefore(holding.purchaseDate) && !e.getKey().isAfter(endDate)) + .filter(e -> e.getValue().containsKey(assetId)) + .sorted(Map.Entry.comparingByKey()) + .map(e -> { + AssetPrice price = e.getValue().get(assetId); BigDecimal priceValue = price.getAdjustedClose() != null ? price.getAdjustedClose() : price.getClose(); BigDecimal value = holding.shares.multiply(priceValue) .setScale(2, RoundingMode.HALF_UP); - return new SimulationResponse.TimelinePoint(price.getDate(), value); + return new SimulationResponse.TimelinePoint(e.getKey(), value); }) .collect(Collectors.toList()); diff --git a/backend/src/test/java/com/moshimo/backend/domain/service/InvestmentSimulationServiceTest.java b/backend/src/test/java/com/moshimo/backend/domain/service/InvestmentSimulationServiceTest.java index a563885..6bc348d 100644 --- a/backend/src/test/java/com/moshimo/backend/domain/service/InvestmentSimulationServiceTest.java +++ b/backend/src/test/java/com/moshimo/backend/domain/service/InvestmentSimulationServiceTest.java @@ -119,13 +119,14 @@ void testCalculateCAGR_doubleInvestmentFiveYears_returnsCorrectResult() { .adjustedClose(new BigDecimal("200.00")) .build(); + when(assetService.getAssetEntityBySymbol("AAPL")).thenReturn(mockStock); when(assetService.getAssetEntityBySymbol("AAPL")).thenReturn(mockStock); when(assetPriceRepository.findByAssetIdAndDate(eq(1L), eq(startDate))) .thenReturn(Optional.of(startPrice)); when(assetPriceRepository.findByAssetIdAndDate(eq(1L), eq(endDate))) .thenReturn(Optional.of(endPrice)); - when(assetPriceRepository.findByAssetIdAndDateBetween( - eq(1L), any(LocalDate.class), any(LocalDate.class))) + when(assetPriceRepository.findByAssetIdsAndDateBetween( + anyList(), any(LocalDate.class), any(LocalDate.class))) .thenReturn(List.of(startPrice, endPrice)); // Act @@ -177,8 +178,8 @@ void testCalculateReturns_doubleInvestment_returns100Percent() { .thenReturn(Optional.of(startPrice)); when(assetPriceRepository.findByAssetIdAndDate(eq(1L), eq(endDate))) .thenReturn(Optional.of(endPrice)); - when(assetPriceRepository.findByAssetIdAndDateBetween( - eq(1L), any(LocalDate.class), any(LocalDate.class))) + when(assetPriceRepository.findByAssetIdsAndDateBetween( + anyList(), any(LocalDate.class), any(LocalDate.class))) .thenReturn(List.of(startPrice, endPrice)); // Act @@ -248,12 +249,9 @@ void testMultiStockPortfolio_twoStocks_combinesCorrectly() { lenient().when(assetPriceRepository.findByAssetIdAndDate(eq(2L), any(LocalDate.class))) .thenReturn(Optional.of(msftCurrent)); - lenient().when(assetPriceRepository.findByAssetIdAndDateBetween( - eq(1L), any(LocalDate.class), any(LocalDate.class))) - .thenReturn(List.of(purchasePrice, currentPrice)); - lenient().when(assetPriceRepository.findByAssetIdAndDateBetween( - eq(2L), any(LocalDate.class), any(LocalDate.class))) - .thenReturn(List.of(msftPurchase, msftCurrent)); + lenient().when(assetPriceRepository.findByAssetIdsAndDateBetween( + anyList(), any(LocalDate.class), any(LocalDate.class))) + .thenReturn(List.of(purchasePrice, currentPrice, msftPurchase, msftCurrent)); // Act SimulationResponse result = service.simulate(request); @@ -301,8 +299,8 @@ void testTimelineAggregation_dailyTimeframe_returnsAllPoints() { .filter(p -> p.getDate().equals(date)) .findFirst(); }); - when(assetPriceRepository.findByAssetIdAndDateBetween( - eq(1L), any(LocalDate.class), any(LocalDate.class))) + when(assetPriceRepository.findByAssetIdsAndDateBetween( + anyList(), any(LocalDate.class), any(LocalDate.class))) .thenReturn(priceData); // Act @@ -311,8 +309,6 @@ void testTimelineAggregation_dailyTimeframe_returnsAllPoints() { // Assert assertNotNull(result); assertNotNull(result.timeline()); - // Timeline may be empty if price data isn't properly linked, but result should exist - assertTrue(result.timeline() != null); } @Test @@ -335,8 +331,8 @@ void testZeroInvestment_returnsZeroValues() { when(assetService.getAssetEntityBySymbol("AAPL")).thenReturn(mockStock); when(assetPriceRepository.findByAssetIdAndDate(eq(1L), any(LocalDate.class))) .thenReturn(Optional.of(purchasePrice)); - when(assetPriceRepository.findByAssetIdAndDateBetween( - eq(1L), any(LocalDate.class), any(LocalDate.class))) + when(assetPriceRepository.findByAssetIdsAndDateBetween( + anyList(), any(LocalDate.class), any(LocalDate.class))) .thenReturn(List.of(purchasePrice)); // Act From ed5bbd5da926afbc2df93849e2370c6821ab5830 Mon Sep 17 00:00:00 2001 From: Lidizz Date: Wed, 25 Feb 2026 23:58:26 +0100 Subject: [PATCH 3/5] perf: batch duplicate detection and saves in AssetPriceUpdateScheduler - Add findDatesByAssetIdAndDateBetween() to AssetPriceRepository - Replace per-record existsByAssetIdAndDate() + save() with: 1. One query to fetch existing dates as a Set 2. In-memory filter against that Set 3. Single saveAll() for new prices - Reduces DB round-trips from 2N to 2 per asset --- .../repository/AssetPriceRepository.java | 13 ++++++++++ .../scheduler/AssetPriceUpdateScheduler.java | 25 +++++++++++++------ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/backend/src/main/java/com/moshimo/backend/domain/repository/AssetPriceRepository.java b/backend/src/main/java/com/moshimo/backend/domain/repository/AssetPriceRepository.java index 986cb2b..6f14f4c 100644 --- a/backend/src/main/java/com/moshimo/backend/domain/repository/AssetPriceRepository.java +++ b/backend/src/main/java/com/moshimo/backend/domain/repository/AssetPriceRepository.java @@ -164,6 +164,19 @@ Optional findNextAvailableDate( @Param("date") LocalDate date ); + /** + * Find all dates that already have price data for a given asset within a range. + * Used by the scheduler to skip duplicates without per-record queries. + */ + @Query("SELECT ap.date FROM AssetPrice ap " + + "WHERE ap.asset.id = :assetId " + + "AND ap.date BETWEEN :startDate AND :endDate") + List findDatesByAssetIdAndDateBetween( + @Param("assetId") Long assetId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + /** * Batch-fetch prices for multiple assets within a date range. * CRITICAL OPTIMIZATION: Replaces N individual queries with single batch query. diff --git a/backend/src/main/java/com/moshimo/backend/infrastructure/scheduler/AssetPriceUpdateScheduler.java b/backend/src/main/java/com/moshimo/backend/infrastructure/scheduler/AssetPriceUpdateScheduler.java index 59e7c43..271c51f 100644 --- a/backend/src/main/java/com/moshimo/backend/infrastructure/scheduler/AssetPriceUpdateScheduler.java +++ b/backend/src/main/java/com/moshimo/backend/infrastructure/scheduler/AssetPriceUpdateScheduler.java @@ -14,7 +14,10 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; +import java.util.Set; +import java.util.HashSet; /** * Scheduled service to update asset prices monthly on the first day of each month. @@ -78,10 +81,14 @@ public void updateMonthlyPrices() { continue; } - // Save new prices (skip duplicates) - int savedCount = 0; + // Batch-fetch existing dates to skip duplicates without per-record queries + Set existingDates = new HashSet<>( + assetPriceRepository.findDatesByAssetIdAndDateBetween( + asset.getId(), startDate, endDate)); + + List newPrices = new ArrayList<>(); for (HistoricalPrice price : prices) { - if (!assetPriceRepository.existsByAssetIdAndDate(asset.getId(), price.date())) { + if (!existingDates.contains(price.date())) { AssetPrice assetPrice = new AssetPrice(); assetPrice.setAsset(asset); assetPrice.setDate(price.date()); @@ -91,16 +98,18 @@ public void updateMonthlyPrices() { assetPrice.setClose(price.close()); assetPrice.setVolume(price.volume()); assetPrice.setAdjustedClose(price.adjustedClose()); - - assetPriceRepository.save(assetPrice); - savedCount++; + newPrices.add(assetPrice); } } - updatedPrices += savedCount; + if (!newPrices.isEmpty()) { + assetPriceRepository.saveAll(newPrices); + } + + updatedPrices += newPrices.size(); successCount++; - log.debug("Updated {} with {} new price records", asset.getSymbol(), savedCount); + log.debug("Updated {} with {} new price records", asset.getSymbol(), newPrices.size()); // Rate limiting: small delay between stocks to avoid hitting API limits Thread.sleep(100); From 303d6e6cb906b44d197fd5f514d5d5b52db1c977 Mon Sep 17 00:00:00 2001 From: Lidizz Date: Wed, 25 Feb 2026 23:59:16 +0100 Subject: [PATCH 4/5] fix: replace @Data with @Getter/@Setter on Asset entity - @Data generates equals/hashCode on ALL fields including mutable id, which breaks Set/Map behaviour when id is assigned after persist - Add manual equals/hashCode based on symbol (natural business key) - Keep @ToString and @Builder unchanged - Trim verbose Learning Notes from class Javadoc --- .../moshimo/backend/domain/model/Asset.java | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/backend/src/main/java/com/moshimo/backend/domain/model/Asset.java b/backend/src/main/java/com/moshimo/backend/domain/model/Asset.java index f8bb927..e7014fe 100644 --- a/backend/src/main/java/com/moshimo/backend/domain/model/Asset.java +++ b/backend/src/main/java/com/moshimo/backend/domain/model/Asset.java @@ -7,18 +7,10 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.Objects; /** * Asset Entity - Represents a tradeable asset (stock, ETF, index, or crypto). - * - * Learning Notes: - * - @Entity: Marks this class as a JPA entity (maps to 'asset' table) - * - @Table: Explicitly specifies table name and indexes - * - @Data: Lombok generates getters, setters, toString, equals, hashCode - * - @Builder: Lombok provides builder pattern for clean object construction - * - @NoArgsConstructor/@AllArgsConstructor: Required by JPA (no-arg) and Builder pattern - * - LocalDate: Java 8+ date type, maps to SQL DATE - * - @CreationTimestamp/@UpdateTimestamp: Hibernate automatically sets timestamps */ @Entity @Table(name = "asset", indexes = { @@ -27,7 +19,9 @@ @Index(name = "idx_asset_is_active", columnList = "is_active"), @Index(name = "idx_asset_asset_type", columnList = "asset_type") }) -@Data +@Getter +@Setter +@ToString @Builder @NoArgsConstructor @AllArgsConstructor @@ -119,4 +113,16 @@ public class Asset { @UpdateTimestamp @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Asset other)) return false; + return Objects.equals(symbol, other.symbol); + } + + @Override + public int hashCode() { + return Objects.hash(symbol); + } } \ No newline at end of file From 630d062b7767e97c98a3a2aa92d3b9c2827914b6 Mon Sep 17 00:00:00 2001 From: Lidizz Date: Thu, 26 Feb 2026 00:00:13 +0100 Subject: [PATCH 5/5] fix: synchronize rate limiter in TwelveDataClient - Add rateLimiterLock object and wrap enforceRateLimit() in synchronized block - Prevents race condition on currentMinuteStart/requestsInCurrentMinute if called from multiple threads (e.g. scheduler + manual trigger) - Clean up emoji characters from log messages --- .../infrastructure/api/TwelveDataClient.java | 66 ++++++++----------- 1 file changed, 27 insertions(+), 39 deletions(-) diff --git a/backend/src/main/java/com/moshimo/backend/infrastructure/api/TwelveDataClient.java b/backend/src/main/java/com/moshimo/backend/infrastructure/api/TwelveDataClient.java index f44623e..0df94b9 100644 --- a/backend/src/main/java/com/moshimo/backend/infrastructure/api/TwelveDataClient.java +++ b/backend/src/main/java/com/moshimo/backend/infrastructure/api/TwelveDataClient.java @@ -49,6 +49,7 @@ public class TwelveDataClient implements MarketDataProvider { // Rate limiting: 8 requests per minute - batch strategy // Make 8 requests as fast as possible, then wait for next minute + private final Object rateLimiterLock = new Object(); private long currentMinuteStart = 0; private int requestsInCurrentMinute = 0; private static final int MAX_REQUESTS_PER_MINUTE = 8; @@ -289,47 +290,34 @@ private List fetchChunk(String symbol, LocalDate startDate, Loc } } - /** - * Smart rate limiting: Batch 8 requests per minute, then wait. - * - * Strategy: - * 1. Make up to 8 requests as fast as possible (API calls are quick ~1-2s each) - * 2. When we hit 8 requests in current minute, wait until next minute starts - * 3. Reset counter and continue - * - * This is MUCH faster than spacing requests 7-8 seconds apart. - * Example: 8 requests take ~15 seconds, then wait 45s = 1 minute per 8 requests - * vs old: 8 requests * 8s = 64 seconds per 8 requests - */ + /** Rate limit: batch up to 8 requests per minute, then wait until next minute boundary. */ private void enforceRateLimit() throws InterruptedException { - long now = System.currentTimeMillis(); - long currentMinute = now / 60000; // Minute boundary (0, 1, 2, ...) - - // Reset counter if we've moved to a new minute - if (currentMinute != currentMinuteStart) { - currentMinuteStart = currentMinute; - requestsInCurrentMinute = 0; - log.info("✓ Rate limit: New minute started, counter reset"); - } - - // Check if we've hit the per-minute limit - if (requestsInCurrentMinute >= MAX_REQUESTS_PER_MINUTE) { - long nextMinuteStart = (currentMinuteStart + 1) * 60000; - long waitTime = nextMinuteStart - now + 500; // +500ms buffer - log.warn("⏸ Rate limit: Hit {}/{} requests, waiting {}s for next minute...", - requestsInCurrentMinute, MAX_REQUESTS_PER_MINUTE, waitTime / 1000); - Thread.sleep(waitTime); - - // Reset for new minute - currentMinuteStart = System.currentTimeMillis() / 60000; - requestsInCurrentMinute = 0; - log.info("✓ Rate limit: New minute started, resuming..."); + synchronized (rateLimiterLock) { + long now = System.currentTimeMillis(); + long currentMinute = now / 60000; + + if (currentMinute != currentMinuteStart) { + currentMinuteStart = currentMinute; + requestsInCurrentMinute = 0; + log.info("Rate limit: New minute started, counter reset"); + } + + if (requestsInCurrentMinute >= MAX_REQUESTS_PER_MINUTE) { + long nextMinuteStart = (currentMinuteStart + 1) * 60000; + long waitTime = nextMinuteStart - now + 500; + log.warn("Rate limit: Hit {}/{} requests, waiting {}s for next minute...", + requestsInCurrentMinute, MAX_REQUESTS_PER_MINUTE, waitTime / 1000); + Thread.sleep(waitTime); + + currentMinuteStart = System.currentTimeMillis() / 60000; + requestsInCurrentMinute = 0; + log.info("Rate limit: New minute started, resuming..."); + } + + requestsInCurrentMinute++; + log.debug("Request {}/{} in current minute", + requestsInCurrentMinute, MAX_REQUESTS_PER_MINUTE); } - - // Increment counter (no delay between requests!) - requestsInCurrentMinute++; - log.debug("⚡ Request {}/{} in current minute", - requestsInCurrentMinute, MAX_REQUESTS_PER_MINUTE); } /**