Skip to content

Commit f0fce66

Browse files
committed
update backtest report
1 parent ca98e26 commit f0fce66

File tree

3 files changed

+185
-51
lines changed

3 files changed

+185
-51
lines changed

web/ui/src/lib/Snippets.svelte

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,10 @@
11
<script lang="ts" module>
22
import type {InOutOrder} from "$lib/order";
33
import { fmtDateStr } from '$lib/dateutil';
4+
import { fmtNumber } from '$lib/common';
45
import * as m from '$lib/paraglide/messages.js'
56
export {orderCard, pagination}
67
7-
8-
function formatNumber(num: number, decimals = 2) {
9-
if(!num) return '0';
10-
if(decimals >= 6 && num > 1){
11-
decimals = 4;
12-
}
13-
return num.toFixed(decimals);
14-
}
15-
168
function formatPercent(num: number, decimals = 1) {
179
if(!num) return '0%';
1810
return num.toFixed(decimals) + '%';
@@ -44,9 +36,8 @@
4436
{/snippet}
4537

4638
{#snippet orderCard(order: InOutOrder, isSelected: boolean, onAnalysis: () => void, onDetail: (e: Event) => void)}
47-
<div class="w-[15em] mr-2 mb-3 bg-base-200 hover:bg-base-300 cursor-pointer shadow-sm hover:shadow-md transition-all rounded-lg"
39+
<div class="w-[15em] mr-2 mb-3 bg-base-200 hover:bg-base-300 cursor-pointer shadow-sm hover:shadow-md transition-all rounded-lg {isSelected ? 'ring-2 ring-primary bg-primary/10' : ''}"
4840
onclick={onAnalysis}
49-
class:bg-slate-200={isSelected}
5041
>
5142
<div class="p-3.5">
5243
<div class="flex mb-2.5 items-center justify-between">
@@ -60,10 +51,7 @@
6051
{order.enter_tag}
6152
</div>
6253
<div class="tooltip font-medium" data-tip={m.enter_price()}>
63-
{formatNumber(order.enter?.average||order.enter?.price || 0, 8)}
64-
</div>
65-
<div class="tooltip opacity-75" data-tip={m.enter_amount()}>
66-
{formatNumber(order.enter?.filled ||order.enter?.amount || 0, 8)}
54+
{fmtNumber(order.enter?.average||order.enter?.price || 0)}
6755
</div>
6856
</div>
6957

web/ui/src/lib/common.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,115 @@ export function getFirstValid(vals: any[]){
2828
export interface StrVal{
2929
str: string
3030
val: any
31-
}
31+
}
32+
33+
/**
34+
* 格式化价格显示
35+
* @param value 要格式化的数字
36+
* @param digits 有效数字位数,默认6位
37+
* @returns 格式化后的字符串
38+
*
39+
* 规则:
40+
* - < 0.001: 使用科学计数法,保留指定位有效数字(截断)
41+
* - 0.001 ~ 1000: 保留指定位有效数字(截断)
42+
* - >= 1000: 使用千位分隔符,保留指定位有效数字(截断)
43+
*/
44+
export function fmtNumber(value: number, digits: number = 6): string {
45+
// 处理特殊值
46+
if (value === 0) return '0';
47+
if (!isFinite(value) || isNaN(value)) return '0';
48+
49+
const absValue = Math.abs(value);
50+
const sign = value < 0 ? '-' : '';
51+
52+
// < 0.001: 使用科学计数法
53+
if (absValue < 0.001) {
54+
return sign + formatScientific(absValue, digits);
55+
}
56+
57+
// 0.001 ~ 1000: 保留有效数字,不使用千位分隔符
58+
if (absValue < 1000) {
59+
return sign + formatWithDigits(absValue, digits);
60+
}
61+
62+
// >= 1000: 使用千位分隔符
63+
// 如果是整数或接近整数,保留整数部分
64+
const integerPart = Math.floor(absValue);
65+
const decimalPart = absValue - integerPart;
66+
67+
// 如果小数部分很小或整数部分已经很大,只显示整数
68+
if (decimalPart < 0.01 || integerPart >= 1000000) {
69+
return sign + addThousandsSeparator(integerPart.toString());
70+
}
71+
72+
// 否则按有效数字格式化
73+
const formatted = formatWithDigits(absValue, digits);
74+
return sign + addThousandsSeparator(formatted);
75+
}
76+
77+
/**
78+
* 格式化科学计数法(截断)
79+
*/
80+
function formatScientific(value: number, digits: number): string {
81+
// 获取指数
82+
const exponent = Math.floor(Math.log10(value));
83+
// 获取尾数
84+
const mantissa = value / Math.pow(10, exponent);
85+
// 截断尾数到指定位数
86+
const factor = Math.pow(10, digits - 1);
87+
const truncatedMantissa = Math.floor(mantissa * factor) / factor;
88+
89+
// 格式化输出,保持固定的小数位数
90+
const mantissaStr = truncatedMantissa.toFixed(digits - 1);
91+
return `${mantissaStr}e${exponent}`;
92+
}
93+
94+
/**
95+
* 按有效数字格式化数字(截断而非四舍五入)
96+
*/
97+
function formatWithDigits(value: number, digits: number): string {
98+
if (value === 0) return '0';
99+
100+
// 计算整数部分的位数
101+
const integerDigits = Math.floor(Math.log10(value)) + 1;
102+
103+
// 如果整数部分位数超过有效数字,优先保留整数
104+
if (integerDigits >= digits) {
105+
// 对于超大数字,保留整数部分
106+
if (integerDigits > digits) {
107+
const factor = Math.pow(10, integerDigits - digits);
108+
const truncated = Math.floor(value / factor) * factor;
109+
return truncated.toString();
110+
}
111+
// 整数部分刚好等于有效数字,返回整数
112+
return Math.floor(value).toString();
113+
}
114+
115+
// 计算需要保留的小数位数
116+
const decimalPlaces = digits - integerDigits;
117+
const factor = Math.pow(10, decimalPlaces);
118+
const truncated = Math.floor(value * factor) / factor;
119+
120+
// 转换为字符串并移除末尾的零
121+
let result = truncated.toFixed(decimalPlaces);
122+
if (result.indexOf('.') >= 0) {
123+
result = result.replace(/\.?0+$/, '');
124+
}
125+
return result;
126+
}
127+
128+
/**
129+
* 添加千位分隔符
130+
*/
131+
function addThousandsSeparator(value: string): string {
132+
const parts = value.split('.');
133+
const integerPart = parts[0];
134+
const decimalPart = parts[1];
135+
136+
// 为整数部分添加千位分隔符
137+
const withSeparator = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
138+
139+
// 如果有小数部分,拼接回去
140+
return decimalPart !== undefined ? `${withSeparator}.${decimalPart}` : withSeparator;
141+
}
142+

web/ui/src/routes/(dev)/backtest/item/+page.svelte

Lines changed: 70 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { getApi } from '$lib/netio';
55
import { alerts } from "$lib/stores/alerts";
66
import CodeMirror from '$lib/dev/CodeMirror.svelte';
7+
import Icon from '$lib/Icon.svelte';
78
import { oneDark } from '@codemirror/theme-one-dark';
89
import type { Extension } from '@codemirror/state';
910
import * as m from '$lib/paraglide/messages.js';
@@ -13,7 +14,7 @@
1314
import type { BacktestDetail, BackTestTask, ExSymbol } from '$lib/dev/common';
1415
import { InOutOrderDetail, type InOutOrder } from '$lib/order';
1516
import { TreeView, type Tree, type Node, buildTree } from '$lib/treeview';
16-
import { writable } from 'svelte/store';
17+
import { writable } from 'svelte/store';
1718
import RangeSlider from '$lib/dev/RangeSlider.svelte';
1819
import { ChartCtx, ChartSave } from '$lib/kline/chart';
1920
import { persisted } from 'svelte-persisted-store';
@@ -23,7 +24,7 @@
2324
import type { OverlayCreate } from 'klinecharts';
2425
import type { TradeInfo } from '$lib/kline/types';
2526
import { pagination, orderCard } from '$lib/Snippets.svelte';
26-
import {getFirstValid} from "$lib/common";
27+
import {getFirstValid, fmtNumber} from "$lib/common";
2728
2829
let id = $state('');
2930
let btPath = $state('');
@@ -448,32 +449,55 @@ ${m.holding()}: ${fmtDuration((td.exit_at - td.enter_at) / 1000)}`;
448449
449450
// 定义导航菜单项
450451
let navItems = $derived.by(() => {
451-
const items = [];
452+
const items = []
452453
453454
if (task?.status == 3) {
454455
if (detail) {
455456
items.push(
456-
{ id: 'overview', label: m.overview() },
457-
{ id: 'assets', label: m.bt_assets() },
458-
{ id: 'enters', label: m.bt_enters() }
457+
{ id: 'overview', label: m.overview(), icon: 'home' },
458+
{ id: 'assets', label: m.bt_assets(), icon: 'chart-bar' },
459+
{ id: 'enters', label: m.bt_enters(), icon: 'double-right' }
459460
);
460461
}
461462
462-
items.push({ id: 'config', label: m.configuration() });
463+
items.push({ id: 'config', label: m.configuration(), icon: 'config' });
463464
464465
if (detail) {
465466
items.push(
466-
{ id: 'orders', label: m.orders() },
467-
{ id: 'analysis', label: m.bt_analysis() },
468-
{ id: 'strat_code', label: m.bt_strat_code() }
467+
{ id: 'orders', label: m.orders(), icon: 'number-list' },
468+
{ id: 'analysis', label: m.bt_analysis(), icon: 'calculate' },
469+
{ id: 'strat_code', label: m.bt_strat_code(), icon: 'code' }
469470
);
470471
}
471472
}
472473
473-
items.push({ id: 'logs', label: m.bt_logs() });
474+
items.push({ id: 'logs', label: m.bt_logs(), icon: 'document-text' });
474475
return items;
475476
});
476477
478+
// 组织次要统计信息的数据,返回最大3列的二维数组
479+
function getInfoColumns() {
480+
if (!detail || !task) return [];
481+
return [
482+
[
483+
{ label: m.strategy(), value: task?.strats || '-' },
484+
{ label: m.time_period(), value: task?.periods || '-' },
485+
{ label: m.start_time(), value: fmtDateStr(detail.startMS), value_tip: curTZ() },
486+
{ label: m.end_time(), value: fmtDateStr(detail.endMS), value_tip: curTZ() },
487+
],[
488+
{ label: m.total_invest(), value: task?.walletAmount ? formatNumber(task.walletAmount) : formatNumber(detail.totalInvest) },
489+
{ label: m.final_balance(), value: formatNumber(detail.finBalance) },
490+
{ label: m.total_withdraw(), value: formatNumber(detail.finWithdraw) },
491+
{ label: m.tot_fee(), value: `${formatNumber(detail.totFee)} (${formatPercent(detail.totFee/detail.totProfit*100)})` },
492+
],[
493+
{ label: m.max_drawdown(), value: `${formatPercent(detail.maxDrawDownPct)} (${formatNumber(detail.maxDrawDownVal)} USD)`},
494+
{ label: m.bar_num(), value: detail.barNum.toString() },
495+
{ label: m.symbol(), value: task?.pairs ? showPairs(task.pairs) : '-' },
496+
{ label: m.max_open_orders(), value: detail.maxOpenOrders.toString() }
497+
]
498+
];
499+
}
500+
477501
function setActiveTab(tab: string) {
478502
activeTab = tab;
479503
if(activeTab === 'orders') {
@@ -499,8 +523,14 @@ ${m.holding()}: ${fmtDuration((td.exit_at - td.enter_at) / 1000)}`;
499523

500524
<div class="px-4 py-6 flex-1 flex flex-col">
501525
<div class="flex justify-between items-center mb-6">
502-
<h1 class="text-2xl font-bold">{m.bt_report()}: {id}</h1>
503-
<div class="text-sm opacity-75">{btPath}</div>
526+
<div class="flex items-center gap-4">
527+
<h1 class="text-2xl font-bold">{m.bt_report()}: {id}</h1>
528+
<div class="text-sm opacity-75">{btPath}</div>
529+
</div>
530+
<button class="btn btn-sm btn-ghost gap-2" onclick={() => history.back()}>
531+
<Icon name="double-left" class="size-4" />
532+
{m.back()}
533+
</button>
504534
</div>
505535

506536
<div class="flex gap-4 flex-1">
@@ -510,6 +540,7 @@ ${m.holding()}: ${fmtDuration((td.exit_at - td.enter_at) / 1000)}`;
510540
{#each navItems as item}
511541
<li>
512542
<button class:menu-active={activeTab === item.id} onclick={() => setActiveTab(item.id)}>
543+
<Icon name={item.icon} class="size-4" />
513544
{item.label}
514545
</button>
515546
</li>
@@ -582,16 +613,9 @@ ${m.holding()}: ${fmtDuration((td.exit_at - td.enter_at) / 1000)}`;
582613
formatPercent(detail.winRatePct),
583614
`${m.order_num()} ${detail.orderNum}`
584615
)}
585-
586-
{@render statCard(
587-
m.max_drawdown(),
588-
'text-warning',
589-
formatPercent(detail.maxDrawDownPct),
590-
`${formatNumber(detail.maxDrawDownVal)} USD`
591-
)}
592616

593617
{@render statCard(
594-
m.show_drawdown(),
618+
m.max_drawdown(),
595619
'text-warning',
596620
formatPercent(detail.showDrawDownPct),
597621
`${formatNumber(detail.showDrawDownVal)} USD`
@@ -605,16 +629,22 @@ ${m.holding()}: ${fmtDuration((td.exit_at - td.enter_at) / 1000)}`;
605629
</div>
606630

607631
<!-- 次要统计信息 -->
608-
<div class="grid grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
609-
{@render infoCard(m.bt_range() + ` (${curTZ()})`, `${fmtDateStr(detail.startMS)} ~ ${fmtDateStr(detail.endMS)}`)}
610-
{@render infoCard(
611-
`${m.total_invest()}/${m.final_balance()}/${m.total_withdraw()}`,
612-
`${task?.walletAmount ? formatNumber(task.walletAmount) : formatNumber(detail.totalInvest)} / ${formatNumber(detail.finBalance)} / ${formatNumber(detail.finWithdraw)}`
613-
)}
614-
{@render infoCard(m.tot_fee(), `${formatNumber(detail.totFee)} (${formatPercent(detail.totFee/detail.totProfit*100)})`)}
615-
{@render infoCard(m.strategy(), task?.strats || '-')}
616-
{@render infoCard(`${m.time_period()}/${m.bar_num()}`, `${task?.periods || '-'} / ${detail.barNum}`)}
617-
{@render infoCard(m.symbol() + '/' + m.max_open_orders(), `${task?.pairs ? showPairs(task.pairs) : '-'} / ${detail.maxOpenOrders}`)}
632+
{@const infoColumns = getInfoColumns()}
633+
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6 mb-6">
634+
{#each infoColumns as column, idx}
635+
<!-- 使用响应式类控制显示 -->
636+
<div class="bg-base-200 rounded-box p-4
637+
{idx === 2 ? 'lg:col-span-2 xl:col-span-1' : ''}">
638+
<div class="space-y-3">
639+
{#each column as item}
640+
<div class="flex justify-between items-center">
641+
<span class="text-sm opacity-75">{item.label}</span>
642+
<span class="text-sm font-medium" title="{item.value_tip}">{item.value}</span>
643+
</div>
644+
{/each}
645+
</div>
646+
</div>
647+
{/each}
618648
</div>
619649

620650
<!-- 分组统计 -->
@@ -625,30 +655,35 @@ ${m.holding()}: ${fmtDuration((td.exit_at - td.enter_at) / 1000)}`;
625655
class="tab"
626656
class:tab-active={activeGroupTab === 'pairs'}
627657
onclick={() => activeGroupTab = 'pairs'}>
658+
<Icon name="chart-bar" class="size-4 mr-2" />
628659
{m.stat_by_pairs()}
629660
</button>
630661
<button role="tab"
631662
class="tab"
632663
class:tab-active={activeGroupTab === 'dates'}
633664
onclick={() => activeGroupTab = 'dates'}>
665+
<Icon name="calender" class="size-4 mr-2" />
634666
{m.stat_by_dates()}
635667
</button>
636668
<button role="tab"
637669
class="tab"
638670
class:tab-active={activeGroupTab === 'profits'}
639671
onclick={() => activeGroupTab = 'profits'}>
672+
<Icon name="dollar" class="size-4 mr-2" />
640673
{m.stat_by_profits()}
641674
</button>
642675
<button role="tab"
643676
class="tab"
644677
class:tab-active={activeGroupTab === 'enters'}
645678
onclick={() => activeGroupTab = 'enters'}>
679+
<Icon name="chevron-right" class="size-4 mr-2" />
646680
{m.stat_by_enters()}
647681
</button>
648682
<button role="tab"
649683
class="tab"
650684
class:tab-active={activeGroupTab === 'exits'}
651685
onclick={() => activeGroupTab = 'exits'}>
686+
<Icon name="chevron-down" class="size-4 mr-2" />
652687
{m.stat_by_exits()}
653688
</button>
654689
</div>
@@ -915,12 +950,12 @@ ${m.holding()}: ${fmtDuration((td.exit_at - td.enter_at) / 1000)}`;
915950
<td>{order.leverage}x</td>
916951
<td>{fmtDateStr(order.enter_at)}</td>
917952
<td>{order.enter_tag}</td>
918-
<td>{formatNumber(order.enter?.average||order.enter?.price || 0, 8)}</td>
919-
<td>{formatNumber(order.enter?.filled ||order.enter?.amount || 0, 8)}</td>
953+
<td>{fmtNumber(order.enter?.average||order.enter?.price || 0)}</td>
954+
<td>{fmtNumber(order.enter?.filled ||order.enter?.amount || 0)}</td>
920955
<td>{fmtDateStr(order.exit_at)}</td>
921956
<td>{order.exit_tag}</td>
922-
<td>{formatNumber(order.exit?.average||order.exit?.price || 0, 8)}</td>
923-
<td>{formatNumber(order.exit?.filled ||order.exit?.amount || 0, 8)}</td>
957+
<td>{fmtNumber(order.exit?.average||order.exit?.price || 0)}</td>
958+
<td>{fmtNumber(order.exit?.filled ||order.exit?.amount || 0)}</td>
924959
<td>{formatNumber(order.profit)}</td>
925960
</tr>
926961
{/each}

0 commit comments

Comments
 (0)