feat: complete heaven readings and screener publication

This commit is contained in:
leefer
2026-08-05 23:44:17 +08:00
parent 6a058c2929
commit b3a21d05b7
34 changed files with 3538 additions and 225 deletions
+4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from backend.features.screener.strategies import ADVANCED_CURATED_STRATEGIES
from backend.features.screener.signals import attach_strategy_validity
REGIMES = {
@@ -705,3 +706,6 @@ for strategy in CURATED_STRATEGIES:
)
BUILTIN_STRATEGIES.extend(CURATED_STRATEGIES)
for strategy in BUILTIN_STRATEGIES:
attach_strategy_validity(strategy)
@@ -0,0 +1,70 @@
from __future__ import annotations
from typing import Any
def resolve_published_batch(
markers: list[dict[str, Any]],
requested_date: str,
legacy_date: str = "",
) -> tuple[dict[str, Any] | None, dict[str, Any], dict[str, Any] | None]:
requested_marker = next(
(
item for item in markers
if str(item.get("trade_date") or "") == requested_date
),
None,
)
published = next(
(item for item in markers if item.get("status") == "complete"),
None,
)
if published is None and legacy_date:
published = {
"trade_date": legacy_date,
"status": "complete",
"legacy_inferred": True,
"completed": [],
"skipped": [],
"failed": [],
}
request_status = dict(requested_marker or {})
request_status.setdefault("trade_date", requested_date)
request_status.setdefault("status", "pending")
descriptor = _published_descriptor(published, requested_date, request_status)
if descriptor and descriptor["is_fallback"]:
request_status["retaining_trade_date"] = descriptor["trade_date"]
return published, request_status, descriptor
def _published_descriptor(
marker: dict[str, Any] | None,
requested_date: str,
request_status: dict[str, Any],
) -> dict[str, Any] | None:
if marker is None:
return None
trade_date = str(marker.get("trade_date") or "")
is_fallback = trade_date != requested_date
status = str(request_status.get("status") or "pending")
notice = ""
if is_fallback:
if status == "running":
notice = "所选日期候选正在生成,当前保留上一成功批次"
elif status in {"failed", "partial"}:
notice = "所选日期候选未完整发布,当前保留上一成功批次"
else:
notice = "所选日期候选尚未发布,当前展示最近成功批次"
return {
"trade_date": trade_date,
"status": "complete",
"started_at": marker.get("started_at") or "",
"finished_at": marker.get("finished_at") or marker.get("updated_at") or "",
"library_version": int(marker.get("library_version") or 0),
"completed_count": len(marker.get("completed") or []),
"skipped_count": len(marker.get("skipped") or []),
"legacy_inferred": bool(marker.get("legacy_inferred")),
"is_fallback": is_fallback,
"notice": notice,
}
@@ -686,6 +686,102 @@ class ScreenerRepositoryMixin:
result.append(payload)
return result
def screener_runs_for_dates(
self, user_id: int, trade_dates: list[str], limit: int = 1200,
) -> list[dict[str, Any]]:
normalized_dates = list(dict.fromkeys(str(item) for item in trade_dates if item))
if not normalized_dates:
return []
safe_limit = max(1, min(2400, int(limit)))
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: list[Any] = [] if int(user_id) == 0 else [int(user_id)]
placeholders = ",".join("?" for _ in normalized_dates)
parameters.extend(normalized_dates)
parameters.append(safe_limit)
with self.connect() as connection:
rows = connection.execute(
f"""
WITH ranked AS (
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
ROW_NUMBER() OVER (
PARTITION BY trade_date, mode, regime, strategy_name
ORDER BY id DESC
) AS context_rank
FROM screener_runs
WHERE {owner_clause} AND trade_date IN ({placeholders})
)
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM ranked
WHERE context_rank = 1
ORDER BY trade_date DESC, id DESC
LIMIT ?
""",
parameters,
).fetchall()
return [
payload
for row in rows
if (payload := self._screener_run_payload(row)) is not None
]
def recent_screener_runs(
self, user_id: int, trade_date: str, mode: str, limit: int = 40,
) -> list[dict[str, Any]]:
if int(user_id) == 0 or mode not in {"smart", "curated", "quant"}:
return []
safe_limit = max(1, min(160, int(limit)))
with self.connect() as connection:
rows = connection.execute(
"""
WITH ranked AS (
SELECT id, trade_date, regime, mode, strategy_name, result, created_at,
ROW_NUMBER() OVER (
PARTITION BY trade_date, mode, regime, strategy_name
ORDER BY id DESC
) AS context_rank
FROM screener_runs
WHERE user_id = ? AND trade_date <= ? AND mode = ?
)
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM ranked
WHERE context_rank = 1
ORDER BY trade_date DESC, id DESC
LIMIT ?
""",
(int(user_id), trade_date, mode, safe_limit),
).fetchall()
return [
payload
for row in rows
if (payload := self._screener_run_payload(row)) is not None
]
def list_screener_batch_markers(
self, end_date: str, limit: int = 30,
) -> list[dict[str, Any]]:
safe_limit = max(1, min(120, int(limit)))
with self.connect() as connection:
rows = connection.execute(
"""
SELECT cache_key, payload, updated_at
FROM data_snapshots
WHERE kind = 'screener_auto_v1' AND cache_key <= ?
ORDER BY cache_key DESC
LIMIT ?
""",
(end_date, safe_limit),
).fetchall()
result = []
for row in rows:
try:
payload = json.loads(row["payload"])
except json.JSONDecodeError:
continue
payload.setdefault("trade_date", str(row["cache_key"] or ""))
payload.setdefault("updated_at", str(row["updated_at"] or ""))
result.append(payload)
return result
def get_screener_run(self, user_id: int, run_id: int) -> dict[str, Any] | None:
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: tuple[Any, ...] = (int(run_id),)
+96 -7
View File
@@ -15,6 +15,11 @@ from backend.features.screener.compiler import (
from backend.features.screener.catalog import FACTOR_FIELDS, FACTOR_GROUPS, REGIMES
from backend.features.screener.data_sync import FactorDataService
from backend.features.screener.formula import compile_local_strategy
from backend.features.screener.publication import resolve_published_batch
from backend.features.screener.signals import (
attach_strategy_validity,
build_candidate_archive,
)
SCREENER_LIBRARY_VERSION = 8
@@ -91,26 +96,71 @@ class ScreenerServiceMixin:
factor_health = self.screener.factor_health(normalized_date)
strategies = self.database.list_screener_strategies(self.current_user_id)
for strategy in strategies:
attach_strategy_validity(strategy)
missing = self._strategy_missing_data(strategy, factor_dates, factor_health)
strategy["data_ready"] = not missing
strategy["missing_data"] = missing
automatic_results = self.database.screener_runs_for_date(0, normalized_date)
personal_results = self.database.screener_runs_for_date(
self.current_user_id, normalized_date
batch_markers = self.database.list_screener_batch_markers(normalized_date, 120)
complete_markers = [
item for item in batch_markers if item.get("status") == "complete"
][:30]
legacy_results = []
legacy_date = ""
if not batch_markers:
legacy_results = self.database.screener_runs_for_date(0, normalized_date)
if legacy_results:
legacy_date = normalized_date
published_marker, automatic_status, published_batch = resolve_published_batch(
batch_markers, normalized_date, legacy_date
)
published_date = str((published_batch or {}).get("trade_date") or "")
automatic_results = (
legacy_results
if legacy_results and published_date == normalized_date
else self.database.screener_runs_for_date(0, published_date)
if published_date
else []
)
personal_results = self.database.recent_screener_runs(
self.current_user_id, normalized_date, "quant", 40
)
recent_results = [
*[item for item in automatic_results if item.get("meta", {}).get("mode") in {"smart", "curated"}],
*[item for item in personal_results if item.get("meta", {}).get("mode") == "quant"],
*personal_results,
]
latest_results: dict[str, dict[str, Any]] = {}
for result in reversed(recent_results):
mode = str(result.get("meta", {}).get("mode") or "smart")
latest_results[mode] = result
automatic_status = self.database.get_data_snapshot(
"screener_auto_v1", normalized_date
) or {}
published_dates = [
str(item.get("trade_date") or "") for item in complete_markers
if item.get("trade_date")
]
if legacy_date and legacy_date not in published_dates:
published_dates.append(legacy_date)
archive_runs = self.database.screener_runs_for_dates(0, published_dates, 1800)
archive_runs.extend(personal_results)
archive_as_of_date = published_date or (factor_dates[-1] if factor_dates else "")
marker_regime = (published_marker or {}).get("regime") or {}
archive_regime = str(
(marker_regime.get("id") if isinstance(marker_regime, dict) else marker_regime)
or regime.get("id") or "repair"
)
active_signals, candidate_history = build_candidate_archive(
archive_runs,
strategies,
factor_dates,
archive_as_of_date,
archive_regime,
)
self._attach_published_strategy_status(
strategies, automatic_results, published_marker, published_date
)
return {
"trade_date": normalized_date,
"requested_trade_date": normalized_date,
"regime": regime,
"regimes": [{"id": key, "label": value} for key, value in REGIMES.items()],
"strategies": strategies,
@@ -141,10 +191,49 @@ class ScreenerServiceMixin:
"latest_results": latest_results,
"recent_results": recent_results,
"automatic_status": automatic_status,
"published_batch": published_batch,
"published_status": published_marker or {},
"active_signals": active_signals,
"candidate_history": candidate_history,
# Kept during the client transition for compatibility with older frontends.
"latest_result": latest_results.get("smart"),
}
@staticmethod
def _attach_published_strategy_status(
strategies: list[dict[str, Any]],
automatic_results: list[dict[str, Any]],
marker: dict[str, Any] | None,
published_date: str,
) -> None:
results_by_name = {
str((item.get("meta") or {}).get("strategy_name") or ""): item
for item in automatic_results
}
skipped_by_name = {
str(item.get("name") or ""): item
for item in (marker or {}).get("skipped") or []
}
for strategy in strategies:
name = str(strategy.get("name") or "")
result = results_by_name.get(name)
skipped = skipped_by_name.get(name)
if result is not None:
candidates = result.get("candidates") or []
status = "ready" if candidates else "no_signal"
detail = f"{len(candidates)} 只候选" if candidates else "数据完整,暂无符合条件个股"
elif skipped is not None:
status = "missing_data"
detail = str(skipped.get("reason") or "缺少策略必需数据")
else:
status = "not_run"
detail = "该成功批次未运行此策略"
strategy["published_run"] = {
"trade_date": published_date,
"status": status,
"detail": detail,
}
def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
return self.strategy_tracking.list_tracking(self.current_user_id, limit)
+198
View File
@@ -0,0 +1,198 @@
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