diff --git a/lib/app/features/tokenized_communities/providers/chart_calculation_data_provider.r.dart b/lib/app/features/tokenized_communities/providers/chart_calculation_data_provider.r.dart index bc6ded6b63..58a3bec315 100644 --- a/lib/app/features/tokenized_communities/providers/chart_calculation_data_provider.r.dart +++ b/lib/app/features/tokenized_communities/providers/chart_calculation_data_provider.r.dart @@ -36,6 +36,7 @@ class ChartCalculationData { ChartCalculationData? chartCalculationData( Ref ref, { required List candles, + required ChartTimeRange selectedRange, }) { if (candles.isEmpty) { return null; @@ -63,16 +64,31 @@ ChartCalculationData? chartCalculationData( .map((entry) => FlSpot(entry.key.toDouble(), entry.value.close)) .toList(); - // Build bottom labels from candle times (max 8 evenly spaced) - final bottomLabelsCount = math.min(8, candles.length); + // Build bottom labels with consistent spacing + // Calculate labels based on scale: 8 labels per screen (35 candles) + // When scale = 1.0 (< 35 candles), limit to actual candle count + // When scale > 1.0, use formula: (totalCandles / 35) * 8 + const maxPointsPerScreen = 35; + const labelsPerScreen = 8; + + final scale = candles.length < maxPointsPerScreen ? 1.0 : candles.length / maxPointsPerScreen; + final calculatedLabels = (scale * labelsPerScreen).round(); + final bottomLabelsCount = candles.length <= 1 + ? candles.length + : scale == 1.0 + ? math.min(candles.length, labelsPerScreen) // Limit to actual candles when scale = 1 + : math.max(1, calculatedLabels); // Use calculated value when scale > 1 + final indexToLabel = {}; double xAxisStep = 1; if (bottomLabelsCount > 0 && candles.length > 1) { - xAxisStep = (candles.length - 1) / (bottomLabelsCount - 1); + // Map labels proportionally to actual candles for (var i = 0; i < bottomLabelsCount; i++) { - final idx = (i * xAxisStep).round().clamp(0, candles.length - 1); - indexToLabel[idx] = formatChartTime(candles[idx].date); + final progress = i / (bottomLabelsCount - 1); + final idx = (progress * (candles.length - 1)).round().clamp(0, candles.length - 1); + indexToLabel[idx] = formatChartAxisLabel(candles[idx].date, selectedRange); } + xAxisStep = (candles.length - 1) / (bottomLabelsCount - 1); } final maxX = (candles.length - 1).toDouble(); diff --git a/lib/app/features/tokenized_communities/providers/chart_processed_data_provider.r.dart b/lib/app/features/tokenized_communities/providers/chart_processed_data_provider.r.dart index 522446403f..e6e84d4a37 100644 --- a/lib/app/features/tokenized_communities/providers/chart_processed_data_provider.r.dart +++ b/lib/app/features/tokenized_communities/providers/chart_processed_data_provider.r.dart @@ -2,6 +2,8 @@ import 'package:decimal/decimal.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ion/app/extensions/num.dart'; +import 'package:ion/app/features/tokenized_communities/utils/chart_candles_normalizer.dart'; import 'package:ion/app/features/tokenized_communities/utils/price_change_calculator.dart'; import 'package:ion/app/features/tokenized_communities/views/components/chart.dart'; import 'package:ion_token_analytics/ion_token_analytics.dart'; @@ -32,11 +34,14 @@ ChartProcessedData chartProcessedData( final chartCandles = _mapOhlcvToChartCandles(candles); final isEmpty = chartCandles.isEmpty; + final normalizedCandles = + chartCandles.length > 1 ? normalizeCandles(chartCandles, selectedRange) : chartCandles; + final candlesToShow = isEmpty - ? _buildFlatCandles(price) - : chartCandles.length == 1 - ? _expandSingleCandleToFlatLine(chartCandles.first, selectedRange) - : chartCandles; + ? _buildFlatCandles(price, selectedRange) + : normalizedCandles.length == 1 + ? _expandSingleCandleToFlatLine(normalizedCandles.first, selectedRange) + : normalizedCandles; final changePercent = isEmpty ? 0.0 : calculatePriceChangePercent(candles, selectedRange.duration); @@ -58,23 +63,21 @@ List _mapOhlcvToChartCandles(List source) { low: candle.low, close: candle.close, price: Decimal.parse(candle.close.toString()), - date: DateTime.fromMillisecondsSinceEpoch( - candle.timestamp ~/ 1000, // timestamp is in microseconds - isUtc: true, - ), + date: candle.timestamp.toDateTime, ), ) .toList(); } // Builds flat candles for empty state (all candles at same price). -List _buildFlatCandles(Decimal price) { +List _buildFlatCandles(Decimal price, ChartTimeRange selectedRange) { final now = DateTime.now(); - const count = 20; + final interval = selectedRange.duration; + const count = 35; final value = double.tryParse(price.toString()) ?? 0; return List.generate(count, (index) { - final date = now.subtract(Duration(minutes: (count - index) * 15)); + final date = now.subtract(interval * (count - index)); return ChartCandle( open: value, high: value, diff --git a/lib/app/features/tokenized_communities/providers/token_olhcv_candles_provider.r.dart b/lib/app/features/tokenized_communities/providers/token_olhcv_candles_provider.r.dart index 6f2d209cb5..2eed3a5561 100644 --- a/lib/app/features/tokenized_communities/providers/token_olhcv_candles_provider.r.dart +++ b/lib/app/features/tokenized_communities/providers/token_olhcv_candles_provider.r.dart @@ -14,6 +14,16 @@ Stream> tokenOhlcvCandles( String interval, ) async* { final client = await ref.watch(ionTokenAnalyticsClientProvider.future); + + // 1. Load initial candles + final initialCandles = await client.communityTokens.loadOhlcvCandles( + externalAddress: externalAddress, + interval: interval, + ); + + yield initialCandles; + + // 2. Subscribe to realtime updates final subscription = await client.communityTokens.subscribeToOhlcvCandles( ionConnectAddress: externalAddress, interval: interval, @@ -21,11 +31,10 @@ Stream> tokenOhlcvCandles( ref.onDispose(subscription.close); - final currentCandles = []; - - // Maximum candles to keep: 50 is sufficient for chart display + final currentCandles = List.from(initialCandles); const maxCandles = 50; + // 3. Process realtime updates await for (final batch in subscription.stream) { if (batch.isEmpty) { yield currentCandles; @@ -44,6 +53,9 @@ Stream> tokenOhlcvCandles( } } + // Defensive: Sort before removing to ensure we remove actual oldest + currentCandles.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + // Remove oldest candles if exceeding max if (currentCandles.length > maxCandles) { final amountToRemove = currentCandles.length - maxCandles; diff --git a/lib/app/features/tokenized_communities/utils/chart_candles_normalizer.dart b/lib/app/features/tokenized_communities/utils/chart_candles_normalizer.dart new file mode 100644 index 0000000000..ab01d27e0c --- /dev/null +++ b/lib/app/features/tokenized_communities/utils/chart_candles_normalizer.dart @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:decimal/decimal.dart'; +import 'package:ion/app/features/tokenized_communities/views/components/chart.dart'; + +// Normalizes candles by filling gaps based on selectedRange interval. +// Example: [10:00, 15:00] with 1h interval → [10:00, 11:00, 12:00, 13:00, 14:00, 15:00] +// Also fills gap from last candle to "now" (works for both single and multiple candles) +List normalizeCandles( + List candles, + ChartTimeRange selectedRange, +) { + if (candles.isEmpty) return candles; + + // Sort by date to ensure correct order + final sorted = List.from(candles)..sort((a, b) => a.date.compareTo(b.date)); + + final interval = selectedRange.duration; + final normalized = []; + + for (var i = 0; i < sorted.length; i++) { + normalized.add(sorted[i]); + + // Check gap before next candle + if (i < sorted.length - 1) { + final current = sorted[i]; + final next = sorted[i + 1]; + final gap = next.date.difference(current.date); + + // Fill gap if it's larger than interval + if (gap > interval) { + var fillDate = current.date.add(interval); + while (fillDate.isBefore(next.date)) { + // Forward-fill: use previous candle's close price + final fillPrice = Decimal.parse(current.close.toStringAsFixed(4)); + normalized.add( + ChartCandle( + open: current.close, + high: current.close, + low: current.close, + close: current.close, + price: fillPrice, + date: fillDate, + ), + ); + fillDate = fillDate.add(interval); + } + } + } + } + + // Fill gap from last candle to "now" + if (normalized.isNotEmpty) { + final lastCandle = normalized.last; + final now = DateTime.now(); + + var fillDate = lastCandle.date.add(interval); + while (!fillDate.isAfter(now)) { + normalized.add( + ChartCandle( + open: lastCandle.close, + high: lastCandle.close, + low: lastCandle.close, + close: lastCandle.close, + price: Decimal.parse(lastCandle.close.toStringAsFixed(4)), + date: fillDate, + ), + ); + fillDate = fillDate.add(interval); + } + } + + return normalized; +} diff --git a/lib/app/features/tokenized_communities/utils/formatters.dart b/lib/app/features/tokenized_communities/utils/formatters.dart index 084cccdf0f..d9cba9cb7f 100644 --- a/lib/app/features/tokenized_communities/utils/formatters.dart +++ b/lib/app/features/tokenized_communities/utils/formatters.dart @@ -1,6 +1,7 @@ // SPDX-License-Identifier: ice License 1.0 import 'package:intl/intl.dart'; +import 'package:ion/app/features/tokenized_communities/views/components/chart.dart'; import 'package:ion/app/utils/num.dart'; String formatPercent(double p) { @@ -124,3 +125,9 @@ String formatChartDate(DateTime date) { String formatChartTime(DateTime date) { return DateFormat('H:mm').format(date); } + +// Formats a DateTime for chart axis labels based on time range. +// Uses dd/MM for 1d interval, H:mm for all others. +String formatChartAxisLabel(DateTime date, ChartTimeRange range) { + return range == ChartTimeRange.d1 ? formatChartDate(date) : formatChartTime(date); +} diff --git a/lib/app/features/tokenized_communities/views/components/chart.dart b/lib/app/features/tokenized_communities/views/components/chart.dart index ec33908ef9..fcf195dff2 100644 --- a/lib/app/features/tokenized_communities/views/components/chart.dart +++ b/lib/app/features/tokenized_communities/views/components/chart.dart @@ -149,6 +149,7 @@ class _ChartContent extends StatelessWidget { aspectRatio: 1.7, child: TokenAreaLineChart( candles: candles, + selectedRange: selectedRange, isLoading: isLoading, ), ), diff --git a/lib/app/features/tokenized_communities/views/components/token_area_line_chart.dart b/lib/app/features/tokenized_communities/views/components/token_area_line_chart.dart index 51e6a0de8d..9ce55bb67f 100644 --- a/lib/app/features/tokenized_communities/views/components/token_area_line_chart.dart +++ b/lib/app/features/tokenized_communities/views/components/token_area_line_chart.dart @@ -12,11 +12,13 @@ import 'package:ion/app/utils/string.dart'; class TokenAreaLineChart extends HookConsumerWidget { const TokenAreaLineChart({ required this.candles, + required this.selectedRange, this.isLoading = false, super.key, }); final List candles; + final ChartTimeRange selectedRange; final bool isLoading; double _calculateReservedSize(double maxY, TextStyle style) { @@ -24,13 +26,23 @@ class TokenAreaLineChart extends HookConsumerWidget { return calculateTextWidth(maxY.toStringAsFixed(4), style) + chartAnnotationPadding.s; } + double _calculateInitialScale(int dataPointCount) { + const maxPointsPerScreen = 35; + + if (dataPointCount < maxPointsPerScreen) { + return 1; + } + + return dataPointCount / maxPointsPerScreen; + } + @override Widget build(BuildContext context, WidgetRef ref) { final colors = context.theme.appColors; final styles = context.theme.appTextThemes; final calcData = ref.watch( - chartCalculationDataProvider(candles: candles), + chartCalculationDataProvider(candles: candles, selectedRange: selectedRange), ); if (calcData == null) { @@ -43,16 +55,56 @@ class TokenAreaLineChart extends HookConsumerWidget { [calcData.chartMaxY, yAxisLabelTextStyle], ); - // UI logic stays here + final initialScale = useMemoized( + () => _calculateInitialScale(candles.length), + [candles.length], + ); + + final chartKey = useMemoized(GlobalKey.new); + final transformationController = useTransformationController( + initialValue: Matrix4.identity()..scaleByDouble(initialScale, initialScale, 1, 1), + ); + + useEffect( + () { + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = chartKey.currentContext; + if (ctx == null) return; + + final box = ctx.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + + final totalWidth = box.size.width; + final drawableWidth = totalWidth - reservedSize; + final translateX = -drawableWidth * (initialScale - 1); + + transformationController.value = Matrix4.identity() + ..translateByDouble(translateX, 0, 0, 1) + ..scaleByDouble(initialScale, initialScale, 1, 1); + }); + + return null; + }, + [initialScale, reservedSize], + ); + final lineColor = isLoading ? colors.tertiaryText.withValues(alpha: 0.4) : colors.primaryAccent; + final canInteract = !isLoading; return LineChart( + key: chartKey, + transformationConfig: FlTransformationConfig( + scaleAxis: FlScaleAxis.horizontal, + panEnabled: canInteract, + scaleEnabled: false, + transformationController: transformationController, + ), LineChartData( minY: calcData.chartMinY, maxY: calcData.chartMaxY, minX: 0, maxX: calcData.maxX, - clipData: const FlClipData.all(), // Clip line to chart bounds + clipData: const FlClipData.all(), borderData: FlBorderData(show: false), gridData: const FlGridData( drawHorizontalLine: false, @@ -103,37 +155,35 @@ class TokenAreaLineChart extends HookConsumerWidget { ), ), ), - lineTouchData: !isLoading - ? LineTouchData( - touchTooltipData: LineTouchTooltipData( - getTooltipColor: (_) => colors.primaryBackground, - getTooltipItems: (touchedSpots) { - return touchedSpots - .map( - (spot) => LineTooltipItem( - spot.y.toStringAsFixed(4), - styles.caption2.copyWith(color: colors.primaryText), - ), - ) - .toList(); - }, + lineTouchData: LineTouchData( + enabled: canInteract, + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => colors.primaryBackground, + getTooltipItems: (touchedSpots) { + return touchedSpots + .map( + (spot) => LineTooltipItem( + spot.y.toStringAsFixed(4), + styles.caption2.copyWith(color: colors.primaryText), + ), + ) + .toList(); + }, + ), + getTouchedSpotIndicator: (barData, spotIndexes) { + return spotIndexes.map((index) { + return TouchedSpotIndicatorData( + FlLine( + color: colors.primaryAccent.withValues(alpha: 0.3), + strokeWidth: 0.5.s, ), - getTouchedSpotIndicator: (barData, spotIndexes) { - return spotIndexes.map((index) { - return TouchedSpotIndicatorData( - FlLine( - color: colors.primaryAccent.withValues(alpha: 0.3), - strokeWidth: 0.5.s, - ), - const FlDotData(), - ); - }).toList(); - }, - getTouchLineStart: (_, __) => 0, - getTouchLineEnd: (_, __) => double.infinity, - ) - // Disable interactions for loading/empty states. - : const LineTouchData(enabled: false), + const FlDotData(), + ); + }).toList(); + }, + getTouchLineStart: (_, __) => 0, + getTouchLineEnd: (_, __) => double.infinity, + ), lineBarsData: [ LineChartBarData( spots: calcData.spots, diff --git a/packages/ion_token_analytics/lib/src/community_tokens/community_tokens_service.dart b/packages/ion_token_analytics/lib/src/community_tokens/community_tokens_service.dart index ca47cfaaa2..2865e83b8a 100644 --- a/packages/ion_token_analytics/lib/src/community_tokens/community_tokens_service.dart +++ b/packages/ion_token_analytics/lib/src/community_tokens/community_tokens_service.dart @@ -81,6 +81,20 @@ class IonCommunityTokensService { return _tokenInfoRepository.subscribeToTokenInfo(externalAddress); } + Future> loadOhlcvCandles({ + required String externalAddress, + required String interval, + int limit = 60, + int offset = 0, + }) { + return _ohlcvCandlesRepository.loadOhlcvCandles( + externalAddress: externalAddress, + interval: interval, + limit: limit, + offset: offset, + ); + } + Future>> subscribeToOhlcvCandles({ required String ionConnectAddress, required String interval, diff --git a/packages/ion_token_analytics/lib/src/community_tokens/ohlcv_candles/ohlcv_candles_repository.dart b/packages/ion_token_analytics/lib/src/community_tokens/ohlcv_candles/ohlcv_candles_repository.dart index 2e55f1a935..f4687efd46 100644 --- a/packages/ion_token_analytics/lib/src/community_tokens/ohlcv_candles/ohlcv_candles_repository.dart +++ b/packages/ion_token_analytics/lib/src/community_tokens/ohlcv_candles/ohlcv_candles_repository.dart @@ -4,6 +4,13 @@ import 'package:ion_token_analytics/src/community_tokens/ohlcv_candles/models/oh import 'package:ion_token_analytics/src/core/network_client.dart'; abstract class OhlcvCandlesRepository { + Future> loadOhlcvCandles({ + required String externalAddress, + required String interval, + int limit = 60, + int offset = 0, + }); + Future>> subscribeToOhlcvCandles({ required String ionConnectAddress, required String interval, diff --git a/packages/ion_token_analytics/lib/src/community_tokens/ohlcv_candles/ohlcv_candles_repository_impl.dart b/packages/ion_token_analytics/lib/src/community_tokens/ohlcv_candles/ohlcv_candles_repository_impl.dart index 57d4a6c020..205cfd8e82 100644 --- a/packages/ion_token_analytics/lib/src/community_tokens/ohlcv_candles/ohlcv_candles_repository_impl.dart +++ b/packages/ion_token_analytics/lib/src/community_tokens/ohlcv_candles/ohlcv_candles_repository_impl.dart @@ -9,6 +9,21 @@ class OhlcvCandlesRepositoryImpl implements OhlcvCandlesRepository { final NetworkClient _client; + @override + Future> loadOhlcvCandles({ + required String externalAddress, + required String interval, + int limit = 60, + int offset = 0, + }) async { + final response = await _client.get>( + '/v1/community-tokens/$externalAddress/ohlcv', + queryParameters: {'interval': interval, 'limit': limit, 'offset': offset}, + ); + + return response.map((json) => OhlcvCandle.fromJson(json as Map)).toList(); + } + @override Future>> subscribeToOhlcvCandles({ required String ionConnectAddress, diff --git a/test/app/features/tokenized_communities/utils/chart_candles_normalizer_test.dart b/test/app/features/tokenized_communities/utils/chart_candles_normalizer_test.dart new file mode 100644 index 0000000000..ea23c8994f --- /dev/null +++ b/test/app/features/tokenized_communities/utils/chart_candles_normalizer_test.dart @@ -0,0 +1,370 @@ +// SPDX-License-Identifier: ice License 1.0 + +import 'package:decimal/decimal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ion/app/features/tokenized_communities/utils/chart_candles_normalizer.dart'; +import 'package:ion/app/features/tokenized_communities/views/components/chart.dart'; + +void main() { + group('normalizeCandles', () { + final baseDate = DateTime(2024, 1, 1, 10); + + const interval15min = Duration(minutes: 15); + const interval1h = Duration(hours: 1); + const interval2h = Duration(hours: 2); + const interval24h = Duration(hours: 24); + + final interval1h15min = interval1h + interval15min; + final interval1h45min = interval2h - interval15min; + final interval2h30min = interval2h + const Duration(minutes: 30); + final interval23h45min = interval24h - interval15min; + + const price1 = 1.0; + const price2 = 2.0; + const price3 = 3.0; + + void expectCandle( + ChartCandle candle, { + required DateTime date, + required double close, + double? open, + double? high, + double? low, + Decimal? price, + }) { + expect(candle.date, date); + expect(candle.close, close); + if (open != null) expect(candle.open, open); + if (high != null) expect(candle.high, high); + if (low != null) expect(candle.low, low); + if (price != null) expect(candle.price, price); + } + + void expectCandleAtDate( + List candles, + DateTime date, { + required double close, + }) { + final candle = candles.firstWhere((c) => c.date == date); + expect(candle.close, close); + } + + ChartCandle createCandle({ + required DateTime date, + double price = 1.0, + double? open, + double? high, + double? low, + double? close, + }) { + final closePrice = close ?? price; + return ChartCandle( + open: open ?? price, + high: high ?? price, + low: low ?? price, + close: closePrice, + price: Decimal.parse(closePrice.toStringAsFixed(4)), + date: date, + ); + } + + group('edge cases', () { + test('returns empty list when input is empty', () { + final result = normalizeCandles([], ChartTimeRange.m15); + expect(result, isEmpty); + }); + + test('fills single candle from past to now', () { + // Create a candle from 1 hour ago + final pastDate = DateTime.now().subtract(const Duration(hours: 1)); + final candle = createCandle(date: pastDate); + final result = normalizeCandles([candle], ChartTimeRange.m15); + + // Should have more than 1 candle (original + filled ones) + expect(result.length, greaterThan(1)); + // First candle should be the original + expectCandle(result[0], date: pastDate, close: price1); + // Last candle should be close to "now" (within one interval) + final lastCandle = result.last; + final now = DateTime.now(); + final gapToNow = now.difference(lastCandle.date); + expect(gapToNow, lessThan(interval15min)); + // All filled candles should use the original close price + for (var i = 1; i < result.length; i++) { + expect(result[i].close, price1); + } + }); + }); + + group('no gaps', () { + test('returns two candles with fill to now when no gap exists', () { + final candles = [ + // ignore: avoid_redundant_argument_values + createCandle(date: baseDate, price: price1), + createCandle(date: baseDate.add(interval15min), price: price2), + ]; + + final result = normalizeCandles(candles, ChartTimeRange.m15); + // Should have at least 2 candles (original) + filled to now + expect(result.length, greaterThanOrEqualTo(2)); + expectCandle(result[0], date: baseDate, close: price1); + expectCandle(result[1], date: baseDate.add(interval15min), close: price2); + // Last candle should be close to "now" + final lastCandle = result.last; + final now = DateTime.now(); + final gapToNow = now.difference(lastCandle.date); + expect(gapToNow, lessThan(interval15min)); + }); + + test('does not fill gap when gap equals interval but fills to now', () { + final candles = [ + createCandle(date: baseDate), + createCandle(date: baseDate.add(interval15min)), + ]; + + final result = normalizeCandles(candles, ChartTimeRange.m15); + // Should have at least 2 candles (original) + filled to now + expect(result.length, greaterThanOrEqualTo(2)); + expectCandle(result[0], date: baseDate, close: price1); + expectCandle(result[1], date: baseDate.add(interval15min), close: price1); + }); + + test('does not fill gap when gap is smaller than interval but fills to now', () { + final candles = [ + createCandle(date: baseDate), + createCandle(date: baseDate.add(const Duration(minutes: 10))), + ]; + + final result = normalizeCandles(candles, ChartTimeRange.m15); + // Should have at least 2 candles (original) + filled to now + expect(result.length, greaterThanOrEqualTo(2)); + }); + }); + + group('single gap filling', () { + test('fills single gap correctly and fills to now', () { + final candles = [ + // ignore: avoid_redundant_argument_values + createCandle(date: baseDate, price: price1), + createCandle(date: baseDate.add(interval2h), price: price2), + ]; + + final result = normalizeCandles(candles, ChartTimeRange.m15); + + // Should have at least 9 candles (2 original + 7 filled between) + filled to now + expect(result.length, greaterThanOrEqualTo(9)); + expectCandle(result[0], date: baseDate, close: price1); + expectCandleAtDate(result, baseDate.add(interval15min), close: price1); + expectCandleAtDate(result, baseDate.add(interval1h45min), close: price1); + // Check that the second original candle exists + expectCandleAtDate(result, baseDate.add(interval2h), close: price2); + // Last candle should be close to "now" + final lastCandle = result.last; + final now = DateTime.now(); + final gapToNow = now.difference(lastCandle.date); + expect(gapToNow, lessThan(interval15min)); + }); + + test('handles very large gaps correctly and fills to now', () { + final candles = [ + createCandle(date: baseDate), + createCandle(date: baseDate.add(interval24h), price: price2), + ]; + + final result = normalizeCandles(candles, ChartTimeRange.m15); + + // Should have at least 97 candles (2 original + 95 filled between) + filled to now + expect(result.length, greaterThanOrEqualTo(97)); + expectCandle(result[0], date: baseDate, close: price1); + expectCandleAtDate(result, baseDate.add(interval15min), close: price1); + expectCandleAtDate(result, baseDate.add(interval2h30min), close: price1); + expectCandleAtDate(result, baseDate.add(interval23h45min), close: price1); + // Check that the second original candle exists + expectCandleAtDate(result, baseDate.add(interval24h), close: price2); + // Last candle should be close to "now" + final lastCandle = result.last; + final now = DateTime.now(); + final gapToNow = now.difference(lastCandle.date); + expect(gapToNow, lessThan(interval15min)); + }); + }); + + group('multiple gaps', () { + test('fills multiple gaps correctly and fills to now', () { + final candles = [ + // ignore: avoid_redundant_argument_values + createCandle(date: baseDate, price: price1), + createCandle(date: baseDate.add(interval1h), price: price2), + createCandle(date: baseDate.add(interval2h30min), price: price3), + ]; + + final result = normalizeCandles(candles, ChartTimeRange.m15); + + // Should have at least 11 candles (3 original + 8 filled between) + filled to now + expect(result.length, greaterThanOrEqualTo(11)); + expectCandle(result[0], date: baseDate, close: price1); + expectCandleAtDate(result, baseDate.add(interval15min), close: price1); + expectCandleAtDate(result, baseDate.add(interval1h), close: price2); + expectCandleAtDate(result, baseDate.add(interval1h15min), close: price2); + // Check that the last original candle exists + expectCandleAtDate(result, baseDate.add(interval2h30min), close: price3); + // Last candle should be close to "now" + final lastCandle = result.last; + final now = DateTime.now(); + final gapToNow = now.difference(lastCandle.date); + expect(gapToNow, lessThan(interval15min)); + }); + }); + + group('data integrity', () { + test('sorts unsorted input before normalizing and fills to now', () { + final candles = [ + createCandle(date: baseDate.add(interval15min), price: price2), + // ignore: avoid_redundant_argument_values + createCandle(date: baseDate, price: price1), + ]; + + final result = normalizeCandles(candles, ChartTimeRange.m15); + + // Should have at least 2 candles (original) + filled to now + expect(result.length, greaterThanOrEqualTo(2)); + expectCandle(result[0], date: baseDate, close: price1); + expectCandle(result[1], date: baseDate.add(interval15min), close: price2); + // Last candle should be close to "now" + final lastCandle = result.last; + final now = DateTime.now(); + final gapToNow = now.difference(lastCandle.date); + expect(gapToNow, lessThan(interval15min)); + }); + + test('preserves original candle properties', () { + const openValue = 1.5; + const highValue = 2.0; + const lowValue = 1.0; + const closeValue = 1.8; + final originalCandle = createCandle( + date: baseDate, + open: openValue, + high: highValue, + low: lowValue, + close: closeValue, + ); + final candles = [ + originalCandle, + createCandle(date: baseDate.add(interval1h), price: price2), + ]; + + final result = normalizeCandles(candles, ChartTimeRange.m15); + + expectCandle( + result[0], + date: baseDate, + close: closeValue, + open: openValue, + high: highValue, + low: lowValue, + price: Decimal.parse('1.8'), + ); + }); + + test('filled candles use close price from previous candle', () { + const fillPrice = 1.2345; + final candles = [ + createCandle( + date: baseDate, + open: price1, + high: 1.5, + low: 0.9, + close: fillPrice, + ), + createCandle(date: baseDate.add(interval1h), price: price2), + ]; + + final result = normalizeCandles(candles, ChartTimeRange.m15); + + expectCandle( + result[1], + date: baseDate.add(interval15min), + close: fillPrice, + open: fillPrice, + high: fillPrice, + low: fillPrice, + price: Decimal.parse('1.2345'), + ); + }); + }); + + group('different intervals', () { + test('works with different time ranges and fills to now', () { + final candles = [ + createCandle(date: baseDate), + createCandle(date: baseDate.add(const Duration(hours: 3))), + ]; + + final result1h = normalizeCandles(candles, ChartTimeRange.h1); + // Should have at least 4 candles (2 original + 2 filled between) + filled to now + expect(result1h.length, greaterThanOrEqualTo(4)); + + final result15m = normalizeCandles(candles, ChartTimeRange.m15); + // Should have at least 13 candles (2 original + 11 filled between) + filled to now + expect(result15m.length, greaterThanOrEqualTo(13)); + }); + }); + + group('fill to now', () { + test('fills gap from last candle to now for multiple candles', () { + // Create candles ending 2 hours ago + final twoHoursAgo = DateTime.now().subtract(const Duration(hours: 2)); + final candles = [ + // ignore: avoid_redundant_argument_values + createCandle(date: twoHoursAgo.subtract(interval1h), price: price1), + createCandle(date: twoHoursAgo, price: price2), + ]; + + final result = normalizeCandles(candles, ChartTimeRange.m15); + + // Should have original candles + filled ones to "now" + expect(result.length, greaterThan(2)); + // First candle should be original + expectCandle(result[0], date: twoHoursAgo.subtract(interval1h), close: price1); + // Second candle should be original + final secondCandle = result.firstWhere((c) => c.date == twoHoursAgo); + expect(secondCandle.close, price2); + // Last candle should be close to "now" + final lastCandle = result.last; + final now = DateTime.now(); + final gapToNow = now.difference(lastCandle.date); + expect(gapToNow, lessThan(interval15min)); + }); + + test('does not fill if last candle is very recent', () { + // Create a candle just 5 minutes ago (less than 15min interval) + final recentDate = DateTime.now().subtract(const Duration(minutes: 5)); + final candle = createCandle(date: recentDate); + final result = normalizeCandles([candle], ChartTimeRange.m15); + + // Should only have the original candle (gap < interval) + expect(result.length, 1); + expectCandle(result[0], date: recentDate, close: price1); + }); + + test('fills single candle from yesterday to now', () { + // Create a candle from yesterday + final yesterday = DateTime.now().subtract(const Duration(days: 1)); + // ignore: avoid_redundant_argument_values + final candle = createCandle(date: yesterday, price: price1); + final result = normalizeCandles([candle], ChartTimeRange.h1); + + // Should have many candles (original + filled ones) + expect(result.length, greaterThan(20)); // At least 24 hours worth + // First candle should be original + expectCandle(result[0], date: yesterday, close: price1); + // Last candle should be close to "now" + final lastCandle = result.last; + final now = DateTime.now(); + final gapToNow = now.difference(lastCandle.date); + expect(gapToNow, lessThan(interval1h)); + }); + }); + }); +}