feat(portfolio): 组合回测加持仓变化图(daily_positions每日聚合count/value经worker/routes透传,柱=持仓数线=市值)+超额收益曲线(策略/基准净值-1,聚宽标配,前端算);2测试 [vps]
This commit is contained in:
@@ -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="无持仓数据">
|
||||
|
||||
@@ -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", []),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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。
|
||||
取最后一日的非零持仓作为选股名单。"""
|
||||
|
||||
@@ -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 列
|
||||
|
||||
Reference in New Issue
Block a user