migration: preserve screener and tracking slice

This commit is contained in:
leefer
2026-07-31 03:57:07 +08:00
parent cf2aad28ec
commit 4bab921d14
28 changed files with 4810 additions and 4152 deletions
+1 -3
View File
@@ -1,3 +1 @@
from .tracking import StrategyTrackingService
__all__ = ["StrategyTrackingService"]
"""Stock screening, custom selection, and strategy tracking feature."""
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from typing import Any
from screener import FACTOR_FIELDS, REGIMES
class LLMCompilerError(RuntimeError):
pass
def test_llm_connection(
api_key: str,
base_url: str,
model: str,
timeout: int = 30,
) -> dict[str, Any]:
if not api_key or not model:
raise LLMCompilerError("API Key 或模型未配置。")
endpoint = f"{base_url.rstrip('/')}/chat/completions"
payload = json.dumps(
{
"model": model,
"messages": [{"role": "user", "content": "只回复 OK"}],
"stream": False,
},
ensure_ascii=False,
).encode("utf-8")
request = urllib.request.Request(
endpoint,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/0.5",
},
method="POST",
)
started = time.perf_counter()
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
result = json.loads(response.read().decode("utf-8"))
reply = str(result["choices"][0]["message"]["content"]).strip()
except urllib.error.HTTPError as exc:
raise LLMCompilerError(_http_error_message(exc)) from exc
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
raise LLMCompilerError(f"模型连接测试失败:{exc}") from exc
return {
"ok": True,
"model": model,
"reply": reply[:100],
"latency_ms": round((time.perf_counter() - started) * 1000),
}
def compile_strategy_with_llm(
prompt: str,
regime: str,
api_key: str,
base_url: str,
model: str,
timeout: int = 45,
) -> dict[str, Any]:
if not api_key or not model:
raise LLMCompilerError("尚未配置 LLM API Key 或模型。")
endpoint = f"{base_url.rstrip('/')}/chat/completions"
schema = {
"name": "策略名称",
"description": "策略说明",
"regimes": [regime],
"formula": {
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [{"field": "return_5d", "op": ">=", "value": 0}],
"score": [{"field": "sector_strength", "weight": 0.3, "direction": "desc"}],
"limit": 15,
"min_score": 0.55,
},
}
system_prompt = (
"你是A股量化策略编译器。只输出JSON对象,不输出Markdown。"
"不得生成Python、SQL、网络请求或未提供的因子。"
f"当前市场阶段为{REGIMES.get(regime, regime)}"
f"可用因子为:{json.dumps(FACTOR_FIELDS, ensure_ascii=False)}"
"运算符只能使用 >, >=, <, <=, ==, !=, between, in。"
"score权重均大于0且不超过1direction只能是asc或desc。"
"退潮和冰点策略必须提高门槛并允许结果为空。"
f"严格遵循以下结构:{json.dumps(schema, ensure_ascii=False)}"
)
payload = json.dumps(
{
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt[:3000]},
],
"stream": False,
},
ensure_ascii=False,
).encode("utf-8")
request = urllib.request.Request(
endpoint,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
"User-Agent": "XiaobaiReviewWeb/0.4",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
result = json.loads(response.read().decode("utf-8"))
content = result["choices"][0]["message"]["content"].strip()
if content.startswith("```"):
content = content.strip("`")
if content.startswith("json"):
content = content[4:].strip()
compiled = json.loads(content)
except urllib.error.HTTPError as exc:
raise LLMCompilerError(_http_error_message(exc).replace("模型连接测试", "LLM 策略编译")) from exc
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError) as exc:
raise LLMCompilerError(f"LLM 策略编译失败:{exc}") from exc
compiled["compiler"] = "llm"
compiled["model"] = model
return compiled
def _http_error_message(exc: urllib.error.HTTPError) -> str:
detail = ""
try:
payload = json.loads(exc.read().decode("utf-8", errors="replace"))
error = payload.get("error")
if isinstance(error, dict):
detail = str(error.get("message") or error.get("code") or "")
elif error:
detail = str(error)
elif payload.get("message"):
detail = str(payload["message"])
except (json.JSONDecodeError, OSError):
detail = ""
suffix = f"{detail[:300]}" if detail else ""
return f"模型连接测试失败(HTTP {exc.code}{suffix}"
File diff suppressed because it is too large Load Diff
+814
View File
@@ -0,0 +1,814 @@
from __future__ import annotations
import json
import sqlite3
from datetime import datetime
from typing import Any
from backend.features.sentiment.engine import build_sentiment_history
def _optional_float(value: Any) -> float | None:
if value in (None, ""):
return None
try:
return float(value)
except (TypeError, ValueError):
return None
class ScreenerRepositoryMixin:
def upsert_benchmark_bars(self, rows: list[dict[str, Any]]) -> int:
values = [
(
str(row.get("trade_date") or ""), str(row.get("ts_code") or ""),
float(row.get("close") or 0), float(row.get("pct_chg") or 0),
)
for row in rows if row.get("trade_date") and row.get("ts_code")
]
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO benchmark_bars (trade_date, ts_code, close, pct_chg)
VALUES (?, ?, ?, ?)
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
close=excluded.close, pct_chg=excluded.pct_chg
""",
values,
)
return len(values)
def upsert_daily_indicators(self, rows: list[dict[str, Any]]) -> int:
values = [
(
str(row.get("trade_date") or ""), row.get("ts_code", ""),
float(row.get("turnover_rate") or 0), float(row.get("volume_ratio") or 0),
float(row.get("total_mv") or 0), float(row.get("circ_mv") or 0),
_optional_float(row.get("pe_ttm")), _optional_float(row.get("pb")),
_optional_float(row.get("ps_ttm")), _optional_float(row.get("dv_ttm")),
)
for row in rows if row.get("trade_date") and row.get("ts_code")
]
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO daily_indicators
(trade_date, ts_code, turnover_rate, volume_ratio, total_mv, circ_mv,
pe_ttm, pb, ps_ttm, dv_ttm)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
turnover_rate=excluded.turnover_rate, volume_ratio=excluded.volume_ratio,
total_mv=excluded.total_mv, circ_mv=excluded.circ_mv,
pe_ttm=excluded.pe_ttm, pb=excluded.pb,
ps_ttm=excluded.ps_ttm, dv_ttm=excluded.dv_ttm
""",
values,
)
return len(values)
def upsert_fundamental_indicators(self, rows: list[dict[str, Any]]) -> int:
values = [
(
str(row.get("end_date") or ""), str(row.get("ann_date") or ""),
str(row.get("ts_code") or ""), _optional_float(row.get("roe")),
_optional_float(row.get("roa")), _optional_float(row.get("roic")),
_optional_float(row.get("grossprofit_margin")),
_optional_float(row.get("netprofit_yoy")), _optional_float(row.get("or_yoy")),
_optional_float(row.get("ocf_to_opincome")),
)
for row in rows
if row.get("end_date") and row.get("ts_code")
]
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO fundamental_indicators
(end_date, ann_date, ts_code, roe, roa, roic, grossprofit_margin,
netprofit_yoy, or_yoy, ocf_to_opincome)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(end_date, ts_code) DO UPDATE SET
ann_date=excluded.ann_date, roe=excluded.roe, roa=excluded.roa,
roic=excluded.roic, grossprofit_margin=excluded.grossprofit_margin,
netprofit_yoy=excluded.netprofit_yoy, or_yoy=excluded.or_yoy,
ocf_to_opincome=excluded.ocf_to_opincome
""",
values,
)
return len(values)
def upsert_moneyflow(self, rows: list[dict[str, Any]]) -> int:
values = []
for row in rows:
if not row.get("trade_date") or not row.get("ts_code"):
continue
large_net = (
float(row.get("buy_lg_amount") or 0) + float(row.get("buy_elg_amount") or 0)
- float(row.get("sell_lg_amount") or 0) - float(row.get("sell_elg_amount") or 0)
)
medium_net = float(row.get("buy_md_amount") or 0) - float(row.get("sell_md_amount") or 0)
small_net = float(row.get("buy_sm_amount") or 0) - float(row.get("sell_sm_amount") or 0)
values.append((
str(row["trade_date"]), row["ts_code"], float(row.get("net_mf_amount") or 0),
large_net, medium_net, small_net,
))
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO moneyflow_daily
(trade_date, ts_code, net_mf_amount, large_net_amount, medium_net_amount, small_net_amount)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(trade_date, ts_code) DO UPDATE SET
net_mf_amount=excluded.net_mf_amount, large_net_amount=excluded.large_net_amount,
medium_net_amount=excluded.medium_net_amount, small_net_amount=excluded.small_net_amount
""",
values,
)
return len(values)
def upsert_earnings_events(self, rows: list[dict[str, Any]]) -> int:
values = [
(
str(row.get("end_date") or ""),
str(row.get("ann_date") or ""),
str(row.get("ts_code") or ""),
_optional_float(row.get("forecast_profit")),
_optional_float(row.get("actual_profit")),
_optional_float(row.get("surprise_pct")),
_optional_float(row.get("revenue_yoy")),
_optional_float(row.get("netprofit_yoy")),
str(row.get("source") or ""),
)
for row in rows
if row.get("end_date") and row.get("ann_date") and row.get("ts_code")
]
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO earnings_events
(end_date, ann_date, ts_code, forecast_profit, actual_profit,
surprise_pct, revenue_yoy, netprofit_yoy, source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(end_date, ann_date, ts_code) DO UPDATE SET
forecast_profit=excluded.forecast_profit,
actual_profit=excluded.actual_profit,
surprise_pct=excluded.surprise_pct,
revenue_yoy=excluded.revenue_yoy,
netprofit_yoy=excluded.netprofit_yoy,
source=excluded.source
""",
values,
)
return len(values)
def daily_indicator_dates(self, end_date: str = "", limit: int = 400) -> list[str]:
where = "WHERE trade_date <= ?" if end_date else ""
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
with self.connect() as connection:
rows = connection.execute(
f"SELECT DISTINCT trade_date FROM daily_indicators {where} "
"ORDER BY trade_date DESC LIMIT ?",
parameters,
).fetchall()
return [row["trade_date"] for row in reversed(rows)]
def fundamental_periods(self) -> list[str]:
with self.connect() as connection:
rows = connection.execute(
"SELECT DISTINCT end_date FROM fundamental_indicators ORDER BY end_date"
).fetchall()
return [str(row["end_date"]) for row in rows]
def factor_dates(self, end_date: str = "", limit: int = 80) -> list[str]:
where = "WHERE trade_date <= ?" if end_date else ""
parameters: tuple[Any, ...] = (end_date, limit) if end_date else (limit,)
with self.connect() as connection:
rows = connection.execute(
f"SELECT DISTINCT trade_date FROM daily_bars {where} ORDER BY trade_date DESC LIMIT ?",
parameters,
).fetchall()
return [row["trade_date"] for row in reversed(rows)]
def factor_health_summary(self, end_date: str) -> dict[str, Any]:
dividend_start = f"{max(0, int(end_date[:4] or 0) - 5)}0101"
with self.connect() as connection:
market = connection.execute(
"SELECT EXISTS(SELECT 1 FROM daily_bars WHERE trade_date <= ? LIMIT 1)",
(end_date,),
).fetchone()[0]
auction = connection.execute(
"SELECT EXISTS(SELECT 1 FROM auction_factors WHERE trade_date <= ? LIMIT 1)",
(end_date,),
).fetchone()[0]
benchmark_rows = connection.execute(
"SELECT COUNT(*) FROM benchmark_bars WHERE ts_code = '000300.SH' AND trade_date <= ?",
(end_date,),
).fetchone()[0]
indicator_date = connection.execute(
"SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?",
(end_date,),
).fetchone()[0]
if indicator_date:
valuation_rows, valuation_available = connection.execute(
"""
SELECT COUNT(*), COALESCE(MAX(pe_ttm IS NOT NULL), 0)
FROM daily_indicators WHERE trade_date = ?
""",
(indicator_date,),
).fetchone()
else:
valuation_rows, valuation_available = 0, 0
dividend_years = connection.execute(
"""
SELECT COUNT(DISTINCT substr(trade_date, 1, 4))
FROM daily_indicators
WHERE trade_date <= ? AND trade_date >= ?
""",
(end_date, dividend_start),
).fetchone()[0]
fundamental_rows = connection.execute(
"""
SELECT COUNT(*) FROM fundamental_indicators fi
INNER JOIN (
SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key
FROM fundamental_indicators
WHERE ann_date = '' OR ann_date <= ?
GROUP BY ts_code
) latest
ON latest.ts_code = fi.ts_code
AND latest.latest_key = (fi.ann_date || ':' || fi.end_date)
""",
(end_date,),
).fetchone()[0]
moneyflow_dates = connection.execute(
"""
SELECT COUNT(DISTINCT trade_date)
FROM moneyflow_daily
WHERE trade_date IN (
SELECT DISTINCT trade_date
FROM daily_bars
WHERE trade_date <= ?
ORDER BY trade_date DESC
LIMIT 5
)
""",
(end_date,),
).fetchone()[0]
earnings_rows = connection.execute(
"""
SELECT COUNT(*) FROM earnings_events
WHERE ann_date <= ? AND ann_date >= replace(date(?, '-45 day'), '-', '')
""",
(end_date, f"{end_date[:4]}-{end_date[4:6]}-{end_date[6:8]}"),
).fetchone()[0]
popularity_rows = connection.execute(
"SELECT COUNT(*) FROM popularity_factors WHERE trade_date = ?",
(end_date,),
).fetchone()[0]
institution_rows = connection.execute(
"SELECT COUNT(*) FROM lhb_institution_daily WHERE trade_date = ?",
(end_date,),
).fetchone()[0]
return {
"market": bool(market),
"auction": bool(auction),
"benchmark": int(benchmark_rows or 0) >= 60,
"benchmark_rows": int(benchmark_rows or 0),
"valuation": bool(valuation_available),
"fundamental": int(fundamental_rows or 0) >= 100,
"dividend_history": int(dividend_years or 0) >= 4,
"valuation_rows": int(valuation_rows or 0),
"fundamental_rows": int(fundamental_rows or 0),
"dividend_years": int(dividend_years or 0),
"moneyflow_history": int(moneyflow_dates or 0) >= 5,
"moneyflow_dates": int(moneyflow_dates or 0),
"earnings_events": int(earnings_rows or 0) > 0,
"earnings_event_rows": int(earnings_rows or 0),
"popularity": int(popularity_rows or 0) > 0,
"popularity_rows": int(popularity_rows or 0),
"institutions": int(institution_rows or 0) > 0,
"institution_rows": int(institution_rows or 0),
}
def load_factor_data(self, end_date: str, limit_dates: int = 80) -> dict[str, Any]:
dates = self.factor_dates(end_date, limit_dates)
if not dates:
return {
"dates": [], "bars": [], "master": [], "indicators": [],
"indicator_history": [], "indicator_series": [], "fundamentals": [],
"moneyflow": [], "moneyflow_history": [], "auction": [],
"benchmarks": [], "fundamental_history": [],
"earnings_events": [], "popularity": [], "institutions": [],
}
placeholders = ",".join("?" for _ in dates)
with self.connect() as connection:
bars = connection.execute(
f"SELECT * FROM daily_bars WHERE trade_date IN ({placeholders}) ORDER BY trade_date, ts_code",
dates,
).fetchall()
master = connection.execute("SELECT * FROM stock_master").fetchall()
indicators = connection.execute(
"""
SELECT * FROM daily_indicators
WHERE trade_date = (
SELECT MAX(trade_date) FROM daily_indicators WHERE trade_date <= ?
)
""",
(end_date,),
).fetchall()
indicator_history = connection.execute(
"""
SELECT di.* FROM daily_indicators di
INNER JOIN (
SELECT ts_code, substr(trade_date, 1, 4) AS year_key,
MAX(trade_date) AS max_date
FROM daily_indicators
WHERE trade_date <= ? AND trade_date >= ?
GROUP BY ts_code, substr(trade_date, 1, 4)
) latest
ON latest.ts_code = di.ts_code AND latest.max_date = di.trade_date
ORDER BY di.trade_date, di.ts_code
""",
(end_date, str(max(0, int(end_date[:4] or 0) - 5)) + "0101"),
).fetchall()
indicator_series = connection.execute(
f"""
SELECT trade_date, ts_code, turnover_rate, volume_ratio,
total_mv, circ_mv, pe_ttm, pb, ps_ttm, dv_ttm
FROM daily_indicators
WHERE trade_date IN ({placeholders})
ORDER BY trade_date, ts_code
""",
dates,
).fetchall()
fundamentals = connection.execute(
"""
SELECT fi.* FROM fundamental_indicators fi
INNER JOIN (
SELECT ts_code, MAX(ann_date || ':' || end_date) AS latest_key
FROM fundamental_indicators
WHERE ann_date = '' OR ann_date <= ?
GROUP BY ts_code
) latest
ON latest.ts_code = fi.ts_code
AND latest.latest_key = (fi.ann_date || ':' || fi.end_date)
""",
(end_date,),
).fetchall()
fundamental_history = connection.execute(
"""
SELECT * FROM fundamental_indicators
WHERE ann_date = '' OR ann_date <= ?
ORDER BY ann_date, end_date, ts_code
""",
(end_date,),
).fetchall()
moneyflow = connection.execute(
"""
SELECT * FROM moneyflow_daily
WHERE trade_date = (
SELECT MAX(trade_date) FROM moneyflow_daily WHERE trade_date <= ?
)
""",
(end_date,),
).fetchall()
flow_dates = dates[-min(5, len(dates)):]
flow_placeholders = ",".join("?" for _ in flow_dates)
moneyflow_history = connection.execute(
f"""
SELECT * FROM moneyflow_daily
WHERE trade_date IN ({flow_placeholders})
ORDER BY trade_date, ts_code
""",
flow_dates,
).fetchall()
auction = connection.execute(
"""
SELECT * FROM auction_factors
WHERE trade_date = (
SELECT MAX(trade_date) FROM auction_factors WHERE trade_date <= ?
)
""",
(end_date,),
).fetchall()
benchmarks = connection.execute(
f"""
SELECT * FROM benchmark_bars
WHERE ts_code = '000300.SH' AND trade_date IN ({placeholders})
ORDER BY trade_date
""",
dates,
).fetchall()
earnings_events = connection.execute(
"""
SELECT * FROM earnings_events
WHERE ann_date <= ?
ORDER BY ann_date, end_date, ts_code
""",
(end_date,),
).fetchall()
popularity = connection.execute(
"SELECT * FROM popularity_factors WHERE trade_date = ? ORDER BY ts_code",
(end_date,),
).fetchall()
institutions = connection.execute(
"SELECT * FROM lhb_institution_daily WHERE trade_date = ? ORDER BY ts_code",
(end_date,),
).fetchall()
return {
"dates": dates,
"bars": [dict(row) for row in bars],
"master": [dict(row) for row in master],
"indicators": [dict(row) for row in indicators],
"indicator_history": [dict(row) for row in indicator_history],
"indicator_series": [dict(row) for row in indicator_series],
"fundamentals": [dict(row) for row in fundamentals],
"fundamental_history": [dict(row) for row in fundamental_history],
"moneyflow": [dict(row) for row in moneyflow],
"moneyflow_history": [dict(row) for row in moneyflow_history],
"auction": [dict(row) for row in auction],
"benchmarks": [dict(row) for row in benchmarks],
"earnings_events": [dict(row) for row in earnings_events],
"popularity": [dict(row) for row in popularity],
"institutions": [dict(row) for row in institutions],
}
def snapshot_summaries(self, end_date: str, limit: int = 10) -> list[dict[str, Any]]:
try:
from sentiment_engine import build_sentiment_history
except ModuleNotFoundError:
from .sentiment_engine import build_sentiment_history
series = build_sentiment_history(self.list_snapshot_payloads(end_date, 260))
return [
{
"trade_date": row["trade_date"],
"sentiment_score": row["score"],
"seal_rate": row["seal_rate"],
"limit_up_count": row["limit_up_count"],
"limit_down_count": row["limit_down_count"],
"broken_count": row["broken_count"],
"up_count": row["up_count"],
"down_count": row["down_count"],
"amount_billion": row["amount_billion"],
}
for row in series[-limit:]
]
def save_screener_strategy(
self, user_id: int | None, name: str, description: str, regimes: list[str], formula: dict[str, Any],
builtin: bool = False, strategy_id: int | None = None,
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
regimes_json = json.dumps(regimes, ensure_ascii=False)
formula_json = json.dumps(formula, ensure_ascii=False, separators=(",", ":"))
with self.connect() as connection:
if strategy_id:
if builtin:
cursor = connection.execute(
"""
UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?,
builtin=1, user_id=NULL, updated_at=? WHERE id=? AND builtin=1
""",
(name, description, regimes_json, formula_json, now, strategy_id),
)
else:
cursor = connection.execute(
"""
UPDATE screener_strategies SET name=?, description=?, regimes=?, formula=?,
updated_at=? WHERE id=? AND builtin=0 AND user_id=?
""",
(name, description, regimes_json, formula_json, now, strategy_id, int(user_id or 0)),
)
if cursor.rowcount == 0:
raise ValueError("选股策略不存在。")
return strategy_id
cursor = connection.execute(
"""
INSERT INTO screener_strategies
(user_id, name, description, regimes, formula, builtin, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(None if builtin else int(user_id or 0), name, description, regimes_json, formula_json, int(builtin), now, now),
)
return int(cursor.lastrowid)
def list_screener_strategies(self, user_id: int | None = None) -> list[dict[str, Any]]:
with self.connect() as connection:
if user_id is None:
rows = connection.execute(
"SELECT * FROM screener_strategies WHERE builtin = 1 ORDER BY updated_at DESC, id"
).fetchall()
else:
rows = connection.execute(
"""
SELECT * FROM screener_strategies
WHERE builtin = 1 OR user_id = ?
ORDER BY builtin DESC, updated_at DESC, id
""",
(int(user_id),),
).fetchall()
result = []
for row in rows:
item = dict(row)
item["regimes"] = json.loads(item["regimes"])
item["formula"] = json.loads(item["formula"])
item["builtin"] = bool(item["builtin"])
result.append(item)
return result
def delete_screener_strategy(self, user_id: int, strategy_id: int) -> bool:
with self.connect() as connection:
row = connection.execute(
"SELECT builtin, user_id FROM screener_strategies WHERE id = ?",
(strategy_id,),
).fetchone()
if not row:
raise ValueError("选股策略不存在。")
if bool(row["builtin"]):
raise ValueError("内置策略不能删除。")
if int(row["user_id"] or 0) != int(user_id):
raise ValueError("无权删除其他账号的策略。")
cursor = connection.execute(
"DELETE FROM screener_strategies WHERE id = ? AND builtin = 0 AND user_id = ?",
(strategy_id, int(user_id)),
)
return cursor.rowcount > 0
def save_screener_run(
self, user_id: int, trade_date: str, regime: str, strategy_name: str,
formula: dict[str, Any], result: dict[str, Any], mode: str = "smart",
) -> int:
normalized_mode = mode if mode in {"smart", "curated", "quant"} else "smart"
now = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
cursor = connection.execute(
"""
INSERT INTO screener_runs
(user_id, trade_date, regime, mode, strategy_name, formula, result, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(None if int(user_id) == 0 else int(user_id), trade_date, regime,
normalized_mode, strategy_name,
json.dumps(formula, ensure_ascii=False, separators=(",", ":")),
json.dumps(result, ensure_ascii=False, separators=(",", ":")), now),
)
return int(cursor.lastrowid)
@staticmethod
def _screener_run_payload(row: sqlite3.Row) -> dict[str, Any] | None:
try:
result = json.loads(row["result"])
except json.JSONDecodeError:
return None
result.setdefault("meta", {}).update(
{
"run_id": int(row["id"]),
"trade_date": str(row["trade_date"] or ""),
"regime": str(row["regime"] or ""),
"mode": str(row["mode"] or "smart"),
"strategy_name": str(row["strategy_name"] or ""),
"created_at": row["created_at"],
}
)
return result
def latest_screener_run(
self, user_id: int, trade_date: str, mode: str = "",
) -> dict[str, Any] | None:
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
parameters += (trade_date,)
mode_clause = ""
if mode in {"smart", "curated", "quant"}:
mode_clause = " AND mode = ?"
parameters += (mode,)
with self.connect() as connection:
row = connection.execute(
f"""
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM screener_runs
WHERE {owner_clause} AND trade_date <= ?{mode_clause}
ORDER BY id DESC LIMIT 1
""",
parameters,
).fetchone()
return self._screener_run_payload(row) if row else None
def latest_screener_runs(self, user_id: int, trade_date: str) -> dict[str, dict[str, Any]]:
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
parameters += (trade_date,)
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT runs.id, runs.trade_date, runs.regime, runs.mode,
runs.strategy_name, runs.result, runs.created_at
FROM screener_runs runs
INNER JOIN (
SELECT mode, MAX(id) AS id
FROM screener_runs
WHERE {owner_clause} AND trade_date <= ?
GROUP BY mode
) latest ON latest.id = runs.id
""",
parameters,
).fetchall()
results: dict[str, dict[str, Any]] = {}
for row in rows:
mode = str(row["mode"] or "smart")
payload = self._screener_run_payload(row)
if mode in {"smart", "curated", "quant"} and payload:
results[mode] = payload
return results
def latest_screener_context_runs(
self, user_id: int, trade_date: str, limit: int = 60,
) -> list[dict[str, Any]]:
safe_limit = max(1, min(120, int(limit)))
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
parameters += (trade_date, 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
mode,
CASE WHEN mode = 'smart' THEN regime ELSE '' END,
CASE WHEN mode IN ('smart', 'curated') THEN strategy_name ELSE '' END
ORDER BY id DESC
) AS context_rank
FROM screener_runs
WHERE {owner_clause} AND trade_date <= ?
)
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM ranked
WHERE context_rank = 1
ORDER BY id DESC
LIMIT ?
""",
parameters,
).fetchall()
return [
payload
for row in rows
if (payload := self._screener_run_payload(row)) is not None
]
def screener_runs_for_date(
self, user_id: int, trade_date: str, limit: int = 80,
) -> list[dict[str, Any]]:
safe_limit = max(1, min(160, int(limit)))
owner_clause = "user_id IS NULL" if int(user_id) == 0 else "user_id = ?"
parameters: tuple[Any, ...] = () if int(user_id) == 0 else (int(user_id),)
parameters += (trade_date, safe_limit)
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM screener_runs
WHERE {owner_clause} AND trade_date = ?
ORDER BY id DESC
LIMIT ?
""",
parameters,
).fetchall()
result = []
seen: set[tuple[str, str, str]] = set()
for row in rows:
key = (
str(row["mode"] or "smart"),
str(row["regime"] or ""),
str(row["strategy_name"] or ""),
)
if key in seen:
continue
seen.add(key)
payload = self._screener_run_payload(row)
if payload is not None:
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),)
if int(user_id) != 0:
parameters += (int(user_id),)
with self.connect() as connection:
row = connection.execute(
f"""
SELECT id, trade_date, regime, mode, strategy_name, result, created_at
FROM screener_runs WHERE id = ? AND {owner_clause}
""",
parameters,
).fetchone()
if not row:
return None
result = self._screener_run_payload(row)
if result is None:
return None
result.setdefault("meta", {}).update(
{
"run_id": int(row["id"]),
"trade_date": row["trade_date"],
"mode": str(row["mode"] or "smart"),
"created_at": row["created_at"],
}
)
result["strategy_name"] = row["strategy_name"]
result["regime"] = row["regime"]
return result
def save_strategy_tracks(
self,
user_id: int,
run_id: int,
selection_date: str,
strategy_name: str,
candidates: list[dict[str, Any]],
) -> int:
now = datetime.now().astimezone().isoformat(timespec="seconds")
values = []
for item in candidates:
ts_code = str(item.get("ts_code") or "").strip()
code = str(item.get("code") or ts_code.split(".")[0]).strip()
entry_price = float(item.get("price") or 0)
if not ts_code or not code or entry_price <= 0:
continue
values.append(
(
int(user_id), int(run_id), selection_date, strategy_name, ts_code, code,
str(item.get("name") or "--"), str(item.get("sector") or "其他"),
entry_price, now, now,
)
)
with self.connect() as connection:
connection.executemany(
"""
INSERT INTO strategy_tracks
(user_id, run_id, selection_date, strategy_name, ts_code, code,
name, sector, entry_price, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, run_id, ts_code) DO UPDATE SET
name=excluded.name, sector=excluded.sector,
entry_price=excluded.entry_price, updated_at=excluded.updated_at
""",
values,
)
return len(values)
def list_strategy_tracks(self, user_id: int, limit_batches: int = 12) -> list[dict[str, Any]]:
limit_batches = max(1, min(50, int(limit_batches)))
with self.connect() as connection:
rows = connection.execute(
"""
SELECT * FROM strategy_tracks
WHERE user_id = ? AND run_id IN (
SELECT run_id FROM strategy_tracks WHERE user_id = ?
GROUP BY run_id ORDER BY run_id DESC LIMIT ?
)
ORDER BY run_id DESC, id
""",
(int(user_id), int(user_id), limit_batches),
).fetchall()
return [dict(row) for row in rows]
def delete_strategy_track(self, user_id: int, track_id: int) -> bool:
with self.connect() as connection:
cursor = connection.execute(
"DELETE FROM strategy_tracks WHERE id = ? AND user_id = ?",
(int(track_id), int(user_id)),
)
return cursor.rowcount > 0
def load_tracking_bars(
self, targets: list[tuple[str, str]], limit: int = 5
) -> dict[tuple[str, str], list[dict[str, Any]]]:
unique_targets = set(targets)
if not unique_targets:
return {}
codes = sorted({ts_code for ts_code, _ in unique_targets})
earliest_date = min(selection_date for _, selection_date in unique_targets)
placeholders = ",".join("?" for _ in codes)
with self.connect() as connection:
rows = connection.execute(
f"""
SELECT ts_code, trade_date, open, high, low, close FROM daily_bars
WHERE ts_code IN ({placeholders}) AND trade_date > ?
ORDER BY ts_code, trade_date
""",
[*codes, earliest_date],
).fetchall()
by_code: dict[str, list[dict[str, Any]]] = {}
for row in rows:
item = dict(row)
by_code.setdefault(str(item["ts_code"]), []).append(item)
row_limit = max(1, min(20, int(limit)))
return {
(ts_code, selection_date): [
row for row in by_code.get(ts_code, []) if row["trade_date"] > selection_date
][:row_limit]
for ts_code, selection_date in unique_targets
}
+435
View File
@@ -0,0 +1,435 @@
from __future__ import annotations
import copy
import re
from datetime import date, datetime
from typing import Any
from backend.bootstrap.config import normalize_date, validate_text
from backend.data.providers.tushare_client import TushareError
from backend.llm import LLMGatewayError
from backend.features.screener.compiler import (
LLMCompilerError,
compile_strategy_with_llm,
)
from backend.features.screener.engine import (
FACTOR_FIELDS,
FACTOR_GROUPS,
REGIMES,
FactorDataService,
compile_local_strategy,
)
SCREENER_LIBRARY_VERSION = 8
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
class ScreenerServiceMixin:
@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 meta.get("requires_valuation") and not factor_health["valuation"]:
missing.append("估值数据")
if meta.get("requires_fundamental") 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日资金流")
if meta.get("requires_earnings_events") and not factor_health.get("earnings_events"):
missing.append("业绩预告与快报")
if meta.get("requires_popularity") and not factor_health.get("popularity"):
missing.append("当日人气榜")
if meta.get("requires_institutions") and not factor_health.get("institutions"):
missing.append("龙虎榜机构席位")
return list(dict.fromkeys(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:
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,
"regimes": [{"id": key, "label": value} for key, value in REGIMES.items()],
"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 "",
"end_date": factor_dates[-1] if factor_dates else "",
"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,
"model": self.llm_primary_model if self.llm_configured else "",
"fallback_configured": self.llm_fallback_configured,
"fallback_model": self.llm_fallback_model if self.llm_fallback_configured else "",
},
"latest_results": latest_results,
"recent_results": recent_results,
"automatic_status": automatic_status,
# Kept during the client transition for compatibility with older frontends.
"latest_result": latest_results.get("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 = ""
if self.configured:
try:
FactorDataService(self.database, self._tushare_client()).sync(
normalized_date, 15
)
except TushareError:
notice = "最新日线暂未补齐,已按现有数据更新跟踪。"
else:
notice = "公共行情尚未配置,已按现有数据更新跟踪。"
return {
"tracking": self.screener_tracking(),
"notice": notice,
}
def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]:
if not self.configured:
raise ValueError("请先配置 Tushare Token。")
normalized_date = normalize_date(trade_date)
lookback = max(25, min(260, int(lookback)))
with self.sync_lock:
return FactorDataService(self.database, self._tushare_client()).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
return self.jobs.submit(
"screener.automatic",
f"{normalized_date}:v{SCREENER_LIBRARY_VERSION}",
lambda: self.run_automatic_screeners(normalized_date),
{"trade_date": normalized_date, "trigger": "post-close"},
)
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, self._tushare_client()
).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:
raise ValueError("策略描述应为 1 至 3000 个字符。")
if regime not in REGIMES:
raise ValueError("市场阶段不支持。")
notice = ""
source = self.llm_source
if source == "platform":
try:
gateway_result = self.llm_gateway.call(
"screener",
"strategy-compiler-v1",
lambda profile: compile_strategy_with_llm(
prompt,
regime,
profile.api_key,
profile.base_url,
profile.model,
),
(LLMCompilerError,),
)
compiled = gateway_result.value
if gateway_result.role == "fallback":
compiled["compiler"] = "llm_fallback"
notice = "智能策略生成服务已自动切换。"
except LLMGatewayError as exc:
if exc.code != "unavailable":
raise
compiled = compile_local_strategy(prompt, regime)
notice = "智能策略生成暂不可用,已使用本地模板。"
else:
compiled = compile_local_strategy(prompt, regime)
notice = "智能策略生成暂不可用,已使用本地模板。"
compiled["formula"] = self.screener.validate_formula(compiled["formula"])
compiled["notice"] = notice
return compiled
def save_screener_strategy(self, payload: dict[str, Any]) -> dict[str, Any]:
name = validate_text(payload.get("name"), "策略名称", 60, required=True)
description = validate_text(payload.get("description"), "策略说明", 1000)
regimes = payload.get("regimes") or []
if not isinstance(regimes, list) or not regimes or any(item not in REGIMES for item in regimes):
raise ValueError("策略适用阶段不正确。")
formula = self.screener.validate_formula(payload.get("formula") or {})
strategy_id = self.database.save_screener_strategy(
self.current_user_id, name, description, regimes, formula
)
return {
"id": strategy_id,
"strategies": self.database.list_screener_strategies(self.current_user_id),
}
def delete_screener_strategy(self, strategy_id: int) -> dict[str, Any]:
deleted = self.database.delete_screener_strategy(self.current_user_id, strategy_id)
return {
"deleted": deleted,
"strategies": self.database.list_screener_strategies(self.current_user_id),
}
def run_screener(self, payload: dict[str, Any]) -> dict[str, Any]:
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
regime = str(payload.get("regime") or "")
if regime not in REGIMES:
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"):
try:
realtime_snapshot = self._tushare_client().realtime_factor_snapshot(trade_date)
except TushareError as exc:
raise ValueError(f"实时选股行情不可用,已停止筛选:{exc}") from exc
result = self.screener.screen(
self.current_user_id, trade_date, formula, regime, strategy_name,
bool(payload.get("run_backtest", True)),
realtime_snapshot,
mode,
)
return result
+486
View File
@@ -0,0 +1,486 @@
from __future__ import annotations
from typing import Any
def _meta(
category: str,
quality: str,
frequency: str,
risk: str,
data_group: str,
history_days: int,
backtest_days: int,
take_profit: float,
stop_loss: float,
**extra: Any,
) -> dict[str, Any]:
return {
"library": "curated",
"category": category,
"quality": quality,
"frequency": frequency,
"risk": risk,
"data_group": data_group,
"history_days": history_days,
"backtest_days": backtest_days,
"take_profit": take_profit,
"stop_loss": stop_loss,
**extra,
}
ADVANCED_CURATED_STRATEGIES = [
{
"name": "中期动量·强者恒强",
"description": "用60日至5日前的中期动量识别持续强势,同时剔除当日无法正常成交的涨停标的。",
"regimes": ["repair", "fermentation", "climax", "divergence"],
"formula": {
"meta": _meta("动量反转", "A-", "每周", "", "历史行情", 80, 10, 8, -5),
"universe": {"exclude_st": True, "listed_days_min": 180},
"filters": [
{"field": "close", "op": "between", "value": [3, 100]},
{"field": "momentum_60_5_rank", "op": ">=", "value": 0.90},
{"field": "is_limit_up_today", "op": "==", "value": 0},
],
"score": [
{"field": "momentum_60_5", "weight": 0.55, "direction": "desc"},
{"field": "relative_strength", "weight": 0.25, "direction": "desc"},
{"field": "amount_billion", "weight": 0.20, "direction": "desc"},
],
"limit": 25,
"min_score": 0.50,
},
},
{
"name": "强者回调",
"description": "在中期强势股池中寻找回踩20日线、短期超卖且近20日无跌停的牛回头候选。",
"regimes": ["repair", "fermentation", "divergence"],
"formula": {
"meta": _meta("动量反转", "A-", "每日", "", "历史行情", 80, 10, 8, -5),
"universe": {"exclude_st": True, "listed_days_min": 180},
"filters": [
{"field": "momentum_60_5_rank", "op": ">=", "value": 0.70},
{"field": "return_5d_rank", "op": "<=", "value": 0.20},
{"field": "above_ma20", "op": "==", "value": 1},
{"field": "rsi_6", "op": "<=", "value": 30},
{"field": "no_limit_down_20d", "op": "==", "value": 1},
],
"score": [
{"field": "momentum_60_5", "weight": 0.42, "direction": "desc"},
{"field": "return_5d", "weight": 0.33, "direction": "asc"},
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
],
"limit": 20,
"min_score": 0.48,
},
},
{
"name": "超跌反转",
"description": "筛选短期极端回撤、充分换手但尚未形成长期单边下跌的修复候选。",
"regimes": ["ice", "repair"],
"formula": {
"meta": _meta("动量反转", "B+", "每日", "", "行情与财务", 80, 5, 8, -5),
"universe": {"exclude_st": True, "listed_days_min": 180},
"filters": [
{"field": "return_5d_rank", "op": "<=", "value": 0.05},
{"field": "turnover_5d", "op": ">=", "value": 30},
{"field": "return_60d", "op": ">=", "value": -40},
{"field": "financial_risk", "op": "==", "value": 0},
{"field": "is_limit_down_today", "op": "==", "value": 0},
],
"score": [
{"field": "return_5d", "weight": 0.45, "direction": "asc"},
{"field": "turnover_5d", "weight": 0.30, "direction": "desc"},
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
],
"limit": 10,
"min_score": 0.50,
},
},
{
"name": "相对强度新高",
"description": "以个股相对沪深300的强度线识别弱市领涨和结构性抱团标的。",
"regimes": ["ice", "repair", "fermentation", "divergence"],
"formula": {
"meta": _meta("动量反转", "A", "每周", "", "行情与指数", 130, 20, 12, -7, requires_benchmark=True),
"universe": {"exclude_st": True, "listed_days_min": 250},
"filters": [
{"field": "amount_billion", "op": ">=", "value": 1},
{"field": "rs_high_120", "op": "==", "value": 1},
{"field": "excess_return_60d", "op": ">=", "value": 10},
{"field": "ma60_slope", "op": ">", "value": 0},
],
"score": [
{"field": "excess_return_60d", "weight": 0.50, "direction": "desc"},
{"field": "ma60_slope", "weight": 0.25, "direction": "desc"},
{"field": "amount_billion", "weight": 0.25, "direction": "desc"},
],
"limit": 20,
"min_score": 0.52,
},
},
{
"name": "均线多头排列",
"description": "使用5、10、20、60日均线多头结构、20日线斜率和250日位置确认趋势。",
"regimes": ["repair", "fermentation", "climax", "divergence"],
"formula": {
"meta": _meta("趋势追踪", "A-", "每周", "中低", "历史行情", 260, 20, 12, -7),
"universe": {"exclude_st": True, "listed_days_min": 365},
"filters": [
{"field": "ma_bull_alignment", "op": "==", "value": 1},
{"field": "ma20_slope_5d", "op": ">", "value": 0},
{"field": "drawdown_from_high_250", "op": "<=", "value": 20},
],
"score": [
{"field": "ma20_slope_5d", "weight": 0.38, "direction": "desc"},
{"field": "drawdown_from_high_250", "weight": 0.32, "direction": "asc"},
{"field": "relative_strength", "weight": 0.30, "direction": "desc"},
],
"limit": 30,
"min_score": 0.50,
},
},
{
"name": "唐奇安通道突破",
"description": "收盘突破前20日高点,并以突破幅度、量能和突破前振幅过滤假突破。",
"regimes": ["repair", "fermentation", "divergence"],
"formula": {
"meta": _meta("趋势追踪", "A-", "每日", "", "历史行情", 80, 20, 12, -7),
"universe": {"exclude_st": True, "listed_days_min": 180},
"filters": [
{"field": "donchian_breakout_pct", "op": ">=", "value": 2},
{"field": "volume_ratio_5d", "op": ">=", "value": 1.8},
{"field": "range_20d", "op": "<=", "value": 35},
],
"score": [
{"field": "volume_ratio_5d", "weight": 0.40, "direction": "desc"},
{"field": "donchian_breakout_pct", "weight": 0.35, "direction": "desc"},
{"field": "range_20d", "weight": 0.25, "direction": "asc"},
],
"limit": 15,
"min_score": 0.52,
},
},
{
"name": "周线趋势·日线买点",
"description": "周线MACD位于多头区间,日线金叉或回踩20日线收阳时确认多周期共振。",
"regimes": ["repair", "fermentation", "divergence"],
"formula": {
"meta": _meta("趋势追踪", "A", "每周", "中低", "多周期行情", 180, 20, 12, -7),
"universe": {"exclude_st": True, "listed_days_min": 365},
"filters": [
{"field": "weekly_trend_signal", "op": "==", "value": 1},
{"field": "daily_buy_trigger", "op": "==", "value": 1},
{"field": "weekly_amount_trend", "op": "==", "value": 1},
],
"score": [
{"field": "ma20_slope_5d", "weight": 0.35, "direction": "desc"},
{"field": "relative_strength", "weight": 0.35, "direction": "desc"},
{"field": "amount_billion", "weight": 0.30, "direction": "desc"},
],
"limit": 20,
"min_score": 0.52,
},
},
]
ADVANCED_CURATED_STRATEGIES.extend(
[
{
"name": "空间板",
"description": "识别当日新晋市场最高板,并要求所属方向具备足够的涨停支撑。",
"regimes": ["repair", "fermentation"],
"formula": {
"meta": _meta("连板接力", "B+", "每日", "很高", "涨停结构", 80, 3, 8, -6),
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "is_market_height", "op": "==", "value": 1},
{"field": "new_space_board", "op": "==", "value": 1},
{"field": "sector_limit_count", "op": ">=", "value": 3},
],
"score": [
{"field": "limit_streak", "weight": 0.50, "direction": "desc"},
{"field": "sector_limit_count", "weight": 0.30, "direction": "desc"},
{"field": "amount_billion", "weight": 0.20, "direction": "desc"},
],
"limit": 5,
"min_score": 0.45,
},
},
{
"name": "龙头首阴",
"description": "筛选三板以上强势股断板后的首次缩量阴线,并结合板块强度观察承接质量。",
"regimes": ["fermentation", "climax"],
"formula": {
"meta": _meta("低吸反核", "B", "每日", "很高", "涨停结构", 80, 5, 8, -6),
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "max_continuous_board_10d", "op": ">=", "value": 3},
{"field": "dragon_first_yin", "op": "==", "value": 1},
{"field": "yin_day_pct", "op": ">=", "value": -7},
{"field": "vol_vs_previous", "op": "<=", "value": 0.8},
],
"score": [
{"field": "max_continuous_board_10d", "weight": 0.45, "direction": "desc"},
{"field": "vol_vs_previous", "weight": 0.30, "direction": "asc"},
{"field": "sector_strength", "weight": 0.25, "direction": "desc"},
],
"limit": 5,
"min_score": 0.48,
},
},
{
"name": "断板反包",
"description": "连板断板后1至3日内,以涨停收复断板高点和量能确认N字反包。",
"regimes": ["repair", "fermentation"],
"formula": {
"meta": _meta("低吸反核", "B+", "每日", "", "涨停结构", 80, 3, 8, -6),
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "broken_reversal", "op": "==", "value": 1},
{"field": "days_since_broken", "op": "between", "value": [1, 3]},
{"field": "close_above_broken_high", "op": "==", "value": 1},
{"field": "vol_vs_broken_day", "op": ">=", "value": 1},
],
"score": [
{"field": "days_since_broken", "weight": 0.35, "direction": "asc"},
{"field": "vol_vs_broken_day", "weight": 0.35, "direction": "desc"},
{"field": "sector_strength", "weight": 0.30, "direction": "desc"},
],
"limit": 5,
"min_score": 0.46,
},
},
{
"name": "核按钮反核",
"description": "近5日强势股盘中深水急杀后收回,并以长下影和非放量结构确认承接。",
"regimes": ["repair", "fermentation"],
"formula": {
"meta": _meta("低吸反核", "B+", "每日", "很高", "历史行情", 80, 5, 8, -6),
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "recent_limit_up_5d", "op": ">=", "value": 1},
{"field": "intraday_min_pct", "op": "<=", "value": -7},
{"field": "pct_chg", "op": ">=", "value": -3},
{"field": "lower_shadow_ratio", "op": ">=", "value": 2},
{"field": "vol_vs_previous", "op": "<=", "value": 1.1},
],
"score": [
{"field": "lower_shadow_ratio", "weight": 0.42, "direction": "desc"},
{"field": "intraday_min_pct", "weight": 0.30, "direction": "asc"},
{"field": "sector_strength", "weight": 0.28, "direction": "desc"},
],
"limit": 5,
"min_score": 0.48,
},
},
]
)
ADVANCED_CURATED_STRATEGIES.extend(
[
{
"name": "景气-趋势-拥挤三维行业打分",
"description": "以行业财务景气、价格趋势和交易拥挤度合成行业得分,再选取行业内动量与成交承载靠前的公司。",
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
"formula": {
"meta": _meta(
"行业轮动", "A-", "双周", "", "行业、财务与交易拥挤", 80, 20, 12, -7,
requires_fundamental=True,
),
"universe": {"exclude_st": True, "listed_days_min": 180},
"filters": [
{"field": "sector_composite_score", "op": ">=", "value": 0.58},
{"field": "sector_crowding_rank", "op": "<=", "value": 0.90},
{"field": "sector_stock_momentum_rank", "op": ">=", "value": 0.50},
{"field": "amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "sector_composite_score", "weight": 0.55, "direction": "desc"},
{"field": "sector_stock_momentum_rank", "weight": 0.25, "direction": "desc"},
{"field": "sector_crowding_rank", "weight": 0.20, "direction": "asc"},
],
"limit": 12,
"min_score": 0.50,
},
},
{
"name": "大小盘/成长价值风格切换(元策略)",
"description": "比较大小盘与成长价值组合近20日相对表现,动态选择当前占优风格中的匹配标的。",
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
"formula": {
"meta": _meta(
"元策略", "A-", "每周", "中低", "行情、估值与财务", 80, 20, 12, -7,
requires_fundamental=True, requires_valuation=True,
),
"universe": {"exclude_st": True, "listed_days_min": 250},
"filters": [
{"field": "style_fit_score", "op": ">=", "value": 0.65},
{"field": "amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "style_fit_score", "weight": 0.70, "direction": "desc"},
{"field": "relative_strength", "weight": 0.30, "direction": "desc"},
],
"limit": 20,
"min_score": 0.52,
},
},
{
"name": "业绩超预期漂移(SUE/PEAD)",
"description": "以业绩预告和业绩快报的同报告期差异识别超预期事件,并限定在公告后的首个交易窗口。",
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
"formula": {
"meta": _meta(
"业绩事件", "A-", "事件驱动", "", "业绩预告与快报", 80, 20, 12, -7,
requires_earnings_events=True,
),
"universe": {"exclude_st": True, "listed_days_min": 180},
"filters": [
{"field": "earnings_surprise_pct", "op": ">=", "value": 10},
{"field": "revenue_yoy", "op": ">", "value": 0},
{"field": "earnings_event_quality", "op": "==", "value": 1},
{"field": "earnings_days_since_announce", "op": "between", "value": [1, 5]},
],
"score": [
{"field": "earnings_surprise_pct", "weight": 0.60, "direction": "desc"},
{"field": "relative_strength", "weight": 0.25, "direction": "desc"},
{"field": "amount_billion", "weight": 0.15, "direction": "desc"},
],
"limit": 15,
"min_score": 0.50,
},
},
{
"name": "多因子综合打分(IC动态加权)",
"description": "将价值、成长、质量、动量和交易情绪标准化,并按近期横截面有效性动态合成综合分。",
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
"formula": {
"meta": _meta(
"多因子", "A-", "每周", "", "行情、估值与财务", 260, 20, 12, -7,
requires_fundamental=True, requires_valuation=True,
),
"universe": {"exclude_st": True, "listed_days_min": 250},
"filters": [
{"field": "multi_factor_composite", "op": ">=", "value": 0.65},
{"field": "financial_risk", "op": "==", "value": 0},
{"field": "amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "multi_factor_composite", "weight": 0.75, "direction": "desc"},
{"field": "relative_strength", "weight": 0.15, "direction": "desc"},
{"field": "amount_billion", "weight": 0.10, "direction": "desc"},
],
"limit": 30,
"min_score": 0.55,
},
},
{
"name": "热度突增潜伏(另类数据)",
"description": "从同花顺和东方财富人气榜中寻找排名快速跃升、但价格尚未明显兑现的观察候选。",
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
"formula": {
"meta": _meta(
"热度观察", "B+", "每日", "", "人气榜与行情", 80, 10, 10, -7,
requires_popularity=True, backtestable=False,
),
"universe": {"exclude_st": True, "listed_days_min": 120},
"filters": [
{"field": "popularity_score", "op": ">=", "value": 15},
{"field": "return_10d", "op": "<=", "value": 5},
{"field": "recent_limit_up_5d", "op": "==", "value": 0},
{"field": "amount_billion", "op": ">=", "value": 0.5},
],
"score": [
{"field": "popularity_score", "weight": 0.50, "direction": "desc"},
{"field": "popularity_rank_change", "weight": 0.25, "direction": "desc"},
{"field": "popularity_dual_source", "weight": 0.10, "direction": "desc"},
{"field": "amount_billion", "weight": 0.15, "direction": "desc"},
],
"limit": 10,
"min_score": 0.48,
},
},
{
"name": "机构榜溢价",
"description": "筛选龙虎榜机构专用席位低位净买入的公司,并以席位数量和成交承载确认信号。",
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
"formula": {
"meta": _meta(
"资金席位", "B+", "每日", "中高", "龙虎榜机构席位", 80, 10, 10, -7,
requires_institutions=True,
),
"universe": {"exclude_st": True, "listed_days_min": 180},
"filters": [
{"field": "institution_net_buy_million", "op": ">=", "value": 30},
{"field": "institution_seat_count", "op": ">=", "value": 1},
{"field": "return_60d", "op": "<=", "value": 30},
{"field": "previous_limit_streak", "op": "<=", "value": 2},
],
"score": [
{"field": "institution_net_buy_million", "weight": 0.55, "direction": "desc"},
{"field": "institution_seat_count", "weight": 0.15, "direction": "desc"},
{"field": "relative_position_60", "weight": 0.20, "direction": "asc"},
{"field": "amount_billion", "weight": 0.10, "direction": "desc"},
],
"limit": 10,
"min_score": 0.48,
},
},
]
)
ADVANCED_CURATED_STRATEGIES.extend(
[
{
"name": "行业动量轮动",
"description": "选择20日涨幅居前的行业,并在行业内部保留趋势与成交承载更强的前排公司。",
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
"formula": {
"meta": _meta("行业轮动", "A-", "双周", "", "行业与历史行情", 80, 20, 12, -7),
"universe": {"exclude_st": True, "listed_days_min": 180},
"filters": [
{"field": "sector_momentum_rank", "op": ">=", "value": 0.90},
{"field": "sector_stock_momentum_rank", "op": ">=", "value": 0.80},
{"field": "amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "sector_return_20d", "weight": 0.38, "direction": "desc"},
{"field": "return_20d", "weight": 0.32, "direction": "desc"},
{"field": "total_mv_billion", "weight": 0.18, "direction": "desc"},
{"field": "amount_billion", "weight": 0.12, "direction": "desc"},
],
"limit": 12,
"min_score": 0.48,
},
},
{
"name": "主力资金行业流入",
"description": "寻找近5日主力资金持续净流入、行业涨幅尚未充分兑现的板块前排。",
"regimes": ["ice", "repair", "fermentation", "climax", "divergence", "retreat"],
"formula": {
"meta": _meta(
"行业轮动", "B+", "每周", "中高", "行业与资金流", 80, 10, 10, -7,
requires_moneyflow_history=True,
),
"universe": {"exclude_st": True, "listed_days_min": 180},
"filters": [
{"field": "sector_flow_rank", "op": ">=", "value": 0.85},
{"field": "sector_net_flow_5d_million", "op": ">", "value": 0},
{"field": "sector_return_5d", "op": "<=", "value": 8},
{"field": "flow_to_circ_mv_5d", "op": ">", "value": 0},
{"field": "amount_billion", "op": ">=", "value": 1},
],
"score": [
{"field": "flow_to_circ_mv_5d", "weight": 0.42, "direction": "desc"},
{"field": "sector_net_flow_5d_million", "weight": 0.30, "direction": "desc"},
{"field": "sector_return_5d", "weight": 0.16, "direction": "asc"},
{"field": "amount_billion", "weight": 0.12, "direction": "desc"},
],
"limit": 15,
"min_score": 0.48,
},
},
]
)