Files
xiaobai-review/backend/features/screener/signals.py
T

199 lines
6.7 KiB
Python

from __future__ import annotations
from typing import Any
FREQUENCY_VALIDITY_DAYS = {
"每日": 1,
"每日9:25": 1,
"每周": 5,
"双周": 10,
"月度": 20,
"事件驱动": 5,
}
def signal_validity(mode: str, formula: dict[str, Any] | None) -> dict[str, Any]:
if mode == "smart":
return {
"type": "until_regime_change",
"label": "当前阶段不变时有效",
}
meta = (formula or {}).get("meta") or {}
frequency = str(meta.get("frequency") or "每日")
days = FREQUENCY_VALIDITY_DAYS.get(frequency, 1)
return {
"type": "trading_days",
"days": days,
"label": f"{days}个交易日",
}
def attach_strategy_validity(strategy: dict[str, Any]) -> None:
formula = strategy.get("formula") or {}
meta = formula.setdefault("meta", {})
mode = "curated" if meta.get("library") == "curated" else "smart"
meta["signal_validity"] = signal_validity(mode, formula)
def build_candidate_archive(
runs: list[dict[str, Any]],
strategies: list[dict[str, Any]],
trading_dates: list[str],
as_of_date: str,
as_of_regime: str,
history_limit: int = 1200,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
strategy_formulas = {
str(item.get("name") or ""): item.get("formula") or {}
for item in strategies
}
date_positions = {trade_date: index for index, trade_date in enumerate(trading_dates)}
as_of_position = date_positions.get(as_of_date, len(trading_dates) - 1)
history: dict[tuple[str, str, str], dict[str, Any]] = {}
active: dict[tuple[str, str], dict[str, Any]] = {}
ordered_runs = sorted(
runs,
key=lambda item: (
str((item.get("meta") or {}).get("trade_date") or ""),
int((item.get("meta") or {}).get("run_id") or 0),
),
reverse=True,
)
for result in ordered_runs:
meta = result.get("meta") or {}
mode = str(meta.get("mode") or "smart")
if mode not in {"smart", "curated", "quant"}:
continue
selection_date = str(meta.get("trade_date") or "").replace("-", "")
strategy_name = str(meta.get("strategy_name") or "未命名策略")
regime = str(meta.get("regime") or "")
formula = result.get("formula") or strategy_formulas.get(strategy_name) or {}
validity = signal_validity(mode, formula)
valid, valid_until, remaining = _signal_state(
validity,
selection_date,
regime,
trading_dates,
date_positions,
as_of_position,
as_of_regime,
)
hit = {
"selection_date": selection_date,
"strategy_name": strategy_name,
"regime": regime,
"run_id": int(meta.get("run_id") or 0),
"validity": validity,
"valid_until": valid_until,
"remaining_trading_days": remaining,
"active": valid,
}
for candidate in result.get("candidates") or []:
code = str(candidate.get("code") or "")
if not code:
continue
history_key = (mode, selection_date, code)
history_row = history.setdefault(
history_key,
_archive_row(candidate, mode, selection_date),
)
candidate_hit = {**hit, "score_display": candidate.get("score_display")}
_append_hit(history_row, candidate_hit)
if valid:
active_key = (mode, code)
active_row = active.get(active_key)
if active_row is None:
active_row = _archive_row(candidate, mode, selection_date)
active[active_key] = active_row
_append_hit(active_row, candidate_hit)
history_rows = sorted(
history.values(),
key=lambda item: (item["selection_date"], _numeric_score(item["score_display"])),
reverse=True,
)[: max(1, int(history_limit))]
active_rows = sorted(
active.values(),
key=lambda item: (item["selection_date"], _numeric_score(item["score_display"])),
reverse=True,
)
for row in [*history_rows, *active_rows]:
_finalize_archive_row(row)
return active_rows, history_rows
def _signal_state(
validity: dict[str, Any],
selection_date: str,
regime: str,
trading_dates: list[str],
date_positions: dict[str, int],
as_of_position: int,
as_of_regime: str,
) -> tuple[bool, str, int | None]:
if validity.get("type") == "until_regime_change":
return regime == as_of_regime, "", None
days = max(1, int(validity.get("days") or 1))
selected_position = date_positions.get(selection_date)
if selected_position is None or as_of_position < selected_position:
return False, "", 0
elapsed = as_of_position - selected_position
valid = elapsed < days
valid_position = selected_position + days - 1
valid_until = (
trading_dates[valid_position]
if 0 <= valid_position < len(trading_dates)
else ""
)
return valid, valid_until, max(0, days - elapsed) if valid else 0
def _archive_row(
candidate: dict[str, Any], mode: str, selection_date: str
) -> dict[str, Any]:
return {
"mode": mode,
"selection_date": selection_date,
"code": str(candidate.get("code") or ""),
"name": str(candidate.get("name") or ""),
"sector": str(candidate.get("sector") or ""),
"score_display": candidate.get("score_display"),
"pct_chg": candidate.get("pct_chg"),
"return_5d": candidate.get("return_5d"),
"hits": [],
}
def _append_hit(row: dict[str, Any], hit: dict[str, Any]) -> None:
identity = (hit["strategy_name"], hit["regime"], hit["run_id"])
existing = {
(item["strategy_name"], item["regime"], item["run_id"])
for item in row["hits"]
}
if identity not in existing:
row["hits"].append(dict(hit))
def _finalize_archive_row(row: dict[str, Any]) -> None:
hits = row.get("hits") or []
active_hits = [item for item in hits if item.get("active")]
row["matched_strategies"] = list(
dict.fromkeys(item["strategy_name"] for item in hits)
)
row["regimes"] = list(dict.fromkeys(item["regime"] for item in hits if item["regime"]))
row["active"] = bool(active_hits)
row["status"] = "持续有效" if active_hits else "已到期"
labels = list(
dict.fromkeys(item["validity"]["label"] for item in (active_hits or hits))
)
row["validity_label"] = " / ".join(labels)
def _numeric_score(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return -1.0