diff --git a/.agents/skills/analyze-quant-robustness/scripts/quant_analysis/reporting.py b/.agents/skills/analyze-quant-robustness/scripts/quant_analysis/reporting.py index 96c7b36..bc5024f 100644 --- a/.agents/skills/analyze-quant-robustness/scripts/quant_analysis/reporting.py +++ b/.agents/skills/analyze-quant-robustness/scripts/quant_analysis/reporting.py @@ -141,6 +141,52 @@ def render_standard_analysis_report( for item in _rows(analysis.get("evidence_rows")) ] attribution = _mapping(analysis.get("attribution")) + loss_events = _rows(attribution.get("loss_events")) + shown_losses = loss_events[:10] + + def evidence_text(event: Mapping[str, Any], key: str) -> str: + item = _mapping(_mapping(event.get("evidence")).get(key)) + return ( + str(item.get("value")) + if item.get("status") == "available" + else "证据不足(来源未提供)" + ) + + loss_rows = [ + [ + str(event.get("security", "—")), + str(event.get("date", "—")), + str(event.get("security_daily_pnl", "—")), + "退出" if event.get("is_exit") else "持仓估值", + str(event.get("source_reason", event.get("reason_code", "—"))), + ] + for event in shown_losses + ] + loss_evidence_rows = [ + [ + str(event.get("security", "—")), + evidence_text(event, "entry"), + evidence_text(event, "common_stop_before"), + evidence_text(event, "previous_trading_day_signal"), + evidence_text(event, "fill_price"), + evidence_text(event, "stop_failure_loss"), + ] + for event in shown_losses + ] + reconciliation_rows = [ + [ + str(row.get("date", "—")), + str(row.get("daily_security_pnl_total", "证据不足(来源未提供)")), + str(row.get("portfolio_daily_pnl", "证据不足(来源未提供)")), + str(row.get("reconciliation_difference", "证据不足(来源未提供)")), + "已勾稽" + if row.get("status") == "reconciled" + else "证据不足(来源未提供)" + if row.get("status") == "evidence_insufficient" + else "不一致", + ] + for row in _rows(attribution.get("loss_reconciliation")) + ] robustness = _mapping(analysis.get("robustness")) lines = [ "# 标准策略分析报告", @@ -214,6 +260,24 @@ def render_standard_analysis_report( f"方法:{attribution.get('method', '—')};" f"原因:{attribution.get('reason', '—')}。", "", + f"### 亏损事件(共 {len(loss_events)} 条,展示前 {min(10, len(loss_events))} 条)", + "", + *_table(["标的", "日期", "单标的盈亏", "事件", "来源原因"], loss_rows), + "", + "### 执行与止损证据", + "", + *_table( + ["标的", "入场证据", "止损线", "前一交易日信号", "实际成交价", "止损失败损失"], + loss_evidence_rows, + ), + "", + "### 日级勾稽", + "", + *_table( + ["日期", "单标的盈亏合计", "组合日盈亏", "差额", "状态"], + reconciliation_rows, + ), + "", "## 稳健性分析", "", ] diff --git a/.agents/skills/analyze-quant-robustness/scripts/quant_analysis/unified_analysis.py b/.agents/skills/analyze-quant-robustness/scripts/quant_analysis/unified_analysis.py index 5da5cd3..88d76be 100644 --- a/.agents/skills/analyze-quant-robustness/scripts/quant_analysis/unified_analysis.py +++ b/.agents/skills/analyze-quant-robustness/scripts/quant_analysis/unified_analysis.py @@ -32,7 +32,7 @@ "NASDAQ100_CNY_TOTAL_RETURN", ) _FORMULA_VERSION = "standard-strategy-analysis/1" -_SCRIPT_VERSION = "analyze-quant-robustness/1" +_SCRIPT_VERSION = "analyze-quant-robustness/2" _SCRIPT_ENTRY = ( ".agents/skills/analyze-quant-robustness/scripts/analyze_quant_robustness.py" ) @@ -314,13 +314,22 @@ def _immutable_json(path: Path, value: object) -> None: def _valuation_facts(scenario: ScenarioInput) -> pd.DataFrame: columns = [ + "event_id", "date", "security", "reason_code", "source_reason", "security_daily_pnl", + "action", + "position_before", + "position_after", + "common_stop_before", "common_stop_after", + "fill_price", "stop_failure_loss", + "daily_security_pnl_total", + "portfolio_daily_pnl", + "reconciliation_difference", ] if scenario.events.empty: return pd.DataFrame(columns=columns) @@ -352,6 +361,7 @@ def _valuation_facts(scenario: ScenarioInput) -> pd.DataFrame: raise UnifiedAnalysisError("valuation security_daily_pnl is invalid") records.append( { + "event_id": str(event["event_id"]), "date": pd.Timestamp(event["date"]).normalize(), "security": str(event["security"]), "reason_code": str(event["reason_code"]), @@ -359,12 +369,28 @@ def _valuation_facts(scenario: ScenarioInput) -> pd.DataFrame: details.get("source_reason", event["reason_code"]) ), "security_daily_pnl": security_pnl, + "action": details.get("action"), + "position_before": _safe_number(details.get("position_before")), + "position_after": _safe_number(details.get("position_after")), + "common_stop_before": _safe_number( + details.get("common_stop_before") + ), "common_stop_after": _safe_number( details.get("common_stop_after") ), + "fill_price": _safe_number(details.get("fill_price")), "stop_failure_loss": _safe_number( details.get("stop_failure_loss") ), + "daily_security_pnl_total": _safe_number( + details.get("daily_security_pnl_total") + ), + "portfolio_daily_pnl": _safe_number( + details.get("portfolio_daily_pnl") + ), + "reconciliation_difference": _safe_number( + details.get("reconciliation_difference") + ), } ) facts = pd.DataFrame.from_records(records, columns=columns) @@ -460,6 +486,86 @@ def _security_pnl_facts( return facts +def _loss_attribution(facts: pd.DataFrame) -> tuple[list[dict[str, object]], list[dict[str, object]]]: + def evidence(value: object) -> dict[str, object]: + return ( + {"status": "available", "value": value} + if value is not None and not pd.isna(value) + else {"status": "evidence_insufficient", "reason": "missing_at_source"} + ) + + losses: list[dict[str, object]] = [] + for row in facts.loc[facts["security_daily_pnl"] < 0].sort_values( + ["security_daily_pnl", "date", "security"] + ).to_dict("records"): + position_before = row["position_before"] + position_after = row["position_after"] + losses.append( + { + "event_id": row["event_id"], + "security": row["security"], + "date": pd.Timestamp(row["date"]).date().isoformat(), + "security_daily_pnl": float(row["security_daily_pnl"]), + "reason_code": row["reason_code"], + "source_reason": row["source_reason"], + "is_exit": row["action"] == "full_exit" + or ( + position_before is not None + and not pd.isna(position_before) + and float(position_before) > 0 + and position_after is not None + and not pd.isna(position_after) + and float(position_after) == 0 + ), + "evidence": { + "entry": { + "status": "evidence_insufficient", + "reason": "missing_at_source", + }, + "common_stop_before": evidence(row["common_stop_before"]), + "previous_trading_day_signal": { + "status": "evidence_insufficient", + "reason": "missing_at_source", + }, + "fill_price": evidence(row["fill_price"]), + "stop_failure_loss": { + "status": "evidence_insufficient", + "reason": "missing_at_source", + }, + }, + } + ) + + reconciliation: list[dict[str, object]] = [] + for date, rows in facts.loc[ + facts["date"].isin(facts.loc[facts["security_daily_pnl"] < 0, "date"]) + ].groupby("date", sort=True): + source = rows.iloc[0] + total = source["daily_security_pnl_total"] + portfolio = source["portfolio_daily_pnl"] + difference = source["reconciliation_difference"] + if any(value is None or pd.isna(value) for value in (total, portfolio, difference)): + reconciliation.append( + { + "date": pd.Timestamp(date).date().isoformat(), + "status": "evidence_insufficient", + "reason": "missing_at_source", + } + ) + continue + reconciliation.append( + { + "date": pd.Timestamp(date).date().isoformat(), + "daily_security_pnl_total": float(total), + "portfolio_daily_pnl": float(portfolio), + "reconciliation_difference": float(difference), + "tolerance": 0.02, + "status": "reconciled" if abs(float(difference)) <= 0.02 else "mismatch", + } + ) + return losses, reconciliation + + def _risk_metrics(scenario: ScenarioInput, positions: pd.DataFrame) -> dict[str, object]: balances = scenario.balances.copy() balances["invested_ratio"] = ( @@ -1225,6 +1331,9 @@ def run_standard_analysis( security_pnl = _security_pnl_facts(baseline, universe) attribution = _attribution(baseline, security_pnl) + loss_events, loss_reconciliation = _loss_attribution(security_pnl) + attribution["loss_events"] = loss_events + attribution["loss_reconciliation"] = loss_reconciliation attribution_row, attribution_evidence = ( _unavailable_result( "baseline-attribution", "deep_attribution", str(attribution.get("reason", "missing_at_source")) diff --git a/docs/comet/specs/standard-strategy-analysis-workflow/spec.md b/docs/comet/specs/standard-strategy-analysis-workflow/spec.md index a0dfaf3..5ef3d65 100644 --- a/docs/comet/specs/standard-strategy-analysis-workflow/spec.md +++ b/docs/comet/specs/standard-strategy-analysis-workflow/spec.md @@ -51,6 +51,20 @@ - **WHEN** 结果包缺少某项分析所需扩展 - **THEN** 系统将该项标记为 `evidence_insufficient`(证据不足),不阻断无依赖的共同分析 +### Requirement: 来源原生亏损事件必须可审计 + +系统 MUST(必须)按来源原生 `valuation.security_daily_pnl`(估值事件单标的日盈亏)从小到大保留全部亏损事件,并在 Markdown(标记文档)报告中展示损失最大的前十条及事件总数。系统 MUST(必须)标识来源明确声明的退出事件,按亏损日期展示来源单标的盈亏合计、组合日盈亏和差额,并以 `0.02` 为勾稽容差。入场、事件前止损线、前一交易日信号、实际成交价或止损失败损失缺少语义匹配的来源字段时,系统 MUST(必须)逐字段标记 `evidence_insufficient`(证据不足),不得从活跃持仓或订单重建完整交易。 + +#### Scenario: 退出日没有活跃持仓行 + +- **WHEN** 来源归因扩展记录一笔退出日负 `security_daily_pnl`,而共同持仓事实没有该退出日证券行 +- **THEN** 确定性 JSON(结构化数据)仍保留该亏损事件,Markdown 报告展示其标的、日期、盈亏、来源原因、退出标识和可用执行证据 + +#### Scenario: 可选执行证据来源缺失 + +- **WHEN** 亏损事件缺少入场、事件前止损线、前一交易日信号、实际成交价或语义匹配的止损失败损失 +- **THEN** 报告逐字段显示证据不足,不从共同持仓或订单事实推断 + ### Requirement: 成本执行压力必须复用标准场景契约 佣金、单边滑点或额外交易日延迟压力 MUST(必须)作为分析计划中 `dimension=cost_execution`(成本执行)的普通场景声明。冻结场景配置 MUST(必须)是压力参数的唯一事实;分析不得维护第二份成本定义、从基线结果推导延迟成交或重新运行研究。 diff --git a/tests/quant_analysis/test_reporting.py b/tests/quant_analysis/test_reporting.py index 7e18769..c5cd44f 100644 --- a/tests/quant_analysis/test_reporting.py +++ b/tests/quant_analysis/test_reporting.py @@ -147,6 +147,70 @@ def test_standard_report_lists_package_identity_capabilities_and_evidence_gaps( assert (workspace / "standard-strategy-analysis-report.md").is_file() +def test_standard_report_shows_top_ten_loss_events_exit_evidence_and_reconciliation() -> None: + analysis = _standard_analysis() + attribution = analysis["attribution"] + assert isinstance(attribution, dict) + attribution.update( + { + "status": "available", + "method": "source_native_security_daily_pnl", + "loss_events": [ + { + "event_id": f"loss-{index}", + "security": f"ETF-{index}", + "date": "2024-01-03", + "security_daily_pnl": -float(20 - index), + "reason_code": "protective_stop", + "source_reason": "protective_stop", + "is_exit": index == 0, + "evidence": { + "entry": { + "status": "evidence_insufficient", + "reason": "missing_at_source", + }, + "common_stop_before": {"status": "available", "value": 9.5}, + "previous_trading_day_signal": { + "status": "evidence_insufficient", + "reason": "missing_at_source", + }, + "fill_price": {"status": "available", "value": 9.0}, + "stop_failure_loss": { + "status": "evidence_insufficient", + "reason": "missing_at_source", + }, + }, + } + for index in range(11) + ], + "loss_reconciliation": [ + { + "date": "2024-01-03", + "daily_security_pnl_total": 10.0, + "portfolio_daily_pnl": 10.0, + "reconciliation_difference": 0.0, + "tolerance": 0.02, + "status": "reconciled", + } + ], + } + ) + + report = render_standard_analysis_report( + analysis, build_standard_recommendation(analysis) + ) + + assert "亏损事件(共 11 条,展示前 10 条)" in report + assert "ETF-0" in report + assert "ETF-9" in report + assert "ETF-10" not in report + assert "退出" in report + assert "证据不足(来源未提供)" in report + assert "9.5" in report + assert "日级勾稽" in report + assert "已勾稽" in report + + def test_standard_delivery_rejects_workspace_outside_repository( tmp_path: Path, ) -> None: diff --git a/tests/quant_analysis/test_standard_analysis_e2e.py b/tests/quant_analysis/test_standard_analysis_e2e.py index 5b0494f..cf18c9c 100644 --- a/tests/quant_analysis/test_standard_analysis_e2e.py +++ b/tests/quant_analysis/test_standard_analysis_e2e.py @@ -6,6 +6,10 @@ from pathlib import Path import subprocess +import pyarrow as pa +import pyarrow.parquet as pq + +from scripts.research.local_quant_research.contracts import ResultExtension from tests.local_quant_research.test_analysis_data_views import _write_result_package from tests.quant_analysis.test_unified_analysis import _standard_package_inputs, _write_json @@ -21,7 +25,58 @@ def _tree_sha(root: Path) -> str: def test_standard_analysis_skill_runs_only_the_read_only_package_flow( repo_root: Path, tmp_path: Path ) -> None: - root, packages, plan, benchmark = _standard_package_inputs(repo_root, tmp_path) + attribution = ResultExtension( + name="audit_events", + schema_version="attribution/1", + table=pa.table( + { + "time": ["2024-01-03 16:00:00", "2024-01-03 16:00:00"], + "event_id": ["loss-exit", "offsetting-gain"], + "event_type": ["valuation", "valuation"], + "security": ["TEST.X", "OFFSET.X"], + "reason_code": ["protective_stop", "mark_to_market"], + "details_json": [ + json.dumps( + { + "action": "full_exit", + "position_before": 10, + "position_after": 0, + "source_reason": "protective_stop", + "security_daily_pnl": -5.0, + "common_stop_before": 9.5, + "fill_price": 9.0, + "daily_security_pnl_total": 10.0, + "portfolio_daily_pnl": 10.0, + "reconciliation_difference": 0.0, + } + ), + json.dumps( + { + "source_reason": "mark_to_market", + "security_daily_pnl": 15.0, + "daily_security_pnl_total": 10.0, + "portfolio_daily_pnl": 10.0, + "reconciliation_difference": 0.0, + } + ), + ], + } + ), + unique_key=("event_id",), + evidence={"status": "complete"}, + ) + root, packages, plan, benchmark = _standard_package_inputs( + repo_root, tmp_path, extensions=(attribution,) + ) + scenario = json.loads((root / "config/baseline.json").read_text(encoding="utf-8")) + scenario["universe"].append({"security": "OFFSET.X", "asset_group": "etf"}) + _write_json(root / "config/baseline.json", scenario) + packages[0] = _write_result_package( + root / "baseline-package", + strategy_id="minimal", + scenario=scenario, + extensions=(attribution,), + ) plan_document = json.loads(plan.read_text(encoding="utf-8")) plan_document["scenarios"].append( { @@ -120,5 +175,11 @@ def test_standard_analysis_skill_runs_only_the_read_only_package_flow( assert (workspace / "recommendation.json").is_file() report = (workspace / "standard-strategy-analysis-report.md").read_text(encoding="utf-8") assert "double-commission" in report + assert "TEST.X" in report + assert "-5.0" in report + assert "protective_stop" in report + assert "退出" in report + assert "已勾稽" in report + assert pq.read_table(packages[0] / "data/positions.parquet").num_rows == 0 assert "market_snapshot_missing_at_source" not in report assert all(_tree_sha(package) == digest for package, digest in before.items()) diff --git a/tests/quant_analysis/test_unified_analysis.py b/tests/quant_analysis/test_unified_analysis.py index 940cece..ca412b1 100644 --- a/tests/quant_analysis/test_unified_analysis.py +++ b/tests/quant_analysis/test_unified_analysis.py @@ -299,6 +299,102 @@ def test_standard_analysis_discovers_attribution_by_fields_not_extension_name( assert result["attribution"]["event_counts"] == {"breakout_entry": 1} +def test_standard_analysis_preserves_source_loss_events_and_daily_reconciliation( + repo_root: Path, tmp_path: Path +) -> None: + extension = ResultExtension( + name="audit_events", + schema_version="attribution/1", + table=pa.table( + { + "time": ["2024-01-03 16:00:00", "2024-01-03 16:00:00"], + "event_id": ["loss-exit", "offsetting-gain"], + "event_type": ["valuation", "valuation"], + "security": ["TEST.X", "OFFSET.X"], + "reason_code": ["protective_stop", "mark_to_market"], + "details_json": [ + json.dumps( + { + "action": "full_exit", + "position_before": 10, + "position_after": 0, + "source_reason": "protective_stop", + "security_daily_pnl": -5.0, + "common_stop_before": 9.5, + "fill_price": 9.0, + "daily_security_pnl_total": 10.0, + "portfolio_daily_pnl": 10.0, + "reconciliation_difference": 0.0, + } + ), + json.dumps( + { + "security_daily_pnl": 15.0, + "source_reason": "mark_to_market", + "daily_security_pnl_total": 10.0, + "portfolio_daily_pnl": 10.0, + "reconciliation_difference": 0.0, + } + ), + ], + } + ), + unique_key=("event_id",), + evidence={"status": "complete"}, + ) + root, packages, plan, benchmark = _standard_package_inputs( + repo_root, tmp_path, extensions=(extension,) + ) + scenario = json.loads((root / "config/baseline.json").read_text(encoding="utf-8")) + scenario["universe"].append({"security": "OFFSET.X", "asset_group": "etf"}) + _write_json(root / "config/baseline.json", scenario) + # Rebuild the package so its immutable manifest binds the expanded universe. + shutil.rmtree(packages[0]) + packages[0] = _write_result_package( + root / "package", + strategy_id="minimal", + scenario=scenario, + extensions=(extension,), + ) + + result = run_standard_analysis(root, packages, plan, benchmark) + + assert result["attribution"]["loss_events"] == [ + { + "event_id": "loss-exit", + "security": "TEST.X", + "date": "2024-01-03", + "security_daily_pnl": -5.0, + "reason_code": "protective_stop", + "source_reason": "protective_stop", + "is_exit": True, + "evidence": { + "entry": {"status": "evidence_insufficient", "reason": "missing_at_source"}, + "common_stop_before": {"status": "available", "value": 9.5}, + "previous_trading_day_signal": { + "status": "evidence_insufficient", + "reason": "missing_at_source", + }, + "fill_price": {"status": "available", "value": 9.0}, + "stop_failure_loss": { + "status": "evidence_insufficient", + "reason": "missing_at_source", + }, + }, + } + ] + assert result["attribution"]["loss_reconciliation"] == [ + { + "date": "2024-01-03", + "daily_security_pnl_total": 10.0, + "portfolio_daily_pnl": 10.0, + "reconciliation_difference": 0.0, + "tolerance": 0.02, + "status": "reconciled", + } + ] + + def test_standard_analysis_rejects_tampered_benchmark_data( repo_root: Path, tmp_path: Path ) -> None: @@ -442,7 +538,7 @@ def test_standard_analysis_keeps_independent_results_and_evidence_gaps( 7 ) assert result["analysis_configuration"]["scenario_params"] - assert result["script"]["version"] == "analyze-quant-robustness/1" + assert result["script"]["version"] == "analyze-quant-robustness/2" assert result["script"]["entry"].endswith("analyze_quant_robustness.py") assert (root / ".local/standard-strategy-analysis" / result["analysis_id"] / "deterministic-analysis.json").is_file()