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,
|
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,
|
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 currentPrice = '/api/v2/trading/price/current';
|
||||||
static const String latestPrice = '/api/v2/trading/price/latest';
|
static const String latestPrice = '/api/v2/trading/price/latest';
|
||||||
static const String priceHistory = '/api/v2/trading/price/history';
|
static const String priceHistory = '/api/v2/trading/price/history';
|
||||||
|
static const String priceKlines = '/api/v2/trading/price/klines';
|
||||||
|
|
||||||
// Trading account endpoints
|
// Trading account endpoints
|
||||||
static String tradingAccount(String accountSequence) =>
|
static String tradingAccount(String accountSequence) =>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import '../../models/price_info_model.dart';
|
||||||
import '../../models/trading_account_model.dart';
|
import '../../models/trading_account_model.dart';
|
||||||
import '../../models/market_overview_model.dart';
|
import '../../models/market_overview_model.dart';
|
||||||
import '../../models/asset_display_model.dart';
|
import '../../models/asset_display_model.dart';
|
||||||
|
import '../../models/kline_model.dart';
|
||||||
import '../../../core/network/api_client.dart';
|
import '../../../core/network/api_client.dart';
|
||||||
import '../../../core/network/api_endpoints.dart';
|
import '../../../core/network/api_endpoints.dart';
|
||||||
import '../../../core/error/exceptions.dart';
|
import '../../../core/error/exceptions.dart';
|
||||||
|
|
@ -47,6 +48,9 @@ abstract class TradingRemoteDataSource {
|
||||||
|
|
||||||
/// 获取指定账户资产显示信息
|
/// 获取指定账户资产显示信息
|
||||||
Future<AssetDisplayModel> getAccountAsset(String accountSequence, {String? dailyAllocation});
|
Future<AssetDisplayModel> getAccountAsset(String accountSequence, {String? dailyAllocation});
|
||||||
|
|
||||||
|
/// 获取K线数据
|
||||||
|
Future<List<KlineModel>> getKlines({String period = '1h', int limit = 100});
|
||||||
}
|
}
|
||||||
|
|
||||||
class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
|
class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
|
||||||
|
|
@ -202,4 +206,18 @@ class TradingRemoteDataSourceImpl implements TradingRemoteDataSource {
|
||||||
throw ServerException(e.toString());
|
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/market_overview.dart';
|
||||||
import '../../domain/entities/trading_account.dart';
|
import '../../domain/entities/trading_account.dart';
|
||||||
import '../../domain/entities/asset_display.dart';
|
import '../../domain/entities/asset_display.dart';
|
||||||
|
import '../../domain/entities/kline.dart';
|
||||||
import '../../domain/repositories/trading_repository.dart';
|
import '../../domain/repositories/trading_repository.dart';
|
||||||
import '../../core/error/exceptions.dart';
|
import '../../core/error/exceptions.dart';
|
||||||
import '../../core/error/failures.dart';
|
import '../../core/error/failures.dart';
|
||||||
|
|
@ -159,4 +160,16 @@ class TradingRepositoryImpl implements TradingRepository {
|
||||||
return Left(const NetworkFailure());
|
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/trading_account.dart';
|
||||||
import '../entities/trade_order.dart';
|
import '../entities/trade_order.dart';
|
||||||
import '../entities/asset_display.dart';
|
import '../entities/asset_display.dart';
|
||||||
|
import '../entities/kline.dart';
|
||||||
import '../../data/models/trade_order_model.dart';
|
import '../../data/models/trade_order_model.dart';
|
||||||
|
|
||||||
abstract class TradingRepository {
|
abstract class TradingRepository {
|
||||||
|
|
@ -47,4 +48,7 @@ abstract class TradingRepository {
|
||||||
|
|
||||||
/// 获取指定账户资产显示信息
|
/// 获取指定账户资产显示信息
|
||||||
Future<Either<Failure, AssetDisplay>> getAccountAsset(String accountSequence, {String? dailyAllocation});
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.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/price_info.dart';
|
||||||
import '../../../domain/entities/market_overview.dart';
|
import '../../../domain/entities/market_overview.dart';
|
||||||
import '../../../domain/entities/trade_order.dart';
|
import '../../../domain/entities/trade_order.dart';
|
||||||
|
import '../../../domain/entities/kline.dart';
|
||||||
import '../../providers/user_providers.dart';
|
import '../../providers/user_providers.dart';
|
||||||
import '../../providers/trading_providers.dart';
|
import '../../providers/trading_providers.dart';
|
||||||
import '../../widgets/shimmer_loading.dart';
|
import '../../widgets/shimmer_loading.dart';
|
||||||
|
|
@ -225,6 +228,8 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
||||||
Widget _buildChartSection(AsyncValue<PriceInfo?> priceAsync) {
|
Widget _buildChartSection(AsyncValue<PriceInfo?> priceAsync) {
|
||||||
final priceInfo = priceAsync.valueOrNull;
|
final priceInfo = priceAsync.valueOrNull;
|
||||||
final currentPrice = priceInfo?.price ?? '0.000000';
|
final currentPrice = priceInfo?.price ?? '0.000000';
|
||||||
|
final klinesAsync = ref.watch(klinesProvider);
|
||||||
|
final klines = klinesAsync.valueOrNull ?? [];
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
|
@ -241,36 +246,39 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
||||||
color: _lightGray,
|
color: _lightGray,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Stack(
|
child: klinesAsync.isLoading
|
||||||
children: [
|
? const Center(child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
CustomPaint(
|
: Stack(
|
||||||
size: const Size(double.infinity, 200),
|
children: [
|
||||||
painter: _CandlestickPainter(),
|
CustomPaint(
|
||||||
),
|
size: const Size(double.infinity, 200),
|
||||||
Positioned(
|
painter: _CandlestickPainter(klines: klines),
|
||||||
right: 0,
|
),
|
||||||
top: 60,
|
if (klines.isNotEmpty)
|
||||||
child: Container(
|
Positioned(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
right: 0,
|
||||||
decoration: BoxDecoration(
|
top: 60,
|
||||||
color: _orange,
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(4),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
boxShadow: [
|
decoration: BoxDecoration(
|
||||||
BoxShadow(
|
color: _orange,
|
||||||
color: Colors.black.withValues(alpha: 0.1),
|
borderRadius: BorderRadius.circular(4),
|
||||||
blurRadius: 2,
|
boxShadow: [
|
||||||
offset: const Offset(0, 1),
|
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),
|
const SizedBox(height: 16),
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
|
|
@ -281,7 +289,11 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => setState(() => _selectedTimeRange = index),
|
onTap: () {
|
||||||
|
setState(() => _selectedTimeRange = index);
|
||||||
|
// 更新选中的周期,触发K线数据刷新
|
||||||
|
ref.read(selectedKlinePeriodProvider.notifier).state = _timeRanges[index];
|
||||||
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -878,80 +890,165 @@ class _TradingPageState extends ConsumerState<TradingPage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// K线图绘制器(简化版本,显示模拟数据)
|
// K线图绘制器(Y轴自适应,显示真实数据)
|
||||||
class _CandlestickPainter extends CustomPainter {
|
class _CandlestickPainter extends CustomPainter {
|
||||||
|
final List<Kline> klines;
|
||||||
|
|
||||||
|
_CandlestickPainter({required this.klines});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final greenPaint = Paint()..color = const Color(0xFF10B981);
|
final greenPaint = Paint()..color = const Color(0xFF10B981);
|
||||||
final redPaint = Paint()..color = const Color(0xFFEF4444);
|
final redPaint = Paint()..color = const Color(0xFFEF4444);
|
||||||
final dashPaint = Paint()
|
final gridPaint = Paint()
|
||||||
..color = const Color(0xFFFF6B00)
|
..color = const Color(0xFFE5E7EB)
|
||||||
..strokeWidth = 1
|
..strokeWidth = 0.5;
|
||||||
..style = PaintingStyle.stroke;
|
final textPaint = TextPainter(textDirection: ui.TextDirection.ltr);
|
||||||
|
|
||||||
// 模拟K线数据
|
// 如果没有数据,显示提示
|
||||||
final candleData = [
|
if (klines.isEmpty) {
|
||||||
{'open': 0.6, 'close': 0.5, 'high': 0.7, 'low': 0.45},
|
textPaint.text = const TextSpan(
|
||||||
{'open': 0.5, 'close': 0.55, 'high': 0.6, 'low': 0.48},
|
text: '暂无K线数据',
|
||||||
{'open': 0.55, 'close': 0.52, 'high': 0.58, 'low': 0.5},
|
style: TextStyle(color: Color(0xFF6B7280), fontSize: 14),
|
||||||
{'open': 0.52, 'close': 0.6, 'high': 0.65, 'low': 0.5},
|
);
|
||||||
{'open': 0.6, 'close': 0.58, 'high': 0.65, 'low': 0.55},
|
textPaint.layout();
|
||||||
{'open': 0.58, 'close': 0.62, 'high': 0.68, 'low': 0.55},
|
textPaint.paint(
|
||||||
{'open': 0.62, 'close': 0.55, 'high': 0.65, 'low': 0.52},
|
canvas,
|
||||||
{'open': 0.55, 'close': 0.58, 'high': 0.62, 'low': 0.52},
|
Offset((size.width - textPaint.width) / 2, (size.height - textPaint.height) / 2),
|
||||||
{'open': 0.58, 'close': 0.52, 'high': 0.6, 'low': 0.5},
|
);
|
||||||
{'open': 0.52, 'close': 0.65, 'high': 0.7, 'low': 0.5},
|
return;
|
||||||
{'open': 0.65, 'close': 0.7, 'high': 0.75, 'low': 0.62},
|
}
|
||||||
{'open': 0.7, 'close': 0.75, 'high': 0.8, 'low': 0.68},
|
|
||||||
];
|
|
||||||
|
|
||||||
final candleWidth = (size.width - 40) / candleData.length;
|
// 计算Y轴范围(自适应)
|
||||||
const padding = 20.0;
|
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++) {
|
// 添加一点余量,使K线不贴边
|
||||||
final data = candleData[i];
|
final priceRange = maxPrice - minPrice;
|
||||||
final open = data['open']!;
|
final padding = priceRange * 0.1; // 上下各留10%空间
|
||||||
final close = data['close']!;
|
minPrice -= padding;
|
||||||
final high = data['high']!;
|
maxPrice += padding;
|
||||||
final low = data['low']!;
|
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 isGreen = close >= open;
|
||||||
final paint = isGreen ? greenPaint : redPaint;
|
final paint = isGreen ? greenPaint : redPaint;
|
||||||
|
|
||||||
final x = padding + i * candleWidth + candleWidth / 2;
|
final x = leftPadding + 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);
|
|
||||||
|
|
||||||
|
// 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(
|
canvas.drawLine(
|
||||||
Offset(x, yHigh),
|
Offset(x, yHigh),
|
||||||
Offset(x, yLow),
|
Offset(x, yLow),
|
||||||
paint..strokeWidth = 1,
|
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(
|
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,
|
paint..style = PaintingStyle.fill,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final dashY = size.height * 0.35;
|
// 绘制最新价格虚线
|
||||||
const dashWidth = 5.0;
|
if (klines.isNotEmpty) {
|
||||||
const dashSpace = 3.0;
|
final lastClose = double.tryParse(klines.last.close) ?? 0;
|
||||||
double startX = 0;
|
final lastY = topPadding + ((maxPrice - lastClose) / adjustedRange) * chartHeight;
|
||||||
while (startX < size.width - 60) {
|
|
||||||
canvas.drawLine(
|
final dashPaint = Paint()
|
||||||
Offset(startX, dashY),
|
..color = const Color(0xFFFF6B00)
|
||||||
Offset(startX + dashWidth, dashY),
|
..strokeWidth = 1
|
||||||
dashPaint,
|
..style = PaintingStyle.stroke;
|
||||||
);
|
|
||||||
startX += dashWidth + dashSpace;
|
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
|
@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/market_overview.dart';
|
||||||
import '../../domain/entities/trading_account.dart';
|
import '../../domain/entities/trading_account.dart';
|
||||||
import '../../domain/entities/trade_order.dart';
|
import '../../domain/entities/trade_order.dart';
|
||||||
|
import '../../domain/entities/kline.dart';
|
||||||
import '../../domain/repositories/trading_repository.dart';
|
import '../../domain/repositories/trading_repository.dart';
|
||||||
import '../../data/models/trade_order_model.dart';
|
import '../../data/models/trade_order_model.dart';
|
||||||
import '../../core/di/injection.dart';
|
import '../../core/di/injection.dart';
|
||||||
|
|
@ -16,6 +17,40 @@ final tradingRepositoryProvider = Provider<TradingRepository>((ref) {
|
||||||
// K线周期选择
|
// K线周期选择
|
||||||
final selectedKlinePeriodProvider = StateProvider<String>((ref) => '1h');
|
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分钟缓存)
|
// 当前价格 Provider (5分钟缓存)
|
||||||
final currentPriceProvider = FutureProvider<PriceInfo?>((ref) async {
|
final currentPriceProvider = FutureProvider<PriceInfo?>((ref) async {
|
||||||
final repository = ref.watch(tradingRepositoryProvider);
|
final repository = ref.watch(tradingRepositoryProvider);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue