diff --git a/frontend/src/api/portfolio.ts b/frontend/src/api/portfolio.ts index 5d339ee..8638bfd 100644 --- a/frontend/src/api/portfolio.ts +++ b/frontend/src/api/portfolio.ts @@ -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 } diff --git a/frontend/src/views/backtest/PortfolioBacktest.vue b/frontend/src/views/backtest/PortfolioBacktest.vue index b63f56c..f391d39 100644 --- a/frontend/src/views/backtest/PortfolioBacktest.vue +++ b/frontend/src/views/backtest/PortfolioBacktest.vue @@ -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(null) const equityCurve = ref([]) const benchmarkCurve = ref([]) const drawdownCurve = ref([]) +const holdingsCurve = ref([]) const stocks = ref([]) const trades = ref([]) const metrics = ref(null) @@ -199,6 +201,75 @@ function renderRolling(): void { setRollOption(option) } +// 持仓变化图(聚宽「每日持仓」):柱=持仓标的数,线=持仓市值(仓位暴露) +const holdEl = ref() +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() +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 { 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 { renderEquity() renderDrawdown() renderRolling() + renderHoldings() + renderExcess() } catch { ElMessage.error('加载结果失败') } @@ -472,6 +546,16 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
+ + +
+ + + + +
+ + diff --git a/sanguo_api/routes_portfolio.py b/sanguo_api/routes_portfolio.py index 5ccac50..a574f55 100644 --- a/sanguo_api/routes_portfolio.py +++ b/sanguo_api/routes_portfolio.py @@ -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", []), } diff --git a/sanguo_orchestrator/portfolio_worker.py b/sanguo_orchestrator/portfolio_worker.py index cb50805..4e10c98 100644 --- a/sanguo_orchestrator/portfolio_worker.py +++ b/sanguo_orchestrator/portfolio_worker.py @@ -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, diff --git a/sanguo_portfolio/runner_backtest.py b/sanguo_portfolio/runner_backtest.py index 1e42f0f..743438f 100644 --- a/sanguo_portfolio/runner_backtest.py +++ b/sanguo_portfolio/runner_backtest.py @@ -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。 取最后一日的非零持仓作为选股名单。""" diff --git a/tests/portfolio/test_runner_curves.py b/tests/portfolio/test_runner_curves.py index 1c5b0b1..d501f35 100644 --- a/tests/portfolio/test_runner_curves.py +++ b/tests/portfolio/test_runner_curves.py @@ -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 列