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