first commit

This commit is contained in:
2026-08-07 16:08:34 +08:00
commit e0b5228008
51 changed files with 5175 additions and 0 deletions

15
frontend/index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
allowBuilds:
esbuild: true
vue-demi: true

13
frontend/src/App.vue Normal file
View 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>

View 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
View 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;
}

View 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>

View 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>

View 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>

View 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
View 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
View 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');

View 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
View 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; }

View 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
View 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
View 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 } },
},
});