Skip to content

Commit 9d103b6

Browse files
committed
support lineChart for backtest card
1 parent c3a012f commit 9d103b6

5 files changed

Lines changed: 224 additions & 8 deletions

File tree

opt/tools.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -658,12 +658,28 @@ func readAssetHtml(file, prefix string, useRate bool) (*AssetData, *errs.Error)
658658
}
659659

660660
// 提取JSON数据
661-
start := strings.Index(string(content), "var chartData = ") + 15
662-
end := strings.Index(string(content)[start:], "\n")
663-
if start < 15 || end < 0 {
661+
text := string(content)
662+
start := strings.Index(text, "chartData = ")
663+
keyLen := len("chartData = ")
664+
if start < 0 {
665+
start = strings.Index(text, "chartData=")
666+
keyLen = len("chartData=")
667+
}
668+
if start < 0 {
664669
return nil, errs.New(errs.CodeInvalidData, fmt.Errorf("invalid html format in file %s", file))
665670
}
666-
jsonStr := string(content)[start : start+end]
671+
start += keyLen
672+
end := strings.Index(text[start:], "\n")
673+
if end < 0 {
674+
end = strings.Index(text[start:], ";")
675+
}
676+
if end < 0 {
677+
end = strings.Index(text[start:], "</script>")
678+
}
679+
if end < 0 {
680+
return nil, errs.New(errs.CodeInvalidData, fmt.Errorf("invalid html format in file %s", file))
681+
}
682+
jsonStr := text[start : start+end]
667683

668684
var data AssetData
669685
if err = utils2.UnmarshalString(jsonStr, &data, utils2.JsonNumDefault); err != nil {

web/dev/api_dev.go

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ func getBtTasks(c *fiber.Ctx) error {
465465
MaxStart int64 `query:"maxStart"`
466466
MaxID int64 `query:"maxId"`
467467
Limit int64 `query:"limit"`
468+
Assets bool `query:"assets"`
468469
}
469470

470471
var args = new(TaskArgs)
@@ -506,14 +507,119 @@ func getBtTasks(c *fiber.Ctx) error {
506507
// 处理返回数据
507508
result := make([]map[string]interface{}, 0, len(tasks))
508509
for _, task := range tasks {
509-
result = append(result, task.ToMap())
510+
taskMap := task.ToMap()
511+
if args.Assets {
512+
appendTaskAssets(task, taskMap)
513+
}
514+
result = append(result, taskMap)
510515
}
511516

512517
return c.JSON(fiber.Map{
513518
"data": result,
514519
})
515520
}
516521

522+
const btCardAssetPoints = 160
523+
524+
type assetChartDataset struct {
525+
Label string `json:"label"`
526+
Data []float64 `json:"data"`
527+
}
528+
529+
type assetChartData struct {
530+
Labels []string `json:"labels"`
531+
Datasets []assetChartDataset `json:"datasets"`
532+
}
533+
534+
func appendTaskAssets(task *ormu.Task, taskMap map[string]interface{}) {
535+
if task == nil || task.Path == "" || task.Status < int64(ormu.BtStatusDone) {
536+
return
537+
}
538+
assetPath := filepath.Join(config.GetDataDir(), "backtest", task.Path, "assets.html")
539+
if !utils.Exists(assetPath) {
540+
return
541+
}
542+
reals, used, err := readAssetLines(assetPath, btCardAssetPoints)
543+
if err != nil || len(reals) == 0 {
544+
return
545+
}
546+
taskMap["reals"] = reals
547+
if len(used) > 0 {
548+
taskMap["used"] = used
549+
}
550+
}
551+
552+
func readAssetLines(file string, maxPoints int) ([]float64, []float64, error) {
553+
content, err := os.ReadFile(file)
554+
if err != nil {
555+
return nil, nil, err
556+
}
557+
text := string(content)
558+
start := strings.Index(text, "chartData = ")
559+
keyLen := len("chartData = ")
560+
if start < 0 {
561+
start = strings.Index(text, "chartData=")
562+
keyLen = len("chartData=")
563+
}
564+
if start < 0 {
565+
return nil, nil, fmt.Errorf("chart data not found")
566+
}
567+
start += keyLen
568+
end := strings.Index(text[start:], "\n")
569+
if end < 0 {
570+
end = strings.Index(text[start:], ";")
571+
}
572+
if end < 0 {
573+
end = strings.Index(text[start:], "</script>")
574+
}
575+
if end < 0 {
576+
return nil, nil, fmt.Errorf("chart data not terminated")
577+
}
578+
jsonStr := strings.TrimSpace(text[start : start+end])
579+
580+
var data assetChartData
581+
if err := utils2.UnmarshalString(jsonStr, &data, utils2.JsonNumDefault); err != nil {
582+
return nil, nil, err
583+
}
584+
585+
var reals []float64
586+
var used []float64
587+
for _, ds := range data.Datasets {
588+
switch ds.Label {
589+
case "Real":
590+
reals = ds.Data
591+
case "Available":
592+
used = ds.Data
593+
}
594+
}
595+
if len(reals) == 0 && len(data.Datasets) > 0 {
596+
reals = data.Datasets[0].Data
597+
}
598+
599+
reals = downsampleSeries(reals, maxPoints)
600+
used = downsampleSeries(used, maxPoints)
601+
return reals, used, nil
602+
}
603+
604+
func downsampleSeries(values []float64, maxPoints int) []float64 {
605+
if maxPoints <= 0 || len(values) <= maxPoints {
606+
return values
607+
}
608+
if maxPoints == 1 {
609+
return []float64{values[len(values)-1]}
610+
}
611+
step := float64(len(values)-1) / float64(maxPoints-1)
612+
result := make([]float64, 0, maxPoints)
613+
for i := 0; i < maxPoints; i++ {
614+
idx := int(step*float64(i) + 0.5)
615+
if idx >= len(values) {
616+
idx = len(values) - 1
617+
}
618+
result = append(result, values[idx])
619+
}
620+
return result
621+
}
622+
517623
// getBtOptions 获取回测选项列表
518624
func getBtOptions(c *fiber.Ctx) error {
519625
qu, conn, err2 := ormu.Conn()

web/ui/src/lib/LinePath.svelte

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<script lang="ts">
2+
/**
3+
* Simple line path renderer for card background curves.
4+
*/
5+
let {
6+
reals = [],
7+
used = [],
8+
width = 300,
9+
height = 200,
10+
realsColor = '#ddd',
11+
usedColor = '#aaa',
12+
strokeWidth = 1,
13+
className = ''
14+
}: {
15+
reals?: number[];
16+
used?: number[];
17+
width?: number;
18+
height?: number;
19+
realsColor?: string;
20+
usedColor?: string;
21+
strokeWidth?: number;
22+
className?: string;
23+
} = $props();
24+
25+
function normalizeDataWithSharedScale(
26+
data: number[],
27+
width: number,
28+
height: number,
29+
min: number,
30+
max: number
31+
): { x: number; y: number }[] {
32+
if (!data || data.length === 0) return [];
33+
const range = max - min || 1;
34+
return data.map((value, index) => {
35+
const x = (index / (data.length - 1)) * width;
36+
const y = height - ((value - min) / range) * height;
37+
return { x, y };
38+
});
39+
}
40+
41+
function generatePath(points: { x: number; y: number }[]): string {
42+
if (points.length === 0) return '';
43+
return points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ');
44+
}
45+
46+
let sharedMin = $derived.by(() => {
47+
const allValues = [...reals, ...used].filter((v) => v !== undefined && v !== null);
48+
return allValues.length > 0 ? Math.min(...allValues) : 0;
49+
});
50+
51+
let sharedMax = $derived.by(() => {
52+
const allValues = [...reals, ...used].filter((v) => v !== undefined && v !== null);
53+
return allValues.length > 0 ? Math.max(...allValues) : 1;
54+
});
55+
56+
let realsPoints = $derived(normalizeDataWithSharedScale(reals, width, height, sharedMin, sharedMax));
57+
let usedPoints = $derived(normalizeDataWithSharedScale(used, width, height, sharedMin, sharedMax));
58+
59+
let realsPath = $derived(generatePath(realsPoints));
60+
let usedPath = $derived(generatePath(usedPoints));
61+
62+
let hasData = $derived(reals.length > 0 || used.length > 0);
63+
</script>
64+
65+
{#if hasData}
66+
<svg
67+
xmlns="http://www.w3.org/2000/svg"
68+
viewBox="0 0 {width} {height}"
69+
preserveAspectRatio="none"
70+
class="absolute inset-0 w-full h-full opacity-30 {className}"
71+
style="z-index: 0;"
72+
>
73+
{#if realsPath}
74+
<path d={realsPath} stroke={realsColor} stroke-width={strokeWidth} fill="none" />
75+
{/if}
76+
{#if usedPath}
77+
<path d={usedPath} stroke={usedColor} stroke-width={strokeWidth} fill="none" />
78+
{/if}
79+
</svg>
80+
{/if}

web/ui/src/lib/dev/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ export interface BtTask {
1818
maxDrawdown: number; // 最大回撤
1919
sharpe: number; // 夏普比率
2020

21+
reals?: number[];
22+
used?: number[];
23+
2124
maxOpenOrders?: number; // 最大开单数
2225
showDrawDownPct?: number; // 显示的回撤百分比
2326
barNum?: number; // 数据柱数量

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import {addListener} from '$lib/dev/websocket';
1111
import {site} from "$lib/stores/site";
1212
import Icon from "$lib/Icon.svelte";
13+
import LinePath from "$lib/LinePath.svelte";
1314
import {localizeHref} from "$lib/paraglide/runtime";
1415
import {modals} from "$lib/stores/modals";
1516
import type {StrVal} from "$lib/common";
@@ -73,6 +74,7 @@
7374
strat: selectedStrat,
7475
period: selectedPeriod,
7576
range: selectedRange,
77+
assets: true,
7678
limit: pageSize,
7779
maxId: maxId || 0
7880
});
@@ -310,14 +312,23 @@ Error: ${task.info}`
310312
<!-- 结果列表 -->
311313
<div class="grid grid-cols-3 gap-6 mb-6">
312314
{#each tasks as task, tidx}
313-
<div class="card bg-base-100 shadow hover:shadow-lg transition-shadow duration-200 cursor-pointer relative {isTaskSelected(task) ? 'ring-2 ring-primary' : ''}"
315+
<div class="card bg-base-100 shadow hover:shadow-lg transition-shadow duration-200 cursor-pointer relative overflow-hidden {isTaskSelected(task) ? 'ring-2 ring-primary' : ''}"
314316
onclick={(e) => isMultiSelect ? toggleTaskSelection(task, e) : clickTask(task)}
315317
onmouseenter={() => hoveredCard = tidx}
316318
onmouseleave={() => hoveredCard = null}>
319+
{#if task.reals && task.reals.length > 0}
320+
<LinePath
321+
reals={task.reals}
322+
used={task.used || []}
323+
realsColor="#36A2EB"
324+
usedColor="#FF6384"
325+
strokeWidth={1}
326+
/>
327+
{/if}
317328
{#if hoveredCard === tidx}
318329
<div class="custom-tooltip {tidx >= 3 ? 'loc-top' : ''}">{taskTooltip(task)}</div>
319330
{/if}
320-
<div class="card-body p-5">
331+
<div class="card-body p-5 relative" style="z-index: 1;">
321332
<!-- 第一行 -->
322333
<div class="flex justify-between items-center mb-3">
323334
<div class="flex items-center gap-3">
@@ -417,4 +428,4 @@ Error: ${task.info}`
417428
</div>
418429
</div>
419430
{/if}
420-
</div>
431+
</div>

0 commit comments

Comments
 (0)