rebuild(stage-9): deliver deterministic intelligent screening
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
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
|
||||
Reference in New Issue
Block a user