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:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user