提交
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<TradesClearResponse> {
|
||||
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<SignalPlanListResponse> {
|
||||
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<SignalPlanOut> {
|
||||
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<SignalPlanOut> {
|
||||
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<SignalPlanOut> {
|
||||
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<void> {
|
||||
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<SignalPlanOut> {
|
||||
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<SignalsAnalyzeResponse> {
|
||||
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<SignalsScanResponse> {
|
||||
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<SignalsEvaluateResult> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<string, number | null>; // "5" -> 收益%(越界/缺数据 null)
|
||||
max_gain: Record<string, number | null>; // 窗口内最大涨幅 %
|
||||
max_dd: Record<string, number | null>; // 窗口内最大回撤 %
|
||||
}
|
||||
|
||||
/** 手动配对(买点 -> 其后最近卖点)的一笔评估 */
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -74,6 +74,59 @@ const tradeMarkerTemplate: OverlayTemplate<TradeMarkExt> = {
|
||||
};
|
||||
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<SignalMarkExt> = {
|
||||
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<unknown>[] = [];
|
||||
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<Partial<Point>>)?.[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 高度,不重建(保留滚动/画线状态)
|
||||
|
||||
@@ -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: '/' },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { postEventBacktest } from '@/api/client';
|
||||
import type { EventBacktestResponse, EventBacktestSpec } from '@/api/types';
|
||||
import ConditionChips from '@/components/ConditionChips.vue';
|
||||
import DetailKLine from '@/components/DetailKLine.vue';
|
||||
import { useStockPreview } from '@/composables/useStockPreview';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const EXAMPLES = [
|
||||
'在连续三天 J 小于 10 的时候第二天开盘购买,之后未来三天的涨幅有多少',
|
||||
@@ -81,10 +87,82 @@ async function rerunAdjusted() {
|
||||
exit_timing: exitTiming.value,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 单只股票回测:K 线买卖点标记图(B=入场日 / S=出场日) ----------
|
||||
const preview = useStockPreview();
|
||||
const klineCode = computed(() =>
|
||||
result.value && result.value.universe !== 'all' ? result.value.universe : '');
|
||||
const pd = computed(() => preview.data.value);
|
||||
const klineBusy = computed(() => preview.loading.value);
|
||||
const klineErr = computed(() => preview.error.value);
|
||||
watch(klineCode, (c) => { if (c) void preview.load(c); }, { immediate: true });
|
||||
const loadOlder = (end: string, count: number) =>
|
||||
klineCode.value ? preview.loadOlder(klineCode.value, end, count) : Promise.resolve(null);
|
||||
|
||||
/** YYYY-MM-DD -> 本地零点时间戳(K 线 bar timestamp 基准,与交割单标记一致) */
|
||||
function toTs(d: string): number {
|
||||
const [y, m, dd] = d.split('-').map(Number);
|
||||
return new Date(y, m - 1, dd).getTime();
|
||||
}
|
||||
|
||||
const klineMarkers = computed(() => {
|
||||
const out: { key: string; ts: number; kind: 'buy' | 'sell' }[] = [];
|
||||
if (!result.value || result.value.universe === 'all') return out;
|
||||
const seen = new Set<string>();
|
||||
for (const t of result.value.trades) {
|
||||
const pairs: [string, 'buy' | 'sell'][] = [
|
||||
[t.entry_date.slice(0, 10), 'buy'], [t.exit_date.slice(0, 10), 'sell'],
|
||||
];
|
||||
for (const [d, kind] of pairs) {
|
||||
if (seen.has(kind + d)) continue; // 多笔交易同日去重(overlay id 须唯一)
|
||||
seen.add(kind + d);
|
||||
out.push({ key: d, ts: toTs(d), kind });
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.ts - b.ts);
|
||||
});
|
||||
// 用中位数标记居中:首笔可能早于首屏窗口(240 根),中位必落在交易密集区
|
||||
const centerTs = computed(() => {
|
||||
const m = klineMarkers.value;
|
||||
return m.length ? m[Math.floor(m.length / 2)].ts : null;
|
||||
});
|
||||
const KLINE_SUB_PANES: string[] = ['vol'];
|
||||
const KLINE_MA = [5, 10, 20, 60];
|
||||
const KLINE_SUB_HEIGHTS: Record<string, number> = {};
|
||||
|
||||
// 买卖点实验室「推送到事件回测」:spec + 当前股票代码经 sessionStorage 交接
|
||||
//(条件可到 10 条,URL 放不下);tsCode 预填后只回测该股,清空输入框即全市场
|
||||
const fromSignals = ref(false);
|
||||
onMounted(() => {
|
||||
fromSignals.value = route.query.from === 'signals';
|
||||
if (!fromSignals.value) return;
|
||||
const raw = sessionStorage.getItem('signals:backtestSpec');
|
||||
if (!raw) return;
|
||||
sessionStorage.removeItem('signals:backtestSpec');
|
||||
try {
|
||||
const s = JSON.parse(raw) as { spec: EventBacktestSpec; tsCode?: string };
|
||||
if (s?.spec?.entry && Array.isArray(s.spec.entry.indicator) && s.spec.entry.indicator.length > 0) {
|
||||
text.value = '(来自买卖点实验室的条件)';
|
||||
if (s.tsCode) tsCode.value = s.tsCode;
|
||||
void run(s.spec);
|
||||
}
|
||||
} catch { /* 坏数据忽略,留在手输模式 */ }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 从实验室推送而来:一键返回调整条件(现场自动恢复) -->
|
||||
<button
|
||||
v-if="fromSignals"
|
||||
type="button"
|
||||
class="mb-3 flex items-center gap-1.5 text-sm font-medium text-[#A8AFB8] transition-colors hover:text-white"
|
||||
@click="router.push('/signals')"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M11 18l-6-6 6-6" /></svg>
|
||||
返回买卖点实验室调整条件
|
||||
</button>
|
||||
|
||||
<!-- 输入区 -->
|
||||
<div class="rounded-xl border border-[#26272E] bg-[#101014] p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -214,6 +292,36 @@ async function rerunAdjusted() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 单只股票回测:K 线买卖点标记图(B=入场日 / S=出场日) -->
|
||||
<div v-if="klineCode" class="mt-4 rounded-xl border border-[#26272E] bg-[#101014] p-4">
|
||||
<div class="mb-2 flex items-baseline justify-between">
|
||||
<div class="text-sm font-medium text-[#A8AFB8]">
|
||||
买卖点标记({{ klineCode }})<span class="ml-2 text-xs text-[#9BA3AE]">B = 入场日 · S = 出场日</span>
|
||||
</div>
|
||||
<div class="text-xs text-[#9BA3AE]">展示最好/最差各 100 条样本的标记</div>
|
||||
</div>
|
||||
<div class="h-[420px]">
|
||||
<div v-if="klineBusy" class="flex h-full items-center justify-center text-sm text-[#9BA3AE]">加载 K 线中…</div>
|
||||
<div v-else-if="klineErr" class="flex h-full items-center justify-center text-sm text-red-400">{{ klineErr }}</div>
|
||||
<DetailKLine
|
||||
v-else-if="pd"
|
||||
:ticker="klineCode"
|
||||
:candles="pd.candles"
|
||||
:indicators="pd.indicators"
|
||||
:has-more="pd.has_more ?? false"
|
||||
:load-older="loadOlder"
|
||||
:sub-panes="KLINE_SUB_PANES"
|
||||
:ma-periods="KLINE_MA"
|
||||
:sub-heights="KLINE_SUB_HEIGHTS"
|
||||
:show-boll="false"
|
||||
:show-zhixing="false"
|
||||
timeframe="1d"
|
||||
:signal-markers="klineMarkers"
|
||||
:center-ts="centerTs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 调参重跑 -->
|
||||
<div class="mt-4 flex flex-wrap items-end gap-3 rounded-xl border border-[#26272E] bg-[#101014] px-4 py-3">
|
||||
<div class="text-[13px] font-medium text-[#A8AFB8]">调整参数重跑(不动信号条件):</div>
|
||||
|
||||
@@ -42,13 +42,20 @@ const features = [
|
||||
title: '回测',
|
||||
desc: '选择标的与策略参数,查看历史表现和关键绩效指标。',
|
||||
},
|
||||
{
|
||||
to: '/signals',
|
||||
icon: 'M12 3v18M5 8l7-5 7 5M7 21h10',
|
||||
accent: 'bg-cyan-500/15 text-cyan-300',
|
||||
title: '买卖点实验室',
|
||||
desc: '在 K 线上标注买卖点,分析共同特征并转成条件扫描、验证收益。',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-6xl">
|
||||
<!-- 功能入口置顶:一屏内即可点进看股/选股等页面 -->
|
||||
<div class="mb-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<div class="mb-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<RouterLink
|
||||
v-for="f in features"
|
||||
:key="f.to"
|
||||
|
||||
Reference in New Issue
Block a user