Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
"# 标准策略分析报告",
Expand Down Expand Up @@ -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,
),
"",
"## 稳健性分析",
"",
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -352,19 +361,36 @@ 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"]),
"source_reason": str(
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)
Expand Down Expand Up @@ -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"] = (
Expand Down Expand Up @@ -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"))
Expand Down
14 changes: 14 additions & 0 deletions docs/comet/specs/standard-strategy-analysis-workflow/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(必须)是压力参数的唯一事实;分析不得维护第二份成本定义、从基线结果推导延迟成交或重新运行研究。
Expand Down
64 changes: 64 additions & 0 deletions tests/quant_analysis/test_reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading