Files
tick-stock-panel/backend/app/services/mining_schedule.py
T
shy3130 697c27bb02 feat(v0.2): 市场阶段与主线识别 + 因子挖掘全链路 + 数据层完善
- 市场环境: 新增情绪周期6阶段(冰点/启动/主升/高潮/退潮/修复, 连板梯队驱动,
  EMA平滑+2日确认+弱档否决, 平均段长9.7天)与概念/行业主线排名(涨停梯队聚合,
  可配置宽基/风格标签过滤); 市场环境页重构, regime 透明加列, 与5档state并存
- 挖掘: 因子与策略挖掘全链路(API/worker/进程锁/候选库/前端工作台/文档),
  周度调度默认关闭且永不自动发布
- 回测: 财务快照因子(点时口径), 批量回测预计算共享下期收益,
  信号路径矩阵列依赖展开修复(consecutive_limit_ups 缺列报错)
- 数据/性能: enriched 生成与预热治理, 重任务限流, 行情/K线缓存复用, 时区修复
- 测试: 后端全量 914 通过; GUI 黑盒验证截图存证 gui-test-screenshots/
2026-08-16 23:39:07 +08:00

392 lines
13 KiB
Python

"""Weekly scheduled mining orchestration and deterministic data claims."""
from __future__ import annotations
import hashlib
import json
import logging
import threading
from datetime import date, datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
import polars as pl
from app.backtest.factor import FACTOR_COLUMNS, FACTOR_METHODOLOGY_VERSION
from app.backtest.mining import (
required_outer_folds,
required_trading_bars,
validation_config_for_profile,
)
from app.services import preferences
from app.services.mining_preflight import enriched_partition_dates
from app.services.regime_builder import load_regime_history, regime_path
logger = logging.getLogger(__name__)
BEIJING_TZ = ZoneInfo("Asia/Shanghai")
MINING_ALGORITHM_VERSION = "mining-v2"
FINGERPRINT_VERSION = "weekly-mining-data-v2"
_PROFILES = frozenset({"balanced", "strict"})
_CLAIM_LOCK = threading.Lock()
def beijing_now(now: datetime | None = None) -> datetime:
"""Return an aware Beijing datetime without depending on the server timezone."""
if now is None:
return datetime.now(BEIJING_TZ)
if now.tzinfo is None:
return now.replace(tzinfo=BEIJING_TZ)
return now.astimezone(BEIJING_TZ)
def beijing_date(now: datetime | None = None) -> date:
return beijing_now(now).date()
def iso_week(value: date) -> tuple[int, int]:
iso_year, week, _ = value.isocalendar()
return iso_year, week
def build_default_request(repo: Any, profile: str) -> dict[str, Any]:
"""Build the bounded V1 stock/full-market request used by the scheduler."""
if profile not in _PROFILES:
raise ValueError(f"unsupported mining profile: {profile}")
latest = repo.latest_enriched_date("stock")
end = latest.isoformat() if latest is not None else None
return {
"factor_names": [item["id"] for item in FACTOR_COLUMNS[:48]],
"strategy_ids": [],
"symbols": None,
"asset_type": "stock",
"start": None,
"end": end,
"budget_profile": profile,
"require_regime": True,
}
def build_data_fingerprint(
repo: Any,
app_state: Any,
request: dict[str, Any],
) -> dict[str, Any]:
"""Hash one stable managed generation plus source metadata."""
for _attempt in range(2):
fingerprint = _build_data_fingerprint_once(repo, app_state, request)
if repo.get_matrix_data_generation(fingerprint["asset_type"]) == fingerprint["generation"]:
return fingerprint
raise ValueError("enriched data changed while building the mining fingerprint")
def _build_data_fingerprint_once(
repo: Any,
app_state: Any,
request: dict[str, Any],
) -> dict[str, Any]:
data_dir = Path(repo.store.data_dir)
asset_type = str(request.get("asset_type") or "stock")
enriched_root = (
data_dir / "kline_daily_enriched"
if asset_type == "stock"
else data_dir / f"kline_{asset_type}_enriched"
)
module_root = Path(__file__).resolve().parents[1]
components = {
"version": FINGERPRINT_VERSION,
"asset_type": asset_type,
"generation": repo.get_matrix_data_generation(asset_type),
"latest_enriched_date": _iso_or_none(repo.latest_enriched_date(asset_type)),
"enriched": _enriched_metadata(enriched_root),
"instruments": _instrument_metadata(repo, asset_type),
"regime": _path_metadata(regime_path(data_dir), root=data_dir),
"algorithm_version": MINING_ALGORITHM_VERSION,
"methodology_version": FACTOR_METHODOLOGY_VERSION,
"implementation": _implementation_metadata(module_root),
"strategies": _selected_strategy_metadata(
app_state,
request.get("strategy_ids") or [],
data_dir,
),
}
payload = _canonical_json(components)
return {
**components,
"digest": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
}
def schedule_claim(day: date) -> str:
iso_year, week = iso_week(day)
return f"weekly-{iso_year}-W{week:02d}"
def run_weekly_mining(app_state: Any, *, now: datetime | None = None) -> dict[str, Any]:
"""Check the weekly gate and enqueue mining; never perform mining synchronously."""
config = preferences.get_mining_schedule()
day = beijing_date(now)
if not config["mining_schedule_enabled"]:
return {"status": "disabled"}
weekday = day.weekday()
if weekday > 4 or weekday < config["mining_schedule_weekday"]:
return {"status": "weekday_mismatch"}
manager = getattr(app_state, "mining_manager", None)
repo = getattr(app_state, "repo", None)
if manager is None or repo is None:
raise RuntimeError("scheduled mining dependencies are not initialized")
store = getattr(manager, "store", None)
if store is None:
raise RuntimeError("scheduled mining manager has no run store")
request = build_default_request(repo, config["mining_budget_profile"])
fingerprint = build_data_fingerprint(repo, app_state, request)
claim = schedule_claim(day)
fingerprint = {**fingerprint, "source": "scheduled", "source_claim": claim}
with _CLAIM_LOCK:
existing = store.get(claim)
if existing is not None:
return {"status": "already_claimed", "run_id": claim}
prerequisite_error = _prerequisite_error(repo, request)
if prerequisite_error is not None:
_record_skipped_prerequisite(store, claim, request, fingerprint, prerequisite_error)
return {
"status": "skipped_prerequisite",
"run_id": claim,
"error": prerequisite_error,
}
run = manager.start(
request,
fingerprint,
force=False,
source="scheduled",
run_id=claim,
)
run_id = run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None)
return {"status": "enqueued", "run_id": run_id or claim}
def _prerequisite_error(repo: Any, request: dict[str, Any]) -> str | None:
data_dir = Path(repo.store.data_dir)
end = request.get("end")
if end is None:
return "stock enriched data is unavailable"
regime = regime_path(data_dir)
try:
if not regime.is_file() or regime.stat().st_size <= 0:
return "regime data is unavailable"
except OSError:
return "regime data is unavailable"
start = request.get("start")
start_date = date.fromisoformat(start) if start is not None else None
end_date = date.fromisoformat(end)
partitions = enriched_partition_dates(
data_dir,
"stock",
start_date,
end_date,
)
covered = [value.isoformat() for value in partitions]
profile = request["budget_profile"]
validation = validation_config_for_profile(profile)
required = required_trading_bars(
validation,
required_outer_folds(profile),
)
if len(covered) < required:
return (
"insufficient enriched trading dates: "
f"need {required}, got {len(covered)}"
)
regime_history = load_regime_history(data_dir)
if regime_history.is_empty() or "date" not in regime_history.columns:
return "regime data is unavailable"
regime_dates = set(
regime_history.select(
pl.col("date").cast(pl.Utf8).str.slice(0, 10)
).to_series().to_list()
)
required_predecessors = set(covered[:-1])
missing_regime = required_predecessors - regime_dates
if missing_regime:
return (
"regime coverage is incomplete for T-1 alignment: "
f"missing {len(missing_regime)} trading dates"
)
return None
def _record_skipped_prerequisite(
store: Any,
claim: str,
request: dict[str, Any],
fingerprint: dict[str, Any],
error: str,
) -> None:
try:
store.create(request, fingerprint, run_id=claim)
except Exception:
if store.get(claim) is not None:
return
raise
store.append_event(
claim,
"skipped_prerequisite",
{"source": "scheduled", "reason": error},
)
store.transition_status(claim, "skipped_prerequisite", error=error)
def _instrument_metadata(repo: Any, asset_type: str) -> dict[str, Any]:
instruments = repo.get_instruments_asset(asset_type)
if instruments is None or instruments.is_empty() or "symbol" not in instruments.columns:
return {"rows": 0, "digest": "no-instruments"}
columns = [
name
for name in (
"symbol",
"name",
"total_shares",
"float_shares",
"limit_up",
"limit_down",
)
if name in instruments.columns
]
payload = instruments.select(columns).sort("symbol").to_dicts()
digest = hashlib.blake2b(
json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8"),
digest_size=20,
).hexdigest()
return {"rows": instruments.height, "columns": columns, "digest": digest}
def _enriched_metadata(root: Path) -> dict[str, Any]:
records: list[dict[str, Any]] = []
for partition in sorted(root.glob("date=*"), key=lambda item: item.name):
try:
date.fromisoformat(partition.name.removeprefix("date="))
except ValueError:
continue
records.append(
{
"partition": partition.name,
"file": _path_metadata(partition / "part.parquet", root=root),
}
)
return {
"partition_count": len(records),
"first_partition": records[0]["partition"] if records else None,
"last_partition": records[-1]["partition"] if records else None,
"metadata_digest": hashlib.sha256(_canonical_json(records).encode("utf-8")).hexdigest(),
}
def _selected_strategy_metadata(
app_state: Any,
strategy_ids: list[str],
data_dir: Path,
) -> list[dict[str, Any]]:
if not strategy_ids:
return []
engine = getattr(app_state, "strategy_engine", None)
if engine is None:
raise RuntimeError("strategy engine is unavailable for scheduled mining fingerprint")
metadata: list[dict[str, Any]] = []
for strategy_id in sorted(strategy_ids):
strategy = engine.get(strategy_id)
if strategy.execution_backend != "matrix_native":
raise ValueError(f"scheduled mining strategy is not matrix-native: {strategy_id}")
source_path = Path(strategy.file_path) if strategy.file_path is not None else None
override_path = data_dir / "user_data" / "strategy_overrides" / f"{strategy_id}.json"
metadata.append(
{
"strategy_id": strategy_id,
"source": _content_metadata(
source_path, root=source_path.parent if source_path else data_dir
),
"source_tree": (
_implementation_metadata(source_path.parent)
if source_path is not None
else None
),
"override": _content_metadata(override_path, root=data_dir),
}
)
return metadata
def _path_metadata(path: Path | None, *, root: Path) -> dict[str, Any] | None:
if path is None:
return None
try:
stat = path.stat()
except OSError:
return {"path": _relative_path(path, root), "exists": False}
return {
"path": _relative_path(path, root),
"exists": True,
"size": stat.st_size,
"mtime_ns": stat.st_mtime_ns,
}
def _content_metadata(path: Path | None, *, root: Path) -> dict[str, Any] | None:
if path is None:
return None
try:
content = path.read_bytes()
except OSError:
return {"path": _relative_path(path, root), "exists": False}
return {
"path": _relative_path(path, root),
"exists": True,
"size": len(content),
"sha256": hashlib.sha256(content).hexdigest(),
}
def _implementation_metadata(module_root: Path) -> dict[str, Any]:
records = []
for path in sorted(module_root.rglob("*.py"), key=lambda item: item.as_posix()):
try:
content = path.read_bytes()
except OSError:
continue
records.append({
"path": path.relative_to(module_root).as_posix(),
"sha256": hashlib.sha256(content).hexdigest(),
})
return {
"file_count": len(records),
"digest": hashlib.sha256(_canonical_json(records).encode("utf-8")).hexdigest(),
}
def _relative_path(path: Path, root: Path) -> str:
try:
return path.resolve().relative_to(root.resolve()).as_posix()
except ValueError:
return path.name
def _iso_or_none(value: date | None) -> str | None:
return value.isoformat() if value is not None else None
def _canonical_json(value: Any) -> str:
return json.dumps(
value,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
)