feat(portfolio): 组合回测加持仓变化图(daily_positions每日聚合count/value经worker/routes透传,柱=持仓数线=市值)+超额收益曲线(策略/基准净值-1,聚宽标配,前端算);2测试 [vps]

This commit is contained in:
2026-08-13 20:40:03 +08:00
parent ced95d006a
commit 929f52e3c2
6 changed files with 153 additions and 1 deletions
+7
View File
@@ -71,6 +71,12 @@ export interface DrawdownPoint {
drawdown: number
}
export interface HoldingsPoint {
date: string
count: number
value: number
}
export interface PortfolioBacktestResult {
strategy: string
period: { start: string; end: string; trading_days: number }
@@ -80,6 +86,7 @@ export interface PortfolioBacktestResult {
metrics: PortfolioMetrics
benchmark_curve?: BenchmarkPoint[]
drawdown_curve?: DrawdownPoint[]
holdings_curve?: HoldingsPoint[]
raw_summary?: Record<string, unknown>
}
@@ -15,6 +15,7 @@ import {
type PortfolioMetrics,
type BenchmarkPoint,
type DrawdownPoint,
type HoldingsPoint,
} from '@/api/portfolio'
// 两态:表单(发起) / 查看(route.query.task_id 历史结果)。任务跟踪统一在「历史任务」页(任务中心)。
@@ -25,6 +26,7 @@ const result = ref<PortfolioBacktestResult | null>(null)
const equityCurve = ref<EquityPoint[]>([])
const benchmarkCurve = ref<BenchmarkPoint[]>([])
const drawdownCurve = ref<DrawdownPoint[]>([])
const holdingsCurve = ref<HoldingsPoint[]>([])
const stocks = ref<StockPicked[]>([])
const trades = ref<PortfolioTrade[]>([])
const metrics = ref<PortfolioMetrics | null>(null)
@@ -199,6 +201,75 @@ function renderRolling(): void {
setRollOption(option)
}
// 持仓变化图(聚宽「每日持仓」):柱=持仓标的数,线=持仓市值(仓位暴露)
const holdEl = ref<HTMLDivElement>()
const { setOption: setHoldOption } = useChart(holdEl)
function renderHoldings(): void {
if (!holdingsCurve.value.length) return
const option: EChartsCoreOption = {
title: darkTitle('持仓变化'),
tooltip: { ...darkTooltip(), trigger: 'axis' },
legend: { ...darkAxis(), top: 4, right: 8, textStyle: { color: 'var(--text-3, #aaa)' } },
grid: darkGrid(),
xAxis: { type: 'category', data: holdingsCurve.value.map((p) => p.date), ...darkAxis() },
yAxis: [
{ type: 'value', name: '持仓数', minInterval: 1, ...darkAxis() },
{ type: 'value', name: '市值(元)', scale: true, ...darkAxis() },
],
series: [
{
type: 'bar', name: '持仓标的数', yAxisIndex: 0,
itemStyle: { color: 'rgba(54, 207, 201, 0.55)' },
data: holdingsCurve.value.map((p) => p.count),
},
{
type: 'line', name: '持仓市值', smooth: true, showSymbol: false, yAxisIndex: 1,
lineStyle: { color: '#e0a458', width: 1.4 },
data: holdingsCurve.value.map((p) => Number((p.value ?? 0).toFixed(0))),
},
],
}
setHoldOption(option)
}
// 超额收益曲线(聚宽标配):策略净值/基准净值 - 1,围绕 0 轴看相对强弱
const excessEl = ref<HTMLDivElement>()
const { setOption: setExcessOption } = useChart(excessEl)
function renderExcess(): void {
const curve = equityCurve.value
if (curve.length < 2 || benchmarkCurve.value.length !== curve.length) return
const base = curve[0]?.equity || 1
const excess = curve.map((p, i) => {
const nav = p.equity / base
const bench = benchmarkCurve.value[i]?.benchmark ?? 1
return Number((((nav / bench) - 1) * 100).toFixed(2))
})
const option: EChartsCoreOption = {
title: darkTitle('超额收益(%)'),
tooltip: { ...darkTooltip(), trigger: 'axis' },
grid: darkGrid(),
xAxis: { type: 'category', data: curve.map((p) => p.date), ...darkAxis() },
yAxis: { type: 'value', name: '超额%', ...darkAxis() },
series: [
{
type: 'line', name: '超额收益', smooth: true, showSymbol: false,
lineStyle: { color: '#c23531', width: 1.4 },
areaStyle: {
color: '#c23531', opacity: 0.10,
origin: 'start',
},
markLine: {
silent: true, symbol: 'none',
lineStyle: { color: 'var(--text-3, #888)', type: 'dashed', width: 1 },
data: [{ yAxis: 0 }],
},
data: excess,
},
],
}
setExcessOption(option)
}
// 月度收益热力图(前端从净值算:月末净值环比,首月为部分月)
interface MonthlyTable {
years: number[]
@@ -257,6 +328,7 @@ async function loadResult(tid: string): Promise<void> {
equityCurve.value = r.equity_curve || []
benchmarkCurve.value = r.benchmark_curve || []
drawdownCurve.value = r.drawdown_curve || []
holdingsCurve.value = r.holdings_curve || []
stocks.value = r.stocks_selected || []
trades.value = r.trades || []
metrics.value = r.metrics || null
@@ -265,6 +337,8 @@ async function loadResult(tid: string): Promise<void> {
renderEquity()
renderDrawdown()
renderRolling()
renderHoldings()
renderExcess()
} catch {
ElMessage.error('加载结果失败')
}
@@ -472,6 +546,16 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
<div ref="rollEl" class="chart-box dd-box" />
</el-card>
<el-card v-if="result && holdingsCurve.length" class="blk" shadow="never">
<template #header><span class="section-title">持仓变化</span></template>
<div ref="holdEl" class="chart-box dd-box" />
</el-card>
<el-card v-if="result && benchmarkCurve.length === equityCurve.length && equityCurve.length" class="blk" shadow="never">
<template #header><span class="section-title">超额收益</span></template>
<div ref="excessEl" class="chart-box dd-box" />
</el-card>
<el-card v-if="result" class="blk" shadow="never">
<template #header><span class="section-title">选股名单(末日持仓)</span></template>
<el-table :data="stocks" stripe size="small" empty-text="无持仓数据">
+1
View File
@@ -106,6 +106,7 @@ def get_portfolio_result(task_id: str):
"stocks_selected": stocks_selected,
"benchmark_curve": stats.get("benchmark_curve", []),
"drawdown_curve": stats.get("drawdown_curve", []),
"holdings_curve": stats.get("holdings_curve", []),
}
+1
View File
@@ -118,6 +118,7 @@ def run_portfolio_task(spec: dict) -> Any:
"stocks_selected": data.get("stocks_selected", []),
"benchmark_curve": data.get("benchmark_curve", []),
"drawdown_curve": data.get("drawdown_curve", []),
"holdings_curve": data.get("holdings_curve", []),
"period": period,
},
equity_curve=pd.DataFrame(equity_list) if equity_list else None,
+34 -1
View File
@@ -393,8 +393,9 @@ def run_backtest_json(params: Dict[str, Any]) -> Dict[str, Any]:
drawdown_curve = _extract_drawdown(equity_curve)
metrics.update(_compute_extended_metrics(equity_curve, benchmark_curve))
# 选股(末日持仓):daily_positions 最后一日
# 选股(末日持仓):daily_positions 最后一日;持仓变化曲线:每日聚合
stocks_selected = _extract_last_positions(raw.get("daily_positions"))
holdings_curve = _extract_holdings_curve(raw.get("daily_positions"))
# 成交明细
trades = _extract_trades(raw.get("trades"))
@@ -412,6 +413,7 @@ def run_backtest_json(params: Dict[str, Any]) -> Dict[str, Any]:
"equity_curve": equity_curve,
"benchmark_curve": benchmark_curve,
"drawdown_curve": drawdown_curve,
"holdings_curve": holdings_curve,
"metrics": metrics,
"raw_summary": summary,
}
@@ -591,6 +593,37 @@ def _compute_extended_metrics(
return out
def _extract_holdings_curve(daily_positions: Any) -> list[Dict[str, Any]]:
"""daily_positions → 每日持仓聚合曲线:[{date, count, value}]。
count=当日非零持仓标的数(聚宽每日持仓图的主序列),
value=当日持仓市值(次轴,看仓位暴露变化)
"""
out: list[Dict[str, Any]] = []
if daily_positions is None:
return out
try:
import pandas as pd # type: ignore
if not (isinstance(daily_positions, pd.DataFrame) and not daily_positions.empty):
return out
df = daily_positions
if "date" not in df.columns:
return out
df = df[pd.to_numeric(df.get("amount"), errors="coerce").fillna(0) > 0]
agg = df.groupby("date").agg(
count=("code", "size"), value=("value", "sum"),
).sort_index()
for date, row in agg.iterrows():
out.append({
"date": str(date)[:10],
"count": int(row["count"]),
"value": float(row["value"] or 0),
})
except Exception as exc:
logger.warning("解析 holdings_curve 失败: %s", exc)
return out
def _extract_last_positions(daily_positions: Any) -> list[Dict[str, Any]]:
"""daily_positions: DataFrame,列含 date/code/amount/avg_cost/price/value。
取最后一日的非零持仓作为选股名单"""
+26
View File
@@ -64,3 +64,29 @@ def test_extended_metrics_values():
def test_extended_metrics_short_series():
assert _compute_extended_metrics([{"date": "d", "equity": 1.0}], []) == {}
assert _compute_extended_metrics([], []) == {}
def test_holdings_curve_aggregates_daily():
"""每日持仓聚合:count=非零标的数,value=市值合计;零持仓行剔除。"""
import pandas as pd
from sanguo_portfolio.runner_backtest import _extract_holdings_curve
df = pd.DataFrame([
{"date": "2024-01-01", "code": "600000", "amount": 100, "value": 1000.0},
{"date": "2024-01-01", "code": "000001", "amount": 200, "value": 2000.0},
{"date": "2024-01-01", "code": "510300", "amount": 0, "value": 0.0}, # 已清仓剔除
{"date": "2024-01-02", "code": "600000", "amount": 100, "value": 1100.0},
])
curve = _extract_holdings_curve(df)
assert curve == [
{"date": "2024-01-01", "count": 2, "value": 3000.0},
{"date": "2024-01-02", "count": 1, "value": 1100.0},
]
def test_holdings_curve_empty_inputs():
from sanguo_portfolio.runner_backtest import _extract_holdings_curve
assert _extract_holdings_curve(None) == []
import pandas as pd
assert _extract_holdings_curve(pd.DataFrame()) == []
assert _extract_holdings_curve(pd.DataFrame({"code": ["600000"]})) == [] # 无 date 列