refactor(notify): 将通知模块从归档移至正式位置

- 将 notify.py 和 oss_utils.py 从 archive/legacy_core 移至 core/common/
- 内联钉钉配置读取函数,移除对 config.settings 的依赖
- 删除 config/ 目录(settings.py 不再需要)
- daily_scheduler.py 移除归档路径的 sys.path hack
- 新增 --no-detail 和 --no-report 命令行参数控制导出
- 全标的排名表新增退场日期和退场价格列
This commit is contained in:
2026-06-08 22:34:03 +08:00
parent c32ce72579
commit 844e609ff7
6 changed files with 898 additions and 82 deletions

View File

@@ -30,10 +30,6 @@ from pathlib import Path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
# 添加归档模块路径(使用原有的 notify 和 oss_utils
archive_path = project_root / 'archive' / 'legacy_core'
sys.path.insert(0, str(archive_path))
# 加载环境变量
from dotenv import load_dotenv
load_dotenv()
@@ -154,7 +150,7 @@ def run_strategy(config_path: str = "strategies/rotation/config.yaml") -> dict:
return {"success": False, "error": str(e)}
def run_simple_rotation(config_path: str = None) -> dict:
def run_simple_rotation(config_path: str = None, no_detail: bool = False, no_report: bool = False) -> dict:
"""
执行 simple_rotation.py 策略回测并生成报告
@@ -173,6 +169,10 @@ def run_simple_rotation(config_path: str = None) -> dict:
]
if config_path:
cmd.extend(["--config", config_path])
if no_detail:
cmd.append("--no-detail")
if no_report:
cmd.append("--no-report")
logger.info(f"执行命令: {' '.join(cmd)}")
@@ -284,7 +284,9 @@ def setup_schedule(target_time: str = "15:30",
def daily_task(config_path: str = "strategies/rotation/config.yaml",
strategy: str = "all",
simple_config: str = None):
simple_config: str = None,
no_detail: bool = False,
no_report: bool = False):
"""
每日任务主流程
@@ -305,7 +307,7 @@ def daily_task(config_path: str = "strategies/rotation/config.yaml",
# 2. 执行 Simple Rotation 策略
if strategy in ("simple", "all"):
result = run_simple_rotation(simple_config)
result = run_simple_rotation(simple_config, no_detail=no_detail, no_report=no_report)
if result["success"]:
if result.get("chart_path"):
send_report_to_dingtalk(
@@ -384,6 +386,16 @@ def main():
action='store_true',
help='非后台模式:执行一次后进入定时循环(测试用)'
)
parser.add_argument(
'--no-detail',
action='store_true',
help='跳过 detail JSON 导出(加速日常运行)'
)
parser.add_argument(
'--no-report',
action='store_true',
help='跳过 report PNG 生成'
)
args = parser.parse_args()
@@ -392,12 +404,12 @@ def main():
if args.now:
# 立即执行一次并退出
daily_task(args.config, args.strategy, args.simple_config)
daily_task(args.config, args.strategy, args.simple_config, args.no_detail, args.no_report)
elif args.no_daemon:
# 非后台模式:执行一次后进入定时循环
setup_schedule(args.time, args.config, args.strategy, args.simple_config)
logger.info("执行一次测试...")
daily_task(args.config, args.strategy, args.simple_config)
daily_task(args.config, args.strategy, args.simple_config, args.no_detail, args.no_report)
logger.info("测试完成启动定时任务循环Ctrl+C 停止)...")
run_scheduler_loop()
else:

View File

@@ -1013,8 +1013,14 @@ class SimpleRotationStrategy:
# Export
# ============================================================
def export_results(self, output_dir: str = None):
"""Export backtest results to CSV and JSON (V2-compatible detail format)"""
def export_results(self, output_dir: str = None, detail: bool = True):
"""Export backtest results to CSV and JSON (V2-compatible detail format)
Args:
output_dir: Output directory path.
detail: If True, export detail JSON (large file for backtest_viewer).
Set to False for daily runs to skip expensive detail generation.
"""
if not self.daily_records:
print(" x No results to export")
return
@@ -1036,81 +1042,82 @@ class SimpleRotationStrategy:
df[['date', 'holdings', 'is_rebalance', 'added', 'removed']].to_csv(sig_path, index=False)
print(f" + Signals: {sig_path}")
# Detail JSON (V2-compatible format)
detail_path = output_dir / 'simple_rotation_detail.json'
days_out = []
# Track entry_info across days for asset detail reconstruction
tracked_entry: Dict[str, dict] = {}
prev_holdings = []
# Detail JSON (V2-compatible format, optional — large file for backtest_viewer)
if detail:
detail_path = output_dir / 'simple_rotation_detail.json'
days_out = []
# Track entry_info across days for asset detail reconstruction
tracked_entry: Dict[str, dict] = {}
prev_holdings = []
# Build date index map for signal_date lookup (calendar T-1, not A-share prev trading day)
date_list = [pd.Timestamp(rec['date']) for rec in self.daily_records]
date_to_signal_date = {d: d - timedelta(days=1) for d in date_list}
# Build date index map for signal_date lookup (calendar T-1, not A-share prev trading day)
date_list = [pd.Timestamp(rec['date']) for rec in self.daily_records]
date_to_signal_date = {d: d - timedelta(days=1) for d in date_list}
for rec in self.daily_records:
date = pd.Timestamp(rec['date'])
signal_date = date_to_signal_date[date] # T-1 for signal
holdings = rec['holdings']
added = set(holdings) - set(prev_holdings)
removed = set(prev_holdings) - set(holdings)
for rec in self.daily_records:
date = pd.Timestamp(rec['date'])
signal_date = date_to_signal_date[date] # T-1 for signal
holdings = rec['holdings']
added = set(holdings) - set(prev_holdings)
removed = set(prev_holdings) - set(holdings)
# Update entry tracking (consistent with run() logic)
for code in added:
trade_code = self.signal_to_trade.get(code, code)
etf_prices = self._get_etf_prices(trade_code, date)
# Entry price = actual buy price at T's open
entry_etf = etf_prices['open'] if etf_prices else None
# Index close at T-1 (signal data used for decision)
idx_close = self._get_index_close(code, signal_date)
tracked_entry[code] = {
'entry_date': date.strftime('%Y-%m-%d'),
'entry_price_etf': entry_etf,
'entry_price_idx': idx_close,
}
for code in removed:
tracked_entry.pop(code, None)
# Update entry tracking (consistent with run() logic)
for code in added:
trade_code = self.signal_to_trade.get(code, code)
etf_prices = self._get_etf_prices(trade_code, date)
# Entry price = actual buy price at T's open
entry_etf = etf_prices['open'] if etf_prices else None
# Index close at T-1 (signal data used for decision)
idx_close = self._get_index_close(code, signal_date)
tracked_entry[code] = {
'entry_date': date.strftime('%Y-%m-%d'),
'entry_price_etf': entry_etf,
'entry_price_idx': idx_close,
}
for code in removed:
tracked_entry.pop(code, None)
# Build signals dict: {code: 1} for selected holdings
signals = {c: 1 for c in holdings}
# Build signals dict: {code: 1} for selected holdings
signals = {c: 1 for c in holdings}
# Build per-asset details
assets = self._build_day_assets(rec, date, tracked_entry)
# Build per-asset details
assets = self._build_day_assets(rec, date, tracked_entry)
days_out.append({
'date': rec['date'],
'nav': rec['nav'],
'daily_return': rec['daily_return'],
'is_rebalance': rec['is_rebalance'],
'signals': signals,
'holdings': holdings,
'added': rec['added'],
'removed': rec['removed'],
'assets': assets,
})
prev_holdings = holdings
days_out.append({
'date': rec['date'],
'nav': rec['nav'],
'daily_return': rec['daily_return'],
'is_rebalance': rec['is_rebalance'],
'signals': signals,
'holdings': holdings,
'added': rec['added'],
'removed': rec['removed'],
'assets': assets,
})
prev_holdings = holdings
detail = {
'meta': {
'mode': 'Simple: Daily Iteration',
'start_date': self.config.backtest.start_date,
'end_date': self.daily_records[-1]['date'] if self.daily_records else self.config.backtest.end_date or 'now',
'total_days': len(self.daily_records),
'select_num': self.select_num,
'n_days': self.n_days,
'trade_cost': self.trade_cost,
'bond_threshold': {
'enabled': self.use_dynamic_threshold,
'bond_code': self.bond_code,
'ratio': self.bond_ratio,
detail_data = {
'meta': {
'mode': 'Simple: Daily Iteration',
'start_date': self.config.backtest.start_date,
'end_date': self.daily_records[-1]['date'] if self.daily_records else self.config.backtest.end_date or 'now',
'total_days': len(self.daily_records),
'select_num': self.select_num,
'n_days': self.n_days,
'trade_cost': self.trade_cost,
'bond_threshold': {
'enabled': self.use_dynamic_threshold,
'bond_code': self.bond_code,
'ratio': self.bond_ratio,
},
'codes': self._build_meta_codes(),
},
'codes': self._build_meta_codes(),
},
'days': days_out,
}
_sanitize_json(detail)
with open(detail_path, 'w', encoding='utf-8') as f:
json.dump(detail, f, ensure_ascii=False, indent=2)
print(f" + Detail: {detail_path} ({len(days_out)} days)")
'days': days_out,
}
_sanitize_json(detail_data)
with open(detail_path, 'w', encoding='utf-8') as f:
json.dump(detail_data, f, ensure_ascii=False, indent=2)
print(f" + Detail: {detail_path} ({len(days_out)} days)")
# Metrics JSON
metrics = self._compute_metrics(sum(1 for r in self.daily_records if r['is_rebalance']))
@@ -1275,6 +1282,7 @@ class SimpleRotationStrategy:
'premium': premium, 'action': action,
'entry_date': entry_date, 'entry_price': entry_price,
'holding_days': holding_days, 'pnl': pnl,
'exit_date': None, 'exit_price': None,
})
# Build exit positions: ONLY when the last day is a rebalance day
@@ -1301,6 +1309,7 @@ class SimpleRotationStrategy:
exit_entry_price = None
exit_holding_days = 0
exit_pnl = None
sell_price = None
for rec in reversed(self.daily_records[:last_idx]):
if code in rec['holdings']:
exit_entry_date = pd.Timestamp(rec['date'])
@@ -1327,6 +1336,7 @@ class SimpleRotationStrategy:
'premium': premium, 'action': '调出',
'entry_date': exit_entry_date, 'entry_price': exit_entry_price,
'holding_days': exit_holding_days, 'pnl': exit_pnl,
'exit_date': last_date, 'exit_price': sell_price,
})
# Build unselected (未入选) positions: all signal codes not held and not exited
@@ -1351,6 +1361,7 @@ class SimpleRotationStrategy:
'premium': premium, 'action': '未入选',
'entry_date': None, 'entry_price': None,
'holding_days': 0, 'pnl': None,
'exit_date': None, 'exit_price': None,
})
# ==================== Plot ====================
@@ -1374,7 +1385,8 @@ class SimpleRotationStrategy:
rank_map = {c: i + 1 for i, c in enumerate(ranked_all)}
col_labels = ["排名", "标的名称", "市场", "指数代码", "ETF代码", "仓位", "得分",
"指数最新价", "ETF收盘价", "溢价率", "状态", "进场日期", "持有天数", "盈亏"]
"指数最新价", "ETF收盘价", "溢价率", "状态",
"进场日期", "持有天数", "盈亏", "退场日期", "退场价格"]
table_data = []
row_actions = [] # track action for coloring
@@ -1391,9 +1403,12 @@ class SimpleRotationStrategy:
pnl_s = f"{p['pnl']:+.2%}" if p['pnl'] is not None else ""
weight_s = f"{p['weight']:.0%}" if p['weight'] > 0 else ""
market = code_config.get(p['code'], {}).get('market', '')
exit_date_s = p['exit_date'].strftime('%Y-%m-%d') if p.get('exit_date') else ""
exit_price_s = f"{p['exit_price']:.3f}" if p.get('exit_price') else ""
table_data.append([
rank, p['name'], market, p['code'], p['etf'], weight_s,
score_s, idx_s, etf_s, prem_s, p['action'], entry_date_s, days_s, pnl_s
score_s, idx_s, etf_s, prem_s, p['action'],
entry_date_s, days_s, pnl_s, exit_date_s, exit_price_s
])
row_actions.append(p['action'])
@@ -1571,12 +1586,21 @@ class SimpleRotationStrategy:
# ============================================================
if __name__ == "__main__":
import argparse
if 'FLASK_API_URL' not in os.environ:
os.environ['FLASK_API_URL'] = 'https://k3s.tokenpluse.xyz'
parser = argparse.ArgumentParser(description='Simple Rotation Strategy Backtest')
parser.add_argument('--no-detail', action='store_true',
help='Skip detail JSON export (faster, for daily runs)')
parser.add_argument('--no-report', action='store_true',
help='Skip report PNG generation')
args = parser.parse_args()
strategy = SimpleRotationStrategy()
result = strategy.run()
if result:
strategy.export_results()
strategy.generate_report()
strategy.export_results(detail=not args.no_detail)
if not args.no_report:
strategy.generate_report()