Files
stock_cursor_v0/backend/smart_selector.py
2026-06-14 11:54:45 +08:00

391 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""智能选股引擎 — 可视化选股器、策略保存、回测验证。
功能:
1. 多条件组合选股
2. 选股策略保存/加载
3. 选股结果历史回测
4. 选股结果对比(新入选/退出)
5. 条件预警
"""
import datetime as dt
import json
from typing import Dict, List, Any, Optional
from sqlalchemy import select, and_, or_, func
import numpy as np
from db import get_session
from models import StockMetric, DailyQuote, Security
class Condition:
"""选股条件"""
def __init__(self, field: str, operator: str, value: Any):
self.field = field
self.operator = operator
self.value = value
def to_dict(self):
return {
"field": self.field,
"operator": self.operator,
"value": self.value
}
@classmethod
def from_dict(cls, data: Dict):
return cls(data["field"], data["operator"], data["value"])
def to_sql(self, model):
"""转换为 SQLAlchemy 查询条件"""
field = getattr(model, self.field, None)
if field is None:
return None
op = self.operator
val = self.value
if op == "==":
return field == val
elif op == ">":
return field > val
elif op == ">=":
return field >= val
elif op == "<":
return field < val
elif op == "<=":
return field <= val
elif op == "between":
return and_(field >= val[0], field <= val[1])
elif op == "in":
return field.in_(val)
else:
return None
class Strategy:
"""选股策略"""
def __init__(self, name: str, description: str = ""):
self.name = name
self.description = description
self.conditions: List[Condition] = []
self.logic = "and" # and / or
def add_condition(self, field: str, operator: str, value: Any):
"""添加条件"""
self.conditions.append(Condition(field, operator, value))
def to_dict(self):
return {
"name": self.name,
"description": self.description,
"logic": self.logic,
"conditions": [c.to_dict() for c in self.conditions]
}
@classmethod
def from_dict(cls, data: Dict):
strategy = cls(data["name"], data.get("description", ""))
strategy.logic = data.get("logic", "and")
for cond in data.get("conditions", []):
strategy.conditions.append(Condition.from_dict(cond))
return strategy
def to_json(self) -> str:
"""序列化为JSON"""
return json.dumps(self.to_dict(), ensure_ascii=False)
@classmethod
def from_json(cls, json_str: str):
"""从JSON反序列化"""
return cls.from_dict(json.loads(json_str))
def run_selector(strategy: Strategy, date: Optional[dt.date] = None) -> Dict[str, Any]:
"""执行选股
Args:
strategy: 选股策略
date: 选股日期None表示最新
Returns:
选股结果
"""
with get_session() as s:
# 确定日期
if date is None:
date = s.execute(select(func.max(StockMetric.date))).scalar()
if not date:
return {"ok": False, "msg": "暂无数据"}
# 构建查询
query = select(StockMetric).where(StockMetric.date == date)
# 应用条件
sql_conditions = []
for cond in strategy.conditions:
sql_cond = cond.to_sql(StockMetric)
if sql_cond is not None:
sql_conditions.append(sql_cond)
if sql_conditions:
if strategy.logic == "and":
query = query.where(and_(*sql_conditions))
else: # or
query = query.where(or_(*sql_conditions))
# 执行查询
rows = s.execute(query).scalars().all()
# 格式化结果
results = [{
"code": r.code,
"name": r.name,
"close": round(r.close, 2),
"pct": round(r.pct, 2),
"ret5": round(r.ret5, 2),
"ret20": round(r.ret20, 2),
"vol_ratio": round(r.vol_ratio, 2),
"rsi14": round(r.rsi14, 2),
"pos60": round(r.pos60 * 100, 1),
"amount": round(r.amount, 2),
"ma_bull": r.ma_bull,
"macd_gold": r.macd_gold
} for r in rows]
return {
"ok": True,
"date": date.isoformat(),
"strategy": strategy.name,
"count": len(results),
"results": results
}
def backtest_selector(strategy: Strategy, days: int = 60) -> Dict[str, Any]:
"""选股策略回测
Args:
strategy: 选股策略
days: 回测天数
Returns:
回测结果
"""
with get_session() as s:
# 获取最近N个交易日
latest_date = s.execute(select(func.max(StockMetric.date))).scalar()
if not latest_date:
return {"ok": False, "msg": "暂无数据"}
start_date = latest_date - dt.timedelta(days=days)
# 获取这段时间内的所有交易日
dates = s.execute(
select(StockMetric.date)
.where(StockMetric.date >= start_date)
.group_by(StockMetric.date)
.order_by(StockMetric.date)
).scalars().all()
if len(dates) < 5:
return {"ok": False, "msg": "数据不足"}
# 逐日选股并统计后续N日收益
daily_results = []
for i, date in enumerate(dates[:-5]): # 至少保留5日用于计算收益
# 执行选股
result = run_selector(strategy, date)
if not result["ok"] or not result["results"]:
continue
selected_codes = [r["code"] for r in result["results"]]
# 查询5日后的收益
future_date = dates[min(i + 5, len(dates) - 1)]
with get_session() as s:
# 获取选中股票5日后的表现
future_rows = s.execute(
select(DailyQuote.code, DailyQuote.close)
.where(
and_(
DailyQuote.code.in_(selected_codes),
DailyQuote.date == future_date
)
)
).all()
current_rows = s.execute(
select(DailyQuote.code, DailyQuote.close)
.where(
and_(
DailyQuote.code.in_(selected_codes),
DailyQuote.date == date
)
)
).all()
# 计算收益
current_prices = {r.code: float(r.close) for r in current_rows}
future_prices = {r.code: float(r.close) for r in future_rows}
returns = []
for code in selected_codes:
if code in current_prices and code in future_prices:
ret = (future_prices[code] / current_prices[code] - 1) * 100
returns.append(ret)
if returns:
daily_results.append({
"date": date.isoformat(),
"count": len(selected_codes),
"avg_return": round(np.mean(returns), 2),
"median_return": round(np.median(returns), 2),
"win_rate": round(sum(1 for r in returns if r > 0) / len(returns) * 100, 1),
"max_return": round(max(returns), 2),
"min_return": round(min(returns), 2)
})
if not daily_results:
return {"ok": False, "msg": "回测数据不足"}
# 汇总统计
avg_returns = [r["avg_return"] for r in daily_results]
win_rates = [r["win_rate"] for r in daily_results]
summary = {
"total_days": len(daily_results),
"avg_return": round(np.mean(avg_returns), 2),
"avg_win_rate": round(np.mean(win_rates), 1),
"best_day": max(daily_results, key=lambda x: x["avg_return"]),
"worst_day": min(daily_results, key=lambda x: x["avg_return"])
}
return {
"ok": True,
"strategy": strategy.name,
"days": days,
"summary": summary,
"daily": daily_results
}
def compare_results(date1: dt.date, date2: dt.date, strategy: Strategy) -> Dict[str, Any]:
"""对比两个日期的选股结果
Args:
date1: 日期1通常是昨日
date2: 日期2通常是今日
strategy: 选股策略
Returns:
对比结果
"""
result1 = run_selector(strategy, date1)
result2 = run_selector(strategy, date2)
if not result1["ok"] or not result2["ok"]:
return {"ok": False, "msg": "数据不足"}
codes1 = set(r["code"] for r in result1["results"])
codes2 = set(r["code"] for r in result2["results"])
# 新入选
new_in = codes2 - codes1
# 退出
dropped = codes1 - codes2
# 持续入选
continued = codes1 & codes2
# 获取详细信息
new_in_stocks = [r for r in result2["results"] if r["code"] in new_in]
dropped_stocks = [r for r in result1["results"] if r["code"] in dropped]
continued_stocks = [r for r in result2["results"] if r["code"] in continued]
return {
"ok": True,
"date1": date1.isoformat(),
"date2": date2.isoformat(),
"count1": len(codes1),
"count2": len(codes2),
"new_in": {
"count": len(new_in),
"stocks": new_in_stocks
},
"dropped": {
"count": len(dropped),
"stocks": dropped_stocks
},
"continued": {
"count": len(continued),
"stocks": continued_stocks
}
}
# 预设策略
PRESET_STRATEGIES = {
"momentum": Strategy("动量突破", "短期强势+放量"),
"value": Strategy("价值洼地", "超跌低位+基本面支撑"),
"growth": Strategy("成长加速", "持续上涨+量价齐升"),
"reversal": Strategy("反转抄底", "超跌企稳+技术反转")
}
# 动量突破
PRESET_STRATEGIES["momentum"].add_condition("ret5", ">", 10)
PRESET_STRATEGIES["momentum"].add_condition("vol_ratio", ">", 2)
PRESET_STRATEGIES["momentum"].add_condition("rsi14", "<", 80)
# 价值洼地
PRESET_STRATEGIES["value"].add_condition("pos60", "<", 0.3)
PRESET_STRATEGIES["value"].add_condition("pct", ">", 0)
PRESET_STRATEGIES["value"].add_condition("amount", ">", 5)
# 成长加速
PRESET_STRATEGIES["growth"].add_condition("ret20", ">", 15)
PRESET_STRATEGIES["growth"].add_condition("ma_bull", "==", True)
PRESET_STRATEGIES["growth"].add_condition("up_streak", ">=", 2)
# 反转抄底
PRESET_STRATEGIES["reversal"].add_condition("ret20", "<", -15)
PRESET_STRATEGIES["reversal"].add_condition("rsi14", "<", 30)
PRESET_STRATEGIES["reversal"].add_condition("pct", ">", 2)
def get_preset_strategies() -> List[Dict[str, Any]]:
"""获取预设策略列表"""
return [
{
"id": key,
"name": strategy.name,
"description": strategy.description,
"conditions_count": len(strategy.conditions)
}
for key, strategy in PRESET_STRATEGIES.items()
]
def get_available_fields() -> List[Dict[str, Any]]:
"""获取可用的选股字段"""
return [
{"field": "close", "name": "现价", "type": "number", "unit": ""},
{"field": "pct", "name": "涨跌幅", "type": "number", "unit": "%"},
{"field": "ret5", "name": "5日涨幅", "type": "number", "unit": "%"},
{"field": "ret20", "name": "20日涨幅", "type": "number", "unit": "%"},
{"field": "ret60", "name": "60日涨幅", "type": "number", "unit": "%"},
{"field": "ma5", "name": "MA5", "type": "number", "unit": ""},
{"field": "ma10", "name": "MA10", "type": "number", "unit": ""},
{"field": "ma20", "name": "MA20", "type": "number", "unit": ""},
{"field": "ma60", "name": "MA60", "type": "number", "unit": ""},
{"field": "vol_ratio", "name": "量比", "type": "number", "unit": ""},
{"field": "rsi14", "name": "RSI", "type": "number", "unit": ""},
{"field": "pos60", "name": "60日分位", "type": "number", "unit": ""},
{"field": "amount", "name": "成交额", "type": "number", "unit": "亿"},
{"field": "up_streak", "name": "连涨天数", "type": "number", "unit": ""},
{"field": "ma_bull", "name": "均线多头", "type": "boolean", "unit": ""},
{"field": "macd_gold", "name": "MACD金叉", "type": "boolean", "unit": ""}
]