diff --git a/.env b/.env index 0dc5d55..d2d7e3f 100644 --- a/.env +++ b/.env @@ -3,10 +3,16 @@ # ==================== Tushare API (中国A股指数数据) ==================== TUSHARE_TOKEN=ae768b520150da8865a38f0d9c480578f695293588c3c684f00077a1 -# 钉钉机器人配置 +# 钉钉机器人配置 - 群1 DINGTALK_WEBHOOK=https://oapi.dingtalk.com/robot/send?access_token=fb70c1561d8beba94b4f11568f4bb15e3ae07ccbdc8ac19676434a9d1cd17546 DINGTALK_SECRET=SEC1ae7cd2f1a6f9da3611af37da3e7d954c1e8533fc073c6c8cc5e5af3b6e5926b +# 钉钉机器人配置 - 群2 +DINGTALK_WEBHOOK_2=https://oapi.dingtalk.com/robot/send?access_token=87c7abfcdd69b699c32da4e4f5981cd2ca6b0445474fc6ffb36f2ed0f6262fbb +DINGTALK_SECRET_2=SECf3d6b43f2f8a87ab91feffd052e71ec314fbf57a1842e483fe07af3c0a0e5aa6 + + + # 数据库配置 DB_HOST=192.168.0.115 DB_PORT=5432 diff --git a/config/settings.py b/config/settings.py index bf04629..68ef77d 100644 --- a/config/settings.py +++ b/config/settings.py @@ -27,13 +27,29 @@ DATA_CACHE_DIR.mkdir(exist_ok=True) # ==================== 钉钉配置 ==================== def get_dingtalk_config() -> dict: - """从环境变量获取钉钉配置""" + """从环境变量获取钉钉配置(默认群1)""" return { "webhook": os.getenv("DINGTALK_WEBHOOK", ""), "secret": os.getenv("DINGTALK_SECRET", ""), } +def get_all_dingtalk_configs() -> list[dict]: + """获取所有已配置的钉钉群配置列表""" + configs = [] + # 群1(主群) + cfg1 = get_dingtalk_config() + if cfg1["webhook"]: + configs.append(cfg1) + # 群2 及后续扩展:DINGTALK_WEBHOOK_2, _3, ... + for i in range(2, 10): + webhook = os.getenv(f"DINGTALK_WEBHOOK_{i}", "") + secret = os.getenv(f"DINGTALK_SECRET_{i}", "") + if webhook: + configs.append({"webhook": webhook, "secret": secret}) + return configs + + # ==================== 数据库配置 ==================== def get_db_config() -> dict: """从环境变量获取数据库配置""" diff --git a/core/common/notify.py b/core/common/notify.py index b7994c7..d80467a 100644 --- a/core/common/notify.py +++ b/core/common/notify.py @@ -12,7 +12,7 @@ import os from loguru import logger from typing import Optional -from config.settings import get_dingtalk_config +from config.settings import get_dingtalk_config, get_all_dingtalk_configs from core.common.oss_utils import upload_image_to_oss @@ -467,6 +467,45 @@ class DingTalkBot: return self.send_image(image_path, title) +def send_to_all_groups( + send_func_name: str, + **kwargs, +) -> bool: + """ + 向所有已配置的钉钉群发送消息 + + Args: + send_func_name: DingTalkBot 的发送方法名,如 'send_text', 'send_markdown', 'send_image_via_oss' + **kwargs: 传递给发送方法的参数 + + Returns: + bool: 是否全部发送成功 + """ + configs = get_all_dingtalk_configs() + if not configs: + logger.warning("没有配置任何钉钉群,消息未发送") + return False + + all_success = True + for i, cfg in enumerate(configs, 1): + bot = DingTalkBot(webhook=cfg["webhook"], secret=cfg["secret"]) + method = getattr(bot, send_func_name, None) + if method is None: + logger.error(f"DingTalkBot 没有方法: {send_func_name}") + return False + try: + success = method(**kwargs) + if success: + logger.info(f"群{i} 发送成功") + else: + logger.error(f"群{i} 发送失败") + all_success = False + except Exception as e: + logger.error(f"群{i} 发送异常: {e}") + all_success = False + return all_success + + class NotificationManager: """通知管理器 - 统一管理多种通知渠道""" diff --git a/scripts/daily_scheduler.py b/scripts/daily_scheduler.py index 89ce757..8f1d142 100644 --- a/scripts/daily_scheduler.py +++ b/scripts/daily_scheduler.py @@ -32,7 +32,7 @@ load_dotenv(project_root / ".env") from loguru import logger import schedule import tushare as ts -from core.common.notify import DingTalkBot +from core.common.notify import DingTalkBot, send_to_all_groups from core.common.oss_utils import upload_image_to_oss # 配置日志 @@ -163,8 +163,9 @@ def send_report_to_dingtalk(chart_path: str, summary_text: str = "") -> bool: today_str = datetime.now().strftime("%Y-%m-%d") - # 发送图文消息 - success = bot.send_image_via_oss( + # 向所有群发送图文消息 + success = send_to_all_groups( + "send_image_via_oss", image_path=chart_path, title=f"ETF轮动策略调仓日报 ({today_str})", text=summary_text, @@ -226,8 +227,8 @@ def daily_task(config_path: str = "config/strategies/rotation.yaml"): if not result["success"]: # 发送错误通知 - bot = DingTalkBot() - bot.send_text(f"策略执行失败: {result.get('error', '未知错误')}") + # 发送错误通知到所有群 + send_to_all_groups("send_text", content=f"策略执行失败: {result.get('error', '未知错误')}") return # 3. 发送报告 diff --git a/轮动策略核心逻辑.md b/轮动策略核心逻辑.md new file mode 100644 index 0000000..998707a --- /dev/null +++ b/轮动策略核心逻辑.md @@ -0,0 +1,160 @@ +# ETF轮动策略核心逻辑总结 + +## 📊 策略概览 + +基于**多因子动量**的跨市场ETF轮动策略,通过量化评分自动选择表现最优的ETF组合进行投资。 + +--- + +## 🎯 候选池配置 + +### 覆盖市场 +- **A股**(17个指数): + - 宽基:沪深300(000300.SH)、中证500(000905.SH)、中证1000(000852.SH)、创业板指(399006.SZ)、上证红利(000015.SH) + - 金融:中证银行(399986.SZ) + - 消费:中证白酒(399997.SZ) + - 医药:中证医疗(399989.SZ) + - 科技:中证信息(000935.SH) + - 新能源:新能源车(399976.SZ) + - 周期资源:国证有色(399395.SZ)、中证煤炭(399998.SZ)、细分化工(399813.SZ)、中证能源(000937.SH) + - 其他:中证军工(399967.SZ)、中证农业(000949.SH)、国债指数(399702.SZ) +- **港股**(1个):恒生科技(HSTECH.HK) +- **美股**(1个):纳指100(NDX) +- **商品**(1个):黄金(AU.SHF) +- **加密货币**(2个):BTC、ETH + +### 指数-ETF映射 +每个指数对应一个可交易的ETF(或直接交易),例如: +- `000300.SH`(沪深300指数) → `510300.SH`(华泰柏瑞沪深300ETF) +- `NDX`(纳指100) → `159501.SZ`(嘉实纳指100ETF) +- `BTC`(比特币) → 无ETF,直接交易 + +**总计:22个候选标的** + +--- + +## ⏱️ 回看周期与调仓周期 + +### 回看周期 +- **窗口长度**:25个交易日 +- **因子类型**:`slope_r2`(斜率×R²趋势得分) + - 对过去25日价格进行线性回归 + - 得分 = 斜率 × R² × 10000 + - 同时考虑趋势强度和稳定性 + +### 调仓周期 +- **最低持有期**:1天(`rebalance_days = 1`) +- **调仓阈值**:0%(`rebalance_threshold = 0.0`) + - 新组合总得分 > 当前组合总得分时即触发调仓 +- **交易成本**:0.1%(双边,含佣金+滑点) + +**实际运行**:每个交易日评估是否调仓,无强制锁定期 + +--- + +## 🧮 得分计算逻辑 + +### 核心公式 +``` +得分 = 斜率(slope) × R² × 10000 +``` + +### 计算步骤 +1. **数据获取**:获取每个标的过去25+日的收盘价 +2. **价格归一化**:将价格序列除以起始价格(消除绝对价格影响) +3. **线性回归**:对归一化价格进行一元线性回归 + - `x = [1, 2, 3, ..., 25]` + - `y = 归一化价格序列` +4. **提取指标**: + - `slope`:回归斜率(代表趋势方向与强度) + - `R²`:拟合优度(代表趋势稳定性) +5. **计算得分**:`score = 10000 × slope × R²` + +### 得分含义 +- **正值**:上升趋势,值越大趋势越强且越稳定 +- **负值**:下降趋势 +- **接近0**:无明显趋势或趋势不稳定 + +### 跨市场数据对齐 +- **基准日历**:以A股交易日为准 +- **非A股标的**:数据前向填充(ffill)到A股交易日 +- **T+1规则**:T日收盘计算信号,仅使用T日及之前数据 + +--- + +## 📦 持仓数量与仓位配置 + +### 持仓数量 +- **选中数量**:5个ETF(`select_num = 5`) +- 每日从22个候选标的中选出得分最高的5个 + +### 仓位配置 +- **等权分配**:每个选中标的占总仓位的20%(1/5) +- **日收益率计算**:`组合日收益 = mean(5个标的的日收益率)` + +### 换仓逻辑 +1. 每日计算所有标的的最新得分 +2. 选出Top 5构成"目标组合" +3. 对比"目标组合"与"当前持仓组合"的总得分 +4. 若 `目标总得分 > 当前总得分`,则触发调仓 +5. 调仓时卖出旧标的,等权买入新标的 + +--- + +## 🛡️ 溢价率控制(跨境ETF) + +### 控制机制 +- **A股ETF**:不启用(溢价通常 < 0.5%) +- **港股ETF**:阈值3%,超过则排除 +- **美股ETF**:阈值2%,超过则排除 +- **商品ETF**:不启用 + +### 处理模式 +- **filter模式**:溢价超阈值的标的直接从候选池排除 +- **penalty模式**(可选):对高溢价标的得分乘以惩罚系数(0.5) + +--- + +## 📅 运行时间安排 + +### 信号生成时间 +- **运行时间**:T+1日上午9:00(北京时间) +- **数据基准**:基于T日(前一交易日)收盘数据 +- **原因**:美股和加密货币数据在T+1日凌晨才可用 + +### 数据源 +- **A股**:Tushare +- **港股/美股/商品**:YFinance +- **加密货币**:CCXT/OKX(通过SSH隧道访问) + +--- + +## 📈 策略特点总结 + +| 维度 | 配置 | +|------|------| +| **策略类型** | 动量轮动(趋势跟踪) | +| **候选池** | 22个标的(A股/港股/美股/商品/加密货币) | +| **回看周期** | 25个交易日 | +| **因子类型** | 斜率×R²(趋势强度×稳定性) | +| **持仓数量** | 5个ETF | +| **仓位配置** | 等权(各20%) | +| **调仓频率** | 每日评估,无锁定期 | +| **调仓条件** | 新组合得分 > 旧组合得分 | +| **交易成本** | 0.1%(双边) | +| **溢价控制** | 港股3%、美股2%阈值过滤 | +| **基准指数** | 沪深300(000300.SH) | + +--- + +## 💡 核心优势 + +1. **跨市场分散**:覆盖股票、商品、加密货币,降低单一市场风险 +2. **趋势+稳定性**:slope_r2因子同时捕捉趋势方向和可靠性 +3. **自动轮动**:量化评分,避免主观判断 +4. **溢价保护**:防止高价买入跨境ETF +5. **灵活配置**:所有参数可通过YAML配置文件调整 + +--- + +*文档生成时间:2026-04-16*