Files

368 lines
15 KiB
Markdown
Raw Permalink 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.
# 策略开发指南
本文档是策略开发的完整参考。人类开发者参考它编写策略;AI 运行时使用同目录下的 `strategy-guide-compact.md` 精简指南生成策略代码。
## 1. 策略文件格式
每个策略是一个 Python 文件,放在以下目录:
- 内置策略: `backend/app/strategy/builtin/`
- 自定义策略: `data/strategies/custom/`,建议文件名和 ID 使用 `custom_时间戳`
- AI 生成策略: `data/strategies/ai/`,文件名和 ID 使用 `ai_时间戳`
> ⚠️ **铁律**:AI/自定义生成的策略**只能**放入 `data/strategies/ai/` 或 `data/strategies/custom/`。严禁放入 `backend/app/strategy/builtin/`(内置策略目录,仅项目维护者可改),严禁借策略定制功能创建多个文件或修改任何项目源代码。
## 2. 文件结构模板
```python
"""策略简短描述"""
import polars as pl
META = {
"id": "strategy_id", # 英文ID, 唯一, 文件名同名;自定义策略建议 custom_时间戳
"name": "策略中文名", # 显示名称
"description": "策略详细描述", # 一句话说明策略逻辑
"tags": ["标签1", "标签2"], # 分类标签
# 基础过滤参数 (Stage 1, 引擎统一处理)
"basic_filter": {
"price_min": 5, # 最低价格
"price_max": 200, # 最高价格
"market_cap_min": 20e8, # 最小总市值 (元)
"amount_min": 1e8, # 最小成交额 (元)
"exclude_st": True, # 排除 ST/*ST/退市
"exclude_new_days": 60, # 排除上市N天内新股
},
# 策略参数 (只把用户可能调节的阈值放这里,公式常数不必参数化)
# type 支持: float / int / bool / select(带 options) / date(格式 "YYYY-MM-DD")
# float/int 可带 min/max/stepselect 带 options: [{label, value}]date 的 default 是字符串
"params": [
],
# 评分权重 (用于排序, 根据策略核心逻辑定制, 权重总和 = 1.0)
"scoring": {
},
"order_by": "score", # 排序字段, 通常用 "score"
"descending": True, # True = 从高到低
"limit": 100, # 最多返回条数
}
# 买入信号 (回测 + 监控用, 根据策略逻辑选择合适的信号列)
ENTRY_SIGNALS = []
# 卖出信号
EXIT_SIGNALS = []
# 止损 (负数, 根据策略类型合理设定, 如做多短线 -0.05~-0.08)
STOP_LOSS = -0.05
# 最长持有天数 (短线 5~20, 中线 20~60)
MAX_HOLD_DAYS = 20
# 策略规则(人类可读,逐条编号,至少 3 条)
RULES = """
1. 规则描述一
2. 规则描述二
3. 规则描述三
"""
def filter(df: pl.DataFrame, params: dict) -> pl.Expr:
"""策略核心过滤逻辑。
df: Stage 1 基础过滤后的 enriched 数据
params: META.params 中定义的参数值 (用户可在前端覆盖)
返回: Polars 布尔表达式 (pl.Expr)
"""
# 用 params.get("param_id", 默认值) 读取参数
return (
(pl.col("close") > pl.col("ma5"))
& (pl.col("rsi_14") < 30)
)
```
### 历史窗口策略(filter_history
普通 `filter()` 只接收当前日期的单日数据。当策略需要以下逻辑时,必须使用 `filter_history()`
- "最近 N 天内出现过某个事件"(如涨停、金叉)
- "某个事件发生后的第 X 天"(如涨停后放量下跌)
- "前高 / 前低 / 上次某事件的价格"等需要回溯历史的自定义字段
- 任何需要多日数据才能计算的时序逻辑
**不需要** `filter_history()` 的场景:只用当日指标列做比较(如 close > ma60、rsi_14 < 30)。
策略必须显式声明唯一执行后端:普通表达式使用 `EXECUTION_BACKEND = "polars_expr"`,历史窗口使用 `EXECUTION_BACKEND = "python_history_legacy"`;不要同时定义 `filter()``filter_history()``META` 同时声明 `asset_types``timeframes`
```python
LOOKBACK_DAYS = 8 # 回看交易日数,根据策略需要设置
def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame:
"""df 包含目标日期之前 LOOKBACK_DAYS 个交易日的数据(所有股票混合)。
每行包含 symbol, date 及所有指标列/信号列。
返回值: 筛选后的 DataFrame。
重要: 返回所有匹配的行,不要只过滤最新日期,否则回测只有最后一天有信号。
"""
if df.is_empty() or "date" not in df.columns:
return df
down_pct = float(params.get("prev_down_pct", -0.02))
vol_ratio = float(params.get("volume_ratio", 1.2))
tolerance = float(params.get("reversal_tolerance", 0.005))
# 示例: 前日明显阴线下跌,今日放量阳线反包前日实体
hist = (
df.sort(["symbol", "date"])
.with_columns([
pl.col("open").shift(1).over("symbol").alias("_prev_open"),
pl.col("high").shift(1).over("symbol").alias("_prev_high"),
pl.col("close").shift(1).over("symbol").alias("_prev_close"),
pl.col("volume").shift(1).over("symbol").alias("_prev_volume"),
pl.col("change_pct").shift(1).over("symbol").alias("_prev_change_pct"),
])
)
return hist.filter(
(pl.col("_prev_close") < pl.col("_prev_open"))
& (pl.col("_prev_change_pct") <= down_pct)
& (pl.col("close") > pl.col("open"))
& (pl.col("close") > pl.col("_prev_open"))
& (pl.col("close") >= pl.col("_prev_high") * (1 - tolerance))
& (pl.col("volume") >= pl.col("_prev_volume") * vol_ratio)
& ((pl.col("close") > pl.col("ma5")) | (pl.col("close") > pl.col("ma10")))
)
```
**关键要点:**
- `LOOKBACK_DAYS` 决定引擎加载多少天的数据,设为策略逻辑需要的最大回看天数
- 优先使用 Polars 的 `with_columns``over("symbol")``group_by``join``filter` 实现历史逻辑,避免把数据转成 Python list/dict 循环
- 只有遇到表达式难以描述的复杂状态机时,才使用 `partition_by("symbol")` + `to_dicts()` 逐股票分析
- **返回所有匹配行,不要过滤 `latest`**;选股引擎会自动取最新日,回测引擎需要全区间命中
- 未声明 `filter_history()` 的策略走普通 `filter()` 路径,不受影响
- **date 类型参数必须先转换再与 `date` 列比较**params 里的 `"type": "date"` 参数从 JSON 传来是字符串(如 `"2024-01-01"`),而数据中 `date` 列是 Polars Date 类型,**不能直接比较**,否则报错。必须先转换:
```python
from datetime import date as _date
anchor_raw = params.get("anchor_date", "2024-01-01")
anchor_date = _date.fromisoformat(anchor_raw) if isinstance(anchor_raw, str) else anchor_raw
# 之后才能: pl.col("date") == anchor_date 或 pl.col("date") > anchor_date
```
## 3. 常用指标列(参考,可直接使用)
以下列可直接引用。使用 `filter_history()` 或其他无法静态解析的 Python 逻辑时,必须声明最终依赖的公开字段,基础 OHLCV 和指标中间列不需要声明:
```python
REQUIRED_FEATURES = {"ma20", "momentum_20d", "vol_ratio_5d"}
```
未声明时回测会明确告警并暂时回退到全量特征计算。
### 通用列
| 列名 | 类型 | 说明 |
|------|------|------|
| symbol | string | 股票代码 (如 600519.SH) |
| date | date | 交易日期 |
### 价格相关
| 列名 | 类型 | 说明 |
|------|------|------|
| open, high, low, close | float | OHLCV 开高低收 (前复权) |
| raw_close, raw_high, raw_low | float | 原始未复权价 |
| prev_close | float | 昨收价 |
| change_pct | float | 涨跌幅 (如 0.032 = +3.2%) |
| change_amount | float | 涨跌额 |
| amount | float | 成交额 |
| amplitude | float | 振幅 |
### 均线
| 列名 | 说明 |
|------|------|
| ma5, ma10, ma20, ma30, ma60 | 简单移动均线 |
| ema5, ema10, ema20, ema30, ema60 | 指数移动均线 |
### 技术指标
| 列名 | 说明 |
|------|------|
| macd_dif | MACD DIF 线 |
| macd_dea | MACD DEA 线 |
| macd_hist | MACD 柱状 |
| boll_upper, boll_lower | 布林带上/下轨 |
| kdj_k, kdj_d, kdj_j | KDJ 指标 |
| rsi_6, rsi_14, rsi_24 | RSI 相对强弱 |
| atr_14 | 平均真实波幅 |
### 量能
| 列名 | 说明 |
|------|------|
| volume | 成交量 |
| vol_ma5, vol_ma10 | 成交量均线 |
| vol_ratio_5d | 5日量比 |
| turnover_rate | 换手率 |
### 动量与波动
| 列名 | 说明 |
|------|------|
| momentum_5d / 10d / 20d / 30d / 60d | N日涨幅 |
| annual_vol_20d | 20日年化波动率 |
| high_60d, low_60d | 60日最高/最低价 |
### 受控虚拟评分字段
| 列名 | 说明 |
|------|------|
| ma20_bias | `close / ma20 - 1`,仅在内存中计算,不写入数据表 |
### 涨跌停
| 列名 | 说明 |
|------|------|
| consecutive_limit_ups | 连续涨停天数 |
| consecutive_limit_downs | 连续跌停天数 |
### 运行时附加列(由引擎从 instruments 表 JOIN
| 列名 | 说明 |
|------|------|
| name | 股票名称 |
| total_shares | 总股本 |
| float_shares | 流通股本 |
`total_shares``float_shares` 用于 `basic_filter` 中计算市值:`close * total_shares`
## 4. 常用信号列(参考)
信号列是布尔值,**必须**使用 `.fill_null(False)` 处理空值。同样仅供参考,根据策略含义自行选择匹配的。
| 列名 | 方向 | 说明 |
|------|------|------|
| signal_ma_golden_5_20 | 买入 | MA5 上穿 MA20 |
| signal_ma_dead_5_20 | 卖出 | MA5 下穿 MA20 |
| signal_ma_golden_20_60 | 买入 | MA20 上穿 MA60 |
| signal_macd_golden | 买入 | MACD 金叉 |
| signal_macd_dead | 卖出 | MACD 死叉 |
| signal_ma20_breakout | 买入 | 突破 MA20 |
| signal_ma20_breakdown | 卖出 | 跌破 MA20 |
| signal_n_day_high | 买入 | 60日新高 |
| signal_n_day_low | 卖出 | 60日新低 |
| signal_boll_breakout_upper | 中性 | 突破布林上轨 |
| signal_boll_breakdown_lower | 中性 | 跌破布林下轨 |
| signal_volume_surge | 中性 | 放量 |
| signal_limit_up | 买入 | 涨停 (依赖 instruments 表,部分环境不生成) |
| signal_limit_down | 卖出 | 跌停 (依赖 instruments 表,部分环境不生成) |
| signal_limit_down_recovery | 买入 | 跌停翘板 (依赖 instruments 表,部分环境不生成) |
| signal_broken_limit_up | 卖出 | 炸板 (依赖 instruments 表,部分环境不生成) |
> **注意**:涨跌停类信号需要 instruments 表(板块代码)才能计算。如果策略只用涨停判断,优先用 `consecutive_limit_ups >= 1`(稳定列,始终可用)。
此外,用户自定义信号(`data/user_data/custom_signals/`)以 `csg_` 前缀注入,也可在 filter() 中引用。
## 5. 不可用的数据(重要)
以下数据**不在** enriched DataFrame 中,策略代码中**不能**直接引用:
| 数据 | 说明 |
|------|------|
| 财务数据 (PE/PB/ROE/净利润/营收/资产负债等) | 存储在独立 financials 表,未 JOIN |
| 扩展数据 (概念/行业/人气排名/资金流向等) | 存储在 ext_data 目录,未 JOIN |
| 盘中实时数据 (分时价/五档盘口等) | 仅前端轮询使用 |
如需财务或扩展数据作为筛选条件,需先在系统层面完成 JOIN 再提供给策略(当前未实现)。
## 6. 规则
1. `filter()` 必须返回 `pl.Expr` (用 `&` `|` 组合布尔表达式)`filter_history()` 返回筛选后的 `DataFrame`
2. 信号列使用 `.fill_null(False)` 处理空值
3. 用户可能调节的数值阈值通过 `params` 暴露;公式常数、固定窗口边界、一次性内部变量不必强行参数化
4. `scoring` 只能使用上述真实数值字段或受控虚拟字段 `ma20_bias`,权重总和必须为 1.0;不要使用条件名称或信号列作为评分字段
5. 遵循 A 股 T+1 规则 (当日买入次日才能卖出)
6. Polars 策略允许 `import polars as pl``from datetime import date/datetime`;矩阵策略只允许 NumPy 和 `app.backtest.matrix` 协议/算子
7. 禁止使用 `open()`, `exec()`, `eval()`, `os`, `sys`, `subprocess`
8. **贴合用户需求优先**:第3/4节的指标列和信号列仅供参考,能用则用;如果用户需求需要自定义计算(如"前高""上次涨停价""N日内某个事件后X天"),直接在 `filter_history()` 中自行设计和计算,不需要局限于已有列
9. `filter_history()` 中优先用 Polars 向量化语法;仅在复杂状态机无法清晰表达时,才用 `partition_by("symbol")` 逐股票分析
## 7. 策略示例
### 强势反包
```python
"""强势反包 — 前日阴线下跌 + 今日放量阳线反包"""
import polars as pl
META = {
"id": "strong_reversal",
"name": "强势反包",
"description": "前一日明显阴线下跌,今日放量阳线收复前一日阴线实体",
"tags": ["反包", "短线", "放量"],
"basic_filter": {
"price_min": 3, "price_max": 200,
"market_cap_min": 10e8, "amount_min": 0.5e8,
"exclude_st": True, "exclude_new_days": 30,
},
"params": [
{"id": "prev_down_pct", "label": "前日最大跌幅", "type": "float",
"default": -0.02, "min": -0.10, "max": -0.005, "step": 0.005},
{"id": "volume_ratio", "label": "成交量放大倍数", "type": "float",
"default": 1.2, "min": 1.0, "max": 5.0, "step": 0.1},
{"id": "reversal_tolerance", "label": "反包容忍误差", "type": "float",
"default": 0.005, "min": 0.0, "max": 0.03, "step": 0.005},
],
"scoring": {"change_pct": 0.4, "vol_ratio_5d": 0.3, "momentum_5d": 0.3},
"order_by": "score", "descending": True, "limit": 100,
}
LOOKBACK_DAYS = 2
ENTRY_SIGNALS = ["signal_broken_board_recovery"]
EXIT_SIGNALS = ["signal_ma20_breakdown"]
STOP_LOSS = -0.05
MAX_HOLD_DAYS = 10
RULES = """
1. 前一交易日为阴线,且跌幅不小于设定阈值
2. 今日为阳线,收盘价收复前一日开盘价并接近或突破前一日高点
3. 今日成交量较前一日明显放大,且收盘价站上 MA5 或 MA10
"""
def filter_history(df: pl.DataFrame, params: dict) -> pl.DataFrame:
if df.is_empty() or "date" not in df.columns:
return df
down_pct = float(params.get("prev_down_pct", -0.02))
vol_ratio = float(params.get("volume_ratio", 1.2))
tolerance = float(params.get("reversal_tolerance", 0.005))
latest = df["date"].max()
hist = (
df.sort(["symbol", "date"])
.with_columns([
pl.col("open").shift(1).over("symbol").alias("_prev_open"),
pl.col("high").shift(1).over("symbol").alias("_prev_high"),
pl.col("close").shift(1).over("symbol").alias("_prev_close"),
pl.col("volume").shift(1).over("symbol").alias("_prev_volume"),
pl.col("change_pct").shift(1).over("symbol").alias("_prev_change_pct"),
])
)
return hist.filter(
(pl.col("_prev_close") < pl.col("_prev_open"))
& (pl.col("_prev_change_pct") <= down_pct)
& (pl.col("close") > pl.col("open"))
& (pl.col("close") > pl.col("_prev_open"))
& (pl.col("close") >= pl.col("_prev_high") * (1 - tolerance))
& (pl.col("volume") >= pl.col("_prev_volume") * vol_ratio)
& ((pl.col("close") > pl.col("ma5")) | (pl.col("close") > pl.col("ma10")))
)
```
## 8. 完整示例
见 [strategy-example.md](./strategy-example.md) — 从零创建强势反包策略的三步完整演示。