329 lines
13 KiB
Python
329 lines
13 KiB
Python
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
|