fix(vps验收五问题之四): ①#84个股回测/回放提交秒败——L2校验end>latest且end<=today(周末/节假日/当日未灌)不再400,引擎区间过滤自然截到latest;新增start>latest仍400(必空跑);VPS实况=结束默认今天周日>最新周五,首提即败 ②#85组合任务标的列永远000300.XSHG——worker symbol=benchmark改pool(spec透传,benchmark已在params);内存任务两处symbol回退symbol→pool→benchmark ③#42影子拉起即崩——live_strategy工厂表补4个TET副本映射(all_weather_ex等,01184c2改名后回测认得但影子/实盘runner不认,VPS shadow#42实录) ④#88影子快照刷垃圾行——_snapshot_loop每30s无条件写date=''行(40号半天97行),改_should_write_balance同日一行+空行情不写(+3测试) ⑤可观测性——supervisor子进程stdout/stderr落logs/shadow_{aid}.log(>5MB重置)+主管自身日志落logs/shadow_supervisor.log(原schtask控制台=黑洞,41/42崩因至今零线索);933绿 [vps]
CI/CD / test (push) Successful in 10s
CI/CD / nas-deploy (push) Successful in 26s
CI/CD / nas-verify (push) Successful in 11s

This commit is contained in:
2026-08-16 18:28:18 +08:00
parent bb934228db
commit 886438fc92
12 changed files with 109 additions and 15 deletions
+2 -2
View File
@@ -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", ""),
+1
View File
@@ -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,
+6 -2
View File
@@ -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:
+2 -1
View File
@@ -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,
+2
View File
@@ -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,
+14
View File
@@ -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(
+10
View File
@@ -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()
+18 -5
View File
@@ -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)
+16 -1
View File
@@ -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,
+5
View File
@@ -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())
+14 -4
View File
@@ -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
@@ -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