feat: expand screeners and stabilize interactive feedback
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user