chore(config): 添加环境变量示例及.gitignore更新

- 新增 .env.example,包含 Tushare API、钉钉机器人和PostgreSQL数据库配置模板
- 更新.gitignore,忽略本地配置文件如 .env.local 和 config_local.py
- 添加对报表文件命名规则的支持,保留示例文件不忽略
- 删除废弃的 chart.py 及相关图表模块代码
- 新增 config/settings.py,实现从环境变量读取配置的统一接口
- 设置数据目录及缓存目录,确保目录存在,提高配置管理规范性
This commit is contained in:
2026-03-18 23:33:40 +08:00
parent 7c93be4b41
commit 988c2335fb
39 changed files with 2983 additions and 1011 deletions

0
strategies/__init__.py Normal file
View File

41
strategies/base.py Normal file
View File

@@ -0,0 +1,41 @@
"""
策略基类定义
"""
from abc import ABC, abstractmethod
from typing import Any
class Strategy(ABC):
"""策略抽象基类"""
def __init__(self, name: str, config: dict = None):
self.name = name
self.config = config or {}
@abstractmethod
def run(self, **kwargs) -> Any:
"""执行策略"""
pass
@abstractmethod
def get_signals(self, **kwargs) -> Any:
"""获取当前信号"""
pass
class BacktestStrategy(Strategy):
"""回测策略基类"""
def __init__(self, name: str, config: dict = None):
super().__init__(name, config)
self.results = None
@abstractmethod
def run_backtest(self, **kwargs) -> dict:
"""执行回测,返回绩效指标"""
pass
def get_results(self) -> dict:
"""获取回测结果"""
return self.results

View File

View File

@@ -0,0 +1,249 @@
"""
ETF轮动策略引擎
整合信号生成和回测逻辑
"""
import pandas as pd
import numpy as np
from typing import Optional
from strategies.base import BacktestStrategy
from core.data.tushare_source import TushareDataSource
from core.factors.momentum import compute_factors, calculate_daily_return
class RotationStrategy(BacktestStrategy):
"""ETF轮动策略"""
def __init__(self, config: dict):
super().__init__("ETF轮动策略", config)
self.data_source = TushareDataSource(use_cache=config.get("use_cache", True))
self.data = None
self.signals = None
self.backtest_result = None
def fetch_data(self) -> pd.DataFrame:
"""获取数据"""
from config.settings import BENCHMARK_CODE
etf_data, benchmark_data, valid_codes = self.data_source.fetch_all(
self.config["code_list"],
BENCHMARK_CODE,
self.config["start_date"],
self.config["end_date"],
)
self.etf_data = etf_data
self.benchmark_data = benchmark_data
self.valid_codes = valid_codes
# 计算因子
factor_data, valid_codes = compute_factors(
etf_data,
valid_codes,
n=self.config["n_days"],
factor_type=self.config["factor_type"],
)
self.data = factor_data
self.valid_codes = valid_codes
return factor_data
def generate_signals(self) -> pd.DataFrame:
"""生成轮动信号"""
if self.data is None:
self.fetch_data()
result = self.data.copy()
score_cols = [f"得分_{code}" for code in self.valid_codes]
select_num = self.config["select_num"]
rebalance_days = self.config["rebalance_days"]
rebalance_threshold = self.config["rebalance_threshold"]
# Step 1: 每日目标组合
if select_num == 1:
daily_target = (
result[score_cols]
.idxmax(axis=1)
.str.replace("得分_", "", regex=False)
)
else:
def top_n_codes(row):
scores = pd.to_numeric(row[score_cols], errors="coerce")
top = scores.nlargest(select_num).index.tolist()
return ",".join([c.replace("得分_", "") for c in top])
daily_target = result.apply(top_n_codes, axis=1)
# Step 2: 逐日生成信号(调仓周期控制)
held_signals = []
current_held = None
last_rebalance_idx = 0
for i in range(len(result)):
target = daily_target.iloc[i]
if current_held is None:
current_held = target
last_rebalance_idx = i
held_signals.append(current_held)
continue
days_since = i - last_rebalance_idx
if days_since >= rebalance_days:
should = self._check_rebalance(
result.iloc[i], current_held, target,
select_num, rebalance_threshold
)
if should:
current_held = target
last_rebalance_idx = i
held_signals.append(current_held)
result["信号_raw"] = held_signals
result["信号"] = result["信号_raw"].shift(1)
result = result.drop(columns=["信号_raw"])
result = result.dropna(subset=["信号"])
self.signals = result
self._print_signal_stats(result, select_num, rebalance_days, rebalance_threshold)
return result
def _check_rebalance(self, row, current_held, target, select_num, threshold):
"""检查是否应该调仓"""
if select_num == 1:
if target == current_held:
return False
new_score = float(row[f"得分_{target}"])
old_score = float(row[f"得分_{current_held}"])
if old_score > 0:
return (new_score / old_score - 1) >= threshold
return new_score > 0
else:
new_codes = target.split(",")
old_codes = current_held.split(",")
if set(new_codes) == set(old_codes):
return False
new_total = sum(float(row[f"得分_{c}"]) for c in new_codes)
old_total = sum(float(row[f"得分_{c}"]) for c in old_codes)
if old_total > 0:
return (new_total / old_total - 1) >= threshold
return new_total > 0
def _print_signal_stats(self, result, select_num, rebalance_days, rebalance_threshold):
"""打印信号统计"""
total_days = len(result)
if select_num == 1:
rebalance_count = (result["信号"] != result["信号"].shift(1)).sum() - 1
else:
prev = None
rebalance_count = 0
for s in result["信号"]:
if prev is not None and s != prev:
if set(s.split(",")) != set(prev.split(",")):
rebalance_count += 1
prev = s
rebalance_count = max(rebalance_count, 0)
avg_hold = total_days / max(rebalance_count, 1)
years = total_days / 252
annual_rebalances = rebalance_count / max(years, 0.1)
print(f"\n信号生成完成:")
print(f" 调仓周期: {rebalance_days} 天 | 阈值: {rebalance_threshold:.1%}")
print(f" 交易天数: {total_days}")
print(f" 调仓次数: {rebalance_count} | 平均持仓: {avg_hold:.1f} 天 | 年均调仓: {annual_rebalances:.1f}")
if select_num == 1:
signal_counts = result["信号"].value_counts()
print(f" 品种持仓分布 (前10):")
for code, count in signal_counts.head(10).items():
pct = count / total_days * 100
print(f" {code}: {count}天 ({pct:.1f}%)")
def run_backtest(self) -> pd.DataFrame:
"""执行回测"""
if self.signals is None:
self.generate_signals()
result = self.signals.copy()
select_num = self.config["select_num"]
trade_cost = self.config["trade_cost"]
# 计算策略日收益率
if select_num == 1:
def calc_return(row):
return row[f"日收益率_{row['信号']}"]
result["轮动策略日收益率"] = result.apply(calc_return, axis=1)
else:
def calc_multi_return(row):
codes = row["信号"].split(",")
returns = [row[f"日收益率_{c}"] for c in codes]
return np.mean(returns)
result["轮动策略日收益率"] = result.apply(calc_multi_return, axis=1)
# 扣除交易成本
if trade_cost > 0:
prev_signal = result["信号"].shift(1)
if select_num == 1:
changed = (result["信号"] != prev_signal) & prev_signal.notna()
result.loc[changed, "轮动策略日收益率"] -= trade_cost
else:
turnover_list = []
for curr, prev in zip(result["信号"], prev_signal):
if pd.isna(prev) or curr == prev:
turnover_list.append(0.0)
else:
old = set(prev.split(","))
new = set(curr.split(","))
swapped = len(old - new)
turnover_list.append(swapped / len(old))
result["换手率"] = turnover_list
result["轮动策略日收益率"] -= result["换手率"] * trade_cost
# 计算净值
result["轮动策略净值"] = (1 + result["轮动策略日收益率"]).cumprod()
# 各ETF单独净值
for code in self.valid_codes:
first_price = result[code].iloc[0]
result[f"净值_{code}"] = result[code] / first_price
# 基准净值
bench_ret = self.benchmark_data.pct_change().dropna()
common_dates = result.index.intersection(bench_ret.index)
bench_ret = bench_ret.loc[common_dates]
result["基准日收益率"] = bench_ret.reindex(result.index, fill_value=0)
result["基准净值"] = (1 + result["基准日收益率"]).cumprod()
self.backtest_result = result
# 打印摘要
total_days = len(result)
strategy_total_return = result["轮动策略净值"].iloc[-1] - 1
benchmark_total_return = result["基准净值"].iloc[-1] - 1
print(f"\n回测完成:")
print(f" 回测区间: {result.index.min().date()} ~ {result.index.max().date()}")
print(f" 交易天数: {total_days}")
print(f" 策略累计收益: {strategy_total_return:.2%}")
print(f" 基准累计收益: {benchmark_total_return:.2%}")
return result
def run(self) -> dict:
"""运行完整流程"""
self.fetch_data()
self.generate_signals()
self.run_backtest()
return self.backtest_result
def get_signals(self) -> pd.DataFrame:
"""获取当前信号"""
if self.signals is None:
self.generate_signals()
return self.signals

View File

@@ -0,0 +1,233 @@
"""
ETF轮动策略 - 持仓跟踪模块
"""
import pandas as pd
from typing import Optional
def track_positions(
backtest_result: pd.DataFrame,
code_name_map: dict = None,
select_num: int = 1,
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""
从回测结果中提取每笔持仓记录
Args:
backtest_result: 回测结果(含 '信号' 列)
code_name_map: 代码→名称映射
select_num: 每次选中的品种数量
Returns:
tuple: (trades_df, summary_df)
"""
code_name_map = code_name_map or {}
data = backtest_result.copy()
dates = data.index.tolist()
signals = data["信号"].tolist()
trades = []
if select_num == 1:
# 单品种轮动
current_code = signals[0]
entry_date = dates[0]
entry_price = data.loc[entry_date, current_code]
entry_nav = data.loc[entry_date, "轮动策略净值"]
for i in range(1, len(dates)):
today_code = signals[i]
if today_code != current_code:
exit_date = dates[i - 1]
exit_price = data.loc[exit_date, current_code]
exit_nav = data.loc[exit_date, "轮动策略净值"]
holding_days = (i - 1) - dates.index(entry_date) + 1
trade_return = exit_price / entry_price - 1 if entry_price != 0 else 0
nav_contrib = exit_nav - entry_nav
trades.append({
"序号": len(trades) + 1,
"品种代码": current_code,
"品种名称": code_name_map.get(current_code, current_code),
"进场日期": entry_date,
"出场日期": exit_date,
"持仓天数": holding_days,
"仓位占比": "100%",
"进场价格": round(entry_price, 2),
"出场价格": round(exit_price, 2),
"持仓收益": trade_return,
"进场净值": round(entry_nav, 4),
"出场净值": round(exit_nav, 4),
"净值贡献": round(nav_contrib, 4),
})
current_code = today_code
entry_date = dates[i]
entry_price = data.loc[entry_date, current_code]
entry_nav = data.loc[entry_date, "轮动策略净值"]
# 最后一笔
exit_date = dates[-1]
exit_price = data.loc[exit_date, current_code]
exit_nav = data.loc[exit_date, "轮动策略净值"]
holding_days = len(dates) - dates.index(entry_date)
trade_return = exit_price / entry_price - 1 if entry_price != 0 else 0
nav_contrib = exit_nav - entry_nav
trades.append({
"序号": len(trades) + 1,
"品种代码": current_code,
"品种名称": code_name_map.get(current_code, current_code),
"进场日期": entry_date,
"出场日期": exit_date,
"持仓天数": holding_days,
"仓位占比": "100%",
"进场价格": round(entry_price, 2),
"出场价格": round(exit_price, 2),
"持仓收益": trade_return,
"进场净值": round(entry_nav, 4),
"出场净值": round(exit_nav, 4),
"净值贡献": round(nav_contrib, 4),
})
else:
# 多品种等权轮动
current_signal = signals[0]
entry_date = dates[0]
codes = current_signal.split(",")
weight = 1.0 / len(codes)
entry_prices = {c: data.loc[entry_date, c] for c in codes}
entry_nav = data.loc[entry_date, "轮动策略净值"]
for i in range(1, len(dates)):
today_signal = signals[i]
if today_signal != current_signal:
exit_date = dates[i - 1]
exit_nav = data.loc[exit_date, "轮动策略净值"]
holding_days = (i - 1) - dates.index(entry_date) + 1
for c in codes:
exit_price = data.loc[exit_date, c]
ep = entry_prices[c]
trade_return = exit_price / ep - 1 if ep != 0 else 0
trades.append({
"序号": len(trades) + 1,
"品种代码": c,
"品种名称": code_name_map.get(c, c),
"进场日期": entry_date,
"出场日期": exit_date,
"持仓天数": holding_days,
"仓位占比": f"{weight:.0%}",
"进场价格": round(ep, 2),
"出场价格": round(exit_price, 2),
"持仓收益": trade_return,
"进场净值": round(entry_nav, 4),
"出场净值": round(exit_nav, 4),
"净值贡献": round((exit_nav - entry_nav) * weight, 4),
})
current_signal = today_signal
entry_date = dates[i]
codes = current_signal.split(",")
weight = 1.0 / len(codes)
entry_prices = {c: data.loc[entry_date, c] for c in codes}
entry_nav = data.loc[entry_date, "轮动策略净值"]
# 最后一笔
exit_date = dates[-1]
exit_nav = data.loc[exit_date, "轮动策略净值"]
holding_days = len(dates) - dates.index(entry_date)
for c in codes:
exit_price = data.loc[exit_date, c]
ep = entry_prices[c]
trade_return = exit_price / ep - 1 if ep != 0 else 0
trades.append({
"序号": len(trades) + 1,
"品种代码": c,
"品种名称": code_name_map.get(c, c),
"进场日期": entry_date,
"出场日期": exit_date,
"持仓天数": holding_days,
"仓位占比": f"{weight:.0%}",
"进场价格": round(ep, 2),
"出场价格": round(exit_price, 2),
"持仓收益": trade_return,
"进场净值": round(entry_nav, 4),
"出场净值": round(exit_nav, 4),
"净值贡献": round((exit_nav - entry_nav) * weight, 4),
})
trades_df = pd.DataFrame(trades)
summary = _summarize_by_code(trades_df, code_name_map)
return trades_df, summary
def _summarize_by_code(trades_df: pd.DataFrame, code_name_map: dict) -> pd.DataFrame:
"""按品种汇总持仓统计"""
if trades_df.empty:
return pd.DataFrame()
groups = trades_df.groupby("品种代码")
rows = []
for code, grp in groups:
total_trades = len(grp)
total_days = grp["持仓天数"].sum()
avg_days = grp["持仓天数"].mean()
win_trades = (grp["持仓收益"] > 0).sum()
win_rate = win_trades / total_trades if total_trades > 0 else 0
avg_return = grp["持仓收益"].mean()
total_return = (1 + grp["持仓收益"]).prod() - 1
max_return = grp["持仓收益"].max()
min_return = grp["持仓收益"].min()
rows.append({
"品种代码": code,
"品种名称": code_name_map.get(code, code),
"调仓次数": total_trades,
"总持仓天数": total_days,
"平均持仓天数": round(avg_days, 1),
"胜率": win_rate,
"平均收益": avg_return,
"累计收益": total_return,
"最大单次收益": max_return,
"最大单次亏损": min_return,
})
summary = pd.DataFrame(rows)
summary = summary.sort_values("总持仓天数", ascending=False).reset_index(drop=True)
return summary
def save_trades(
trades_df: pd.DataFrame,
summary_df: pd.DataFrame,
save_path: str = "report",
) -> None:
"""保存调仓明细和汇总到CSV"""
import os
os.makedirs(os.path.dirname(save_path) if os.path.dirname(save_path) else ".", exist_ok=True)
trades_out = trades_df.copy()
trades_out["持仓收益"] = trades_out["持仓收益"].apply(lambda x: f"{x:.2%}")
trades_out["进场日期"] = trades_out["进场日期"].apply(
lambda x: x.strftime("%Y-%m-%d") if hasattr(x, "strftime") else str(x)[:10]
)
trades_out["出场日期"] = trades_out["出场日期"].apply(
lambda x: x.strftime("%Y-%m-%d") if hasattr(x, "strftime") else str(x)[:10]
)
trades_path = f"{save_path}_trades.csv"
trades_out.to_csv(trades_path, index=False, encoding="utf-8-sig")
print(f"\n调仓明细已保存: {trades_path}")
summary_out = summary_df.copy()
for col in ["胜率", "平均收益", "累计收益", "最大单次收益", "最大单次亏损"]:
summary_out[col] = summary_out[col].apply(lambda x: f"{x:.2%}")
summary_path = f"{save_path}_summary.csv"
summary_out.to_csv(summary_path, index=False, encoding="utf-8-sig")
print(f"品种汇总已保存: {summary_path}")

View File

@@ -0,0 +1,175 @@
"""
ETF轮动策略 - 绩效报告模块
"""
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from typing import Optional
from core.common.utils import calculate_cagr, calculate_max_drawdown, calculate_sharpe
def generate_performance_report(
backtest_result: pd.DataFrame,
code_list: list,
code_name_map: dict = None,
benchmark_name: str = "沪深300指数",
save_path: str = "report",
select_num: int = 1,
) -> dict:
"""
生成完整的绩效报告
Args:
backtest_result: 回测结果
code_list: ETF代码列表
code_name_map: 代码到名称映射
benchmark_name: 基准名称
save_path: 报告保存路径前缀
select_num: 选中数量
Returns:
dict: 绩效指标字典
"""
import os
os.makedirs(os.path.dirname(save_path) if os.path.dirname(save_path) else ".", exist_ok=True)
code_name_map = code_name_map or {}
strategy_nav = backtest_result["轮动策略净值"]
strategy_ret = backtest_result["轮动策略日收益率"]
benchmark_nav = backtest_result["基准净值"]
benchmark_ret = backtest_result["基准日收益率"]
# 计算绩效指标
s_cagr_nat = calculate_cagr(strategy_nav, "natural_days")
s_cagr_trd = calculate_cagr(strategy_nav, "trading_days")
s_total_return = strategy_nav.iloc[-1] - 1
s_sharpe = calculate_sharpe(strategy_ret)
s_max_dd, s_dd_start, s_dd_end = calculate_max_drawdown(strategy_nav)
s_win_rate = (strategy_ret > 0).sum() / len(strategy_ret)
s_calmar = s_cagr_nat / abs(s_max_dd) if s_max_dd != 0 else np.inf
b_cagr_nat = calculate_cagr(benchmark_nav, "natural_days")
b_cagr_trd = calculate_cagr(benchmark_nav, "trading_days")
b_total_return = benchmark_nav.iloc[-1] - 1
b_sharpe = calculate_sharpe(benchmark_ret)
b_max_dd, _, _ = calculate_max_drawdown(benchmark_nav)
# 打印绩效表格
print("\n" + "=" * 70)
print(" 绩效评估报告")
print("=" * 70)
print(f" 回测区间: {strategy_nav.index.min().date()} ~ {strategy_nav.index.max().date()}")
print(f" 交易天数: {len(strategy_nav)}")
print("-" * 70)
print(f' {"指标":<25} {"轮动策略":>15} {"基准(" + benchmark_name + ")":>18}')
print("-" * 70)
print(f' {"累计收益":<25} {s_total_return:>14.2%} {b_total_return:>17.2%}')
print(f' {"CAGR(自然日口径)":<25} {s_cagr_nat:>14.2%} {b_cagr_nat:>17.2%}')
print(f' {"CAGR(交易日口径)":<25} {s_cagr_trd:>14.2%} {b_cagr_trd:>17.2%}')
print(f' {"年化夏普比率":<25} {s_sharpe:>14.2f} {b_sharpe:>17.2f}')
print(f' {"最大回撤":<25} {s_max_dd:>14.2%} {b_max_dd:>17.2%}')
print(f' {"Calmar比率":<23} {s_calmar:>14.2f} {"--":>17}')
print(f' {"日胜率":<25} {s_win_rate:>14.2%} {"--":>17}')
print(f' {"最大回撤区间":<22} {str(s_dd_start.date()):>10} ~ {str(s_dd_end.date())}')
print("=" * 70)
# 绘制图表
_plot_report_chart(
backtest_result, code_list, code_name_map,
benchmark_name, save_path, select_num
)
# 返回指标字典
return {
"累计收益": s_total_return,
"CAGR_自然日": s_cagr_nat,
"CAGR_交易日": s_cagr_trd,
"夏普比率": s_sharpe,
"最大回撤": s_max_dd,
"Calmar比率": s_calmar,
"日胜率": s_win_rate,
"基准累计收益": b_total_return,
"基准CAGR_自然日": b_cagr_nat,
"基准夏普比率": b_sharpe,
"基准最大回撤": b_max_dd,
}
def _plot_report_chart(
backtest_result: pd.DataFrame,
code_list: list,
code_name_map: dict,
benchmark_name: str,
save_path: str,
select_num: int,
):
"""绘制报告图表"""
plt.rcParams["font.sans-serif"] = ["Arial Unicode MS", "SimHei", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
strategy_nav = backtest_result["轮动策略净值"]
benchmark_nav = backtest_result["基准净值"]
fig, axes = plt.subplots(3, 1, figsize=(14, 12))
# 面板1: 净值曲线
ax1 = axes[0]
ax1.plot(strategy_nav.index, strategy_nav.values,
label="轮动策略", linewidth=2, color="#E74C3C")
ax1.plot(benchmark_nav.index, benchmark_nav.values,
label=benchmark_name, linewidth=1.5, color="#3498DB", alpha=0.8)
chart_colors = plt.cm.tab20.colors
show_legend_n = min(len(code_list), 10)
for i, code in enumerate(code_list):
name = code_name_map.get(code, code)
lbl = name if i < show_legend_n else None
ax1.plot(backtest_result.index, backtest_result[f"净值_{code}"].values,
label=lbl, linewidth=0.8, alpha=0.4,
color=chart_colors[i % len(chart_colors)])
ax1.set_title("ETF轮动策略 - 净值曲线", fontsize=16, fontweight="bold")
ax1.set_ylabel("净值")
ax1.legend(loc="upper left", fontsize=8, ncol=2)
ax1.grid(True, alpha=0.3)
ax1.set_yscale("log")
# 面板2: 回撤曲线
ax2 = axes[1]
cummax = strategy_nav.cummax()
drawdown = (strategy_nav - cummax) / cummax
ax2.fill_between(drawdown.index, drawdown.values, 0, alpha=0.5, color="#E74C3C")
ax2.set_title("策略回撤", fontsize=12)
ax2.set_ylabel("回撤")
ax2.grid(True, alpha=0.3)
# 面板3: 持仓分布
ax3 = axes[2]
signal_series = backtest_result["信号"]
for i, code in enumerate(code_list):
name = code_name_map.get(code, code)
if select_num > 1:
mask = signal_series.str.contains(code, regex=False, na=False)
else:
mask = signal_series == code
if mask.any():
ax3.fill_between(signal_series.index, i, i + 0.8,
where=mask, alpha=0.7,
color=chart_colors[i % len(chart_colors)],
label=name)
ylabels = [code_name_map.get(c, c) for c in code_list]
ax3.set_title("每日持仓分布", fontsize=12)
ax3.set_yticks(range(len(ylabels)))
ax3.set_yticklabels(ylabels, fontsize=7)
ax3.grid(True, alpha=0.3)
plt.tight_layout()
chart_path = f"{save_path}_chart.png"
plt.savefig(chart_path, dpi=150, bbox_inches="tight")
plt.close()
print(f"\n报告图表已保存: {chart_path}")

View File

View File

@@ -0,0 +1,68 @@
"""
标的筛选器基类
用于基于技术指标筛选符合条件的标的
"""
from abc import ABC, abstractmethod
from typing import Any
import pandas as pd
class Screener(ABC):
"""筛选器抽象基类"""
def __init__(self, name: str, config: dict = None):
self.name = name
self.config = config or {}
@abstractmethod
def screen(self, data: Any) -> dict:
"""
执行筛选
Args:
data: 输入数据DataFrame或其他格式
Returns:
dict: 筛选结果,必须包含 'triggered' 键表示是否触发
"""
pass
@abstractmethod
def screen_batch(self, data_dict: dict) -> list:
"""
批量筛选多个标的
Args:
data_dict: {code: data} 格式的字典
Returns:
list: 符合条件的标的列表
"""
pass
class DataFrameScreener(Screener):
"""基于DataFrame的筛选器基类"""
def __init__(self, name: str, config: dict = None):
super().__init__(name, config)
def validate_data(self, df: pd.DataFrame) -> bool:
"""验证数据格式"""
required_cols = ["open", "high", "low", "close", "volume"]
return all(col in df.columns for col in required_cols)
def screen_batch(self, data_dict: dict) -> list:
"""批量筛选"""
results = []
for code, data in data_dict.items():
if isinstance(data, pd.DataFrame) and self.validate_data(data):
result = self.screen(data)
if result.get("triggered", False):
results.append({
"code": code,
**result
})
return results

186
strategies/screener/cci.py Normal file
View File

@@ -0,0 +1,186 @@
"""
CCI技术指标筛选器
基于商品通道指数(CCI)筛选超卖标的
"""
import pandas as pd
from datetime import datetime
from .base import DataFrameScreener
from core.factors.technical import calculate_cci, resample_to_weekly
from core.common.db import DatabaseManager
from core.common.notify import NotificationManager
class CCIScreener(DataFrameScreener):
"""CCI超卖筛选器"""
def __init__(self, config: dict = None):
super().__init__("CCI超卖筛选", config)
self.day_period = config.get("day_period", 14)
self.week_period = config.get("week_period", 14)
self.threshold = config.get("threshold", -100)
self.use_weekly = config.get("use_weekly", True)
self.db_manager = DatabaseManager()
self.notifier = NotificationManager()
def screen(self, df: pd.DataFrame) -> dict:
"""
对单只标的进行CCI筛选
Args:
df: DataFrame with OHLCV data
Returns:
dict: {
'triggered': bool,
'day_cci': float,
'week_cci': float or None,
'current_price': float,
}
"""
if not self.validate_data(df):
return {"triggered": False, "error": "数据格式错误"}
# 计算日线CCI
day_cci = calculate_cci(df, period=self.day_period).iloc[-1]
current_price = df["close"].iloc[-1]
result = {
"triggered": day_cci < self.threshold,
"day_cci": round(day_cci, 2),
"week_cci": None,
"current_price": round(current_price, 2),
}
# 计算周线CCI
if self.use_weekly:
weekly_df = resample_to_weekly(df)
if len(weekly_df) >= self.week_period:
week_cci = calculate_cci(weekly_df, period=self.week_period).iloc[-1]
result["week_cci"] = round(week_cci, 2)
# 日线或周线任一超卖即触发
result["triggered"] = (
day_cci < self.threshold or week_cci < self.threshold
)
return result
def get_data_from_db(self, code: str, limit: int = 100) -> pd.DataFrame:
"""从数据库获取数据"""
sql = f"""
SELECT date, open, high, low, close, volume
FROM public.index_kline
WHERE code = '{code}'
ORDER BY date DESC
LIMIT {limit}
"""
result = self.db_manager.execute_query(sql)
if not result:
return pd.DataFrame()
df = pd.DataFrame(result)
df["date"] = pd.to_datetime(df["date"])
# 转换数值类型
for col in ["open", "high", "low", "close", "volume"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
df = df.sort_values("date").reset_index(drop=True)
return df
def run_screening(self, code_list: list = None) -> list:
"""
执行批量筛选
Args:
code_list: 标的代码列表None则从配置文件读取
Returns:
list: 符合条件的标的列表
"""
if code_list is None:
# 从CSV文件读取指数列表
import os
csv_path = self.config.get("index_fund_info_file", "index_fund_info.csv")
if os.path.exists(csv_path):
df = pd.read_csv(csv_path, encoding="utf-8-sig")
code_list = df.drop_duplicates(subset=["指数代码"]).to_dict("records")
else:
raise ValueError(f"找不到标的列表文件: {csv_path}")
signals = []
today_str = datetime.now().strftime("%Y-%m-%d")
print(f"开始CCI筛选{len(code_list)} 个标的...")
for i, code_info in enumerate(code_list):
if isinstance(code_info, dict):
code = code_info.get("指数代码")
name = code_info.get("指数名称", code)
else:
code = code_info
name = code
try:
df = self.get_data_from_db(code, limit=self.config.get("lookback_days", 100))
if len(df) < self.day_period:
continue
# 检查最新日期
if df["date"].max().strftime("%Y-%m-%d") != today_str:
continue
result = self.screen(df)
if result["triggered"]:
signals.append({
"code": code,
"name": name,
"day_cci": result["day_cci"],
"week_cci": result["week_cci"],
"price": result["current_price"],
})
print(f"{code} ({name}): 日线CCI={result['day_cci']:.2f}")
except Exception as e:
print(f"{code}: {e}")
continue
print(f"\n筛选完成,{len(signals)} 个标的符合CCI超卖条件")
# 发送通知
if signals:
self.notifier.notify_signal(signals, signal_type="CCI超卖")
return signals
def run_daily(self):
"""每日定时运行"""
from datetime import datetime
# 检查是否为交易日
if datetime.today().weekday() >= 5 and self.config.get("skip_weekend", True):
print("非交易日,跳过")
return
self.run_screening()
def create_cci_screener_from_config(config_path: str = None) -> CCIScreener:
"""从配置文件创建CCI筛选器"""
import yaml
import os
if config_path is None:
config_path = os.path.join(
os.path.dirname(__file__), "..", "..", "config", "strategies", "cci.yaml"
)
with open(config_path, "r", encoding="utf-8") as f:
config = yaml.safe_load(f)
return CCIScreener(config)