feat: expand screeners and stabilize interactive feedback

This commit is contained in:
leefer
2026-07-28 22:47:50 +08:00
parent f4b2d7152a
commit 1cc80583b3
22 changed files with 2707 additions and 509 deletions
+346 -35
View File
@@ -66,6 +66,7 @@ from screener import (
from security import SecretVault, hash_password, token_hash, verify_password
from sentiment_engine import (
COMPONENT_WEIGHTS,
SENTIMENT_ENGINE_VERSION,
apply_sentiment_to_dashboard,
build_sentiment_history,
latest_contiguous_history,
@@ -75,6 +76,30 @@ from trade_journal import TradeJournalService
from tushare_client import TushareClient, TushareError, _sector_coverage_issue
SCREENER_LIBRARY_VERSION = 7
def automatic_screener_jobs(
strategies: list[dict[str, Any]], regime_id: str
) -> list[dict[str, Any]]:
"""Build the close-of-day jobs; only stage screening is regime-gated."""
smart_strategy = next(
(
item for item in strategies
if item.get("formula", {}).get("meta", {}).get("library") != "curated"
and regime_id in (item.get("regimes") or [])
),
None,
)
curated = [
item for item in strategies
if item.get("formula", {}).get("meta", {}).get("library") == "curated"
]
jobs = ([{"mode": "smart", "strategy": smart_strategy}] if smart_strategy else [])
jobs.extend({"mode": "curated", "strategy": item} for item in curated)
return jobs
LEGACY_SECRET_KEYS = {
"TUSHARE_TOKEN",
"IFIND_REFRESH_TOKEN",
@@ -175,6 +200,8 @@ class DashboardService:
self.sync_lock = threading.Lock()
self.auth_lock = threading.Lock()
self.system_lock = threading.Lock()
self.auto_screener_lock = threading.Lock()
self._auto_screener_last_attempt: dict[str, datetime] = {}
self._ifind_event_lock = threading.Lock()
self._request_context = threading.local()
self._system_credentials = self._load_system_credentials(environment_credentials)
@@ -788,6 +815,7 @@ class DashboardService:
snapshot = self.database.get_snapshot(today) or {}
if self._realtime_snapshot_due(today, snapshot):
self._run_background_sync(today)
self._schedule_automatic_screeners(today, snapshot)
except Exception:
pass
self._background_stop.wait(5)
@@ -936,6 +964,11 @@ class DashboardService:
)
if not self._dashboard_sentiment_ready(snapshot):
snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date)
self.database.save_snapshot(
normalized_date,
str((snapshot.get("meta") or {}).get("source") or "tushare"),
snapshot,
)
snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date)
return self._apply_reason_overrides(self._with_storage(snapshot, cached=True))
resolved = self.database.get_data_snapshot(
@@ -968,7 +1001,7 @@ class DashboardService:
@staticmethod
def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool:
overview = dashboard.get("overview") or {}
return all(
return int(overview.get("sentiment_engine_version") or 0) == SENTIMENT_ENGINE_VERSION and all(
key in overview
for key in (
"sentiment_score",
@@ -1091,7 +1124,7 @@ class DashboardService:
dashboard: dict[str, Any],
end_date: str,
) -> dict[str, Any]:
history = self.database.list_snapshot_payloads(end_date, 240)
history = self.database.list_snapshot_payloads(end_date, 260)
return apply_sentiment_to_dashboard(dashboard, history)
def sentiment_history(self, trade_date: str, limit: int = 20) -> dict[str, Any]:
@@ -1168,6 +1201,103 @@ class DashboardService:
"rows": rows,
}
def rotation_sector_members(self, trade_date: str, sector_name: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
sector_name = validate_text(sector_name, "板块名称", 60, required=True)
dashboard = self.get_dashboard(normalized_date)
actual_date = normalize_date(
str((dashboard.get("meta") or {}).get("trade_date") or normalized_date)
)
cache_key = f"{actual_date}:{sector_name}"
cached = self.database.get_data_snapshot("rotation_sector_members_v1", cache_key)
if cached:
cached["meta"] = {**(cached.get("meta") or {}), "cached": True}
return cached
if not self.configured:
raise ValueError("板块成分数据暂不可用。")
representative = next(
(
item for item in dashboard.get("limits") or []
if str(item.get("sector") or "").strip() == sector_name
),
None,
)
if not representative:
raise ValueError("未找到该板块的代表股票,暂时无法核验成分股。")
raw_code = str(representative.get("ts_code") or representative.get("code") or "")
if "." in raw_code:
ts_code = raw_code
elif raw_code.startswith(("4", "8", "92")):
ts_code = f"{raw_code}.BJ"
elif raw_code.startswith(("6", "68", "90")):
ts_code = f"{raw_code}.SH"
else:
ts_code = f"{raw_code}.SZ"
client = TushareClient(self.token)
try:
industry = client.sw_stock_industry(ts_code, actual_date)
sector_code = str(industry.get("l2_code") or "")
members = client.sw_sector_members(sector_code, actual_date)
except TushareError as exc:
raise ValueError(f"该板块成分股暂不可用:{exc}") from exc
daily_rows = self.database.daily_bars_for_date(actual_date)
if len(daily_rows) < 1000:
try:
daily_rows = client.query(
"daily",
{"trade_date": actual_date},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
if daily_rows:
self.database.upsert_daily_bars(daily_rows)
except TushareError:
daily_rows = self.database.daily_bars_for_date(actual_date)
daily_map = {str(item.get("ts_code") or ""): item for item in daily_rows}
rows = []
for member in members:
member_code = str(member.get("ts_code") or "")
quote = daily_map.get(member_code) or {}
rows.append(
{
"code": member_code.split(".")[0],
"ts_code": member_code,
"name": str(member.get("name") or "--"),
"change": quote.get("pct_chg"),
"open": quote.get("open"),
"close": quote.get("close"),
"amount_billion": (
round(float(quote.get("amount") or 0) / 100000, 2)
if quote else None
),
"quoted": bool(quote),
}
)
rows.sort(
key=lambda item: (
bool(item.get("quoted")),
float(item.get("change") or -999),
float(item.get("amount_billion") or 0),
),
reverse=True,
)
result = {
"meta": {
"trade_date": self._display_compact_date(actual_date),
"sector_name": str(industry.get("l2_name") or sector_name),
"sector_code": sector_code,
"member_count": len(rows),
"quoted_count": sum(bool(item.get("quoted")) for item in rows),
"cached": False,
},
"rows": rows,
}
self.database.save_data_snapshot(
"rotation_sector_members_v1", cache_key, "tushare", result
)
return result
def status(self) -> dict[str, Any]:
llm_access = self.llm_access_status()
return {
@@ -1234,35 +1364,63 @@ class DashboardService:
return match.group(1)
return ""
def screener_setup(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
regime = self.screener.detect_regime(normalized_date)
factor_dates = self.database.factor_dates(normalized_date, 100)
auction_dates = self.database.auction_factor_dates(normalized_date, 100)
factor_health = self.screener.factor_health(normalized_date)
strategies = self.database.list_screener_strategies(self.current_user_id)
@staticmethod
def _strategy_missing_data(
strategy: dict[str, Any], factor_dates: list[str], factor_health: dict[str, Any]
) -> list[str]:
formula = strategy.get("formula") or {}
meta = formula.get("meta") or {}
used_fields = {
str(item.get("field") or "")
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
}
valuation_fields = {"pe_ttm", "pb", "ps_ttm", "dividend_yield_ttm", "total_mv_billion"}
fundamental_fields = {"roe", "roa", "roic", "gross_margin", "netprofit_yoy", "revenue_yoy", "ocf_to_opincome"}
auction_fields = {"auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio"}
missing = []
required_history = max(21, min(260, int(meta.get("history_days") or 21)))
if len(factor_dates) < required_history:
missing.append(f"历史行情(需{required_history}日)")
if used_fields & valuation_fields and not factor_health["valuation"]:
missing.append("估值数据")
if used_fields & fundamental_fields and not factor_health["fundamental"]:
missing.append("财务质量")
if "dividend_years" in used_fields and not factor_health["dividend_history"]:
missing.append("历年分红")
if used_fields & auction_fields and not factor_health["auction"]:
missing.append("竞价数据")
if meta.get("requires_benchmark") and not factor_health.get("benchmark"):
missing.append("沪深300基准")
if meta.get("requires_moneyflow_history") and not factor_health.get("moneyflow_history"):
missing.append("近5日资金流")
return missing
def screener_setup(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
regime = self.screener.detect_regime(normalized_date)
factor_dates = self.database.factor_dates(normalized_date, 300)
auction_dates = self.database.auction_factor_dates(normalized_date, 100)
factor_health = self.screener.factor_health(normalized_date)
strategies = self.database.list_screener_strategies(self.current_user_id)
for strategy in strategies:
formula = strategy.get("formula") or {}
used_fields = {
str(item.get("field") or "")
for item in list(formula.get("filters") or []) + list(formula.get("score") or [])
}
missing = []
if len(factor_dates) < 21:
missing.append("基础行情")
if used_fields & valuation_fields and not factor_health["valuation"]:
missing.append("估值数据")
if used_fields & fundamental_fields and not factor_health["fundamental"]:
missing.append("财务质量")
if "dividend_years" in used_fields and not factor_health["dividend_history"]:
missing.append("历年分红")
if used_fields & auction_fields and not factor_health["auction"]:
missing.append("竞价数据")
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
)
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"],
]
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 {}
return {
"trade_date": normalized_date,
"regime": regime,
@@ -1292,16 +1450,11 @@ class DashboardService:
"fallback_configured": self.llm_fallback_configured,
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
},
"latest_results": self.database.latest_screener_runs(
self.current_user_id, normalized_date
),
"recent_results": self.database.latest_screener_context_runs(
self.current_user_id, normalized_date
),
"latest_results": latest_results,
"recent_results": recent_results,
"automatic_status": automatic_status,
# Kept during the client transition for compatibility with older frontends.
"latest_result": self.database.latest_screener_run(
self.current_user_id, normalized_date, "smart"
),
"latest_result": latest_results.get("smart"),
}
def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
@@ -1565,12 +1718,158 @@ class DashboardService:
if not self.configured:
raise ValueError("请先配置 Tushare Token。")
normalized_date = normalize_date(trade_date)
lookback = max(25, min(80, int(lookback)))
lookback = max(25, min(260, int(lookback)))
with self.sync_lock:
return FactorDataService(self.database, TushareClient(self.token)).sync(
normalized_date, lookback
)
def _schedule_automatic_screeners(
self, trade_date: str, snapshot: dict[str, Any] | None = None
) -> bool:
normalized_date = normalize_date(trade_date)
now = datetime.now().astimezone()
if (
normalized_date != now.strftime("%Y%m%d")
or now.weekday() >= 5
or now.time().replace(tzinfo=None) < datetime.strptime("15:10", "%H:%M").time()
or self.auto_screener_lock.locked()
):
return False
snapshot = snapshot or self.database.get_snapshot(normalized_date) or {}
actual_date = str((snapshot.get("meta") or {}).get("trade_date") or "").replace("-", "")
if actual_date != normalized_date:
return False
marker = self.database.get_data_snapshot("screener_auto_v1", normalized_date) or {}
if (
marker.get("status") == "complete"
and int(marker.get("library_version") or 0) == SCREENER_LIBRARY_VERSION
):
return False
last_attempt = self._auto_screener_last_attempt.get(normalized_date)
if last_attempt and (now - last_attempt).total_seconds() < 600:
return False
self._auto_screener_last_attempt[normalized_date] = now
threading.Thread(
target=self.run_automatic_screeners,
args=(normalized_date,),
name=f"automatic-screeners-{normalized_date}",
daemon=True,
).start()
return True
def run_automatic_screeners(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
with self.auto_screener_lock:
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
status: dict[str, Any] = {
"trade_date": normalized_date,
"library_version": SCREENER_LIBRARY_VERSION,
"status": "running",
"started_at": started_at,
"completed": [],
"skipped": [],
"failed": [],
}
self.database.save_data_snapshot(
"screener_auto_v1", normalized_date, "system", status
)
try:
factor_sync = FactorDataService(
self.database, TushareClient(self.token)
).sync(normalized_date, 260)
factor_dates = self.database.factor_dates(normalized_date, 300)
if not factor_dates or factor_dates[-1] != normalized_date:
raise ValueError("当日收盘行情尚未入库")
factor_health = self.screener.factor_health(normalized_date)
regime = self.screener.detect_regime(normalized_date)
regime_id = str(regime.get("id") or "repair")
strategies = self.database.list_screener_strategies(None)
jobs = automatic_screener_jobs(strategies, regime_id)
existing = {
(
str(item.get("meta", {}).get("mode") or "smart"),
str(item.get("meta", {}).get("strategy_name") or ""),
)
for item in self.database.screener_runs_for_date(0, normalized_date)
if int(item.get("meta", {}).get("library_version") or 0)
== SCREENER_LIBRARY_VERSION
}
required_history = max(
[
int((job["strategy"].get("formula", {}).get("meta", {}) or {}).get("history_days") or 80)
for job in jobs if job.get("strategy")
] or [80]
)
factors, actual_date = self.screener.build_factors(
normalized_date, history_days=required_history
)
if actual_date != normalized_date:
raise ValueError("当日因子尚未完成收盘定格")
for job in jobs:
strategy = job["strategy"]
mode = str(job["mode"])
name = str(strategy.get("name") or "未命名策略")
if (mode, name) in existing:
status["completed"].append({"mode": mode, "name": name, "cached": True})
continue
missing = self._strategy_missing_data(
strategy, factor_dates, factor_health
)
if missing:
status["skipped"].append(
{"mode": mode, "name": name, "reason": "".join(missing)}
)
continue
try:
formula = copy.deepcopy(strategy.get("formula") or {})
formula.setdefault("meta", {})["library_version"] = (
SCREENER_LIBRARY_VERSION
)
result = self.screener.screen(
0,
normalized_date,
formula,
regime_id,
name,
False,
None,
mode,
factors,
actual_date,
)
status["completed"].append(
{
"mode": mode,
"name": name,
"candidate_count": len(result.get("candidates") or []),
}
)
except Exception as exc:
status["failed"].append(
{"mode": mode, "name": name, "reason": str(exc)}
)
status.update(
{
"status": "complete" if not status["failed"] else "partial",
"finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"factor_sync": factor_sync,
"regime": regime,
}
)
except Exception as exc:
status.update(
{
"status": "failed",
"finished_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"error": str(exc),
}
)
self.database.save_data_snapshot(
"screener_auto_v1", normalized_date, "system", status
)
return status
def compile_screener_strategy(self, prompt: str, regime: str) -> dict[str, Any]:
prompt = prompt.strip()
if not prompt or len(prompt) > 3000:
@@ -4641,6 +4940,18 @@ class RequestHandler(BaseHTTPRequestHandler):
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/rotation/members":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.rotation_sector_members(
query.get("trade_date", [date.today().isoformat()])[0],
query.get("sector", [""])[0],
)
)
except (TypeError, ValueError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/dragon-tiger":
query = parse_qs(parsed.query)
trade_date = query.get("trade_date", [date.today().isoformat()])[0]