diff --git a/sanguo_portfolio/weekly_report.py b/sanguo_portfolio/weekly_report.py index 084db1ec..bc5bf291 100644 --- a/sanguo_portfolio/weekly_report.py +++ b/sanguo_portfolio/weekly_report.py @@ -31,13 +31,16 @@ def _week_start(d: date) -> date: def daily_returns(series: list[tuple[str, float]]) -> dict[str, float]: - """[(date, equity)] → {date: 当日收益率};首日无前值跳过,前值 0 防除零. - - 平盘日(收益 0.0)照记不剔除——剔除会低估 TE(M5 终审裁定). + """[(date, equity)] → {date: 当日收益率};同日多行取末次写入(多写者日末终值), + 首日无前值跳过,前值 0 防除零.平盘日(收益 0.0)照记不剔除——剔除会低估 TE(M5 终审裁定). """ + last: dict[str, float] = {} + for d, eq in series: + last[d] = eq out: dict[str, float] = {} prev: float | None = None - for d, eq in sorted(series): + for d in sorted(last): + eq = last[d] if prev is not None and prev != 0: out[d] = eq / prev - 1.0 prev = eq @@ -57,7 +60,7 @@ def load_actual_daily_returns(db_path: str, account_id: int, def load_backtest_daily_returns(db_path: str, task_id: str) -> dict[str, float] | None: - """回测基准:backtest_stats 最新行 → equity_path JSON(date/balance,orient=records).""" + """回测基准:backtest_stats 最新行 → equity_path JSON(date/balance|equity,orient=records).""" with sqlite3.connect(db_path) as conn: row = conn.execute( "SELECT equity_path FROM backtest_stats WHERE task_id=? " @@ -67,11 +70,13 @@ def load_backtest_daily_returns(db_path: str, try: import pandas as pd ec = pd.read_json(row[0], orient="records") - if "balance" not in ec.columns: + col = "balance" if "balance" in ec.columns else ( + "equity" if "equity" in ec.columns else None) + if col is None: return None dates = pd.to_datetime(ec["date"]).dt.strftime("%Y-%m-%d") series = list(zip(dates.tolist(), - pd.to_numeric(ec["balance"], errors="coerce").tolist())) + pd.to_numeric(ec[col], errors="coerce").tolist())) series = [(d, float(v)) for d, v in series if v == v] # NaN 剔除 return daily_returns(series) if series else None except Exception as exc: diff --git a/tests/portfolio/test_weekly_report.py b/tests/portfolio/test_weekly_report.py index 117dc3a5..8563c1ad 100644 --- a/tests/portfolio/test_weekly_report.py +++ b/tests/portfolio/test_weekly_report.py @@ -84,6 +84,15 @@ def test_daily_returns_keeps_flat_days(): assert r["c"] == pytest.approx(0.01) +def test_daily_returns_dedupes_same_date(): + # 同日多行(paper_daily_balance 每日 2-3 行多写者)取末次后再 diff; + # 不去重则排序后同日第二行覆写成日内 0.0,毒化跨日收益 + r = daily_returns([("2026-09-21", 100.0), ("2026-09-22", 110.0), + ("2026-09-22", 110.0), ("2026-09-23", 121.0)]) + assert r == {"2026-09-22": pytest.approx(0.1), + "2026-09-23": pytest.approx(0.1)} + + def test_tracking_error_aligned_and_none(): a = {"d1": 0.01, "d2": -0.01, "d3": 0.01} b = {"d1": 0.01, "d2": -0.01, "d3": 0.01} @@ -114,6 +123,21 @@ def test_load_backtest_daily_returns(main_db): assert load_backtest_daily_returns(main_db, "nope") is None +def test_backtest_loader_accepts_equity_column(main_db, tmp_path): + # 生产回测 equity JSON 用 equity 列(balance-only 校验把真文件全拒→te/vs 恒 None) + eq_path = str(tmp_path / "bt_equity.json") + with open(eq_path, "w", encoding="utf-8") as f: + json.dump([{"date": "2026-09-21", "equity": 100.0}, + {"date": "2026-09-22", "equity": 101.0}], f) + conn = sqlite3.connect(main_db) + conn.execute("INSERT INTO backtest_stats(task_id, equity_path) " + "VALUES('bt_run_equity',?)", (eq_path,)) + conn.commit() + conn.close() + got = load_backtest_daily_returns(main_db, "bt_run_equity") + assert got == {"2026-09-22": pytest.approx(0.01)} + + def test_load_actual_daily_returns_skips_null_equity(tmp_path): # M4:total_equity NULL 行剔除(不折算成 0 毒化收益链) p = str(tmp_path / "null_eq.db")