feat: complete heaven readings and screener publication
This commit is contained in:
@@ -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),)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user