feat: redesign workspaces and stabilize screeners

This commit is contained in:
leefer
2026-07-26 13:31:45 +08:00
parent 149c468240
commit 6adeb54458
21 changed files with 18786 additions and 917 deletions
+178 -15
View File
@@ -56,6 +56,7 @@ from market_insights import MarketInsightsService
from realtime_aggregator import WebRealtimeAggregator
from screener import (
FACTOR_FIELDS,
FACTOR_GROUPS,
REGIMES,
FactorDataService,
ScreenerEngine,
@@ -1093,12 +1094,44 @@ class DashboardService:
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)
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"}
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("竞价数据")
strategy["data_ready"] = not missing
strategy["missing_data"] = missing
return {
"trade_date": normalized_date,
"regime": regime,
"regimes": [{"id": key, "label": value} for key, value in REGIMES.items()],
"strategies": self.database.list_screener_strategies(self.current_user_id),
"strategies": strategies,
"factor_fields": [{"id": key, "label": value} for key, value in FACTOR_FIELDS.items()],
"factor_groups": [
{
"name": name,
"fields": [{"id": field, "label": FACTOR_FIELDS[field]} for field in fields],
}
for name, fields in FACTOR_GROUPS.items()
],
"operators": [">", ">=", "<", "<=", "==", "between"],
"factor_data": {
"date_count": len(factor_dates),
"start_date": factor_dates[0] if factor_dates else "",
@@ -1106,6 +1139,7 @@ class DashboardService:
"ready": len(factor_dates) >= 21,
"auction_date_count": len(auction_dates),
"auction_ready": bool(auction_dates and auction_dates[-1] == factor_dates[-1]) if factor_dates else False,
"health": factor_health,
},
"llm": {
"configured": self.llm_configured,
@@ -1113,12 +1147,31 @@ class DashboardService:
"fallback_configured": self.llm_fallback_configured,
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
},
"latest_result": self.database.latest_screener_run(self.current_user_id, normalized_date),
"latest_results": self.database.latest_screener_runs(
self.current_user_id, normalized_date
),
# Kept during the client transition for compatibility with older frontends.
"latest_result": self.database.latest_screener_run(
self.current_user_id, normalized_date, "smart"
),
}
def screener_tracking(self, limit: int = 12) -> dict[str, Any]:
return self.strategy_tracking.list_tracking(self.current_user_id, limit)
def add_screener_tracking(self, payload: dict[str, Any]) -> dict[str, Any]:
try:
run_id = int(payload.get("run_id") or 0)
except (TypeError, ValueError) as exc:
raise ValueError("选股批次无效。") from exc
code = str(payload.get("code") or "").strip()
if run_id <= 0 or not re.fullmatch(r"\d{6}", code):
raise ValueError("选股批次或股票代码无效。")
return self.strategy_tracking.add_candidate(self.current_user_id, run_id, code)
def remove_screener_tracking(self, track_id: int) -> dict[str, Any]:
return self.strategy_tracking.remove_candidate(self.current_user_id, track_id)
def refresh_screener_tracking(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
notice = ""
@@ -1167,6 +1220,86 @@ class DashboardService:
self.current_user_id, start_date, end_date, code
)
def review_watchlist(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
items = self.database.list_watchlist(self.current_user_id)
if not items:
return {"items": [], "trade_date": normalized_date}
resolved_date = normalized_date
if self.configured:
try:
client = TushareClient(self.token)
resolved_date, _ = client.resolve_trade_context(normalized_date)
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
missing_codes = [
str(item["code"]) for item in items
if len(history.get(str(item["code"])) or []) < 6
]
start_date = (
datetime.strptime(resolved_date, "%Y%m%d") - timedelta(days=24)
).strftime("%Y%m%d")
for code in missing_codes:
rows = client.query(
"daily",
{
"ts_code": tushare_code(code),
"start_date": start_date,
"end_date": resolved_date,
},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
if rows:
self.database.upsert_daily_bars(rows)
if missing_codes:
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
except (TushareError, ValueError):
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
else:
history = self.database.watchlist_price_history(
[str(item["code"]) for item in items], resolved_date
)
auction_scores: dict[str, Any] = {}
try:
auction = self.auction_center(normalized_date, False)
auction_scores = {
str(row.get("code") or ""): row.get("attention_score")
for row in (auction.get("watchlist_rows") or [])
if row.get("available", True)
}
except (TushareError, ValueError):
pass
enriched = []
for item in items:
code = str(item.get("code") or "")
bars = history.get(code) or []
latest = bars[-1] if bars else {}
close = float(latest.get("close") or 0)
base_close = float(bars[-6].get("close") or 0) if len(bars) >= 6 else 0
enriched.append(
{
**item,
"change": (
round(float(latest.get("pct_chg") or 0), 2) if latest else None
),
"return_5d": (
round((close / base_close - 1) * 100, 2)
if close > 0 and base_close > 0 else None
),
"attention_score": auction_scores.get(code),
"market_date": str(latest.get("trade_date") or ""),
}
)
return {"items": enriched, "trade_date": resolved_date}
def save_trade_entry(self, payload: dict[str, Any]) -> dict[str, Any]:
trade_id = self.trade_journal.save(self.current_user_id, payload)
return {"id": trade_id, **self.trade_entries()}
@@ -2955,6 +3088,21 @@ class DashboardService:
raise ValueError("市场阶段不支持。")
strategy_name = validate_text(payload.get("strategy_name"), "策略名称", 60, required=True)
formula = payload.get("formula") or {}
requested_mode = str(payload.get("mode") or "").strip()
if requested_mode and requested_mode not in {"smart", "curated", "quant"}:
raise ValueError("选股模式不受支持。")
if requested_mode:
mode = requested_mode
else:
meta = formula.get("meta") if isinstance(formula, dict) else {}
library = str((meta or {}).get("library") or "")
category = str((meta or {}).get("category") or "")
if library == "curated":
mode = "curated"
elif library == "quant" or (library == "custom" and category == "量化公式"):
mode = "quant"
else:
mode = "smart"
realtime_snapshot = None
dashboard = self.get_dashboard(trade_date)
if self.configured and dashboard.get("meta", {}).get("realtime"):
@@ -2966,13 +3114,7 @@ class DashboardService:
self.current_user_id, trade_date, formula, regime, strategy_name,
bool(payload.get("run_backtest", True)),
realtime_snapshot,
)
self.strategy_tracking.record_run(
self.current_user_id,
int(result.get("meta", {}).get("run_id") or 0),
normalize_date(str(result.get("meta", {}).get("trade_date") or trade_date)),
strategy_name,
list(result.get("candidates") or []),
mode,
)
return result
@@ -4007,9 +4149,15 @@ class RequestHandler(BaseHTTPRequestHandler):
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/watchlist":
self.send_json(
{"items": SERVICE.database.list_watchlist(SERVICE.current_user_id)}
)
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.review_watchlist(
query.get("trade_date", [date.today().isoformat()])[0]
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/notes":
query = parse_qs(parsed.query)
@@ -4193,6 +4341,13 @@ class RequestHandler(BaseHTTPRequestHandler):
if parsed.path == "/api/screener/run":
self.run_screener()
return
if parsed.path == "/api/screener/tracking":
try:
result = SERVICE.add_screener_tracking(self.read_json_body())
self.send_json({"ok": True, **result})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/screener/tracking/refresh":
self.refresh_screener_tracking()
return
@@ -4250,6 +4405,11 @@ class RequestHandler(BaseHTTPRequestHandler):
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
tracking_match = re.fullmatch(r"/api/screener/tracking/(\d+)", parsed.path)
if tracking_match:
result = SERVICE.remove_screener_tracking(int(tracking_match.group(1)))
self.send_json({"ok": True, **result})
return
watchlist_match = re.fullmatch(r"/api/watchlist/(\d{6})", parsed.path)
if watchlist_match:
deleted = SERVICE.database.delete_watchlist(
@@ -4576,8 +4736,9 @@ class RequestHandler(BaseHTTPRequestHandler):
color = str(body.get("color") or "red")
if color not in {"red", "blue", "green", "amber"}:
raise ValueError("标记颜色不支持。")
remark = validate_text(body.get("remark"), "跟踪备注", 240)
SERVICE.database.save_watchlist(
SERVICE.current_user_id, code, name, sector, color
SERVICE.current_user_id, code, name, sector, color, remark
)
self.send_json(
{
@@ -4596,10 +4757,11 @@ class RequestHandler(BaseHTTPRequestHandler):
code = validate_stock_code(code)
stock_name = validate_text(body.get("stock_name"), "股票名称", 30)
trade_date = normalize_date(str(body.get("trade_date") or date.today().isoformat()))
summary = validate_text(body.get("summary"), "盘面摘要", 500)
content = validate_text(body.get("content"), "复盘内容", 5000)
plan = validate_text(body.get("plan"), "明日计划", 2000)
if not content and not plan:
raise ValueError("复盘内容和明日计划不能同时为空。")
if not summary and not content and not plan:
raise ValueError("每日复盘内容不能全部为空。")
raw_id = body.get("id")
note_id = int(raw_id) if raw_id else None
saved_id = SERVICE.database.save_note(
@@ -4610,6 +4772,7 @@ class RequestHandler(BaseHTTPRequestHandler):
content,
plan,
note_id,
summary=summary,
)
self.send_json({"ok": True, "id": saved_id})
except (ValueError, TypeError, json.JSONDecodeError) as exc: