diff --git a/backend/services/trading-service/src/api/controllers/price.controller.ts b/backend/services/trading-service/src/api/controllers/price.controller.ts index ad2241af..71b85dfc 100644 --- a/backend/services/trading-service/src/api/controllers/price.controller.ts +++ b/backend/services/trading-service/src/api/controllers/price.controller.ts @@ -43,4 +43,22 @@ export class PriceController { limit ?? 1440, ); } + + @Get('klines') + @Public() + @ApiOperation({ summary: '获取K线数据(OHLC格式)' }) + @ApiQuery({ + name: 'period', + required: false, + type: String, + description: 'K线周期: 1m, 5m, 15m, 30m, 1h, 4h, 1d', + example: '1h', + }) + @ApiQuery({ name: 'limit', required: false, type: Number, description: '返回数量,默认100' }) + async getKlines( + @Query('period') period: string = '1h', + @Query('limit') limit: number = 100, + ) { + return this.priceService.getKlines(period, Math.min(limit, 500)); + } } diff --git a/backend/services/trading-service/src/application/services/price.service.ts b/backend/services/trading-service/src/application/services/price.service.ts index 371da09f..449e0f64 100644 --- a/backend/services/trading-service/src/application/services/price.service.ts +++ b/backend/services/trading-service/src/application/services/price.service.ts @@ -226,4 +226,157 @@ export class PriceService { snapshotTime: snapshot.snapshotTime, }; } + + /** + * 获取K线数据(OHLC格式) + * 将价格快照按周期聚合为K线数据 + * + * @param period K线周期: 1m, 5m, 15m, 30m, 1h, 4h, 1d + * @param limit 返回数量 + */ + async getKlines( + period: string = '1h', + limit: number = 100, + ): Promise< + Array<{ + time: string; + open: string; + high: string; + low: string; + close: string; + volume: string; + }> + > { + // 解析周期为分钟数 + const periodMinutes = this.parsePeriodToMinutes(period); + + // 计算时间范围 + const endTime = new Date(); + const startTime = new Date(endTime.getTime() - periodMinutes * limit * 60 * 1000); + + // 获取原始快照数据 + const snapshots = await this.priceSnapshotRepository.getPriceHistory( + startTime, + endTime, + periodMinutes * limit, // 获取足够多的数据点 + ); + + if (snapshots.length === 0) { + return []; + } + + // 按周期聚合为K线 + const klines = this.aggregateToKlines(snapshots, periodMinutes); + + // 返回最近的limit条 + return klines.slice(-limit); + } + + /** + * 解析周期字符串为分钟数 + */ + private parsePeriodToMinutes(period: string): number { + const periodMap: Record = { + '1m': 1, + '5m': 5, + '15m': 15, + '30m': 30, + '1h': 60, + '4h': 240, + '1d': 1440, + }; + return periodMap[period] || 60; + } + + /** + * 将快照数据聚合为K线 + */ + private aggregateToKlines( + snapshots: Array<{ + snapshotTime: Date; + price: Money; + greenPoints: Money; + blackHoleAmount: Money; + circulationPool: Money; + effectiveDenominator: Money; + minuteBurnRate: Money; + }>, + periodMinutes: number, + ): Array<{ + time: string; + open: string; + high: string; + low: string; + close: string; + volume: string; + }> { + if (snapshots.length === 0) return []; + + const klineMap = new Map< + number, + { + time: Date; + prices: Decimal[]; + } + >(); + + // 按周期分组 + for (const snapshot of snapshots) { + const periodStart = this.getPeriodStart(snapshot.snapshotTime, periodMinutes); + const key = periodStart.getTime(); + + if (!klineMap.has(key)) { + klineMap.set(key, { + time: periodStart, + prices: [], + }); + } + + klineMap.get(key)!.prices.push(snapshot.price.value); + } + + // 转换为K线格式 + const klines: Array<{ + time: string; + open: string; + high: string; + low: string; + close: string; + volume: string; + }> = []; + + const sortedKeys = Array.from(klineMap.keys()).sort((a, b) => a - b); + + for (const key of sortedKeys) { + const data = klineMap.get(key)!; + const prices = data.prices; + + if (prices.length === 0) continue; + + const open = prices[0]; + const close = prices[prices.length - 1]; + const high = Decimal.max(...prices); + const low = Decimal.min(...prices); + + klines.push({ + time: data.time.toISOString(), + open: open.toFixed(18), + high: high.toFixed(18), + low: low.toFixed(18), + close: close.toFixed(18), + volume: '0', // 该系统无交易量概念 + }); + } + + return klines; + } + + /** + * 获取周期的起始时间 + */ + private getPeriodStart(time: Date, periodMinutes: number): Date { + const msPerPeriod = periodMinutes * 60 * 1000; + const periodStart = Math.floor(time.getTime() / msPerPeriod) * msPerPeriod; + return new Date(periodStart); + } } diff --git a/frontend/mining-app/lib/core/network/api_endpoints.dart b/frontend/mining-app/lib/core/network/api_endpoints.dart index 88984bcd..689b1fe0 100644 --- a/frontend/mining-app/lib/core/network/api_endpoints.dart +++ b/frontend/mining-app/lib/core/network/api_endpoints.dart @@ -27,6 +27,7 @@ class ApiEndpoints { static const String currentPrice = '/api/v2/trading/price/current'; static const String latestPrice = '/api/v2/trading/price/latest'; static const String priceHistory = '/api/v2/trading/price/history'; + static const String priceKlines = '/api/v2/trading/price/klines'; // Trading account endpoints static String tradingAccount(String accountSequence) => diff --git a/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart b/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart index f30f99c1..b598e226 100644 --- a/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart +++ b/frontend/mining-app/lib/data/datasources/remote/trading_remote_datasource.dart @@ -3,6 +3,7 @@ import '../../models/price_info_model.dart'; import '../../models/trading_account_model.dart'; import '../../models/market_overview_model.dart'; import '../../models/asset_display_model.dart'; +import '../../models/kline_model.dart'; import '../../../core/network/api_client.dart'; import '../../../core/network/api_endpoints.dart'; import '../../../core/error/exceptions.dart'; @@ -47,6 +48,9 @@ abstract class TradingRemoteDataSource { /// 获取指定账户资产显示信息 Future getAccountAsset(String accountSequence, {String? dailyAllocation}); + + /// 获取K线数据 + Future> getKlines({String period = '1h', int limit = 100}); } class TradingRemoteDataSourceImpl implements TradingRemoteDataSource { @@ -202,4 +206,18 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource { throw ServerException(e.toString()); } } + + @override + Future> getKlines({String period = '1h', int limit = 100}) async { + try { + final response = await client.get( + ApiEndpoints.priceKlines, + queryParameters: {'period': period, 'limit': limit}, + ); + final List data = response.data; + return data.map((json) => KlineModel.fromJson(json)).toList(); + } catch (e) { + throw ServerException(e.toString()); + } + } } diff --git a/frontend/mining-app/lib/data/repositories/trading_repository_impl.dart b/frontend/mining-app/lib/data/repositories/trading_repository_impl.dart index b9785625..99f6227f 100644 --- a/frontend/mining-app/lib/data/repositories/trading_repository_impl.dart +++ b/frontend/mining-app/lib/data/repositories/trading_repository_impl.dart @@ -3,6 +3,7 @@ import '../../domain/entities/price_info.dart'; import '../../domain/entities/market_overview.dart'; import '../../domain/entities/trading_account.dart'; import '../../domain/entities/asset_display.dart'; +import '../../domain/entities/kline.dart'; import '../../domain/repositories/trading_repository.dart'; import '../../core/error/exceptions.dart'; import '../../core/error/failures.dart'; @@ -159,4 +160,16 @@ class TradingRepositoryImpl implements TradingRepository { return Left(const NetworkFailure()); } } + + @override + Future>> getKlines({String period = '1h', int limit = 100}) async { + try { + final result = await remoteDataSource.getKlines(period: period, limit: limit); + return Right(result); + } on ServerException catch (e) { + return Left(ServerFailure(e.message)); + } on NetworkException { + return Left(const NetworkFailure()); + } + } } diff --git a/frontend/mining-app/lib/domain/repositories/trading_repository.dart b/frontend/mining-app/lib/domain/repositories/trading_repository.dart index 9b70bbb1..9cd53e03 100644 --- a/frontend/mining-app/lib/domain/repositories/trading_repository.dart +++ b/frontend/mining-app/lib/domain/repositories/trading_repository.dart @@ -5,6 +5,7 @@ import '../entities/market_overview.dart'; import '../entities/trading_account.dart'; import '../entities/trade_order.dart'; import '../entities/asset_display.dart'; +import '../entities/kline.dart'; import '../../data/models/trade_order_model.dart'; abstract class TradingRepository { @@ -47,4 +48,7 @@ abstract class TradingRepository { /// 获取指定账户资产显示信息 Future> getAccountAsset(String accountSequence, {String? dailyAllocation}); + + /// 获取K线数据 + Future>> getKlines({String period = '1h', int limit = 100}); } diff --git a/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart b/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart index 6c3e44b1..5d5d43cb 100644 --- a/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart +++ b/frontend/mining-app/lib/presentation/pages/trading/trading_page.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; @@ -7,6 +9,7 @@ import '../../../data/models/trade_order_model.dart'; import '../../../domain/entities/price_info.dart'; import '../../../domain/entities/market_overview.dart'; import '../../../domain/entities/trade_order.dart'; +import '../../../domain/entities/kline.dart'; import '../../providers/user_providers.dart'; import '../../providers/trading_providers.dart'; import '../../widgets/shimmer_loading.dart'; @@ -225,6 +228,8 @@ class _TradingPageState extends ConsumerState { Widget _buildChartSection(AsyncValue priceAsync) { final priceInfo = priceAsync.valueOrNull; final currentPrice = priceInfo?.price ?? '0.000000'; + final klinesAsync = ref.watch(klinesProvider); + final klines = klinesAsync.valueOrNull ?? []; return Container( margin: const EdgeInsets.symmetric(horizontal: 16), @@ -241,36 +246,39 @@ class _TradingPageState extends ConsumerState { color: _lightGray, borderRadius: BorderRadius.circular(8), ), - child: Stack( - children: [ - CustomPaint( - size: const Size(double.infinity, 200), - painter: _CandlestickPainter(), - ), - Positioned( - right: 0, - top: 60, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - color: _orange, - borderRadius: BorderRadius.circular(4), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 2, - offset: const Offset(0, 1), + child: klinesAsync.isLoading + ? const Center(child: CircularProgressIndicator(strokeWidth: 2)) + : Stack( + children: [ + CustomPaint( + size: const Size(double.infinity, 200), + painter: _CandlestickPainter(klines: klines), + ), + if (klines.isNotEmpty) + Positioned( + right: 0, + top: 60, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: _orange, + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Text( + formatPrice(currentPrice), + style: const TextStyle(fontSize: 10, color: Colors.white), + ), + ), ), - ], - ), - child: Text( - formatPrice(currentPrice), - style: const TextStyle(fontSize: 10, color: Colors.white), - ), + ], ), - ), - ], - ), ), const SizedBox(height: 16), SingleChildScrollView( @@ -281,7 +289,11 @@ class _TradingPageState extends ConsumerState { return Padding( padding: const EdgeInsets.only(right: 8), child: GestureDetector( - onTap: () => setState(() => _selectedTimeRange = index), + onTap: () { + setState(() => _selectedTimeRange = index); + // 更新选中的周期,触发K线数据刷新 + ref.read(selectedKlinePeriodProvider.notifier).state = _timeRanges[index]; + }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration( @@ -878,80 +890,165 @@ class _TradingPageState extends ConsumerState { } } -// K线图绘制器(简化版本,显示模拟数据) +// K线图绘制器(Y轴自适应,显示真实数据) class _CandlestickPainter extends CustomPainter { + final List klines; + + _CandlestickPainter({required this.klines}); + @override void paint(Canvas canvas, Size size) { final greenPaint = Paint()..color = const Color(0xFF10B981); final redPaint = Paint()..color = const Color(0xFFEF4444); - final dashPaint = Paint() - ..color = const Color(0xFFFF6B00) - ..strokeWidth = 1 - ..style = PaintingStyle.stroke; + final gridPaint = Paint() + ..color = const Color(0xFFE5E7EB) + ..strokeWidth = 0.5; + final textPaint = TextPainter(textDirection: ui.TextDirection.ltr); - // 模拟K线数据 - final candleData = [ - {'open': 0.6, 'close': 0.5, 'high': 0.7, 'low': 0.45}, - {'open': 0.5, 'close': 0.55, 'high': 0.6, 'low': 0.48}, - {'open': 0.55, 'close': 0.52, 'high': 0.58, 'low': 0.5}, - {'open': 0.52, 'close': 0.6, 'high': 0.65, 'low': 0.5}, - {'open': 0.6, 'close': 0.58, 'high': 0.65, 'low': 0.55}, - {'open': 0.58, 'close': 0.62, 'high': 0.68, 'low': 0.55}, - {'open': 0.62, 'close': 0.55, 'high': 0.65, 'low': 0.52}, - {'open': 0.55, 'close': 0.58, 'high': 0.62, 'low': 0.52}, - {'open': 0.58, 'close': 0.52, 'high': 0.6, 'low': 0.5}, - {'open': 0.52, 'close': 0.65, 'high': 0.7, 'low': 0.5}, - {'open': 0.65, 'close': 0.7, 'high': 0.75, 'low': 0.62}, - {'open': 0.7, 'close': 0.75, 'high': 0.8, 'low': 0.68}, - ]; + // 如果没有数据,显示提示 + if (klines.isEmpty) { + textPaint.text = const TextSpan( + text: '暂无K线数据', + style: TextStyle(color: Color(0xFF6B7280), fontSize: 14), + ); + textPaint.layout(); + textPaint.paint( + canvas, + Offset((size.width - textPaint.width) / 2, (size.height - textPaint.height) / 2), + ); + return; + } - final candleWidth = (size.width - 40) / candleData.length; - const padding = 20.0; + // 计算Y轴范围(自适应) + double minPrice = double.infinity; + double maxPrice = double.negativeInfinity; + for (final kline in klines) { + final low = double.tryParse(kline.low) ?? 0; + final high = double.tryParse(kline.high) ?? 0; + if (low < minPrice) minPrice = low; + if (high > maxPrice) maxPrice = high; + } - for (int i = 0; i < candleData.length; i++) { - final data = candleData[i]; - final open = data['open']!; - final close = data['close']!; - final high = data['high']!; - final low = data['low']!; + // 添加一点余量,使K线不贴边 + final priceRange = maxPrice - minPrice; + final padding = priceRange * 0.1; // 上下各留10%空间 + minPrice -= padding; + maxPrice += padding; + final adjustedRange = maxPrice - minPrice; + + // 绘图区域 + const leftPadding = 10.0; + const rightPadding = 50.0; // 右侧留出价格标签空间 + const topPadding = 10.0; + const bottomPadding = 10.0; + final chartWidth = size.width - leftPadding - rightPadding; + final chartHeight = size.height - topPadding - bottomPadding; + + // 绘制水平网格线和价格标签 + const gridLines = 4; + for (int i = 0; i <= gridLines; i++) { + final y = topPadding + (chartHeight / gridLines) * i; + canvas.drawLine( + Offset(leftPadding, y), + Offset(size.width - rightPadding, y), + gridPaint, + ); + + // 价格标签 + final price = maxPrice - (adjustedRange / gridLines) * i; + final priceText = _formatPriceLabel(price); + textPaint.text = TextSpan( + text: priceText, + style: const TextStyle(color: Color(0xFF6B7280), fontSize: 9), + ); + textPaint.layout(); + textPaint.paint(canvas, Offset(size.width - rightPadding + 4, y - textPaint.height / 2)); + } + + // 计算K线宽度 + final candleWidth = chartWidth / klines.length; + final bodyWidth = math.max(candleWidth * 0.6, 2.0); // 实体宽度,最小2px + + // 绘制K线 + for (int i = 0; i < klines.length; i++) { + final kline = klines[i]; + final open = double.tryParse(kline.open) ?? 0; + final close = double.tryParse(kline.close) ?? 0; + final high = double.tryParse(kline.high) ?? 0; + final low = double.tryParse(kline.low) ?? 0; final isGreen = close >= open; final paint = isGreen ? greenPaint : redPaint; - final x = padding + i * candleWidth + candleWidth / 2; - final yOpen = size.height - (open * size.height * 0.8 + size.height * 0.1); - final yClose = size.height - (close * size.height * 0.8 + size.height * 0.1); - final yHigh = size.height - (high * size.height * 0.8 + size.height * 0.1); - final yLow = size.height - (low * size.height * 0.8 + size.height * 0.1); + final x = leftPadding + i * candleWidth + candleWidth / 2; + // Y坐标转换(价格 -> 屏幕坐标) + double priceToY(double price) { + return topPadding + ((maxPrice - price) / adjustedRange) * chartHeight; + } + + final yOpen = priceToY(open); + final yClose = priceToY(close); + final yHigh = priceToY(high); + final yLow = priceToY(low); + + // 绘制影线 canvas.drawLine( Offset(x, yHigh), Offset(x, yLow), paint..strokeWidth = 1, ); - final bodyTop = isGreen ? yClose : yOpen; - final bodyBottom = isGreen ? yOpen : yClose; + // 绘制实体 + final bodyTop = math.min(yOpen, yClose); + final bodyBottom = math.max(yOpen, yClose); + // 确保实体至少有1px高度 + final minBodyHeight = 1.0; + final actualBodyBottom = bodyBottom - bodyTop < minBodyHeight ? bodyTop + minBodyHeight : bodyBottom; + canvas.drawRect( - Rect.fromLTRB(x - candleWidth * 0.3, bodyTop, x + candleWidth * 0.3, bodyBottom), + Rect.fromLTRB(x - bodyWidth / 2, bodyTop, x + bodyWidth / 2, actualBodyBottom), paint..style = PaintingStyle.fill, ); } - final dashY = size.height * 0.35; - const dashWidth = 5.0; - const dashSpace = 3.0; - double startX = 0; - while (startX < size.width - 60) { - canvas.drawLine( - Offset(startX, dashY), - Offset(startX + dashWidth, dashY), - dashPaint, - ); - startX += dashWidth + dashSpace; + // 绘制最新价格虚线 + if (klines.isNotEmpty) { + final lastClose = double.tryParse(klines.last.close) ?? 0; + final lastY = topPadding + ((maxPrice - lastClose) / adjustedRange) * chartHeight; + + final dashPaint = Paint() + ..color = const Color(0xFFFF6B00) + ..strokeWidth = 1 + ..style = PaintingStyle.stroke; + + const dashWidth = 5.0; + const dashSpace = 3.0; + double startX = leftPadding; + while (startX < size.width - rightPadding) { + canvas.drawLine( + Offset(startX, lastY), + Offset(math.min(startX + dashWidth, size.width - rightPadding), lastY), + dashPaint, + ); + startX += dashWidth + dashSpace; + } + } + } + + // 格式化价格标签 + String _formatPriceLabel(double price) { + if (price >= 1) { + return price.toStringAsFixed(4); + } else if (price >= 0.0001) { + return price.toStringAsFixed(6); + } else { + return price.toStringAsExponential(2); } } @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; + bool shouldRepaint(covariant _CandlestickPainter oldDelegate) { + return oldDelegate.klines != klines; + } } diff --git a/frontend/mining-app/lib/presentation/providers/trading_providers.dart b/frontend/mining-app/lib/presentation/providers/trading_providers.dart index a26657f9..339beef3 100644 --- a/frontend/mining-app/lib/presentation/providers/trading_providers.dart +++ b/frontend/mining-app/lib/presentation/providers/trading_providers.dart @@ -4,6 +4,7 @@ import '../../domain/entities/price_info.dart'; import '../../domain/entities/market_overview.dart'; import '../../domain/entities/trading_account.dart'; import '../../domain/entities/trade_order.dart'; +import '../../domain/entities/kline.dart'; import '../../domain/repositories/trading_repository.dart'; import '../../data/models/trade_order_model.dart'; import '../../core/di/injection.dart'; @@ -16,6 +17,40 @@ final tradingRepositoryProvider = Provider((ref) { // K线周期选择 final selectedKlinePeriodProvider = StateProvider((ref) => '1h'); +// 周期字符串映射到API参数 +String _periodToApiParam(String period) { + const periodMap = { + '1分': '1m', + '5分': '5m', + '15分': '15m', + '30分': '30m', + '1时': '1h', + '4时': '4h', + '日': '1d', + }; + return periodMap[period] ?? '1h'; +} + +// K线数据 Provider (根据选中周期获取) +final klinesProvider = FutureProvider>((ref) async { + final repository = ref.watch(tradingRepositoryProvider); + final selectedPeriod = ref.watch(selectedKlinePeriodProvider); + final apiPeriod = _periodToApiParam(selectedPeriod); + + final result = await repository.getKlines(period: apiPeriod, limit: 100); + + ref.keepAlive(); + final timer = Timer(const Duration(minutes: 1), () { + ref.invalidateSelf(); + }); + ref.onDispose(() => timer.cancel()); + + return result.fold( + (failure) => throw Exception(failure.message), + (klines) => klines, + ); +}); + // 当前价格 Provider (5分钟缓存) final currentPriceProvider = FutureProvider((ref) async { final repository = ref.watch(tradingRepositoryProvider);