From 886438fc920e26e289b3b26e0884dce88cc38f58 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sun, 16 Aug 2026 18:28:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(vps=E9=AA=8C=E6=94=B6=E4=BA=94=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E4=B9=8B=E5=9B=9B):=20=E2=91=A0#84=E4=B8=AA=E8=82=A1?= =?UTF-8?q?=E5=9B=9E=E6=B5=8B/=E5=9B=9E=E6=94=BE=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E7=A7=92=E8=B4=A5=E2=80=94=E2=80=94L2=E6=A0=A1=E9=AA=8Cend>lat?= =?UTF-8?q?est=E4=B8=94end<=3Dtoday(=E5=91=A8=E6=9C=AB/=E8=8A=82=E5=81=87?= =?UTF-8?q?=E6=97=A5/=E5=BD=93=E6=97=A5=E6=9C=AA=E7=81=8C)=E4=B8=8D?= =?UTF-8?q?=E5=86=8D400,=E5=BC=95=E6=93=8E=E5=8C=BA=E9=97=B4=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E8=87=AA=E7=84=B6=E6=88=AA=E5=88=B0latest;=E6=96=B0?= =?UTF-8?q?=E5=A2=9Estart>latest=E4=BB=8D400(=E5=BF=85=E7=A9=BA=E8=B7=91);?= =?UTF-8?q?VPS=E5=AE=9E=E5=86=B5=3D=E7=BB=93=E6=9D=9F=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E4=BB=8A=E5=A4=A9=E5=91=A8=E6=97=A5>=E6=9C=80=E6=96=B0?= =?UTF-8?q?=E5=91=A8=E4=BA=94,=E9=A6=96=E6=8F=90=E5=8D=B3=E8=B4=A5=20?= =?UTF-8?q?=E2=91=A1#85=E7=BB=84=E5=90=88=E4=BB=BB=E5=8A=A1=E6=A0=87?= =?UTF-8?q?=E7=9A=84=E5=88=97=E6=B0=B8=E8=BF=9C000300.XSHG=E2=80=94?= =?UTF-8?q?=E2=80=94worker=20symbol=3Dbenchmark=E6=94=B9pool(spec=E9=80=8F?= =?UTF-8?q?=E4=BC=A0,benchmark=E5=B7=B2=E5=9C=A8params);=E5=86=85=E5=AD=98?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E4=B8=A4=E5=A4=84symbol=E5=9B=9E=E9=80=80sym?= =?UTF-8?q?bol=E2=86=92pool=E2=86=92benchmark=20=E2=91=A2#42=E5=BD=B1?= =?UTF-8?q?=E5=AD=90=E6=8B=89=E8=B5=B7=E5=8D=B3=E5=B4=A9=E2=80=94=E2=80=94?= =?UTF-8?q?live=5Fstrategy=E5=B7=A5=E5=8E=82=E8=A1=A8=E8=A1=A54=E4=B8=AATE?= =?UTF-8?q?T=E5=89=AF=E6=9C=AC=E6=98=A0=E5=B0=84(all=5Fweather=5Fex?= =?UTF-8?q?=E7=AD=89,01184c2=E6=94=B9=E5=90=8D=E5=90=8E=E5=9B=9E=E6=B5=8B?= =?UTF-8?q?=E8=AE=A4=E5=BE=97=E4=BD=86=E5=BD=B1=E5=AD=90/=E5=AE=9E?= =?UTF-8?q?=E7=9B=98runner=E4=B8=8D=E8=AE=A4,VPS=20shadow#42=E5=AE=9E?= =?UTF-8?q?=E5=BD=95)=20=E2=91=A3#88=E5=BD=B1=E5=AD=90=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E5=88=B7=E5=9E=83=E5=9C=BE=E8=A1=8C=E2=80=94=E2=80=94=5Fsnapsh?= =?UTF-8?q?ot=5Floop=E6=AF=8F30s=E6=97=A0=E6=9D=A1=E4=BB=B6=E5=86=99date?= =?UTF-8?q?=3D''=E8=A1=8C(40=E5=8F=B7=E5=8D=8A=E5=A4=A997=E8=A1=8C),?= =?UTF-8?q?=E6=94=B9=5Fshould=5Fwrite=5Fbalance=E5=90=8C=E6=97=A5=E4=B8=80?= =?UTF-8?q?=E8=A1=8C+=E7=A9=BA=E8=A1=8C=E6=83=85=E4=B8=8D=E5=86=99(+3?= =?UTF-8?q?=E6=B5=8B=E8=AF=95)=20=E2=91=A4=E5=8F=AF=E8=A7=82=E6=B5=8B?= =?UTF-8?q?=E6=80=A7=E2=80=94=E2=80=94supervisor=E5=AD=90=E8=BF=9B?= =?UTF-8?q?=E7=A8=8Bstdout/stderr=E8=90=BDlogs/shadow=5F{aid}.log(>5MB?= =?UTF-8?q?=E9=87=8D=E7=BD=AE)+=E4=B8=BB=E7=AE=A1=E8=87=AA=E8=BA=AB?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E8=90=BDlogs/shadow=5Fsupervisor.log(?= =?UTF-8?q?=E5=8E=9Fschtask=E6=8E=A7=E5=88=B6=E5=8F=B0=3D=E9=BB=91?= =?UTF-8?q?=E6=B4=9E,41/42=E5=B4=A9=E5=9B=A0=E8=87=B3=E4=BB=8A=E9=9B=B6?= =?UTF-8?q?=E7=BA=BF=E7=B4=A2);933=E7=BB=BF=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sanguo_api/routes.py | 4 ++-- sanguo_api/routes_portfolio.py | 1 + sanguo_api/validation.py | 8 +++++-- sanguo_orchestrator/portfolio_worker.py | 3 ++- sanguo_orchestrator/runner.py | 2 ++ sanguo_portfolio/live_strategy.py | 14 +++++++++++++ sanguo_trader/shadow/__main__.py | 10 +++++++++ sanguo_trader/shadow/runner.py | 23 ++++++++++++++++----- sanguo_trader/shadow/supervisor.py | 17 ++++++++++++++- tests/api/test_portfolio_live.py | 5 +++++ tests/api/test_validation.py | 18 ++++++++++++---- tests/trader/test_shadow_snapshot_dedupe.py | 19 +++++++++++++++++ 12 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 tests/trader/test_shadow_snapshot_dedupe.py diff --git a/sanguo_api/routes.py b/sanguo_api/routes.py index 67083b7..9b07f33 100644 --- a/sanguo_api/routes.py +++ b/sanguo_api/routes.py @@ -420,7 +420,7 @@ def list_tasks(type: str | None = None, status: str | None = None, "type": ttype, "status": st, "strategy": strat, - "symbol": spec.get("symbol", "") or spec.get("benchmark", ""), + "symbol": spec.get("symbol", "") or spec.get("pool", "") or spec.get("benchmark", ""), "start": spec.get("start", ""), "end": spec.get("end", ""), "created_at": _to_bj(submissions.get(tid)), @@ -509,7 +509,7 @@ def task_params(task_id: str): "type": getattr(task, "task_type", "") or "", "status": st, "strategy": strat, - "symbol": spec.get("symbol", "") or spec.get("benchmark", ""), + "symbol": spec.get("symbol", "") or spec.get("pool", "") or spec.get("benchmark", ""), "params": spec.get("params") or spec.get("grid") or {}, "start": spec.get("start", ""), "end": spec.get("end", ""), diff --git a/sanguo_api/routes_portfolio.py b/sanguo_api/routes_portfolio.py index b4f1956..cbd2765 100644 --- a/sanguo_api/routes_portfolio.py +++ b/sanguo_api/routes_portfolio.py @@ -70,6 +70,7 @@ async def run_portfolio_backtest(req: PortfolioBacktestRequest): benchmark=req.benchmark, strategy=req.strategy, max_pool=req.max_pool, + pool=req.pool, provider_config=None, commission_rate=req.commission_rate, stamp_duty_rate=req.stamp_duty_rate, diff --git a/sanguo_api/validation.py b/sanguo_api/validation.py index 2976c7e..8f84e08 100644 --- a/sanguo_api/validation.py +++ b/sanguo_api/validation.py @@ -42,8 +42,12 @@ def validate_backtest_range(start: str, end: str, min_days: int = _MIN_SPAN_DAYS if end > today: raise HTTPException(400, f"结束日期 {end} 在未来,请选择历史日期(今天: {today})") latest = get_latest_daily_date() - if latest and end > latest: - raise HTTPException(400, f"结束日期 {end} 超出数据范围:日线数据最新到 {latest}") + # 2026-08-16 口径修正:end>latest 但 end<=today(周末/节假日/当日未灌)不再 400—— + # 用户意图「测到今天」,引擎按区间过滤自然截到 latest,静默放行(原 400 把 + # 周末默认提交全拦,VPS 实况:结束默认=今天周日 > 最新周五,首提即败)。 + # start>latest 仍拦:开始就在数据之外,回测必空跑,属明确错误输入。 + if latest and start > latest: + raise HTTPException(400, f"开始日期 {start} 超出数据范围:日线数据最新到 {latest}") def validate_capital(name: str, value: float) -> None: diff --git a/sanguo_orchestrator/portfolio_worker.py b/sanguo_orchestrator/portfolio_worker.py index 0c9187b..2bf9d37 100644 --- a/sanguo_orchestrator/portfolio_worker.py +++ b/sanguo_orchestrator/portfolio_worker.py @@ -108,7 +108,8 @@ def run_portfolio_task(spec: dict) -> Any: type="portfolio", status="done", strategy=data.get("strategy", ""), - symbol=benchmark, + # #85 标的列显示标的池(hs300_subset/all);benchmark 已存 params 不丢 + symbol=spec.get("pool") or benchmark, # params 全量存任务参数(含资金/费用),供任务列表「点ID跳参数页」完整预填 params={ "benchmark": benchmark, diff --git a/sanguo_orchestrator/runner.py b/sanguo_orchestrator/runner.py index 0a41f0d..49f5d14 100644 --- a/sanguo_orchestrator/runner.py +++ b/sanguo_orchestrator/runner.py @@ -140,6 +140,7 @@ class Orchestrator: async def submit_portfolio(self, start: str, end: str, cash: float, benchmark: str, strategy: str = "all_weather", max_pool: int = 30, + pool: str = "hs300_subset", provider_config=None, commission_rate: float = 0.0003, stamp_duty_rate: float = 0.001, @@ -166,6 +167,7 @@ class Orchestrator: benchmark=benchmark, strategy=strategy, max_pool=max_pool, + pool=pool, provider_config=provider_config, commission_rate=commission_rate, stamp_duty_rate=stamp_duty_rate, diff --git a/sanguo_portfolio/live_strategy.py b/sanguo_portfolio/live_strategy.py index cb40e3b..f39e37c 100644 --- a/sanguo_portfolio/live_strategy.py +++ b/sanguo_portfolio/live_strategy.py @@ -23,10 +23,14 @@ def _build_live_strategy(provider): """env 配置 → StrategyTemplate 实例(对齐 runner_backtest._build_strategy)。""" from sanguo_portfolio.strategies import ( AllWeatherConfig, AllWeatherStrategy, + AllWeatherExConfig, AllWeatherExStrategy, ChannelTestConfig, ChannelTestStrategy, MomentumTimingConfig, MomentumTimingStrategy, + MomentumTimingExConfig, MomentumTimingExStrategy, SmallCapConfig, SmallCapStrategy, + SmallCapExConfig, SmallCapExStrategy, ValueSelectionConfig, ValueSelectionStrategy, + ValueSelectionExConfig, ValueSelectionExStrategy, ) name = os.environ.get("SANGUO_LIVE_STRATEGY", "all_weather") @@ -42,6 +46,16 @@ def _build_live_strategy(provider): provider=provider, config=SmallCapConfig(max_pool=max_pool)), "channel_test": lambda: ChannelTestStrategy( provider=provider, config=ChannelTestConfig()), + # TET Phase2 副本(issue#19):影子/实盘账户可用 _ex 副本发起 + # (2026-08-16 VPS 实况:shadow#42=all_weather_ex 因工厂表缺项拉起即崩) + "all_weather_ex": lambda: AllWeatherExStrategy( + provider=provider, config=AllWeatherExConfig(max_pool=max_pool)), + "momentum_timing_ex": lambda: MomentumTimingExStrategy( + provider=provider, config=MomentumTimingExConfig(max_pool=max_pool)), + "value_selection_ex": lambda: ValueSelectionExStrategy( + provider=provider, config=ValueSelectionExConfig(max_pool=max_pool)), + "small_cap_ex": lambda: SmallCapExStrategy( + provider=provider, config=SmallCapExConfig(max_pool=max_pool)), } if name not in factories: raise ValueError( diff --git a/sanguo_trader/shadow/__main__.py b/sanguo_trader/shadow/__main__.py index c0fa5ff..8c4a139 100644 --- a/sanguo_trader/shadow/__main__.py +++ b/sanguo_trader/shadow/__main__.py @@ -46,6 +46,16 @@ def main() -> int: format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) if "--auto" in sys.argv: + # supervisor 自身日志落文件(schtasks 控制台输出是黑洞,拉起/重启决策无处可查) + try: + _log_dir = Path(__file__).resolve().parents[2] / "logs" + _log_dir.mkdir(parents=True, exist_ok=True) + _fh = logging.FileHandler(_log_dir / "shadow_supervisor.log") + _fh.setFormatter(logging.Formatter( + "%(asctime)s %(levelname)s %(name)s: %(message)s")) + logging.getLogger().addHandler(_fh) + except Exception: + pass # 日志失败不挡主管 from .supervisor import run_auto_supervisor run_auto_supervisor() diff --git a/sanguo_trader/shadow/runner.py b/sanguo_trader/shadow/runner.py index e33e72e..6da9085 100644 --- a/sanguo_trader/shadow/runner.py +++ b/sanguo_trader/shadow/runner.py @@ -134,11 +134,21 @@ def _paper_on_trade(db: str, account_id: int, strategy_id: str): return hook +def _should_write_balance(as_of: str, last_written: str) -> bool: + """#88:有行情日期且与上次已写日期不同才写(同日一行;空日期=无行情不写)。 + + 原实现每 30s 无条件写一行,VPS 实况 40 号账户半天刷 97 行 date='' 垃圾 + (周末无行情 as_of='',净值恒 100 万),把「日净值表」刷成流水账。 + """ + return bool(as_of) and as_of != last_written + + def _snapshot_loop(broker: Any, db: str, account_id: int, interval_sec: float = 30.0) -> None: """后台线程:定期把影子账户快照落 paper_positions/paper_daily_balance。""" from sanguo_trader.persistence import save_daily_balance, save_positions + last_as_of = "" while True: time.sleep(interval_sec) try: @@ -150,11 +160,14 @@ def _snapshot_loop(broker: Any, db: str, account_id: int, } save_positions(db, account_id, "account", positions, date=broker.trades[-1]["datetime"][:10] if broker.trades else "") - save_daily_balance( - db, account_id, info.get("as_of", ""), - cash=info["available_cash"], market_value=info["market_value"], - total_equity=info["total_value"], - ) + as_of = info.get("as_of", "") + if _should_write_balance(as_of, last_as_of): + save_daily_balance( + db, account_id, as_of, + cash=info["available_cash"], market_value=info["market_value"], + total_equity=info["total_value"], + ) + last_as_of = as_of except Exception as exc: # noqa: BLE001 - 落库失败不中断柜台 logger.warning("[shadow-snapshot] 落库失败 (account=%s): %s", account_id, exc) diff --git a/sanguo_trader/shadow/supervisor.py b/sanguo_trader/shadow/supervisor.py index 1c9ff1f..288c2d7 100644 --- a/sanguo_trader/shadow/supervisor.py +++ b/sanguo_trader/shadow/supervisor.py @@ -70,7 +70,22 @@ def spawn_child(acc: dict[str, Any], db_path: str) -> subprocess.Popen: "--account", str(acc["id"])] logger.info("[shadow-supervisor] 拉起账户 #%s(%s) 影子柜台", acc["id"], env["SANGUO_LIVE_STRATEGY"]) - return subprocess.Popen(argv, env=env) + # #88 可观测性:子进程 stdout/stderr 落 logs/shadow_{aid}.log(>5MB 轮转截断)。 + # 原实现继承 schtask 控制台(=黑洞),子进程秒崩时零线索——2026-08-16 + # VPS 实况 41/42 拉不起,连一行报错都没有,只能瞎猜。 + from pathlib import Path as _P + log_dir = _P(__file__).resolve().parents[2] / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + log_path = log_dir / f"shadow_{acc['id']}.log" + try: + if log_path.exists() and log_path.stat().st_size > 5 * 1024 * 1024: + log_path.write_text("", encoding="utf-8") # 超限重置,避免崩溃循环刷爆盘 + fh = open(log_path, "ab") + fh.write(f"\n==== spawn {time.strftime('%Y-%m-%d %H:%M:%S')} ====\n".encode()) + return subprocess.Popen(argv, env=env, stdout=fh, stderr=subprocess.STDOUT) + except Exception: + logger.warning("[shadow-supervisor] 日志重定向失败,退回继承控制台", exc_info=True) + return subprocess.Popen(argv, env=env) def _maybe_daily_reconcile(db_path: str, done_dates: set, diff --git a/tests/api/test_portfolio_live.py b/tests/api/test_portfolio_live.py index 2ae2325..dbd07df 100644 --- a/tests/api/test_portfolio_live.py +++ b/tests/api/test_portfolio_live.py @@ -142,6 +142,11 @@ def test_live_strategy_adapter_builds_all_strategies(monkeypatch): ("momentum_timing", "MomentumTimingStrategy"), ("value_selection", "ValueSelectionStrategy"), ("small_cap", "SmallCapStrategy"), + # TET Phase2 副本:影子/实盘同样可发起(2026-08-16 VPS shadow#42 因缺项拉起即崩) + ("all_weather_ex", "AllWeatherExStrategy"), + ("momentum_timing_ex", "MomentumTimingExStrategy"), + ("value_selection_ex", "ValueSelectionExStrategy"), + ("small_cap_ex", "SmallCapExStrategy"), ): monkeypatch.setenv("SANGUO_LIVE_STRATEGY", name) s = live_strategy._build_live_strategy(_FakeProvider()) diff --git a/tests/api/test_validation.py b/tests/api/test_validation.py index f09bcc2..308b684 100644 --- a/tests/api/test_validation.py +++ b/tests/api/test_validation.py @@ -61,11 +61,21 @@ def test_capital_and_rate_checks(): # ===== L2 数据最新日 ===== -def test_end_beyond_latest_data_rejected(monkeypatch): - # end 是过去日期(过 L1)但超过 mock 的数据最新日 → L2 拦 - monkeypatch.setattr(V, "get_latest_daily_date", lambda: "2026-07-31") +def test_end_beyond_latest_but_past_allowed(monkeypatch): + """2026-08-16 口径修正:end>latest 但 end<=today(周末/当日未灌)放行。 + + VPS 实况:结束默认=今天(周日) > 数据最新(周五),原 400 首提即败; + 引擎按区间过滤自然截到 latest,无需拦。 + """ + monkeypatch.setattr(V, "get_latest_daily_date", lambda: "2026-08-14") + assert V.validate_backtest_range("2026-01-01", "2026-08-16") is None + + +def test_start_beyond_latest_data_rejected(monkeypatch): + # 开始日期在数据最新日之后 → 必空跑,明确错误输入,L2 拦 + monkeypatch.setattr(V, "get_latest_daily_date", lambda: "2026-06-30") with pytest.raises(HTTPException) as e: - V.validate_backtest_range("2026-01-01", "2026-08-05") + V.validate_backtest_range("2026-07-01", "2026-08-05") assert "数据最新到" in e.value.detail diff --git a/tests/trader/test_shadow_snapshot_dedupe.py b/tests/trader/test_shadow_snapshot_dedupe.py new file mode 100644 index 0000000..81bb7ce --- /dev/null +++ b/tests/trader/test_shadow_snapshot_dedupe.py @@ -0,0 +1,19 @@ +"""#88 影子快照结算行去重:同日一行/空行情日期不写。""" +from sanguo_trader.shadow.runner import _should_write_balance + + +def test_empty_as_of_never_writes(): + """周末/未开盘 as_of='' → 不写(原实现每 30s 刷一行 date='' 垃圾)。""" + assert _should_write_balance("", "") is False + assert _should_write_balance("", "2026-08-14") is False + + +def test_same_day_written_once(): + """同一天只写第一行,后续轮询跳过。""" + assert _should_write_balance("2026-08-14", "") is True + assert _should_write_balance("2026-08-14", "2026-08-14") is False + + +def test_new_day_writes_again(): + """跨交易日再写一行(日净值语义)。""" + assert _should_write_balance("2026-08-17", "2026-08-14") is True