mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
fix(mining): enriched 数据并发更新时重读快照而非整轮失败
用户在数据更新进行中跑挖掘: 面板读取期间 enriched 发布提交新世代, assert_data_generation 直接抛 EnrichedGenerationUnavailableError, 整轮运行作废且只留一句英文堆栈; 启动接口撞上发布中状态还会 500。 - mining_runtime: 面板+撮合矩阵读取包进有限重试环 (默认 3 次)。 世代变化时丢弃半新半旧的读取, 用新世代整体重读; 发布未完成时先等 5s; 耗尽后以带指引的中文错误终止。排队指纹仍只在首轮校验, 重试跟随 新世代等价于"更新后立刻重跑" - api/mining: build_data_fingerprint 撞上发布中状态映射为 400 中文 提示 (原来未捕获直接 500), 前端 task.error 红字通路直接可见 - 测试: 读取中单次世代漂移重读后成功 / 持续漂移耗尽后带指引失败 / 发布中启动返回 400; 全量挖掘回归 103 passed
This commit is contained in:
@@ -20,6 +20,7 @@ from app.backtest.mining import (
|
||||
MAX_FINALISTS,
|
||||
evaluate_candidate_gate,
|
||||
)
|
||||
from app.enriched_generation import EnrichedGenerationUnavailableError
|
||||
from app.services import preferences
|
||||
from app.services.mining_jobs import (
|
||||
RUN_STATUSES,
|
||||
@@ -208,6 +209,13 @@ def start_run(payload: MiningStartRequest, request: Request) -> dict[str, Any]:
|
||||
return projected
|
||||
except (MiningRunValidationError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except EnrichedGenerationUnavailableError as exc:
|
||||
# build_data_fingerprint 读世代时撞上正在进行的 enriched 发布 (如盘后更新):
|
||||
# 映射为 400 带指引, 而不是 500 英文堆栈。
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="行情数据正在更新(enriched 发布中), 请等数据更新完成后再开始挖掘",
|
||||
) from exc
|
||||
except MiningRunStoreError as exc:
|
||||
raise HTTPException(status_code=500, detail="failed to persist mining run") from exc
|
||||
|
||||
|
||||
@@ -53,6 +53,10 @@ from app.backtest.strategy import (
|
||||
_merge_resolved_feature_plans,
|
||||
build_matrix_cache_profile,
|
||||
)
|
||||
from app.enriched_generation import (
|
||||
EnrichedGenerationUnavailableError,
|
||||
enriched_publication_incomplete,
|
||||
)
|
||||
from app.services.mining_jobs import MiningRunStore
|
||||
from app.services.mining_preflight import enriched_partition_dates
|
||||
from app.services.mining_schedule import MINING_ALGORITHM_VERSION
|
||||
@@ -80,6 +84,11 @@ _REGIME_FILTERS: dict[str, dict[str, list[str]]] = {
|
||||
"weak": {"states": ["lean_weak", "weak"]},
|
||||
}
|
||||
|
||||
# 面板/撮合矩阵读取期间 enriched 世代被并发发布打断时, 允许用新世代整体重读的次数
|
||||
# 与发布未完成时的等待秒数; 超过后以带指引的错误终止运行。
|
||||
_SNAPSHOT_MAX_ATTEMPTS = 3
|
||||
_SNAPSHOT_PUBLISH_WAIT_SECONDS = 5.0
|
||||
|
||||
|
||||
class MiningRuntimeCancelledError(RuntimeError):
|
||||
pass
|
||||
@@ -462,7 +471,6 @@ def run_mining_runtime(
|
||||
emit({"phase": "panel", "label": "加载因子面板", "done": 0, "total": 1})
|
||||
start_phase()
|
||||
_raise_if_cancelled(cancel_check)
|
||||
panel_started = time.perf_counter()
|
||||
factor_service = FactorBacktestService(service.engine)
|
||||
factor_config = FactorBatchConfig(
|
||||
factor_names=list(request.factor_names),
|
||||
@@ -474,8 +482,23 @@ def run_mining_runtime(
|
||||
stamp_tax_pct=request.stamp_tax_pct,
|
||||
slippage_bps=request.slippage_bps,
|
||||
)
|
||||
phase_ms: dict[str, float] = {}
|
||||
panel: pl.DataFrame | None = None
|
||||
base_market: np.ndarray | None = None
|
||||
generation: str | None = None
|
||||
for attempt in range(_SNAPSHOT_MAX_ATTEMPTS):
|
||||
if attempt:
|
||||
# 读取期间 enriched 发布了新世代, 面板可能新旧混合: 丢弃本轮,
|
||||
# 用新世代整体重读。排队指纹只在首轮校验; 数据在运行中前进,
|
||||
# 重试跟随新世代属于预期 (等价于"更新后立刻重跑")。
|
||||
emit({"phase": "panel", "label": "数据已更新, 重新读取快照", "done": 0, "total": 1})
|
||||
if enriched_publication_incomplete(data_dir):
|
||||
time.sleep(_SNAPSHOT_PUBLISH_WAIT_SECONDS)
|
||||
service.engine.clear_panel_cache()
|
||||
panel_started = time.perf_counter()
|
||||
try:
|
||||
generation = factor_service._data_generation(request.asset_type)
|
||||
if generation != expected_generation:
|
||||
if attempt == 0 and generation != expected_generation:
|
||||
raise ValueError(
|
||||
"mining data generation changed after the run was queued"
|
||||
)
|
||||
@@ -509,9 +532,9 @@ def run_mining_runtime(
|
||||
del source_panel
|
||||
if panel.is_empty():
|
||||
raise ValueError("mining panel contains no valid price rows")
|
||||
phase_ms: dict[str, float] = {
|
||||
"panel": round((time.perf_counter() - panel_started) * 1000.0, 3)
|
||||
}
|
||||
phase_ms["panel"] = round(
|
||||
(time.perf_counter() - panel_started) * 1000.0, 3
|
||||
)
|
||||
finish_phase("panel")
|
||||
emit({
|
||||
"phase": "panel",
|
||||
@@ -539,7 +562,9 @@ def run_mining_runtime(
|
||||
cancel_check=cancel_check,
|
||||
)
|
||||
factor_service._assert_data_generation(request.asset_type, generation)
|
||||
phase_ms["matrix"] = round((time.perf_counter() - matrix_started) * 1000.0, 3)
|
||||
phase_ms["matrix"] = round(
|
||||
(time.perf_counter() - matrix_started) * 1000.0, 3
|
||||
)
|
||||
finish_phase("matrix")
|
||||
emit({
|
||||
"phase": "matrix",
|
||||
@@ -548,6 +573,15 @@ def run_mining_runtime(
|
||||
"total": 1,
|
||||
"matrix_bytes": base_market.nbytes,
|
||||
})
|
||||
break
|
||||
except EnrichedGenerationUnavailableError as exc:
|
||||
if attempt + 1 >= _SNAPSHOT_MAX_ATTEMPTS:
|
||||
raise ValueError(
|
||||
"行情数据正在更新: 挖掘读取期间 enriched 数据世代反复变化, "
|
||||
"重试后仍拿不到稳定快照; 请等数据更新完成后再开始挖掘"
|
||||
) from exc
|
||||
if panel is None or base_market is None or generation is None:
|
||||
raise RuntimeError("mining data snapshot did not settle")
|
||||
|
||||
metric_provider = TrainingMetricProvider(request.mining_request.target_column)
|
||||
evaluator = MatcherCandidateEvaluator(
|
||||
|
||||
@@ -480,3 +480,126 @@ def test_spawn_walkforward_skips_folds_before_available_matrix_data(tmp_path):
|
||||
assert result["skipped"][0]["reason"] == "训练区间无可用行情数据"
|
||||
assert result["n_folds"] == 3
|
||||
assert result["worker"]["worker_exitcode"] == 0
|
||||
|
||||
|
||||
def _mining_runtime_services(data_dir):
|
||||
"""按 worker._worker_entry 的方式在进程内构造挖掘运行时依赖 (便于 monkeypatch)。"""
|
||||
from app.backtest.engine import BacktestEngine
|
||||
from app.backtest.strategy import StrategyBacktestService
|
||||
from app.strategy import config as strategy_config
|
||||
from app.strategy.engine import StrategyEngine
|
||||
from app.tickflow.repository import DataStore, KlineRepository
|
||||
|
||||
store = DataStore(data_dir)
|
||||
repo = KlineRepository(store)
|
||||
strategy_engine = StrategyEngine(
|
||||
strategy_dirs=worker_module._strategy_dirs(data_dir),
|
||||
override_loader=lambda sid: strategy_config.load_override(data_dir, sid),
|
||||
)
|
||||
service = StrategyBacktestService(BacktestEngine(repo), strategy_engine)
|
||||
return service, strategy_engine
|
||||
|
||||
|
||||
def _queue_mining_run(data_dir, start: date, run_id: str) -> dict:
|
||||
store = MiningRunStore(data_dir)
|
||||
manifest = store.create(
|
||||
{
|
||||
"factor_names": ["turnover_rate"],
|
||||
"strategy_ids": [],
|
||||
"symbols": None,
|
||||
"asset_type": "stock",
|
||||
"start": (start - timedelta(days=7)).isoformat(),
|
||||
"end": (start + timedelta(days=225)).isoformat(),
|
||||
"budget_profile": "exploratory",
|
||||
"forward_horizon": 1,
|
||||
"commission_pct": 0.0,
|
||||
"stamp_tax_pct": 0.0,
|
||||
"slippage_bps": 0.0,
|
||||
"correlation_threshold": 0.75,
|
||||
"max_combination_factors": 1,
|
||||
"beam_width": 2,
|
||||
"max_finalists": 2,
|
||||
"require_regime": False,
|
||||
},
|
||||
{"generation": get_enriched_generation(data_dir, "stock")},
|
||||
run_id=run_id,
|
||||
)
|
||||
return {
|
||||
"run_id": manifest["run_id"],
|
||||
"request": manifest["request"],
|
||||
"data_fingerprint": manifest["data_fingerprint"],
|
||||
"source": "manual",
|
||||
}
|
||||
|
||||
|
||||
def _patch_generation_drift(monkeypatch, data_dir, *, always: bool) -> dict:
|
||||
"""让世代校验第一次(或每次)调用前先 bump 再抛错, 模拟读取期间并发发布完成。"""
|
||||
from app.backtest.engine import BacktestEngine
|
||||
from app.enriched_generation import EnrichedGenerationUnavailableError
|
||||
|
||||
original = BacktestEngine.assert_data_generation
|
||||
state = {"drifts": 0}
|
||||
|
||||
def drift(engine_self, asset_type, expected):
|
||||
if expected is not None and (always or state["drifts"] == 0):
|
||||
state["drifts"] += 1
|
||||
bump_enriched_generation(data_dir, asset_type)
|
||||
raise EnrichedGenerationUnavailableError(
|
||||
"simulated concurrent publication"
|
||||
)
|
||||
return original(engine_self, asset_type, expected)
|
||||
|
||||
monkeypatch.setattr(BacktestEngine, "assert_data_generation", drift)
|
||||
return state
|
||||
|
||||
|
||||
def test_mining_rereads_snapshot_when_generation_commits_mid_read(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
start = date(2023, 1, 2)
|
||||
data_dir = tmp_path / "data"
|
||||
_write_mining_market_data(data_dir, start)
|
||||
payload = _queue_mining_run(data_dir, start, "midread_drift_mining")
|
||||
service, strategy_engine = _mining_runtime_services(data_dir)
|
||||
state = _patch_generation_drift(monkeypatch, data_dir, always=False)
|
||||
|
||||
from app.backtest.mining_runtime import run_mining_runtime
|
||||
|
||||
events: list[dict] = []
|
||||
result = run_mining_runtime(
|
||||
payload,
|
||||
data_dir=data_dir,
|
||||
service=service,
|
||||
strategy_engine=strategy_engine,
|
||||
progress_cb=events.append,
|
||||
cancel_check=None,
|
||||
)
|
||||
|
||||
assert result["status"] in {"succeeded", "succeeded_with_budget_exhausted"}
|
||||
assert state["drifts"] == 1
|
||||
assert any(
|
||||
event.get("label") == "数据已更新, 重新读取快照" for event in events
|
||||
)
|
||||
|
||||
|
||||
def test_mining_fails_with_guidance_when_generation_keeps_drifting(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
start = date(2023, 1, 2)
|
||||
data_dir = tmp_path / "data"
|
||||
_write_mining_market_data(data_dir, start)
|
||||
payload = _queue_mining_run(data_dir, start, "endless_drift_mining")
|
||||
service, strategy_engine = _mining_runtime_services(data_dir)
|
||||
_patch_generation_drift(monkeypatch, data_dir, always=True)
|
||||
|
||||
from app.backtest.mining_runtime import run_mining_runtime
|
||||
|
||||
with pytest.raises(ValueError, match="稳定快照"):
|
||||
run_mining_runtime(
|
||||
payload,
|
||||
data_dir=data_dir,
|
||||
service=service,
|
||||
strategy_engine=strategy_engine,
|
||||
progress_cb=None,
|
||||
cancel_check=None,
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.mining import router
|
||||
from app.backtest.mining import compute_candidate_signature
|
||||
from app.enriched_generation import EnrichedGenerationUnavailableError
|
||||
from app.services.mining_jobs import MiningRunStore
|
||||
from app.strategy.engine import StrategyEngine
|
||||
|
||||
@@ -604,3 +605,33 @@ def test_config_patch_merges_current_values(tmp_path, monkeypatch):
|
||||
assert response.status_code == 200
|
||||
assert saved == [(True, 4, "balanced")]
|
||||
assert client.patch("/api/backtest/mining/config", json={}).status_code == 400
|
||||
|
||||
|
||||
class _PublishingRepo(_Repo):
|
||||
"""模拟 enriched 发布进行中: 世代读取抛 EnrichedGenerationUnavailableError。"""
|
||||
|
||||
@staticmethod
|
||||
def get_matrix_data_generation(asset_type="stock"):
|
||||
raise EnrichedGenerationUnavailableError(
|
||||
"enriched data is being published; retry after the update finishes"
|
||||
)
|
||||
|
||||
|
||||
def test_start_returns_400_with_guidance_while_enriched_publication_active(
|
||||
tmp_path,
|
||||
):
|
||||
_write_enriched_dates(tmp_path, 219, first=date(2022, 8, 15))
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.state.repo = _PublishingRepo(tmp_path)
|
||||
app.state.mining_manager = _Manager(tmp_path)
|
||||
app.state.strategy_engine = SimpleNamespace()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/api/backtest/mining/runs",
|
||||
json={"factor_names": ["turnover_rate"], "budget_profile": "exploratory"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "数据更新" in response.json()["detail"]
|
||||
|
||||
Reference in New Issue
Block a user