diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index ecd99e7..179c384 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -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) diff --git a/backend/app/backtest/events.py b/backend/app/backtest/events.py index f230bd8..b142ac3 100644 --- a/backend/app/backtest/events.py +++ b/backend/app/backtest/events.py @@ -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)) diff --git a/backend/app/indicators.py b/backend/app/indicators.py index b2da9fb..7f0d613 100644 --- a/backend/app/indicators.py +++ b/backend/app/indicators.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py index 1cf4528..1d221be 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 010b54c..72dd9e3 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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" + diff --git a/backend/app/screener/engine.py b/backend/app/screener/engine.py index 99fb1c0..2d66e04 100644 --- a/backend/app/screener/engine.py +++ b/backend/app/screener/engine.py @@ -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: diff --git a/backend/app/screener/llm.py b/backend/app/screener/llm.py index 48f95ac..f02866c 100644 --- a/backend/app/screener/llm.py +++ b/backend/app/screener/llm.py @@ -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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5f8e2c8..79c48b9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -22,6 +22,17 @@ import type { ScreenerRunResponse, ScreenerSyncRequest, ScreenerSyncStatus, + SignalPlanCreate, + SignalPlanListResponse, + SignalPlanOut, + SignalPlanUpdate, + SignalPointIn, + SignalsAnalyzeRequest, + SignalsAnalyzeResponse, + SignalsEvaluateRequest, + SignalsEvaluateResult, + SignalsScanRequest, + SignalsScanResponse, StockCompanyInfo, StockDividendOut, StockFacets, @@ -439,3 +450,62 @@ export async function clearTrades(): Promise { if (!res.ok) throw new ApiError(await readError(res, `清空成交失败 (HTTP ${res.status})`), res.status); return (await res.json()) as TradesClearResponse; } + +// ---------- 买卖点实验室(方案 CRUD + 分析/扫描/评估) ---------- +export async function getSignalPlans(tsCode?: string): Promise { + const q = tsCode ? `?ts_code=${encodeURIComponent(tsCode)}` : ''; + const res = await apiFetch(`/api/signals/plans${q}`); + if (!res.ok) throw new ApiError(await readError(res, '获取方案列表失败'), res.status); + return (await res.json()) as SignalPlanListResponse; +} + +export async function createSignalPlan(req: SignalPlanCreate): Promise { + const res = await apiFetch('/api/signals/plans', { method: 'POST', body: JSON.stringify(req) }); + if (!res.ok) throw new ApiError(await readError(res, '新建方案失败'), res.status); + return (await res.json()) as SignalPlanOut; +} + +export async function getSignalPlan(id: number): Promise { + const res = await apiFetch(`/api/signals/plans/${id}`); + if (!res.ok) throw new ApiError(await readError(res, '获取方案失败'), res.status); + return (await res.json()) as SignalPlanOut; +} + +export async function patchSignalPlan(id: number, req: SignalPlanUpdate): Promise { + const res = await apiFetch(`/api/signals/plans/${id}`, { method: 'PATCH', body: JSON.stringify(req) }); + if (!res.ok) throw new ApiError(await readError(res, '更新方案失败'), res.status); + return (await res.json()) as SignalPlanOut; +} + +export async function deleteSignalPlan(id: number): Promise { + const res = await apiFetch(`/api/signals/plans/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new ApiError(await readError(res, '删除方案失败'), res.status); +} + +/** 买卖点全量替换(每次变更整包提交;并发下 last-write-wins) */ +export async function putSignalPlanPoints(id: number, points: SignalPointIn[]): Promise { + const res = await apiFetch(`/api/signals/plans/${id}/points`, { method: 'PUT', body: JSON.stringify(points) }); + if (!res.ok) throw new ApiError(await readError(res, '保存买卖点失败'), res.status); + return (await res.json()) as SignalPlanOut; +} + +/** 买点共同特征分析(纯算法统计)。单股全历史 pandas 计算,timeout 放宽到 2 分钟。 */ +export async function postSignalsAnalyze(req: SignalsAnalyzeRequest): Promise { + const res = await apiFetch('/api/signals/analyze', { method: 'POST', body: JSON.stringify(req) }); + if (!res.ok) throw new ApiError(await readError(res, '共同点分析失败'), res.status); + return (await res.json()) as SignalsAnalyzeResponse; +} + +/** 条件扫描:AND 条件全历史命中日 + 逐点收益矩阵。 */ +export async function postSignalsScan(req: SignalsScanRequest): Promise { + const res = await apiFetch('/api/signals/scan', { method: 'POST', body: JSON.stringify(req) }); + if (!res.ok) throw new ApiError(await readError(res, '条件扫描失败'), res.status); + return (await res.json()) as SignalsScanResponse; +} + +/** 手动买卖点收益评估(固定持有期窗口 + 手动配对)。 */ +export async function postSignalsEvaluate(req: SignalsEvaluateRequest): Promise { + const res = await apiFetch('/api/signals/evaluate', { method: 'POST', body: JSON.stringify(req) }); + if (!res.ok) throw new ApiError(await readError(res, '收益评估失败'), res.status); + return (await res.json()) as SignalsEvaluateResult; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b287ac6..4196019 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -635,3 +635,147 @@ export interface IndexWeights { total: number; items: IndexWeightItem[]; } + +// ---------- 买卖点实验室(镜像 app/schemas.py signals 段) ---------- +export interface SignalPlanCreate { + ts_code: string; + name?: string; // 空则后端默认「{code} 买点方案」 +} + +export interface SignalPlanUpdate { + name?: string | null; // null=不改 + note?: string | null; + conditions?: IndicatorCondition[] | null; // null=不改;[] = 清空 +} + +export interface SignalPointIn { + kind: 'buy' | 'sell'; + trade_date: string; // ISO YYYY-MM-DD + source?: 'manual' | 'scan'; +} + +export interface SignalPointOut { + kind: string; // buy | sell + trade_date: string; + source: string; // manual | scan +} + +export interface SignalPlanOut { + id: number; + ts_code: string; + name: string; + note?: string | null; + conditions: IndicatorCondition[]; + points: SignalPointOut[]; + created_at: string; + updated_at: string; +} + +export interface SignalPlanListResponse { + items: SignalPlanOut[]; +} + +/** 共同特征发现:特征在买点日的命中率 vs 全期基线比例(纯算法统计) */ +export interface FeatureFindingOut { + key: string; + label: string; + samples_hit: number; + samples: number; + hit_rate: number; // 买点命中率 %(0-100) + base_rate: number; // 全期基线比例 % + lift: number; // 命中率 / 基线 + mappable: boolean; // 是否可转成 IndicatorCondition(进扫描/回测) + condition?: IndicatorCondition | null; +} + +/** 连续值摘要:买点日中位数 vs 全期中位数 */ +export interface ValueSummaryOut { + key: string; + label: string; + at_points: number | null; + baseline: number | null; +} + +export interface SignalsAnalyzeRequest { + ts_code: string; + buy_dates: string[]; // ISO 日期,1-200 个 +} + +export interface SignalsAnalyzeResponse { + ts_code: string; + samples: number; // 有效买点数(落在该股交易日上) + total_bars: number; + findings: FeatureFindingOut[]; + summaries: ValueSummaryOut[]; +} + +export interface WindowStatOut { + window: number; // 持有 N 交易日 + samples: number; + mean: number; // 平均收益 % + median: number; + win_rate: number; // 收益>0 占比 % + p10: number; + p90: number; +} + +/** 单个买点的评估:未来 N 交易日收益矩阵(复权校正),键为窗口字符串 */ +export interface EvalPointOut { + date: string; // 买点(信号)日 + entry_date?: string | null; // 实际入场日(默认信号次日) + entry_price?: number | null; + rets: Record; // "5" -> 收益%(越界/缺数据 null) + max_gain: Record; // 窗口内最大涨幅 % + max_dd: Record; // 窗口内最大回撤 % +} + +/** 手动配对(买点 -> 其后最近卖点)的一笔评估 */ +export interface EvalPairOut { + buy_date: string; + sell_date: string; + entry_date?: string | null; + entry_price?: number | null; + exit_price?: number | null; + ret_pct?: number | null; // 复权校正收益 % +} + +export interface PairStatsOut { + samples: number; + mean: number; + median: number; + win_rate: number; + max: number; + min: number; +} + +export interface SignalsEvaluateResult { + samples: number; + points: EvalPointOut[]; + stats: WindowStatOut[]; + pairs: EvalPairOut[]; + pair_stats?: PairStatsOut | null; +} + +export interface SignalsScanRequest { + ts_code: string; + conditions: IndicatorCondition[]; // 1-10 条 AND + windows?: number[]; + start?: string | null; + end?: string | null; +} + +export interface SignalsScanResponse { + ts_code: string; + dates: string[]; // 命中信号日(升序,已丢弃指标预热区) + total: number; + evaluate: SignalsEvaluateResult; +} + +export interface SignalsEvaluateRequest { + ts_code: string; + buy_dates: string[]; // 1-500 个 + sell_dates?: string[]; // 手动卖点(有则逐对计算) + windows?: number[]; + entry_timing?: 'next_open' | 'next_close'; // 默认与事件回测对齐 + exit_timing?: 'close' | 'open'; +} diff --git a/frontend/src/components/DetailKLine.vue b/frontend/src/components/DetailKLine.vue index b1f6f16..4be1c7b 100644 --- a/frontend/src/components/DetailKLine.vue +++ b/frontend/src/components/DetailKLine.vue @@ -74,6 +74,59 @@ const tradeMarkerTemplate: OverlayTemplate = { }; registerOverlay(tradeMarkerTemplate); +// ---------- 买卖点实验室标记(/signals 页面,独立于交割单 tradeMarker) ---------- +// 买/卖徽章与交割单 B/S 完全同款(白字彩底圆角块):B=红 贴 low 下方、S=蓝 贴 high +// 上方(signals 页不传 tradeMarkers,同款样式不会与实盘字母撞车);scan 命中=空心黄圆 +// 贴 low 再下一档(+38,同日与 B 错位不叠)。点击标记由父组件处理(删除/勾选)。 +interface SignalMarkExt { kind: 'buy' | 'sell' | 'scan' } +const SCAN_COLOR = '#F5C518'; +const signalMarkerTemplate: OverlayTemplate = { + name: 'signalMarker', + totalStep: 2, + needDefaultPointFigure: false, + needDefaultXAxisFigure: false, + needDefaultYAxisFigure: false, + createPointFigures: ({ overlay, coordinates }) => { + const c = coordinates[0]; + const ext = overlay.extendData; + if (!c || !ext) return []; + if (ext.kind === 'scan') { + return [ + { // 空心黄圆:style:'stroke' 时描边取 borderColor/borderSize(fill 才用 color,见 drawCircle) + type: 'circle', + attrs: { x: c.x, y: c.y + 38, r: 4.5 }, + styles: { style: 'stroke', color: 'rgba(0,0,0,0)', borderColor: SCAN_COLOR, borderSize: 1.5 }, + }, + { // 透明命中区(排最后最先接管事件,与 tradeMarker 同理) + type: 'circle', + attrs: { x: c.x, y: c.y + 38, r: 9 }, + styles: { style: 'fill', color: 'rgba(0,0,0,0)', borderColor: 'rgba(0,0,0,0)' }, + }, + ]; + } + const isBuy = ext.kind === 'buy'; + const ly = isBuy ? c.y + 22 : c.y - 22; + return [ + { // 与 tradeMarker 的 B/S 徽章同款:白字彩底圆角小徽章(用户要求与实盘 B/S 一致) + type: 'text', + attrs: { x: c.x, y: ly, text: isBuy ? 'B' : 'S', align: 'center', baseline: 'middle' }, + styles: { + color: '#FFFFFF', backgroundColor: isBuy ? TRADE_COLORS.B : TRADE_COLORS.S, + size: 12, weight: 'bold', borderRadius: 3, + paddingLeft: 3, paddingRight: 3, paddingTop: 1, paddingBottom: 1, + }, + ignoreEvent: true, + }, + { // 透明命中区(排最后最先接管事件,与 tradeMarker 同理) + type: 'circle', + attrs: { x: c.x, y: ly, r: 9 }, + styles: { style: 'fill', color: 'rgba(0,0,0,0)', borderColor: 'rgba(0,0,0,0)' }, + }, + ]; + }, +}; +registerOverlay(signalMarkerTemplate); + const props = defineProps<{ ticker: string; candles: Candle[]; @@ -110,11 +163,21 @@ const props = defineProps<{ /** 分红事件标记(tushare dividend,按除权除息日贴 high 上方,kind 固定 'D'): * rows 为悬停明细(每股分红/送转/登记日等)。与买卖点同模板不同 groupId,独立开关 */ dividendMarkers?: { key: string; ts: number; rows: TradeRow[] }[]; + /** 买卖点实验室标记(/signals 页面;与交割单标记两组独立):买=B 红徽章、卖=S 蓝徽章、 + * scan=空心黄圆。key 通常是日期字符串(signalClick 原样回抛);ts 为本地零点时间戳,与交割单 + * 标记同基准;只画落在已渲染窗口内的(左滑翻页后自动补画) */ + signalMarkers?: { key: string; ts: number; kind: 'buy' | 'sell' | 'scan' }[]; + /** 标注模式:非空时点击 K 线本体向上抛 chartClick(null=纯看图不响应点击标注) */ + annotateMode?: 'buy' | 'sell' | null; }>(); const emit = defineEmits<{ /** 日期跳转锚点在本次数据窗口里找不到(早于上市/晚于最后一根):请父组件回退到最新行情并提示 */ (e: 'centerMiss', ts: number): void; + /** 标注模式下点击了某根 K 线本体(该 bar 的 timestamp;K 线间隙不触发) */ + (e: 'chartClick', ts: number): void; + /** 点击了图上的实验室标记(父组件据此删除该点) */ + (e: 'signalClick', key: string, kind: 'buy' | 'sell' | 'scan'): void; }>(); // A股语义色(黑底高对比);UP/DOWN 跟随设置中的涨跌配色 @@ -620,10 +683,85 @@ function renderMarkerGroup(groupId: string, markers: { key: string; ts: number; if (creates.length) chart.createOverlay(creates); } -/** 全部事件标记重画(买卖点 + 分红),触发点:build 尾部 / serveOlder 左扩 / props 变化 / 清画线 */ +/** 全部事件标记重画(买卖点 + 分红 + 实验室),触发点:build 尾部 / serveOlder 左扩 / props 变化 / 清画线 */ function renderMarkers() { renderMarkerGroup(TRADE_GROUP, props.tradeMarkers); renderMarkerGroup(DIVIDEND_GROUP, props.dividendMarkers?.map((m) => ({ ...m, kind: 'D' as const }))); + renderSignalGroup(); +} + +// ---------- 实验室标记渲染(/signals 页面,signalMarker 模板) ---------- +const SIGNAL_GROUP = 'signals'; + +/** 实验室标记整组重建(先删后建,幂等):日期吸附与交割单同策略(ahead 按周期放大), + * buy/scan 贴 low、sell 贴 high;点击向上抛 signalClick(父组件删除该点); + * 右键吞掉防 v10 默认删除;列表为空也必须清组(切股/清点后旧标记不能残留)。 */ +function renderSignalGroup() { + if (!chart) return; + chart.removeOverlay({ groupId: SIGNAL_GROUP }); + const markers = props.signalMarkers; + if (!markers?.length) return; + const list = chart.getDataList(); + if (list.length === 0) return; + const lastTs = list[list.length - 1].timestamp; + const aheadMs = TRADE_AHEAD_MS[props.timeframe] ?? 0; + const creates: OverlayCreate[] = []; + for (const m of markers) { + const i = idxAtOrBefore(list, m.ts); + if (i < 0 || m.ts > lastTs + aheadMs) continue; // 未翻到 / 行情尚未覆盖该周期 + const bar = list[i]; + creates.push({ + id: `${SIGNAL_GROUP}-${m.kind}-${m.key}`, + groupId: SIGNAL_GROUP, + name: 'signalMarker', + points: [{ timestamp: bar.timestamp, value: m.kind === 'sell' ? bar.high : bar.low }], + extendData: { kind: m.kind }, + onClick: (ev) => { + // 记录本次物理点击的容器坐标:原生 click(onAnnotateClick)据此让位, + // 避免「signalClick 删除 + chartClick 又加回」相互抵消 + if (typeof ev.x === 'number' && typeof ev.y === 'number') { + lastSignalMarkClick = { x: ev.x, y: ev.y, t: performance.now() }; + } + emit('signalClick', m.key, m.kind); + }, + // v10 右键命中 figure 会默认 removeOverlay(lock 只拦左键按下),显式吞掉 + onRightClick: (ev) => { ev.preventDefault?.(); }, + lock: true, + }); + } + if (creates.length) chart.createOverlay(creates); +} + +// ---------- 标注模式点击(/signals):容器原生 click,全图命中 ---------- +// v10 的 onCandleBarClick 只在命中 K 线实体/影线时触发,间隙/副图/贴边点击全部丢失 +//(实测"经常点不上")。改用容器原生 click:x 像素经 convertFromPixel 吸附最近一根 bar +//(十字光标同款换算),图上任意位置都能点中当根。 +// 两个去重:① 拖拽平移结束后同元素也派发 click——按库同款曼哈顿距离 ≥5px 判拖动; +// ② klinecharts 的 overlay onClick 由 mouseup 合成、早于原生 click,点击已有标记时 +// signalClick 已消费该次物理点击,按「位置+时间」匹配让位,避免 toggle 又把点加回来。 +let annotateDownX = -1; +let annotateDownY = -1; +let lastSignalMarkClick: { x: number; y: number; t: number } | null = null; + +function onAnnotateDown(e: MouseEvent) { + annotateDownX = e.clientX; + annotateDownY = e.clientY; +} + +function onAnnotateClick(e: MouseEvent) { + // 画线工具激活时点击属于画线,不当标注;右键/非左键不响应 + if (!props.annotateMode || !chart || e.button !== 0 || activeTool.value) return; + if (Math.abs(e.clientX - annotateDownX) + Math.abs(e.clientY - annotateDownY) >= 5) return; + const rect = container.value?.getBoundingClientRect(); + if (!rect) return; + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const lc = lastSignalMarkClick; + if (lc && performance.now() - lc.t < 400 && Math.abs(x - lc.x) < 5 && Math.abs(y - lc.y) < 5) return; + // 右侧价格轴/数据范围外:convertFromPixel 会外推出不存在的时间戳,须校验落在真实 bar 上 + const ts = ((chart.convertFromPixel([{ x }]) as Array>)?.[0])?.timestamp; + if (ts == null || idxOfTs(ts) < 0) return; + emit('chartClick', ts); } // ---------- 事件标记悬停明细(悬停 B/S/T/D 字母才显示,离开/滚动即隐) ---------- @@ -767,6 +905,8 @@ function build() { const from = (payload as { data?: { from?: unknown } }).data?.from; if (typeof from === 'number' && from < 200) maybePrefetch(myEpoch); }); + // 标注模式(/signals 页面)的点击改走容器原生 click(onAnnotateClick,见 onMounted), + // 不再用 onCandleBarClick:它只命中 K 线实体/影线,间隙/副图点击全部丢失 // 右侧留白(scrollToRealTime 以它为锚点,先后顺序不能换) ch.setOffsetRightDistance(BASE_RIGHT_PX); ch.scrollToRealTime(); @@ -788,13 +928,24 @@ function teardown() { activeTool.value = ''; } -onMounted(build); -onBeforeUnmount(teardown); +onMounted(() => { + build(); + // 原生监听挂在容器上、与图表实例解耦:build/teardown 反复 init/dispose 不重复挂载 + container.value?.addEventListener('mousedown', onAnnotateDown); + container.value?.addEventListener('click', onAnnotateClick); +}); +onBeforeUnmount(() => { + container.value?.removeEventListener('mousedown', onAnnotateDown); + container.value?.removeEventListener('click', onAnnotateClick); + teardown(); +}); // 浅 watch 即可:父组件对 data 是整体替换(新数组引用),props 引用变化必触发; // deep 反而每次深遍历几百根 K 线的嵌套数组(父组件从无原地改写) watch(() => [props.candles, props.indicators, props.subPanes, props.showBoll, props.showZhixing, props.zhixingBlocks, props.maPeriods, props.timeframe], () => { teardown(); build(); }); // 事件标记数据变化(导入/清空/开关显示/分红数据到达):只重画标记,不重建图表(保留滚动位置与用户画线) watch(() => [props.tradeMarkers, props.dividendMarkers], renderMarkers); +// 实验室标记数据变化(标注/删除/扫描结果到达):只重画该组,不重建图表 +watch(() => props.signalMarkers, renderSignalGroup); // 涨跌配色切换:重建图表以应用新颜色 watch(() => settings.priceTone, () => { teardown(); build(); }); // 副图高度变化:仅调 pane 高度,不重建(保留滚动/画线状态) diff --git a/frontend/src/router.ts b/frontend/src/router.ts index 2e9b8ca..aab830c 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -13,6 +13,7 @@ const router = createRouter({ { path: '/indexes', name: 'indexes', component: () => import('@/views/IndexesView.vue') }, { path: '/indexes/:code', name: 'index-detail', component: () => import('@/views/IndexDetailView.vue') }, { path: '/backtest', name: 'backtest', component: () => import('@/views/BacktestView.vue') }, + { path: '/signals', name: 'signals', component: () => import('@/views/SignalsView.vue') }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], }); diff --git a/frontend/src/views/BacktestView.vue b/frontend/src/views/BacktestView.vue index 2a78c8a..c578e7c 100644 --- a/frontend/src/views/BacktestView.vue +++ b/frontend/src/views/BacktestView.vue @@ -1,8 +1,14 @@