feat(trading): 实现K线图真实数据展示与Y轴自适应

后端 (trading-service):
- 新增 GET /api/v2/price/klines API 端点
- 支持多周期K线聚合 (1m/5m/15m/30m/1h/4h/1d)
- 将 PriceSnapshot 数据聚合为 OHLC 格式

前端 (mining-app):
- 添加 klinesProvider 获取K线数据
- 重写 _CandlestickPainter 使用真实数据
- 实现 Y轴自适应显示,放大价格变化
- 周期选择器联动数据刷新

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-15 23:57:12 -08:00
parent 8ab11c8f50
commit 72b3b44d37
8 changed files with 416 additions and 77 deletions

View File

@ -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));
}
}

View File

@ -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<string, number> = {
'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);
}
}

View File

@ -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) =>

View File

@ -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<AssetDisplayModel> getAccountAsset(String accountSequence, {String? dailyAllocation});
/// K线数据
Future<List<KlineModel>> 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<List<KlineModel>> getKlines({String period = '1h', int limit = 100}) async {
try {
final response = await client.get(
ApiEndpoints.priceKlines,
queryParameters: {'period': period, 'limit': limit},
);
final List<dynamic> data = response.data;
return data.map((json) => KlineModel.fromJson(json)).toList();
} catch (e) {
throw ServerException(e.toString());
}
}
}

View File

@ -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<Either<Failure, List<Kline>>> 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());
}
}
}

View File

@ -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<Either<Failure, AssetDisplay>> getAccountAsset(String accountSequence, {String? dailyAllocation});
/// K线数据
Future<Either<Failure, List<Kline>>> getKlines({String period = '1h', int limit = 100});
}

View File

@ -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<TradingPage> {
Widget _buildChartSection(AsyncValue<PriceInfo?> 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<TradingPage> {
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<TradingPage> {
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<TradingPage> {
}
}
// K线图绘制器
// K线图绘制器Y轴自适应
class _CandlestickPainter extends CustomPainter {
final List<Kline> 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;
}
}

View File

@ -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<TradingRepository>((ref) {
// K线周期选择
final selectedKlinePeriodProvider = StateProvider<String>((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<List<Kline>>((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<PriceInfo?>((ref) async {
final repository = ref.watch(tradingRepositoryProvider);