first commit
This commit is contained in:
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="app-dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>股市回测平台</title>
|
||||
<style>
|
||||
html, body { background-color: #0b0e14; margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "stock-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.0.0",
|
||||
"echarts": "^6.0.0",
|
||||
"lightweight-charts": "^5.0.0",
|
||||
"pinia": "^2.3.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^5.0.0",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^2.1.0"
|
||||
}
|
||||
}
|
||||
1204
frontend/pnpm-lock.yaml
generated
Normal file
1204
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
frontend/pnpm-workspace.yaml
Normal file
3
frontend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
13
frontend/src/App.vue
Normal file
13
frontend/src/App.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import BacktestView from '@/views/BacktestView.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<header class="app-header">
|
||||
<h1>股市回测平台</h1>
|
||||
<span class="sub">历史回测 · 回放式模拟 · A股(红涨绿跌)</span>
|
||||
</header>
|
||||
<BacktestView />
|
||||
</div>
|
||||
</template>
|
||||
29
frontend/src/api/client.ts
Normal file
29
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { BacktestRequest, BacktestResponse, SyncRequest, SyncResponse } from './types';
|
||||
|
||||
// dev 用 Vite 代理(/api -> :8000);生产构建设 VITE_API_BASE 指向后端地址。
|
||||
const BASE = import.meta.env.VITE_API_BASE ?? '';
|
||||
|
||||
export async function postBacktest(req: BacktestRequest): Promise<BacktestResponse> {
|
||||
const res = await fetch(`${BASE}/api/backtest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`回测请求失败 (HTTP ${res.status}): ${await res.text()}`);
|
||||
}
|
||||
return (await res.json()) as BacktestResponse;
|
||||
}
|
||||
|
||||
export async function syncData(req: SyncRequest): Promise<SyncResponse> {
|
||||
const res = await fetch(`${BASE}/api/data/sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(req),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`数据拉取失败 (HTTP ${res.status}): ${await res.text()}`);
|
||||
}
|
||||
return (await res.json()) as SyncResponse;
|
||||
}
|
||||
|
||||
76
frontend/src/api/types.ts
Normal file
76
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// 与后端 app/schemas.py 一一对应的 TypeScript 类型(OpenAPI 契约的前端镜像)。
|
||||
// 后续可由 openapi-typescript-codegen 自动生成;MVP 先手写保持同步。
|
||||
|
||||
export interface Candle {
|
||||
ts: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface BacktestRequest {
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
strategy: string;
|
||||
params: Record<string, number>;
|
||||
initial_cash: number;
|
||||
fast_mode: boolean;
|
||||
start?: string;
|
||||
end?: string;
|
||||
}
|
||||
|
||||
export interface SignalOut {
|
||||
ts: string;
|
||||
side: 'buy' | 'sell';
|
||||
price: number;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface EquityPoint {
|
||||
ts: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface IndicatorOut {
|
||||
strategy: string;
|
||||
data: Record<string, (number | null)[]>;
|
||||
}
|
||||
|
||||
export interface MetricsOut {
|
||||
total_return: number;
|
||||
max_drawdown: number;
|
||||
sharpe: number;
|
||||
volatility: number;
|
||||
num_trades: number;
|
||||
win_rate: number;
|
||||
}
|
||||
|
||||
export interface BacktestResponse {
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
strategy: string;
|
||||
candles: Candle[];
|
||||
indicators: IndicatorOut;
|
||||
signals: SignalOut[];
|
||||
equity: EquityPoint[];
|
||||
metrics: MetricsOut;
|
||||
final_cash: number;
|
||||
final_position: number;
|
||||
initial_cash: number;
|
||||
}
|
||||
|
||||
export interface SyncRequest {
|
||||
symbol: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
source?: string; // auto | tushare | akshare
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncResponse {
|
||||
symbol: string;
|
||||
bars: number;
|
||||
source: string;
|
||||
}
|
||||
90
frontend/src/components/BacktestForm.vue
Normal file
90
frontend/src/components/BacktestForm.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import Select from 'primevue/select';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import Button from 'primevue/button';
|
||||
import type { BacktestRequest } from '@/api/types';
|
||||
|
||||
defineProps<{ loading: boolean }>();
|
||||
const emit = defineEmits<{ run: [req: BacktestRequest] }>();
|
||||
|
||||
interface ParamDef { k: string; label: string; def: number; }
|
||||
const STRATS: { id: string; label: string; params: ParamDef[] }[] = [
|
||||
{ id: 'ma_cross', label: '双均线交叉', params: [{ k: 'fast', label: '快均线', def: 5 }, { k: 'slow', label: '慢均线', def: 20 }] },
|
||||
{ id: 'single_ma', label: '单均线(价格穿越)', params: [{ k: 'period', label: '均线周期', def: 20 }] },
|
||||
{ id: 'macd_cross', label: 'MACD 金叉死叉', params: [{ k: 'fast', label: '快线', def: 12 }, { k: 'slow', label: '慢线', def: 26 }, { k: 'signal', label: '信号线', def: 9 }] },
|
||||
];
|
||||
const stratOptions = STRATS.map((s) => ({ label: s.label, value: s.id }));
|
||||
|
||||
const form = reactive({
|
||||
symbol: '000001',
|
||||
timeframe: '1d',
|
||||
strategy: 'ma_cross',
|
||||
params: {} as Record<string, number>,
|
||||
initial_cash: 1000000,
|
||||
fast_mode: false,
|
||||
});
|
||||
|
||||
function applyDefaults(stratId: string) {
|
||||
const s = STRATS.find((x) => x.id === stratId)!;
|
||||
form.params = Object.fromEntries(s.params.map((p) => [p.k, p.def]));
|
||||
}
|
||||
watch(() => form.strategy, (id) => applyDefaults(id));
|
||||
applyDefaults(form.strategy);
|
||||
|
||||
const currentParams = computed(() => STRATS.find((x) => x.id === form.strategy)!.params);
|
||||
|
||||
function onRun() {
|
||||
emit('run', {
|
||||
symbol: form.symbol,
|
||||
timeframe: form.timeframe,
|
||||
strategy: form.strategy,
|
||||
params: { ...form.params },
|
||||
initial_cash: form.initial_cash,
|
||||
fast_mode: form.fast_mode,
|
||||
} satisfies BacktestRequest);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<div class="field">
|
||||
<label>策略</label>
|
||||
<Select v-model="form.strategy" :options="stratOptions" optionLabel="label" optionValue="value" size="small" style="width: 170px" />
|
||||
</div>
|
||||
<div class="field" v-for="p in currentParams" :key="p.k">
|
||||
<label>{{ p.label }}</label>
|
||||
<InputNumber v-model="form.params[p.k]" :min="1" :max="250" size="small" inputStyle="width:64px" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>周期</label>
|
||||
<Select v-model="form.timeframe" :options="[{label:'日线',value:'1d'},{label:'周线',value:'1w'},{label:'月线',value:'1M'},{label:'年线',value:'1y'}]" optionLabel="label" optionValue="value" size="small" style="width: 100px" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>标的</label>
|
||||
<InputText v-model="form.symbol" size="small" style="width: 110px" placeholder="如 000001" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>初始资金</label>
|
||||
<InputNumber v-model="form.initial_cash" :min="1000" :step="100000" size="small" mode="currency" currency="CNY" inputStyle="width:130px" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>fast 模式</label>
|
||||
<ToggleSwitch v-model="form.fast_mode" />
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<Button label="开始回测" icon="pi pi-play" :loading="loading" size="small" @click="onRun" />
|
||||
</div>
|
||||
|
||||
<div class="quick">
|
||||
<span class="qlabel">快捷:</span>
|
||||
<button v-for="q in [{code:'000001',name:'平安银行'},{code:'600519',name:'贵州茅台'},{code:'000858',name:'五粮液'},{code:'601318',name:'中国平安'},{code:'DEMO',name:'合成数据'}]" :key="q.code" class="qchip" :class="{ active: form.symbol === q.code }" type="button" @click="form.symbol = q.code">
|
||||
{{ q.code }} <span class="qname">{{ q.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
策略可选 双均线 / 单均线 / MACD;参数随策略自适应。<code>DEMO</code> 为合成数据,其余为真实 A 股(首次自动经 Tushare 拉取并缓存)。
|
||||
</div>
|
||||
</template>
|
||||
60
frontend/src/components/EquityChart.vue
Normal file
60
frontend/src/components/EquityChart.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
import type { EquityPoint } from '@/api/types';
|
||||
|
||||
const props = defineProps<{ equity: EquityPoint[] }>();
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
let chart: echarts.ECharts | null = null;
|
||||
|
||||
function buildOption() {
|
||||
const dates = props.equity.map(p => p.ts.slice(0, 10));
|
||||
const vals = props.equity.map(p => Number(p.value.toFixed(2)));
|
||||
const first = vals.length ? vals[0] : 0;
|
||||
const last = vals.length ? vals[vals.length - 1] : 0;
|
||||
const lineColor = last >= first ? '#f6465d' : '#0ecb81'; // A股:盈利红、亏损绿
|
||||
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
grid: { left: 64, right: 18, top: 14, bottom: 26 },
|
||||
tooltip: {
|
||||
trigger: 'axis' as const,
|
||||
backgroundColor: '#1b2230', borderColor: 'rgba(255,255,255,0.1)', borderWidth: 1,
|
||||
textStyle: { color: '#e6edf3' },
|
||||
valueFormatter: (v: number) => (v ?? 0).toLocaleString(undefined, { maximumFractionDigits: 0 }),
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category', data: dates, boundaryGap: false,
|
||||
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.1)' } },
|
||||
axisLabel: { color: '#5c6675' }, axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value', scale: true,
|
||||
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)' } },
|
||||
axisLabel: { color: '#5c6675' },
|
||||
},
|
||||
series: [{
|
||||
type: 'line', data: vals, symbol: 'none', smooth: false,
|
||||
lineStyle: { color: lineColor, width: 2 },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: lineColor + '40' },
|
||||
{ offset: 1, color: lineColor + '00' },
|
||||
]),
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (container.value) chart = echarts.init(container.value, undefined, { renderer: 'canvas' });
|
||||
chart?.setOption(buildOption());
|
||||
});
|
||||
onBeforeUnmount(() => { chart?.dispose(); chart = null; });
|
||||
watch(() => props.equity, () => chart?.setOption(buildOption(), true), { deep: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="chart-equity"></div>
|
||||
</template>
|
||||
237
frontend/src/components/KLineChart.vue
Normal file
237
frontend/src/components/KLineChart.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import {
|
||||
createChart, CandlestickSeries, HistogramSeries, LineSeries,
|
||||
createSeriesMarkers, CrosshairMode, LineStyle,
|
||||
type IChartApi, type ISeriesApi, type ISeriesMarkersPluginApi, type SeriesMarker, type Time,
|
||||
} from 'lightweight-charts';
|
||||
import type { Candle, IndicatorOut, SignalOut } from '@/api/types';
|
||||
|
||||
const props = defineProps<{
|
||||
candles: Candle[];
|
||||
indicators: IndicatorOut;
|
||||
signals: SignalOut[];
|
||||
symbol?: string;
|
||||
timeframe?: string;
|
||||
strategy?: string;
|
||||
}>();
|
||||
|
||||
const TF_LABEL: Record<string, string> = { '1d': '日线', '1w': '周线', '1M': '月线', '1y': '年线' };
|
||||
const STRAT_LABEL: Record<string, string> = { macd_cross: 'MACD', ma_cross: '双均线', single_ma: '单均线' };
|
||||
const WD = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
|
||||
const UP = '#f6465d';
|
||||
const DOWN = '#0ecb81';
|
||||
const DIF = '#5b8ff9';
|
||||
const DEA = '#f6bd16';
|
||||
const MA_COLORS = ['#5b8ff9', '#f6bd16', '#c084fc', '#34d399'];
|
||||
|
||||
const IND_LABEL: Record<string, string> = { macd: 'DIF', signal: 'DEA', hist: 'MACD', fast: '快线', slow: '慢线', ma: '均线' };
|
||||
const IND_COLOR: Record<string, string> = {
|
||||
macd: DIF, signal: DEA, hist: '#9aa4b2', fast: DIF, slow: DEA, ma: '#c084fc',
|
||||
};
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
|
||||
let chart: IChartApi | null = null;
|
||||
let candleSeries: ISeriesApi<'Candlestick'> | null = null;
|
||||
let volumeSeries: ISeriesApi<'Histogram'> | null = null;
|
||||
let difSeries: ISeriesApi<'Line'> | null = null;
|
||||
let deaSeries: ISeriesApi<'Line'> | null = null;
|
||||
let histSeries: ISeriesApi<'Histogram'> | null = null;
|
||||
let maSeriesArr: { key: string; series: ISeriesApi<'Line'> }[] = [];
|
||||
let markersApi: ISeriesMarkersPluginApi<Time> | null = null;
|
||||
|
||||
interface DayRec {
|
||||
open: number; high: number; low: number; close: number; volume: number;
|
||||
prevClose: number | null; ind: Record<string, number | null>;
|
||||
}
|
||||
let byTime: Record<string, DayRec> = {};
|
||||
|
||||
const tip = ref<{ visible: boolean; x: number; y: number }>({ visible: false, x: 0, y: 0 });
|
||||
const tipData = ref<ReturnType<typeof buildTip> | null>(null);
|
||||
|
||||
const isMACD = computed(() => props.strategy === 'macd_cross' || 'hist' in (props.indicators.data ?? {}));
|
||||
|
||||
const legendChips = computed(() => {
|
||||
const keys = Object.keys(props.indicators.data ?? {});
|
||||
if (isMACD.value) return [{ label: 'DIF', color: DIF }, { label: 'DEA', color: DEA }];
|
||||
return keys.map((k, i) => ({ label: IND_LABEL[k] ?? k, color: MA_COLORS[i % MA_COLORS.length] }));
|
||||
});
|
||||
|
||||
const t = (ts: string): Time => ts.slice(0, 10) as Time;
|
||||
function timeKey(time: Time): string {
|
||||
if (typeof time === 'string') return time.slice(0, 10);
|
||||
const bd = time as { year: number; month: number; day: number };
|
||||
if (bd && typeof bd === 'object' && 'year' in bd) {
|
||||
return `${bd.year}-${String(bd.month).padStart(2, '0')}-${String(bd.day).padStart(2, '0')}`;
|
||||
}
|
||||
return String(time);
|
||||
}
|
||||
const fmt2 = (v: number | null) => (v == null ? '—' : v.toFixed(2));
|
||||
const fmt3 = (v: number | null) => (v == null ? '—' : v.toFixed(3));
|
||||
function weekdayOf(s: string) { return WD[new Date(s + 'T00:00:00').getDay()] ?? ''; }
|
||||
function hexA(hex: string, a: number) {
|
||||
const n = hex.replace('#', '');
|
||||
return `rgba(${parseInt(n.slice(0, 2), 16)},${parseInt(n.slice(2, 4), 16)},${parseInt(n.slice(4, 6), 16)},${a})`;
|
||||
}
|
||||
function toLine(arr: (number | null)[], c: Candle[]) {
|
||||
return arr.map((v, i) => (v == null ? { time: t(c[i].ts) } : { time: t(c[i].ts), value: v }));
|
||||
}
|
||||
|
||||
function buildTip(rec: DayRec, key: string) {
|
||||
const prev = rec.prevClose ?? rec.open;
|
||||
const change = rec.close - prev;
|
||||
return {
|
||||
date: key, weekday: weekdayOf(key),
|
||||
open: rec.open, high: rec.high, low: rec.low, close: rec.close,
|
||||
change, chgPct: prev ? (change / prev) * 100 : 0,
|
||||
amplitude: prev ? ((rec.high - rec.low) / prev) * 100 : 0,
|
||||
volLots: Math.round(rec.volume / 100),
|
||||
ind: rec.ind, up: change >= 0,
|
||||
};
|
||||
}
|
||||
|
||||
function build() {
|
||||
if (!container.value) return;
|
||||
const ch = createChart(container.value, {
|
||||
autoSize: true,
|
||||
layout: { background: { color: 'transparent' }, textColor: '#9aa4b2', fontSize: 11, attributionLogo: false },
|
||||
grid: { vertLines: { color: 'rgba(255,255,255,0.04)' }, horzLines: { color: 'rgba(255,255,255,0.04)' } },
|
||||
crosshair: {
|
||||
mode: CrosshairMode.Normal,
|
||||
vertLine: { color: 'rgba(255,255,255,0.25)', width: 1, style: LineStyle.Dashed, labelBackgroundColor: '#2a2e39' },
|
||||
horzLine: { color: 'rgba(255,255,255,0.25)', width: 1, style: LineStyle.Dashed, labelBackgroundColor: '#2a2e39' },
|
||||
},
|
||||
rightPriceScale: { borderColor: 'rgba(255,255,255,0.08)', scaleMargins: { top: 0.08, bottom: 0.28 } },
|
||||
timeScale: { borderColor: 'rgba(255,255,255,0.08)', rightOffset: 6, barSpacing: 8 },
|
||||
});
|
||||
chart = ch;
|
||||
|
||||
candleSeries = ch.addSeries(CandlestickSeries, {
|
||||
upColor: UP, downColor: DOWN, borderUpColor: UP, borderDownColor: DOWN, wickUpColor: UP, wickDownColor: DOWN,
|
||||
priceFormat: { type: 'price', precision: 2, minMove: 0.01 },
|
||||
}, 0);
|
||||
volumeSeries = ch.addSeries(HistogramSeries, { priceFormat: { type: 'volume' }, priceScaleId: 'vol' }, 0);
|
||||
volumeSeries.priceScale().applyOptions({ scaleMargins: { top: 0.82, bottom: 0 } });
|
||||
|
||||
const keys = Object.keys(props.indicators.data ?? {});
|
||||
if (isMACD.value && props.indicators.data?.hist) {
|
||||
// MACD 进副图(震荡指标,独立刻度)
|
||||
difSeries = ch.addSeries(LineSeries, { color: DIF, lineWidth: 2, priceScaleId: 'macd', priceLineVisible: false, lastValueVisible: true }, 1);
|
||||
deaSeries = ch.addSeries(LineSeries, { color: DEA, lineWidth: 2, priceScaleId: 'macd', priceLineVisible: false, lastValueVisible: true }, 1);
|
||||
histSeries = ch.addSeries(HistogramSeries, { priceScaleId: 'macd', priceLineVisible: false, lastValueVisible: false }, 1);
|
||||
try { ch.panes()[1]?.setHeight(140); } catch { /* pane 未就绪 */ }
|
||||
} else {
|
||||
// 均线叠加在主图(价格刻度,与 K 线同坐标系)
|
||||
maSeriesArr = keys.map((k, i) => ({
|
||||
key: k,
|
||||
series: ch.addSeries(LineSeries, {
|
||||
color: MA_COLORS[i % MA_COLORS.length], lineWidth: 1, priceLineVisible: false,
|
||||
lastValueVisible: false, crosshairMarkerVisible: true,
|
||||
}, 0),
|
||||
}));
|
||||
}
|
||||
|
||||
markersApi = createSeriesMarkers(candleSeries, []);
|
||||
|
||||
ch.subscribeCrosshairMove((param) => {
|
||||
const pt = param.point;
|
||||
if (!param.time || !pt || !container.value) { tip.value.visible = false; return; }
|
||||
const key = timeKey(param.time);
|
||||
const rec = byTime[key];
|
||||
if (!rec) { tip.value.visible = false; return; }
|
||||
tipData.value = buildTip(rec, key);
|
||||
const W = container.value.clientWidth, H = container.value.clientHeight;
|
||||
const TW = 220, TH = 188;
|
||||
let x = pt.x + 16; if (x + TW > W) x = pt.x - TW - 16; if (x < 4) x = 4;
|
||||
let y = pt.y + 16; if (y + TH > H) y = H - TH - 6; if (y < 4) y = 4;
|
||||
tip.value = { visible: true, x, y };
|
||||
});
|
||||
|
||||
fillData();
|
||||
ch.timeScale().fitContent();
|
||||
}
|
||||
|
||||
function fillData() {
|
||||
if (!chart || !candleSeries) return;
|
||||
const c = props.candles;
|
||||
const keys = Object.keys(props.indicators.data ?? {});
|
||||
|
||||
byTime = {};
|
||||
c.forEach((k, i) => {
|
||||
const ind: Record<string, number | null> = {};
|
||||
keys.forEach((key) => { ind[key] = props.indicators.data[key]?.[i] ?? null; });
|
||||
byTime[t(k.ts) as unknown as string] = {
|
||||
open: k.open, high: k.high, low: k.low, close: k.close, volume: k.volume,
|
||||
prevClose: i > 0 ? c[i - 1].close : null, ind,
|
||||
};
|
||||
});
|
||||
|
||||
candleSeries.setData(c.map(k => ({ time: t(k.ts), open: k.open, high: k.high, low: k.low, close: k.close })));
|
||||
volumeSeries?.setData(c.map(k => ({
|
||||
time: t(k.ts), value: k.volume, color: k.close >= k.open ? hexA(UP, 0.5) : hexA(DOWN, 0.5),
|
||||
})));
|
||||
|
||||
if (difSeries && deaSeries && histSeries && props.indicators.data?.hist) {
|
||||
difSeries.setData(toLine(props.indicators.data.macd ?? [], c));
|
||||
deaSeries.setData(toLine(props.indicators.data.signal ?? [], c));
|
||||
histSeries.setData((props.indicators.data.hist ?? []).map((v, i) => ({
|
||||
time: t(c[i].ts), value: v ?? 0, color: (v ?? 0) >= 0 ? hexA(UP, 0.6) : hexA(DOWN, 0.6),
|
||||
})));
|
||||
} else {
|
||||
maSeriesArr.forEach((m) => m.series.setData(toLine(props.indicators.data[m.key] ?? [], c)));
|
||||
}
|
||||
|
||||
const markers: SeriesMarker<Time>[] = props.signals.map(s => ({
|
||||
time: t(s.ts),
|
||||
position: s.side === 'buy' ? 'belowBar' : 'aboveBar',
|
||||
color: s.side === 'buy' ? UP : DOWN,
|
||||
shape: s.side === 'buy' ? 'arrowUp' : 'arrowDown',
|
||||
text: s.side === 'buy' ? 'B' : 'S',
|
||||
}));
|
||||
markersApi?.setMarkers(markers);
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
chart?.remove();
|
||||
chart = null; candleSeries = null; volumeSeries = null;
|
||||
difSeries = deaSeries = histSeries = null; maSeriesArr = []; markersApi = null;
|
||||
}
|
||||
|
||||
onMounted(build);
|
||||
onBeforeUnmount(teardown);
|
||||
watch(() => [props.candles, props.indicators, props.signals, props.strategy], () => { teardown(); build(); }, { deep: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kline-wrap">
|
||||
<div class="lc-legend">
|
||||
<span class="sym">{{ symbol ?? '—' }}<small>{{ TF_LABEL[timeframe ?? '1d'] ?? timeframe }} · {{ STRAT_LABEL[strategy ?? 'macd_cross'] ?? strategy }}</small></span>
|
||||
<span v-for="(chip, i) in legendChips" :key="i" class="chip"><i :style="{ background: chip.color }"></i>{{ chip.label }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="tip.visible && tipData" class="lc-tooltip" :style="{ left: tip.x + 'px', top: tip.y + 'px' }">
|
||||
<div class="tt-date">{{ tipData.date }} <span class="tt-wd">{{ tipData.weekday }}</span></div>
|
||||
<div class="tt-grid">
|
||||
<div>开 <b :class="tipData.up ? 'pos' : 'neg'">{{ fmt2(tipData.open) }}</b></div>
|
||||
<div>高 <b class="pos">{{ fmt2(tipData.high) }}</b></div>
|
||||
<div>低 <b class="neg">{{ fmt2(tipData.low) }}</b></div>
|
||||
<div>收 <b :class="tipData.up ? 'pos' : 'neg'">{{ fmt2(tipData.close) }}</b></div>
|
||||
</div>
|
||||
<div class="tt-row">
|
||||
涨跌 <b :class="tipData.up ? 'pos' : 'neg'">{{ tipData.change >= 0 ? '+' : '' }}{{ fmt2(tipData.change) }}</b>
|
||||
· 涨幅 <b :class="tipData.up ? 'pos' : 'neg'">{{ tipData.chgPct.toFixed(2) }}%</b>
|
||||
</div>
|
||||
<div class="tt-row">振幅 {{ tipData.amplitude.toFixed(2) }}% · 量 {{ tipData.volLots.toLocaleString() }} 手</div>
|
||||
<div class="tt-sep"></div>
|
||||
<div class="tt-ind">
|
||||
<span v-for="(v, k) in tipData.ind" :key="k" :style="{ color: IND_COLOR[k] ?? '#9aa4b2' }">
|
||||
{{ IND_LABEL[k] ?? k }} {{ fmt3(v) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="container" class="chart-kline"></div>
|
||||
</div>
|
||||
</template>
|
||||
20
frontend/src/components/MetricsPanel.vue
Normal file
20
frontend/src/components/MetricsPanel.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { MetricsOut } from '@/api/types';
|
||||
|
||||
defineProps<{ metrics: MetricsOut }>();
|
||||
|
||||
const pct = (x: number) => `${(x * 100).toFixed(2)}%`;
|
||||
const num = (x: number) => x.toFixed(2);
|
||||
const sign = (x: number) => (x >= 0 ? 'pos' : 'neg'); // A股:正=红、负=绿
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stats">
|
||||
<div class="stat"><span class="label">总收益</span><span class="value" :class="sign(metrics.total_return)">{{ pct(metrics.total_return) }}</span></div>
|
||||
<div class="stat"><span class="label">最大回撤</span><span class="value neg">{{ pct(metrics.max_drawdown) }}</span></div>
|
||||
<div class="stat"><span class="label">夏普</span><span class="value" :class="sign(metrics.sharpe)">{{ num(metrics.sharpe) }}</span></div>
|
||||
<div class="stat"><span class="label">年化波动</span><span class="value">{{ pct(metrics.volatility) }}</span></div>
|
||||
<div class="stat"><span class="label">胜率</span><span class="value">{{ pct(metrics.win_rate) }}</span></div>
|
||||
<div class="stat"><span class="label">交易数</span><span class="value">{{ metrics.num_trades }}</span></div>
|
||||
</div>
|
||||
</template>
|
||||
14
frontend/src/env.d.ts
vendored
Normal file
14
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE?: string;
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
22
frontend/src/main.ts
Normal file
22
frontend/src/main.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import PrimeVue from 'primevue/config';
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
|
||||
import 'primeicons/primeicons.css';
|
||||
import './style.css';
|
||||
|
||||
import App from './App.vue';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
// 以后想做深色:给 <html> 加 .app-dark 即可
|
||||
darkModeSelector: '.app-dark',
|
||||
},
|
||||
},
|
||||
});
|
||||
app.mount('#app');
|
||||
43
frontend/src/stores/backtest.ts
Normal file
43
frontend/src/stores/backtest.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { postBacktest, syncData } from '@/api/client';
|
||||
import type { BacktestRequest, BacktestResponse } from '@/api/types';
|
||||
|
||||
export const useBacktestStore = defineStore('backtest', () => {
|
||||
const loading = ref(false);
|
||||
const stage = ref<'idle' | 'syncing' | 'backtesting'>('idle');
|
||||
const note = ref<string | null>(null);
|
||||
const result = ref<BacktestResponse | null>(null);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function run(req: BacktestRequest) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
result.value = null;
|
||||
note.value = null;
|
||||
|
||||
try {
|
||||
// 非演示标的:先拉取并缓存真实行情(首次较慢;回测端点也会兜底)
|
||||
if (req.symbol.trim().toUpperCase() !== 'DEMO') {
|
||||
stage.value = 'syncing';
|
||||
note.value = `正在拉取 ${req.symbol} 行情数据(首次较慢,已自动缓存)…`;
|
||||
try {
|
||||
await syncData({ symbol: req.symbol, source: 'auto' });
|
||||
} catch {
|
||||
/* 忽略:回测端点会兜底拉取或复用缓存 */
|
||||
}
|
||||
}
|
||||
stage.value = 'backtesting';
|
||||
note.value = '回测中…';
|
||||
result.value = await postBacktest(req);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '回测失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
stage.value = 'idle';
|
||||
note.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, stage, note, result, error, run };
|
||||
});
|
||||
123
frontend/src/style.css
Normal file
123
frontend/src/style.css
Normal file
@@ -0,0 +1,123 @@
|
||||
:root {
|
||||
/* 深色专业交易终端配色(A股:红涨绿跌) */
|
||||
--bg: #0b0e14;
|
||||
--surface: #11151c;
|
||||
--surface-2: #161c26;
|
||||
--border: rgba(255, 255, 255, 0.07);
|
||||
--border-2: rgba(255, 255, 255, 0.12);
|
||||
|
||||
--ink: #e6edf3;
|
||||
--ink-2: #9aa4b2;
|
||||
--ink-3: #5c6675;
|
||||
|
||||
--up: #f6465d; /* A股:涨 / 买入 = 红 */
|
||||
--down: #0ecb81; /* A股:跌 / 卖出 = 绿 */
|
||||
--dif: #5b8ff9; /* MACD DIF */
|
||||
--dea: #f6bd16; /* MACD DEA */
|
||||
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { margin: 0; min-height: 100%; }
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(1200px 560px at 78% -12%, #182030 0%, rgba(24, 32, 48, 0) 55%),
|
||||
var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.app-shell { max-width: 1500px; margin: 0 auto; padding: 16px 20px 40px; }
|
||||
|
||||
.app-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.app-header h1 { font-size: 16px; margin: 0; font-weight: 600; letter-spacing: 0.3px; }
|
||||
.app-header .sub { color: var(--ink-3); font-size: 12px; }
|
||||
|
||||
/* 参数工具栏 */
|
||||
.toolbar {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 11px 14px;
|
||||
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 16px;
|
||||
}
|
||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.field label { font-size: 11px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.spacer { flex: 1 1 auto; }
|
||||
.hint { margin-top: 8px; font-size: 12px; color: var(--ink-3); }
|
||||
.hint code { color: var(--ink-2); background: rgba(255,255,255,0.05); padding: 1px 5px; border-radius: 4px; }
|
||||
|
||||
/* 标的快捷选择 */
|
||||
.quick { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 10px; }
|
||||
.quick .qlabel { font-size: 12px; color: var(--ink-3); margin-right: 2px; }
|
||||
.qchip {
|
||||
font-size: 12px; color: var(--ink-2); background: var(--surface-2);
|
||||
border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; cursor: pointer;
|
||||
font-variant-numeric: tabular-nums; transition: 0.15s;
|
||||
}
|
||||
.qchip:hover { color: var(--ink); border-color: var(--border-2); }
|
||||
.qchip.active { color: #fff; background: var(--dif); border-color: var(--dif); }
|
||||
.qchip .qname { color: var(--ink-3); margin-left: 4px; }
|
||||
.qchip.active .qname { color: rgba(255, 255, 255, 0.85); }
|
||||
|
||||
/* 绩效指标:紧凑单行数据条 */
|
||||
.stats { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.stat {
|
||||
flex: 1 1 0; min-width: 0;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 8px 11px; display: flex; align-items: baseline; gap: 7px; white-space: nowrap;
|
||||
}
|
||||
.stat .label { font-size: 11px; color: var(--ink-3); }
|
||||
.stat .value { font-size: 14px; font-weight: 600; }
|
||||
.stat .value.pos { color: var(--up); }
|
||||
.stat .value.neg { color: var(--down); }
|
||||
|
||||
/* 图表面板 */
|
||||
.panel {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 10px 10px 6px; margin-top: 14px;
|
||||
}
|
||||
.panel-title { font-size: 12px; color: var(--ink-2); margin: 4px 4px 6px; }
|
||||
|
||||
.kline-wrap { position: relative; }
|
||||
.chart-kline { height: 520px; }
|
||||
.chart-equity { height: 240px; }
|
||||
|
||||
/* K 线左上角标识 + 系列图例 */
|
||||
.lc-legend {
|
||||
position: absolute; top: 6px; left: 10px; z-index: 5;
|
||||
display: flex; gap: 14px; align-items: center;
|
||||
font-size: 12px; color: var(--ink-2); pointer-events: none;
|
||||
}
|
||||
.lc-legend .sym { color: var(--ink); font-weight: 600; }
|
||||
.lc-legend .sym small { color: var(--ink-3); font-weight: 400; margin-left: 6px; }
|
||||
.lc-legend .chip { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.lc-legend .chip i { width: 12px; height: 3px; border-radius: 2px; display: inline-block; }
|
||||
|
||||
/* 悬停弹框(同花顺式) */
|
||||
.lc-tooltip {
|
||||
position: absolute; z-index: 20; pointer-events: none; min-width: 196px;
|
||||
background: rgba(17, 21, 28, 0.97); border: 1px solid var(--border-2);
|
||||
border-radius: 8px; padding: 8px 11px; font-size: 11.5px; color: var(--ink-2);
|
||||
line-height: 1.7; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.lc-tooltip .tt-date { color: var(--ink); font-weight: 600; margin-bottom: 3px; }
|
||||
.lc-tooltip .tt-date .tt-wd { color: var(--ink-3); font-weight: 400; margin-left: 5px; }
|
||||
.lc-tooltip .tt-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
|
||||
.lc-tooltip .tt-grid b { color: var(--ink); font-weight: 500; float: right; }
|
||||
.lc-tooltip .tt-row b { color: var(--ink); font-weight: 500; }
|
||||
.lc-tooltip .tt-sep { height: 1px; background: var(--border); margin: 5px 0; }
|
||||
.lc-tooltip .tt-ind span { margin-right: 10px; }
|
||||
.lc-tooltip .pos { color: var(--up); }
|
||||
.lc-tooltip .neg { color: var(--down); }
|
||||
|
||||
.error-banner {
|
||||
background: rgba(246, 70, 93, 0.08); color: var(--up);
|
||||
border: 1px solid rgba(246, 70, 93, 0.3); border-radius: var(--radius);
|
||||
padding: 10px 14px; font-size: 13px; margin-top: 14px;
|
||||
}
|
||||
.placeholder { color: var(--ink-3); font-size: 13px; padding: 56px 0; text-align: center; }
|
||||
45
frontend/src/views/BacktestView.vue
Normal file
45
frontend/src/views/BacktestView.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import BacktestForm from '@/components/BacktestForm.vue';
|
||||
import KLineChart from '@/components/KLineChart.vue';
|
||||
import EquityChart from '@/components/EquityChart.vue';
|
||||
import MetricsPanel from '@/components/MetricsPanel.vue';
|
||||
import { useBacktestStore } from '@/stores/backtest';
|
||||
import type { BacktestRequest } from '@/api/types';
|
||||
|
||||
const store = useBacktestStore();
|
||||
function onRun(req: BacktestRequest) {
|
||||
store.run(req);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BacktestForm :loading="store.loading" @run="onRun" />
|
||||
|
||||
<div v-if="store.error" class="error-banner">{{ store.error }}</div>
|
||||
|
||||
<div v-if="store.loading && store.note" class="placeholder">{{ store.note }}</div>
|
||||
|
||||
<template v-if="store.result">
|
||||
<MetricsPanel :metrics="store.result.metrics" />
|
||||
|
||||
<div class="panel">
|
||||
<KLineChart
|
||||
:candles="store.result.candles"
|
||||
:indicators="store.result.indicators"
|
||||
:signals="store.result.signals"
|
||||
:symbol="store.result.symbol"
|
||||
:timeframe="store.result.timeframe"
|
||||
:strategy="store.result.strategy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-title">净值曲线</div>
|
||||
<EquityChart :equity="store.result.equity" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="!store.loading" class="placeholder">
|
||||
选好周期与参数,点「开始回测」——先用 DEMO 合成数据跑通。
|
||||
</div>
|
||||
</template>
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"useDefineForClassFields": true,
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
// 前端 /api 直接代理到后端,免去 CORS(生产环境可用 VITE_API_BASE 指向真实后端)
|
||||
proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true } },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user