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:
parent
8ab11c8f50
commit
72b3b44d37
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue