from __future__ import annotations import json from datetime import datetime, time from typing import Any from zoneinfo import ZoneInfo from backend.data.contracts import SnapshotState from backend.data.gateway import DataGateway from backend.data.repository import MarketRepository from backend.database.connection import Database from backend.features.screener.catalog import ( CatalogError, factor_catalog, strategy_catalog, validate_formula, ) from backend.features.screener.engine import execute_formula from backend.features.screener.factors import build_factor_snapshot from backend.features.screener.repository import ( ScreenerRepository, decode_custom, decode_run, decode_track, ) SHANGHAI = ZoneInfo("Asia/Shanghai") PHASE_REGIMES = { "冰点": "ice", "修复": "repair", "发酵": "fermentation", "高潮": "climax", "分化": "divergence", "退潮": "retreat", } FINISHED = frozenset({"completed", "no_signal", "data_incomplete"}) class ScreenerError(RuntimeError): pass class ScreenerService: def __init__( self, database: Database, repository: ScreenerRepository, market_repository: MarketRepository, gateway: DataGateway, ) -> None: self._database = database self._repository = repository self._market_repository = market_repository self._gateway = gateway def catalog(self) -> dict[str, Any]: factors = factor_catalog() strategies = strategy_catalog() return { "factor_groups": factors["groups"], "factors": factors["factors"], "stage": [_public_strategy(item) for item in strategies if item["kind"] == "stage"], "curated": [_public_strategy(item) for item in strategies if item["kind"] == "curated"], } def workspace(self, requested_date: str, user_id: int) -> dict[str, Any]: through = self._gateway.trade_context(requested_date).actual_date with self._database.read() as connection: custom = [ decode_custom(row) for row in self._repository.custom_strategies(connection, user_id) ] if through is None: return { "trade_date": None, "message": "等待管理员首次同步真实收盘行情", "catalog": self.catalog(), "stage_runs": [], "curated_runs": [], "custom_strategies": custom, "custom_runs": [], } with self._database.read() as connection: stage = [ decode_run(row) for row in self._repository.latest_runs(connection, "stage", through) ] curated = [ decode_run(row) for row in self._repository.latest_runs(connection, "curated", through) ] custom_runs = [ decode_run(row) for row in self._repository.latest_runs(connection, "custom", through, user_id) ] return { "trade_date": through, "message": "", "catalog": self.catalog(), "stage_runs": stage, "curated_runs": curated, "custom_strategies": custom, "custom_runs": custom_runs, } def sync_and_run(self, trade_date: str) -> dict[str, Any]: market = self._market_snapshot(trade_date) inputs, coverage, sources = self._gateway.screener_inputs(trade_date) snapshot = build_factor_snapshot(trade_date, inputs, coverage, sources) state = str(market["state"]) with self._database.transaction() as connection: snapshot_id = self._repository.save_factor_snapshot( connection, trade_date=trade_date, version=snapshot["version"], observed_at=snapshot["observed_at"], state=state, sources=snapshot["sources"], coverage=snapshot["coverage"], rows=snapshot["rows"], ) phase = str((market["payload"].get("sentiment") or {}).get("phase") or "") regime = PHASE_REGIMES.get(phase) stage_strategies, curated_strategies = automatic_strategies(regime) runs = [ self._run(snapshot_id, trade_date, strategy, None) for strategy in [*stage_strategies, *curated_strategies] ] self._update_tracks(trade_date, snapshot["rows"]) return { "trade_date": trade_date, "factor_version": snapshot["version"], "factor_count": len(snapshot["rows"]), "phase": phase, "stage_runs": len(stage_strategies), "curated_runs": len(curated_strategies), "completed": sum(run and run["status"] in FINISHED for run in runs), "failed": sum(run and run["status"] == "failed" for run in runs), } def run_after_close(self, now: datetime | None = None) -> dict[str, Any] | None: clock = now or datetime.now(SHANGHAI) if clock.time() < time(15, 10): return None trade_date = clock.date().isoformat() market = self._market_snapshot(trade_date) if market["trade_date"] != trade_date or market["state"] != SnapshotState.FINAL.value: return None return self.sync_and_run(trade_date) def save_custom(self, user_id: int, name: str, formula: dict[str, Any]) -> dict[str, Any]: normalized = " ".join(name.split()) if not normalized or len(normalized) > 30: raise ScreenerError("自定义策略名称应为1至30个字符") try: validate_formula(formula) except CatalogError as exc: raise ScreenerError(str(exc)) from exc with self._database.transaction() as connection: return decode_custom( self._repository.save_custom_strategy(connection, user_id, normalized, formula) ) def delete_custom(self, user_id: int, strategy_id: int) -> None: with self._database.transaction() as connection: if not self._repository.delete_custom_strategy(connection, user_id, strategy_id): raise ScreenerError("未找到该自定义策略") def run_custom(self, user_id: int, strategy_id: int, through: str) -> dict[str, Any]: with self._database.read() as connection: custom = self._repository.custom_strategy(connection, user_id, strategy_id) snapshot = self._repository.latest_factor_snapshot(connection, through) if custom is None: raise ScreenerError("未找到该自定义策略") if snapshot is None: raise ScreenerError("当前日期尚未生成完整因子快照") strategy = { "id": f"custom-{custom['id']}", "name": str(custom["name"]), "version": int(custom["version"]), "kind": "custom", "formula": json.loads(str(custom["formula_json"])), } result = self._run(int(snapshot["id"]), str(snapshot["trade_date"]), strategy, user_id) if result is None: raise ScreenerError("自定义策略执行失败") return result def tracks(self, user_id: int) -> list[dict[str, Any]]: with self._database.read() as connection: return [ decode_track(row, self._repository.track_bars(connection, int(row["id"]))) for row in self._repository.tracks(connection, user_id) ] def add_track(self, user_id: int, run_id: int, identifier: str) -> int: with self._database.transaction() as connection: run = self._repository.run_for_user(connection, run_id, user_id) if run is None: raise ScreenerError("未找到可访问的选股结果") items = json.loads(str(run["result_json"])) candidate = next( (item for item in items if str(item.get("identifier")) == identifier), None ) if ( candidate is None or not isinstance(candidate.get("close"), (int, float)) or float(candidate["close"]) <= 0 ): raise ScreenerError("该候选无法加入持续跟踪") return self._repository.add_track( connection, user_id=user_id, run=run, candidate=candidate ) def remove_track(self, user_id: int, track_id: int) -> None: with self._database.transaction() as connection: if not self._repository.remove_track(connection, user_id, track_id): raise ScreenerError("未找到该跟踪记录") def _run( self, snapshot_id: int, trade_date: str, strategy: dict[str, Any], owner_user_id: int | None, ) -> dict[str, Any] | None: mode = str(strategy["kind"]) with self._database.transaction() as connection: row = self._repository.begin_run( connection, owner_user_id=owner_user_id, mode=mode, strategy_id=str(strategy["id"]), strategy_name=str(strategy["name"]), strategy_version=int(strategy["version"]), selection_date=trade_date, factor_snapshot_id=snapshot_id, ) existing = decode_run(row) if existing and existing["status"] in FINISHED: return existing snapshot = self._repository.factor_snapshot(connection, snapshot_id) rows = self._repository.factor_rows(connection, snapshot_id) if snapshot is None: raise ScreenerError("因子快照不存在") coverage = json.loads(str(snapshot["coverage_json"])) try: outcome = execute_formula(rows, strategy["formula"], coverage) status = str(outcome["status"]) missing = [*outcome["missing_datasets"], *outcome["missing_fields"]] score_coverage = min(outcome["field_coverage"].values(), default=0) error = "" except (CatalogError, KeyError, TypeError, ValueError) as exc: status, missing, score_coverage, outcome, error = ( "failed", [], 0, {"items": []}, str(exc), ) with self._database.transaction() as connection: self._repository.finish_run( connection, int(row["id"]), status=status, coverage=score_coverage, missing_fields=missing, result=outcome["items"], error_message=error, ) return decode_run( connection.execute( "SELECT * FROM screener_runs WHERE id = ?", (row["id"],) ).fetchone() ) def _market_snapshot(self, trade_date: str) -> dict[str, Any]: with self._database.read() as connection: row = self._market_repository.latest_summary(connection, trade_date) if row is None or str(row["trade_date"]) != trade_date: raise ScreenerError("当日收盘行情尚未完成,选股任务未启动") if str(row["state"]) not in {SnapshotState.FINAL.value, SnapshotState.ARCHIVE.value}: raise ScreenerError("行情快照尚未收盘定稿") return { "trade_date": str(row["trade_date"]), "state": str(row["state"]), "payload": json.loads(str(row["payload_json"])), } def _update_tracks(self, trade_date: str, rows: list[dict[str, Any]]) -> None: current = {str(row["identifier"]): row for row in rows} with self._database.transaction() as connection: for track in self._repository.tracked_before(connection, trade_date): row = current.get(str(track["identifier"])) if row is None or any( row.get(field) is None for field in ("open", "high", "low", "close") ): continue self._repository.save_track_bar(connection, int(track["id"]), trade_date, row) days = len(self._repository.track_bars(connection, int(track["id"]))) if days in {1, 5}: self._repository.record_track_event(connection, int(track["id"]), f"t{days}") def _public_strategy(strategy: dict[str, Any]) -> dict[str, Any]: return { "id": strategy["id"], "version": strategy["version"], "kind": strategy["kind"], "name": strategy["name"], "display_name": strategy.get("display_name") or strategy["name"], "description": strategy["description"], "regimes": strategy.get("regimes") or [], "formula": strategy["formula"], } def automatic_strategies( regime: str | None, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: strategies = strategy_catalog() stage = [ item for item in strategies if item["kind"] == "stage" and regime in (item.get("regimes") or []) ] curated = [item for item in strategies if item["kind"] == "curated"] return stage, curated