This commit is contained in:
2026-09-29 22:48:41 +08:00
parent a490fdc110
commit 4a27e35ff4
13 changed files with 719 additions and 15 deletions
+3
View File
@@ -5,6 +5,7 @@
market.py /api/market/*(总览/打板/概念板块/指数 K 线与详情)
backtest.py /api/backtest + /api/backtest/event
screener.py /api/screener/*(选股/历史/同步/个股预览)
signals.py /api/signals*(买卖点实验室:方案/分析/扫描/评估)
user.py /api/preferences + /api/watchlist* + /api/trades*
_deps.py 共享件:JSON 直返缓存、复权换算、行转 Bar、共享常量与 SQL
@@ -18,6 +19,7 @@ from .backtest import router as backtest_router
from .etfs import router as etfs_router
from .market import router as market_router
from .screener import router as screener_router
from .signals import router as signals_router
from .stocks import router as stocks_router
from .user import router as user_router
@@ -27,4 +29,5 @@ router.include_router(etfs_router)
router.include_router(market_router)
router.include_router(backtest_router)
router.include_router(screener_router)
router.include_router(signals_router)
router.include_router(user_router)
+6 -2
View File
@@ -135,12 +135,16 @@ def _scan_batch(
if not candle_rows:
return trades
bars = pd.DataFrame(
candle_rows, columns=["symbol", "ts", "open", "high", "low", "close"]
candle_rows, columns=["symbol", "ts", "open", "high", "low", "close", "volume", "turnover"]
)
for symbol, g in bars.groupby("symbol", sort=False):
if len(g) < 30:
continue
g = g.reset_index(drop=True)
# pct_chg / amplitude 是派生列(candles 无现成涨跌幅/振幅):按股内环比补算,
# 与选股引擎 _load_bars 同口径——否则「日涨跌幅/当日振幅」条件进事件回测直接 KeyError
g["pct_chg"] = g["close"].pct_change() * 100
g["amplitude"] = (g["high"] - g["low"]) / g["close"].shift(1) * 100
ts_code_l = code_by_symbol[symbol]
cache: dict = {"_families": set()}
mask = _signal_mask(g, spec, cache)
@@ -243,7 +247,7 @@ async def run_event_backtest(
code_by_symbol = {sym: code for code, sym in batch}
candle_rows = (await session.execute(
select(Candle.symbol, Candle.ts, Candle.open, Candle.high,
Candle.low, Candle.close)
Candle.low, Candle.close, Candle.volume, Candle.turnover)
.where(and_(Candle.timeframe == "1d",
Candle.symbol.in_(symbols),
Candle.ts >= buffer_ts, Candle.ts <= end_ts))
+9
View File
@@ -56,6 +56,15 @@ def ma(close: pd.Series, period: int) -> pd.Series:
return close.rolling(period, min_periods=1).mean()
def vol_ratio(volume: pd.Series, period: int = 5) -> pd.Series:
"""量比(通达信口径):当日成交量 / 前 N 日均量(不含当日,即 REF(MA(VOL,N),1))。
>2 约=倍量、<0.7 约=显著缩量;首根无前值、均量为 0(长期停牌)时为 NaN。
"""
base = volume.rolling(period, min_periods=1).mean().shift(1)
return volume / base.replace(0, np.nan)
def ema2(close: pd.Series, span: int = 10) -> pd.Series:
"""知行短期趋势线:EMA(EMA(C, span), span)。"""
return ema(ema(close, span), span)
+38
View File
@@ -362,6 +362,44 @@ class ScreenerQuery(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, index=True)
class SignalPlan(Base):
"""买卖点实验室方案(一股一方案:手动标注/条件扫描的买卖点集合与指标条件)。
conditions_json 存 IndicatorCondition[].model_dump_json()(结构化条件可直接
推送到 /api/backtest/event 做全市场事件回测);点集在 signal_points 子表。
"""
__tablename__ = "signal_plans"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), index=True)
ts_code: Mapped[str] = mapped_column(String(12), index=True)
name: Mapped[str] = mapped_column(String(64))
note: Mapped[str | None] = mapped_column(Text)
conditions_json: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, onupdate=_utcnow)
__table_args__ = (
Index("ix_signal_plans_user_code", "user_id", "ts_code"),
)
class SignalPoint(Base):
"""方案内的买卖点(manual=K线图手动点击标注;scan=条件扫描命中后勾选保留)。"""
__tablename__ = "signal_points"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
plan_id: Mapped[int] = mapped_column(Integer, ForeignKey("signal_plans.id", ondelete="CASCADE"), index=True)
kind: Mapped[str] = mapped_column(String(4)) # buy | sell
trade_date: Mapped[date] = mapped_column(Date)
source: Mapped[str] = mapped_column(String(6)) # manual | scan
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow)
__table_args__ = (
UniqueConstraint("plan_id", "kind", "trade_date", name="uq_signal_point_plan_kind_date"),
)
class TradeCalendar(Base):
"""交易日历缓存(trade_cal 拉取一次宽范围后本地维护,低积分 token 限频 1 次/小时)。"""
__tablename__ = "trade_calendar"
+147
View File
@@ -710,3 +710,150 @@ class IndexWeightsResponse(BaseModel):
total: int # 成分股总数(返回 items 为按权重降序的子集)
items: list[IndexWeightItemOut] = []
# ---------- 买卖点实验室(/signals:手动标注共同点分析 + 条件扫描) ----------
class SignalPlanCreate(BaseModel):
ts_code: str = Field(min_length=6, max_length=12)
name: str = Field(default="", max_length=64) # 空则后端默认「{code} 买点方案」
class SignalPlanUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=64)
note: str | None = None
conditions: list[IndicatorCondition] | None = None # None=不改;[] = 清空
class SignalPointIn(BaseModel):
kind: Literal["buy", "sell"]
trade_date: date
source: Literal["manual", "scan"] = "manual"
class SignalPointOut(BaseModel):
kind: str # buy | sell
trade_date: date
source: str # manual | scan
model_config = {"from_attributes": True}
class SignalPlanOut(BaseModel):
id: int
ts_code: str
name: str
note: str | None = None
conditions: list[IndicatorCondition] = Field(default_factory=list)
points: list[SignalPointOut] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class SignalPlanListResponse(BaseModel):
items: list[SignalPlanOut]
class SignalsAnalyzeRequest(BaseModel):
ts_code: str = Field(min_length=6, max_length=12)
buy_dates: list[date] = Field(min_length=1, max_length=200)
class FeatureFindingOut(BaseModel):
"""共同特征发现:特征在买点日的命中率 vs 全期基线比例(纯算法统计)。"""
key: str
label: str
samples_hit: int # 命中特征的买点数
samples: int # 有效买点数
hit_rate: float # 买点命中率 %(0-100)
base_rate: float # 全期基线比例 %(0-100)
lift: float # 命中率 / 基线
mappable: bool # 是否可转成 IndicatorCondition(进扫描/回测)
condition: IndicatorCondition | None = None
class ValueSummaryOut(BaseModel):
"""连续值摘要:买点日中位数 vs 全期中位数(直觉对照用)。"""
key: str
label: str
at_points: float | None
baseline: float | None
class SignalsAnalyzeResponse(BaseModel):
ts_code: str
samples: int # 有效买点数(落在该股交易日上)
total_bars: int
findings: list[FeatureFindingOut]
summaries: list[ValueSummaryOut]
class WindowStatOut(BaseModel):
window: int # 持有 N 交易日
samples: int
mean: float # 平均收益 %
median: float
win_rate: float # 收益>0 占比 %
p10: float
p90: float
class EvalPointOut(BaseModel):
"""单个买点的评估:未来 N 交易日收益矩阵(复权校正)。"""
date: date # 买点(信号)日
entry_date: date | None = None # 实际入场日(默认信号次日)
entry_price: float | None = None
rets: dict[str, float | None] = Field(default_factory=dict) # "5" -> 收益%(越界/缺数据 None)
max_gain: dict[str, float | None] = Field(default_factory=dict) # 窗口内最大涨幅 %
max_dd: dict[str, float | None] = Field(default_factory=dict) # 窗口内最大回撤 %
class EvalPairOut(BaseModel):
"""手动配对(买点 -> 其后最近卖点)的一笔评估。"""
buy_date: date
sell_date: date
entry_date: date | None = None
entry_price: float | None = None
exit_price: float | None = None
ret_pct: float | None = None # 复权校正收益 %
class PairStatsOut(BaseModel):
samples: int
mean: float
median: float
win_rate: float
max: float
min: float
class SignalsEvaluateResult(BaseModel):
samples: int
points: list[EvalPointOut] = Field(default_factory=list)
stats: list[WindowStatOut] = Field(default_factory=list)
pairs: list[EvalPairOut] = Field(default_factory=list)
pair_stats: PairStatsOut | None = None
class SignalsScanRequest(BaseModel):
ts_code: str = Field(min_length=6, max_length=12)
conditions: list[IndicatorCondition] = Field(min_length=1, max_length=10)
windows: list[int] = Field(default_factory=lambda: [1, 3, 5, 10, 20, 60])
start: date | None = None
end: date | None = None
class SignalsScanResponse(BaseModel):
ts_code: str
dates: list[date] # 命中信号日(升序,已丢弃指标预热区)
total: int
evaluate: SignalsEvaluateResult
class SignalsEvaluateRequest(BaseModel):
ts_code: str = Field(min_length=6, max_length=12)
buy_dates: list[date] = Field(min_length=1, max_length=500)
sell_dates: list[date] = Field(default_factory=list, max_length=500)
windows: list[int] = Field(default_factory=lambda: [1, 3, 5, 10, 20, 60])
# 进出场时机默认与 /api/backtest/event 对齐:信号次日入场、到期收盘卖出
entry_timing: Literal["next_open", "next_close"] = "next_open"
exit_timing: Literal["close", "open"] = "close"
+26 -4
View File
@@ -56,6 +56,10 @@ def _ma(df: pd.DataFrame, p: dict) -> dict[str, pd.Series]:
return {"ma": ind.ma(df["close"], period=int(p["period"]))}
def _vol_ratio(df: pd.DataFrame, p: dict) -> dict[str, pd.Series]:
return {"vol_ratio": ind.vol_ratio(df["volume"], period=int(p["period"]))}
def _boll(df: pd.DataFrame, p: dict) -> dict[str, pd.Series]:
out = ind.bollinger(df["close"], period=int(p["period"]), std=float(p["std"]))
return {"boll_upper": out["upper"], "boll_mid": out["mid"], "boll_lower": out["lower"]}
@@ -75,6 +79,16 @@ def _pct_chg(df: pd.DataFrame, p: dict) -> dict[str, pd.Series]:
return {"pct_chg": df["pct_chg"]}
def _amplitude(df: pd.DataFrame, p: dict) -> dict[str, pd.Series]:
# 当日振幅 %(通达信口径:(最高-最低)/昨收×100),依赖派生列 amplitude
return {"amplitude": df["amplitude"]}
def _turnover_rate(df: pd.DataFrame, p: dict) -> dict[str, pd.Series]:
# 逐日换手率 %(candles.turnover 直读;ETF/缺数据日为 NaN,条件按 False 处理)
return {"turnover_rate": df["turnover"]}
@dataclass(frozen=True)
class FamilyDef:
label: str # 族中文标签(条件回显/表头)
@@ -89,20 +103,24 @@ FAMILIES: dict[str, FamilyDef] = {
"macd": FamilyDef("MACD", ("fast", "slow", "signal"), {"fast": 12, "slow": 26, "signal": 9}, 60, _macd),
"rsi": FamilyDef("RSI", ("period",), {"period": 14}, 25, _rsi),
"ma": FamilyDef("MA", ("period",), {"period": 20}, 25, _ma),
"vol_ratio": FamilyDef("量比", ("period",), {"period": 5}, 10, _vol_ratio),
"boll": FamilyDef("BOLL", ("period", "std"), {"period": 20, "std": 2}, 25, _boll),
"zhixing": FamilyDef("知行", ("m1", "m2", "m3", "m4"),
{"m1": 14, "m2": 28, "m3": 57, "m4": 114}, 114, _zhixing),
"close": FamilyDef("收盘价", (), {}, 1, _close),
"pct_chg": FamilyDef("日涨跌幅", (), {}, 1, _pct_chg),
"amplitude": FamilyDef("当日振幅", (), {}, 2, _amplitude),
"turnover_rate": FamilyDef("换手率", (), {}, 1, _turnover_rate),
}
INDICATOR_FAMILY: dict[str, str] = {
"kdj_k": "kdj", "kdj_d": "kdj", "kdj_j": "kdj",
"macd_dif": "macd", "macd_dea": "macd", "macd_hist": "macd",
"rsi": "rsi", "ma": "ma",
"rsi": "rsi", "ma": "ma", "vol_ratio": "vol_ratio",
"boll_upper": "boll", "boll_mid": "boll", "boll_lower": "boll",
"zhixing_dkx": "zhixing", "zhixing_trend": "zhixing",
"close": "close", "pct_chg": "pct_chg",
"amplitude": "amplitude", "turnover_rate": "turnover_rate",
}
_IND_SUFFIX = {"kdj_k": "K", "kdj_d": "D", "kdj_j": "J",
@@ -317,23 +335,27 @@ async def _load_bars(session: AsyncSession, ts_codes: list[str],
"""
symbols = [plain_code(t) for t in ts_codes]
stmt = select(
Candle.symbol, Candle.ts, Candle.open, Candle.high, Candle.low, Candle.close,
Candle.symbol, Candle.ts, Candle.open, Candle.high, Candle.low, Candle.close, Candle.volume,
Candle.turnover,
).where(Candle.timeframe == "1d", Candle.ts >= min_date, Candle.ts <= target_date)
if len(symbols) <= 2000:
stmt = stmt.where(Candle.symbol.in_(set(symbols)))
rows = (await session.execute(stmt)).all()
df = pd.DataFrame(rows, columns=["symbol", "trade_date", "open", "high", "low", "close"])
df = pd.DataFrame(rows, columns=["symbol", "trade_date", "open", "high", "low", "close", "volume", "turnover"])
if not df.empty and len(symbols) > 2000:
df = df[df["symbol"].isin(set(symbols))]
df = df.sort_values(["symbol", "trade_date"]).reset_index(drop=True)
if df.empty:
df["ts_code"] = pd.Series(dtype=object)
df["pct_chg"] = pd.Series(dtype=object)
df["amplitude"] = pd.Series(dtype=object)
else:
prev_close = df.groupby("symbol")["close"].shift(1)
df["pct_chg"] = df.groupby("symbol")["close"].pct_change() * 100
df["amplitude"] = (df["high"] - df["low"]) / prev_close * 100
ts_map = {plain_code(t): t for t in ts_codes}
df["ts_code"] = df["symbol"].map(ts_map)
return df[["ts_code", "trade_date", "open", "high", "low", "close", "pct_chg"]]
return df[["ts_code", "trade_date", "open", "high", "low", "close", "volume", "turnover", "pct_chg", "amplitude"]]
def _f(v) -> float | None:
+4 -4
View File
@@ -22,8 +22,8 @@ SYSTEM_PROMPT = """你是 A 股选股条件解析器。把用户的自然语言
(indicator 与 snapshot 至少一个非空;用户没有提到的条件不要编造)
【indicator 数组】技术指标条件,元素字段:
- "indicator": 指标名,白名单:kdj_k / kdj_d / kdj_j(KDJ 的 K/D/J 值)、rsi、macd_dif / macd_dea / macd_hist(MACD 的 DIF/DEA/柱)、ma(收盘价均线)、boll_upper / boll_mid / boll_lower(布林轨道)、zhixing_dkx(知行多空线,四条收盘价均线的均值)、zhixing_trend(知行短期趋势线)、close(收盘价)、pct_chg(日涨跌幅%)
- "params": 指标参数(可选),默认:KDJ {"n":9,"m1":3,"m2":3};RSI {"period":14};MACD {"fast":12,"slow":26,"signal":9};MA {"period":20};BOLL {"period":20,"std":2};知行多空线 {"m1":14,"m2":28,"m3":57,"m4":114};知行趋势线无参数(固定算法),不要给它填 params
- "indicator": 指标名,白名单:kdj_k / kdj_d / kdj_j(KDJ 的 K/D/J 值)、rsi、macd_dif / macd_dea / macd_hist(MACD 的 DIF/DEA/柱)、ma(收盘价均线)、vol_ratio(量比,当日成交量/前5日均量)、boll_upper / boll_mid / boll_lower(布林轨道)、zhixing_dkx(知行多空线,四条收盘价均线的均值)、zhixing_trend(知行短期趋势线)、close(收盘价)、pct_chg(日涨跌幅%)、amplitude(当日振幅%,(最高-最低)/昨收)、turnover_rate(换手率%,逐日历史值)
- "params": 指标参数(可选),默认:KDJ {"n":9,"m1":3,"m2":3};RSI {"period":14};MACD {"fast":12,"slow":26,"signal":9};MA {"period":20};量比 {"period":5};BOLL {"period":20,"std":2};知行多空线 {"m1":14,"m2":28,"m3":57,"m4":114};知行趋势线/振幅/换手率无参数,不要给它们填 params
- "op": "gt" | "ge" | "lt" | "le" | "between"
- "value": 比较数值(between 时为下界),"value2": between 上界
- "value_indicator": 可选。指标与指标比较时填另一指标名(同白名单),如 "DIF大于DEA" -> indicator=macd_dif, op=gt, value_indicator=macd_dea, value=0;"股价在布林带下轨之下" -> indicator=close, op=lt, value_indicator=boll_lower, value=0
@@ -171,8 +171,8 @@ EVENT_SYSTEM_PROMPT = """你是 A 股事件回测参数解析器。用户描述
{"entry": {"indicator": [...], "snapshot": [], "exclude_st": true, "exclude_delisted": true, "exclude_bj": true}, "entry_timing": "next_open", "holding_days": 3, "exit_timing": "close"}
【entry.indicator 数组】入场信号条件(必填,至少 1 条),元素字段与白名单:
- "indicator": kdj_k / kdj_d / kdj_j(KDJ 的 K/D/J 值)、rsi、macd_dif / macd_dea / macd_hist(MACD 的 DIF/DEA/柱)、ma(收盘价均线)、boll_upper / boll_mid / boll_lower(布林轨道)、zhixing_dkx(知行多空线)、zhixing_trend(知行短期趋势线)、close(收盘价)、pct_chg(日涨跌幅%)
- "params": 指标参数(可选),默认:KDJ {"n":9,"m1":3,"m2":3};RSI {"period":14};MACD {"fast":12,"slow":26,"signal":9};MA {"period":20};BOLL {"period":20,"std":2};知行多空线 {"m1":14,"m2":28,"m3":57,"m4":114};知行趋势线无参数(固定算法),不要给它填 params
- "indicator": kdj_k / kdj_d / kdj_j(KDJ 的 K/D/J 值)、rsi、macd_dif / macd_dea / macd_hist(MACD 的 DIF/DEA/柱)、ma(收盘价均线)、vol_ratio(量比,当日成交量/前5日均量)、boll_upper / boll_mid / boll_lower(布林轨道)、zhixing_dkx(知行多空线)、zhixing_trend(知行短期趋势线)、close(收盘价)、pct_chg(日涨跌幅%)、amplitude(当日振幅%,(最高-最低)/昨收)、turnover_rate(换手率%,逐日历史值)
- "params": 指标参数(可选),默认:KDJ {"n":9,"m1":3,"m2":3};RSI {"period":14};MACD {"fast":12,"slow":26,"signal":9};MA {"period":20};量比 {"period":5};BOLL {"period":20,"std":2};知行多空线 {"m1":14,"m2":28,"m3":57,"m4":114};知行趋势线/振幅/换手率无参数,不要给它们填 params
- "op": "gt" | "ge" | "lt" | "le" | "between";"value"(between 时为下界)、"value2"(上界)
- "value_indicator": 指标与指标比较时填另一指标名(同白名单),如 "DIF 大于 DEA" -> indicator=macd_dif, op=gt, value_indicator=macd_dea, value=0
- "value_params": 比较对象指标参数不同时指定,如 "MA5 上穿 MA20" -> indicator=ma, params={"period":5}, op=gt, value_indicator=ma, value_params={"period":20}, value=0