fix(trading): K线Y轴动态缩放 + 价格显示格式统一 + 首日涨幅修复
- K线图Y轴改为只基于可见K线计算范围,实现动态缩放(类似TradingView)
- 价格显示统一使用 0.0{n}xxx 格式替代科学计数法,覆盖全局 formatPrice、
K线图、交易记录页、挖矿记录页
- 修复"较上线首日"百分比始终显示 +0.00% 的问题:getFirstSnapshot 过滤
price=0 的早期快照
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f13814e577
commit
c24f383501
|
|
@ -29,10 +29,11 @@ export class PriceSnapshotRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取最早的价格快照(上线首日价格)
|
* 获取最早的非零价格快照(上线首日价格)
|
||||||
*/
|
*/
|
||||||
async getFirstSnapshot(): Promise<PriceSnapshotEntity | null> {
|
async getFirstSnapshot(): Promise<PriceSnapshotEntity | null> {
|
||||||
const record = await this.prisma.priceSnapshot.findFirst({
|
const record = await this.prisma.priceSnapshot.findFirst({
|
||||||
|
where: { price: { gt: 0 } },
|
||||||
orderBy: { snapshotTime: 'asc' },
|
orderBy: { snapshotTime: 'asc' },
|
||||||
});
|
});
|
||||||
if (!record) {
|
if (!record) {
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,30 @@ String formatPercent(String? value, [int precision = 2]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
String formatPrice(String? value) {
|
String formatPrice(String? value) {
|
||||||
return formatDecimal(value, 8);
|
if (value == null || value.isEmpty) return '0';
|
||||||
|
try {
|
||||||
|
final decimal = Decimal.parse(value);
|
||||||
|
if (decimal >= Decimal.one) return decimal.toStringAsFixed(4);
|
||||||
|
if (decimal >= Decimal.parse('0.0001')) return decimal.toStringAsFixed(6);
|
||||||
|
if (decimal <= Decimal.zero) return '0';
|
||||||
|
// 0.00000980 → 0.0{5}980
|
||||||
|
final str = decimal.toStringAsFixed(18);
|
||||||
|
final dotIndex = str.indexOf('.');
|
||||||
|
int zeroCount = 0;
|
||||||
|
for (int i = dotIndex + 1; i < str.length; i++) {
|
||||||
|
if (str[i] == '0') {
|
||||||
|
zeroCount++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final sigStart = dotIndex + 1 + zeroCount;
|
||||||
|
final sigEnd = sigStart + 3 > str.length ? str.length : sigStart + 3;
|
||||||
|
final significant = str.substring(sigStart, sigEnd);
|
||||||
|
return '0.0{$zeroCount}$significant';
|
||||||
|
} catch (e) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String formatAmount(String? value) {
|
String formatAmount(String? value) {
|
||||||
|
|
|
||||||
|
|
@ -302,7 +302,24 @@ class _MiningRecordsListPageState extends ConsumerState<MiningRecordsListPage> {
|
||||||
String _formatPrice(String price) {
|
String _formatPrice(String price) {
|
||||||
try {
|
try {
|
||||||
final value = double.parse(price);
|
final value = double.parse(price);
|
||||||
return value.toStringAsFixed(8);
|
if (value >= 1) return value.toStringAsFixed(4);
|
||||||
|
if (value >= 0.0001) return value.toStringAsFixed(6);
|
||||||
|
if (value <= 0) return '0';
|
||||||
|
// 0.00000980 → 0.0{5}980
|
||||||
|
final str = value.toStringAsFixed(18);
|
||||||
|
final dotIndex = str.indexOf('.');
|
||||||
|
int zeroCount = 0;
|
||||||
|
for (int i = dotIndex + 1; i < str.length; i++) {
|
||||||
|
if (str[i] == '0') {
|
||||||
|
zeroCount++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final sigStart = dotIndex + 1 + zeroCount;
|
||||||
|
final sigEnd = sigStart + 3 > str.length ? str.length : sigStart + 3;
|
||||||
|
final significant = str.substring(sigStart, sigEnd);
|
||||||
|
return '0.0{$zeroCount}$significant';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return price;
|
return price;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -511,7 +511,24 @@ class _TradingRecordsPageState extends ConsumerState<TradingRecordsPage> with Si
|
||||||
String _formatPrice(String price) {
|
String _formatPrice(String price) {
|
||||||
try {
|
try {
|
||||||
final value = double.parse(price);
|
final value = double.parse(price);
|
||||||
return value.toStringAsFixed(4);
|
if (value >= 1) return value.toStringAsFixed(4);
|
||||||
|
if (value >= 0.0001) return value.toStringAsFixed(6);
|
||||||
|
if (value <= 0) return '0';
|
||||||
|
// 0.00000980 → 0.0{5}980
|
||||||
|
final str = value.toStringAsFixed(18);
|
||||||
|
final dotIndex = str.indexOf('.');
|
||||||
|
int zeroCount = 0;
|
||||||
|
for (int i = dotIndex + 1; i < str.length; i++) {
|
||||||
|
if (str[i] == '0') {
|
||||||
|
zeroCount++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final sigStart = dotIndex + 1 + zeroCount;
|
||||||
|
final sigEnd = sigStart + 3 > str.length ? str.length : sigStart + 3;
|
||||||
|
final significant = str.substring(sigStart, sigEnd);
|
||||||
|
return '0.0{$zeroCount}$significant';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return price;
|
return price;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -730,7 +730,22 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
|
||||||
String _formatPrice(double price) {
|
String _formatPrice(double price) {
|
||||||
if (price >= 1) return price.toStringAsFixed(4);
|
if (price >= 1) return price.toStringAsFixed(4);
|
||||||
if (price >= 0.0001) return price.toStringAsFixed(6);
|
if (price >= 0.0001) return price.toStringAsFixed(6);
|
||||||
return price.toStringAsExponential(2);
|
if (price <= 0) return '0';
|
||||||
|
// 0.00000980 → 0.0{5}980
|
||||||
|
final str = price.toStringAsFixed(18);
|
||||||
|
final dotIndex = str.indexOf('.');
|
||||||
|
int zeroCount = 0;
|
||||||
|
for (int i = dotIndex + 1; i < str.length; i++) {
|
||||||
|
if (str[i] == '0') {
|
||||||
|
zeroCount++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final sigStart = dotIndex + 1 + zeroCount;
|
||||||
|
final sigEnd = math.min(sigStart + 3, str.length);
|
||||||
|
final significant = str.substring(sigStart, sigEnd);
|
||||||
|
return '0.0{$zeroCount}$significant';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatVolume(double volume) {
|
String _formatVolume(double volume) {
|
||||||
|
|
@ -746,17 +761,30 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
|
||||||
}
|
}
|
||||||
|
|
||||||
double _calcPriceY(double price, double chartHeight) {
|
double _calcPriceY(double price, double chartHeight) {
|
||||||
if (widget.klines.isEmpty) return chartHeight / 2;
|
if (widget.klines.isEmpty || _chartWidth == 0) return chartHeight / 2;
|
||||||
|
|
||||||
|
// 与 KlinePainter 保持一致:只基于可见K线计算Y轴范围
|
||||||
|
const leftPadding = 8.0;
|
||||||
|
const rightPadding = 50.0;
|
||||||
|
final drawableWidth = _chartWidth - leftPadding - rightPadding;
|
||||||
|
|
||||||
|
final visibleStart = math.max(0, (_scrollX / _candleWidth - 2).floor());
|
||||||
|
final visibleEnd = math.min(widget.klines.length - 1, ((_scrollX + drawableWidth) / _candleWidth + 1).ceil());
|
||||||
|
|
||||||
double minPrice = double.infinity;
|
double minPrice = double.infinity;
|
||||||
double maxPrice = double.negativeInfinity;
|
double maxPrice = double.negativeInfinity;
|
||||||
for (final kline in widget.klines) {
|
for (int i = visibleStart; i <= visibleEnd; i++) {
|
||||||
|
final kline = widget.klines[i];
|
||||||
final low = double.tryParse(kline.low) ?? 0;
|
final low = double.tryParse(kline.low) ?? 0;
|
||||||
final high = double.tryParse(kline.high) ?? 0;
|
final high = double.tryParse(kline.high) ?? 0;
|
||||||
if (low < minPrice) minPrice = low;
|
if (low < minPrice) minPrice = low;
|
||||||
if (high > maxPrice) maxPrice = high;
|
if (high > maxPrice) maxPrice = high;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (minPrice == double.infinity || maxPrice == double.negativeInfinity) {
|
||||||
|
return chartHeight / 2;
|
||||||
|
}
|
||||||
|
|
||||||
final range = maxPrice - minPrice;
|
final range = maxPrice - minPrice;
|
||||||
// 防止 range 为 0 导致 NaN
|
// 防止 range 为 0 导致 NaN
|
||||||
final safeRange = range > 0 ? range : (maxPrice > 0 ? maxPrice * 0.1 : 1.0);
|
final safeRange = range > 0 ? range : (maxPrice > 0 ? maxPrice * 0.1 : 1.0);
|
||||||
|
|
|
||||||
|
|
@ -59,21 +59,31 @@ class KlinePainter extends CustomPainter {
|
||||||
final chartWidth = size.width - leftPadding - rightPadding;
|
final chartWidth = size.width - leftPadding - rightPadding;
|
||||||
final chartHeight = size.height - topPadding - bottomPadding;
|
final chartHeight = size.height - topPadding - bottomPadding;
|
||||||
|
|
||||||
// 计算价格范围
|
// 计算K线宽度(提前计算,用于确定可见范围)
|
||||||
|
final actualCandleWidth = candleWidth ?? (chartWidth / klines.length);
|
||||||
|
final bodyWidth = math.max(actualCandleWidth * 0.7, 2.0);
|
||||||
|
|
||||||
|
// 计算可见K线的索引范围
|
||||||
|
final visibleStart = math.max(0, (scrollOffset / actualCandleWidth - 2).floor());
|
||||||
|
final visibleEnd = math.min(klines.length - 1, ((scrollOffset + chartWidth) / actualCandleWidth + 1).ceil());
|
||||||
|
|
||||||
|
// 计算价格范围(只基于可见K线,实现Y轴动态缩放)
|
||||||
double minPrice = double.infinity;
|
double minPrice = double.infinity;
|
||||||
double maxPrice = double.negativeInfinity;
|
double maxPrice = double.negativeInfinity;
|
||||||
|
|
||||||
for (final kline in klines) {
|
for (int i = visibleStart; i <= visibleEnd; i++) {
|
||||||
|
final kline = klines[i];
|
||||||
final low = double.tryParse(kline.low) ?? 0;
|
final low = double.tryParse(kline.low) ?? 0;
|
||||||
final high = double.tryParse(kline.high) ?? 0;
|
final high = double.tryParse(kline.high) ?? 0;
|
||||||
if (low < minPrice) minPrice = low;
|
if (low < minPrice) minPrice = low;
|
||||||
if (high > maxPrice) maxPrice = high;
|
if (high > maxPrice) maxPrice = high;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 考虑MA/EMA/BOLL线的范围
|
// 考虑可见区域内MA/EMA/BOLL线的范围
|
||||||
if (maData != null) {
|
if (maData != null) {
|
||||||
for (final values in maData!.values) {
|
for (final values in maData!.values) {
|
||||||
for (final v in values) {
|
for (int i = visibleStart; i <= visibleEnd && i < values.length; i++) {
|
||||||
|
final v = values[i];
|
||||||
if (v != null) {
|
if (v != null) {
|
||||||
if (v < minPrice) minPrice = v;
|
if (v < minPrice) minPrice = v;
|
||||||
if (v > maxPrice) maxPrice = v;
|
if (v > maxPrice) maxPrice = v;
|
||||||
|
|
@ -84,7 +94,8 @@ class KlinePainter extends CustomPainter {
|
||||||
|
|
||||||
if (emaData != null) {
|
if (emaData != null) {
|
||||||
for (final values in emaData!.values) {
|
for (final values in emaData!.values) {
|
||||||
for (final v in values) {
|
for (int i = visibleStart; i <= visibleEnd && i < values.length; i++) {
|
||||||
|
final v = values[i];
|
||||||
if (v != null) {
|
if (v != null) {
|
||||||
if (v < minPrice) minPrice = v;
|
if (v < minPrice) minPrice = v;
|
||||||
if (v > maxPrice) maxPrice = v;
|
if (v > maxPrice) maxPrice = v;
|
||||||
|
|
@ -95,7 +106,8 @@ class KlinePainter extends CustomPainter {
|
||||||
|
|
||||||
if (bollData != null) {
|
if (bollData != null) {
|
||||||
for (final values in bollData!.values) {
|
for (final values in bollData!.values) {
|
||||||
for (final v in values) {
|
for (int i = visibleStart; i <= visibleEnd && i < values.length; i++) {
|
||||||
|
final v = values[i];
|
||||||
if (v != null) {
|
if (v != null) {
|
||||||
if (v < minPrice) minPrice = v;
|
if (v < minPrice) minPrice = v;
|
||||||
if (v > maxPrice) maxPrice = v;
|
if (v > maxPrice) maxPrice = v;
|
||||||
|
|
@ -104,6 +116,12 @@ class KlinePainter extends CustomPainter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 防止无可见数据时的异常
|
||||||
|
if (minPrice == double.infinity || maxPrice == double.negativeInfinity) {
|
||||||
|
minPrice = 0;
|
||||||
|
maxPrice = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// 添加上下留白
|
// 添加上下留白
|
||||||
final priceRange = maxPrice - minPrice;
|
final priceRange = maxPrice - minPrice;
|
||||||
// 避免 priceRange 为 0 导致 NaN
|
// 避免 priceRange 为 0 导致 NaN
|
||||||
|
|
@ -122,10 +140,6 @@ class KlinePainter extends CustomPainter {
|
||||||
// 绘制网格和价格标签
|
// 绘制网格和价格标签
|
||||||
_drawGrid(canvas, size, minPrice, maxPrice, leftPadding, rightPadding, topPadding, chartHeight);
|
_drawGrid(canvas, size, minPrice, maxPrice, leftPadding, rightPadding, topPadding, chartHeight);
|
||||||
|
|
||||||
// 计算K线宽度 - 使用传入的宽度或自动计算
|
|
||||||
final actualCandleWidth = candleWidth ?? (chartWidth / klines.length);
|
|
||||||
final bodyWidth = math.max(actualCandleWidth * 0.7, 2.0);
|
|
||||||
|
|
||||||
// 裁剪绘图区域,防止K线超出边界
|
// 裁剪绘图区域,防止K线超出边界
|
||||||
canvas.save();
|
canvas.save();
|
||||||
canvas.clipRect(Rect.fromLTRB(leftPadding, 0, size.width - rightPadding, size.height));
|
canvas.clipRect(Rect.fromLTRB(leftPadding, 0, size.width - rightPadding, size.height));
|
||||||
|
|
@ -540,7 +554,22 @@ class KlinePainter extends CustomPainter {
|
||||||
String _formatPrice(double price) {
|
String _formatPrice(double price) {
|
||||||
if (price >= 1) return price.toStringAsFixed(4);
|
if (price >= 1) return price.toStringAsFixed(4);
|
||||||
if (price >= 0.0001) return price.toStringAsFixed(6);
|
if (price >= 0.0001) return price.toStringAsFixed(6);
|
||||||
return price.toStringAsExponential(2);
|
if (price <= 0) return '0';
|
||||||
|
// 0.00000980 → 0.0{5}980
|
||||||
|
final str = price.toStringAsFixed(18);
|
||||||
|
final dotIndex = str.indexOf('.');
|
||||||
|
int zeroCount = 0;
|
||||||
|
for (int i = dotIndex + 1; i < str.length; i++) {
|
||||||
|
if (str[i] == '0') {
|
||||||
|
zeroCount++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final sigStart = dotIndex + 1 + zeroCount;
|
||||||
|
final sigEnd = math.min(sigStart + 3, str.length);
|
||||||
|
final significant = str.substring(sigStart, sigEnd);
|
||||||
|
return '0.0{$zeroCount}$significant';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue