Compare commits

...

7 Commits

Author SHA1 Message Date
07d6f1451c fix(rotation): raise RuntimeError on held asset data failure
- Add data integrity check: if any currently held asset is missing
  from factors, raise RuntimeError immediately to prevent false rebalance
- Previously missing data would silently cause incorrect sell signals
- Now fails fast with clear error message identifying the missing assets
  and the date of failure
2026-06-02 01:16:44 +08:00
4791d3cf40 refactor(scheduler): move daily_scheduler.py to rotation/ and add simple_rotation support
- Move scripts/daily_scheduler.py -> rotation/daily_scheduler.py
- Add run_simple_rotation() to execute simple_rotation.py via subprocess
- Add --strategy flag (simple/legacy/all) for flexible strategy selection
- Add --simple-config flag for custom simple rotation config path
- Update Dockerfile and docker-compose.yml path references
- Add configurable title to send_report_to_dingtalk()
2026-06-02 01:16:34 +08:00
5e11b6b690 fix(rotation): 溢价率缓存增加增量更新逻辑
- preload_premium: 检查缓存日期范围,不足时增量拉取
- 新增 _fetch_premium_api: 拉取并合并新溢价率数据
- 调用时传入 end_date 触发增量检查

修复前: premium CSV存在即返回旧数据,明天9点运行时拿不到最新
修复后: 检测 latest_cached < end_date 时自动拉取增量
2026-06-01 23:56:18 +08:00
19f1c63981 fix(rotation): 修复溢价率计算,改用Flask API真实premium_series数据
- _fetch_api: 提取premium_series并存入df.attrs和CSV缓存
- DataCache: 新增premium_data字典、preload_premium方法
- preload_premium: 无缓存时主动请求API获取全量历史溢价率
- _preload_data: 加载ETF后同步调用preload_premium
- _compute_premium(trade_code, date): 从内存缓存按日期查找真实溢价率
- 新增trade_code_to_group映射,确保BOND资产正确识别

修复前: 溢价率 = (ETF价格 - 指数点位) / 指数点位 → -99.9%
修复后: 使用API返回的(ETF价格 - NAV) / NAV → 合理范围
2026-06-01 23:31:36 +08:00
6d0b928894 fix(rotation): 消除前视偏差 + V2兼容detail导出
时序对齐修复:
- 信号生成改用 T-1 收盘数据(9AM信号时T日未开盘)
- entry_price_etf 改用 T 日 open(实际买入价)
- 年化收益: 52.66% → 25.12%(去除约4倍虚高)

V2兼容detail JSON:
- _generate_signals 返回 (holdings, factors, bond_momentum)
- 6个helper方法: build_meta_codes, get_index/etf_close, daily_returns, premium, day_assets
- 每日11资产×16字段完整记录(momentum/rank/holding_days/cum_return等)
- export_results 同步修复 entry_info 时序逻辑

Backtest (2020-01-10 ~ 2026-06-01, 1545天):
- 总收益 295.14%, 年化 25.12%
- 最大回撤 -14.74%, 夏普 1.33, 卡尔马 1.70
2026-06-01 23:13:43 +08:00
451ffa33d2 clean(rotation): add simple rotation strategy and remove unused files
New:
- rotation/simple_rotation.py: daily-iteration rotation strategy (584 lines)
- rotation/config_loader.py: standalone config loader
- rotation/config_simple.yaml: 11 assets, 7 groups
- rotation/README_SIMPLE.md: usage guide
- scripts/get_trading_calendar.py: trading calendar fetcher

Removed:
- rotation/example_usage.py, run_strategy.py (replaced by simple_rotation.py)
- rotation/results/ output files (gitignored)
- scripts/verify_*.py, calculate_returns_from_detail.py (one-off scripts)
- scripts/README_TRADING_CALENDAR.md

Backtest result (2020-01-10 ~ 2026-06-01):
- Total return: 1237.6%, Annual: 52.66%
- Max drawdown: -11.71%, Sharpe: 2.50
2026-06-01 22:28:26 +08:00
3b0208d7d3 docs(viewer): 添加 backtest_viewer.html 到 git 追踪
- 修改 .gitignore 添加 HTML 文件例外规则
- 将 visualization/backtest_viewer.html 纳入版本控制
- 保留回测可视化查看器供团队使用
2026-05-26 23:33:06 +08:00
12 changed files with 3242 additions and 56 deletions

4
.gitignore vendored
View File

@@ -189,6 +189,10 @@ data_cache/
*.gif
*.svg
# Exception: backtest viewer HTML files
!visualization/backtest_viewer.html
!framework_v2/backtest_viewer.html
# Report files (keep examples)
report*.csv

View File

@@ -24,4 +24,4 @@ EXPOSE 80
CMD ["python", "datasource/flask_server.py", "--host", "0.0.0.0"]
# 运行定时任务调度器如需使用Flask服务取消上面注释并注释掉下面
# CMD ["python", "scripts/daily_scheduler.py", "--time", "09:00"]
# CMD ["python", "rotation/daily_scheduler.py", "--time", "09:00"]

View File

@@ -19,8 +19,8 @@ services:
# 挂载 results 目录(保存报告)
- ./results:/app/results
# 默认daemon模式运行只需简单命令即可
# command: ["python", "scripts/daily_scheduler.py"]
# command: ["python", "rotation/daily_scheduler.py"]
# 如需立即执行一次并退出:
# command: ["python", "scripts/daily_scheduler.py", "--run-now"]
# command: ["python", "rotation/daily_scheduler.py", "--now"]
# 如需执行一次后进入定时循环:
# command: ["python", "scripts/daily_scheduler.py", "--no-daemon"]
# command: ["python", "rotation/daily_scheduler.py", "--no-daemon"]

View File

@@ -72,7 +72,18 @@ def run_backtest():
print("=" * 70)
strategy = GlobalRotationStrategy(config)
result = strategy.run()
# 运行回测并导出 detail
output_dir = project_root / "framework_v2" / "results"
output_dir.mkdir(exist_ok=True)
detail_path = output_dir / "backtest_detail_v2.json"
print(f"\n导出 detail JSON: {detail_path}")
result = strategy.run(
export_detail=True,
detail_path=str(detail_path)
)
# 打印结果
print("\n" + "=" * 70)

View File

@@ -391,31 +391,55 @@ class GlobalRotationStrategy(StrategyBase):
aligner = CrossMarketAligner(target_calendar=trading_calendar)
# 提取交易标的的收盘价,并对齐到 A 股日历
print(" [对齐] 对齐 ETF 价格到 A 股日历...")
close_dict = {}
print(" [对齐] 构建可实现价格序列(模拟真实交易)...")
executable_close_dict = {}
for signal_code, trade_code in signal_to_trade.items():
if trade_code in data:
# 提取收盘价
close_series = data[trade_code]['close']
# 使用 signal_code 作为键(与 positions 列名一致)
close_dict[signal_code] = close_series
# 提取开盘价和收盘价
etf_df = data[trade_code]
open_series = etf_df['open'].reindex(trading_calendar, method='ffill')
close_series = etf_df['close'].reindex(trading_calendar, method='ffill')
# 默认使用收盘价
exec_close = close_series.copy()
# 检测调仓日,调整价格以反映真实交易
for i in range(1, len(trading_calendar)):
date = trading_calendar[i]
prev_date = trading_calendar[i-1]
# 获取仓位变化
prev_pos = positions.loc[prev_date, signal_code] if signal_code in positions.columns else 0
curr_pos = positions.loc[date, signal_code] if signal_code in positions.columns else 0
# 买入日:修改前一天价格为当日开盘价
# 这样收益率 = (close[t] - open[t]) / open[t] = 日内收益
if pd.isna(prev_pos) or prev_pos == 0:
if pd.notna(curr_pos) and curr_pos > 0:
exec_close.loc[prev_date] = open_series.loc[date]
# 卖出日:不需要修改(因为 positions[t]=0不会计算收益
executable_close_dict[signal_code] = exec_close
else:
print(f" 警告: {trade_code} 数据不存在,跳过")
# 使用 CrossMarketAligner 对齐多标的收益率
# 内部逻辑:先 ffill 价格到 A 股日历,再计算收益率
print(" [对齐] 计算收益率(先对齐价格,再计算...")
returns_df = aligner.align_multi_asset(close_dict)
print(" [对齐] 计算收益率(使用可实现价格...")
returns_df = aligner.align_multi_asset(executable_close_dict)
print(f" [对齐] 收益率数据: {len(returns_df)} 天, {len(returns_df.columns)} 个标的")
# 对齐 positions 到 A 股日历
# 注意:必须先 reindex 再 ffill因为 reindex(method='ffill') 不会填充已有的 NaN
positions = positions.reindex(trading_calendar)
positions = positions.ffill()
# 卖出日不向前填充(保持 0
positions = positions.ffill().fillna(0)
# 计算策略收益(仓位加权,T+1 执行
positions_delayed = positions.shift(1).fillna(0)
strategy_returns = (positions_delayed * returns_df).sum(axis=1)
# 计算策略收益(仓位加权,无需延迟
# 因为 positions[t] 已表示 t 日的实际持仓,且价格已调整为可实现价格
strategy_returns = (positions * returns_df).sum(axis=1)
# 扣除交易成本
strategy_returns, rebalance_count = self._apply_trade_cost(
@@ -649,6 +673,33 @@ class GlobalRotationStrategy(StrategyBase):
index_return_dict = {}
etf_return_dict = {}
# 构建 ETF 可实现价格序列(与回测一致)
executable_etf_close = {}
for signal_code, trade_code in signal_to_trade.items():
if trade_code in self._data:
etf_df = self._data[trade_code]
open_series = etf_df['open'].reindex(trading_calendar, method='ffill')
close_series = etf_df['close'].reindex(trading_calendar, method='ffill')
# 默认使用 close
exec_close = close_series.copy()
# 检测调仓日,调整价格
for i in range(1, len(trading_calendar)):
date = trading_calendar[i]
prev_date = trading_calendar[i-1]
# 获取仓位变化
prev_pos = positions.loc[prev_date, signal_code] if signal_code in positions.columns else 0
curr_pos = positions.loc[date, signal_code] if signal_code in positions.columns else 0
# 买入日:修改前一天价格为 open
if pd.isna(prev_pos) or prev_pos == 0:
if pd.notna(curr_pos) and curr_pos > 0:
exec_close.loc[prev_date] = open_series.loc[date]
executable_etf_close[signal_code] = exec_close
for signal_code, trade_code in signal_to_trade.items():
# 指数收益率
if signal_code in index_close_dict:
@@ -656,10 +707,10 @@ class GlobalRotationStrategy(StrategyBase):
idx_return = idx_close.pct_change(fill_method=None).fillna(0)
index_return_dict[signal_code] = idx_return
# ETF 收益率
if signal_code in etf_close_dict:
etf_close = etf_close_dict[signal_code].reindex(trading_calendar, method='ffill')
etf_return = etf_close.pct_change(fill_method=None).fillna(0)
# ETF 收益率(使用可实现价格)
if signal_code in executable_etf_close:
etf_exec = executable_etf_close[signal_code]
etf_return = etf_exec.pct_change(fill_method=None).fillna(0)
etf_return_dict[signal_code] = etf_return
# 对齐因子

64
rotation/README_SIMPLE.md Normal file
View File

@@ -0,0 +1,64 @@
# 精简版轮动策略(日迭代)
## 概述
从 A 股交易日历出发,逐日迭代模拟实盘信号生成流程。
**与 V2 的核心差异:**
| 特性 | V2 向量化版本 | 精简版(日迭代) |
|------|--------------|------------------|
| 数据获取 | 一次性获取所有历史数据 | 预加载+CSV缓存 |
| 因子计算 | 全量 rolling 计算 | 每日单独计算 |
| 日历对齐 | 先算因子,后对齐 | 从 A 股日历出发 |
| 收益计算 | 向量化 | 逐日迭代 |
## 快速使用
```bash
cd /path/to/etf
FLASK_API_URL=https://k3s.tokenpluse.xyz python rotation/simple_rotation.py
```
或作为模块导入:
```python
from rotation.simple_rotation import SimpleRotationStrategy
strategy = SimpleRotationStrategy('rotation/config_simple.yaml')
result = strategy.run()
strategy.export_results()
```
## 策略逻辑
### 动量计算
- 加权线性回归:`score = annualized_return * R^2`
- 窗口25 天(可配置)
- 崩盘过滤:连续 3 天跌 > 5% 则清零
### 信号生成(与 V2 完全一致)
1. 每个 group 内选 Top 1非 BOND 组需超过短债动量)
2. 从各组 Top 1 中按动量排序选 Top 3
3. 不足用 BOND 填充
### T+1 执行收益
- **持有日**: close-to-close
- **卖出日**: close-to-open开盘已卖出
- **买入日**: open-to-close日内收益
- 调仓日扣除 0.1% 交易成本
## 输出文件
```
results/
├── simple_rotation_nav.csv # 净值曲线
├── simple_rotation_signals.csv # 每日信号
├── simple_rotation_detail.json # 完整详情
└── simple_rotation_metrics.json # 绩效指标
```
## 数据缓存
首次运行会从 Flask API 下载数据并缓存到 `data/simple_rotation_cache/`
后续运行直接读取缓存,速度显著提升。

457
rotation/config_loader.py Normal file
View File

@@ -0,0 +1,457 @@
"""
配置加载器 - rotation 目录专用(独立实现)
不依赖 framework_v2完全独立的配置加载和验证机制。
使用示例:
from config_loader import load_rotation_config
# 加载配置(自动处理环境变量)
config = load_rotation_config('config_simple.yaml')
# 访问配置
print(f"策略版本: {config.metadata.version}")
print(f"动量窗口: {config.factor.n_days}")
print(f"资产数量: {config.asset_pools.count()}")
"""
import os
import re
import yaml
from pathlib import Path
from typing import Optional, Dict, List, Any
from pydantic import BaseModel, Field, field_validator
from enum import Enum
# ============================================================
# 枚举类型
# ============================================================
class FactorType(str, Enum):
"""因子类型枚举"""
MOMENTUM = "momentum"
SLOPE_R2 = "slope_r2"
WEIGHTED_MOMENTUM = "weighted_momentum"
class PremiumMode(str, Enum):
"""溢价控制模式"""
FILTER = "filter" # 完全排除
PENALIZE = "penalize" # 降权
class ThresholdMode(str, Enum):
"""阈值模式"""
FIXED = "fixed" # 固定阈值
DYNAMIC = "dynamic" # 动态阈值
class DataSourceType(str, Enum):
"""数据源类型"""
FLASK_API = "flask_api"
TUSHARE = "tushare"
YFINANCE = "yfinance"
# ============================================================
# Schema 定义(独立实现)
# ============================================================
class PremiumConfig(BaseModel):
"""溢价控制配置"""
enabled: bool = Field(default=True, description="是否启用")
threshold: float = Field(default=0.10, ge=0, le=1.0, description="溢价阈值")
class AssetConfig(BaseModel):
"""标的配置"""
name: str = Field(..., description="标的名称")
group: str = Field(..., description="策略分组")
signal_source: str = Field(..., description="信号来源代码")
trade_source: str = Field(..., description="交易来源代码")
description: Optional[str] = Field(None, description="标的描述")
etf: Optional[str] = Field(None, description="ETF代码兼容旧配置")
premium_control: Optional["PremiumConfig"] = Field(None, description="溢价控制配置")
@property
def is_cross_market(self) -> bool:
"""是否跨市场"""
return self.signal_source != self.trade_source
class AssetPool(BaseModel):
"""资产池(扁平化设计)"""
assets: Dict[str, AssetConfig] = Field(
default_factory=dict,
description="所有标的"
)
@property
def by_group(self) -> Dict[str, Dict[str, AssetConfig]]:
"""按策略分组"""
groups = {}
for code, asset in self.assets.items():
group = asset.group
if group not in groups:
groups[group] = {}
groups[group][code] = asset
return groups
@property
def groups(self) -> list:
"""获取所有策略分组"""
return list(self.by_group.keys())
def get_signal_codes(self, group: str = None) -> list:
"""获取信号标的"""
if group:
return [
asset.signal_source
for asset in self.assets.values()
if asset.group == group
]
return [asset.signal_source for asset in self.assets.values()]
def get_trade_codes(self, group: str = None) -> list:
"""获取交易标的"""
if group:
return [
asset.trade_source
for asset in self.assets.values()
if asset.group == group
]
return [asset.trade_source for asset in self.assets.values()]
def get_signal_to_trade_mapping(self, group: str = None) -> Dict[str, str]:
"""获取信号→交易映射"""
mapping = {}
for asset in self.assets.values():
if group and asset.group != group:
continue
mapping[asset.signal_source] = asset.trade_source
return mapping
def count(self, group: str = None) -> int:
"""获取标的数量"""
if group:
return len([a for a in self.assets.values() if a.group == group])
return len(self.assets)
class FactorConfig(BaseModel):
"""因子配置"""
type: FactorType = Field(default=FactorType.WEIGHTED_MOMENTUM)
n_days: int = Field(default=25, ge=5, le=250)
class DynamicThresholdConfig(BaseModel):
"""动态阈值配置"""
reference: str = Field(..., description="阈值参考标的")
ratio: float = Field(default=1.0, ge=0, description="阈值倍数")
fallback_enabled: bool = Field(default=True)
fallback_value: float = Field(default=0.0)
class ThresholdConfig(BaseModel):
"""阈值配置"""
mode: ThresholdMode = Field(default=ThresholdMode.DYNAMIC)
fixed_value: float = Field(default=0.0)
dynamic: Optional[DynamicThresholdConfig] = Field(None)
class RotationConfig(BaseModel):
"""轮动配置"""
select_num: int = Field(default=3, ge=1, le=10)
diversified: bool = Field(default=True)
threshold: ThresholdConfig = Field(default_factory=ThresholdConfig)
class RebalanceConfig(BaseModel):
"""调仓配置"""
min_hold_days: int = Field(default=1, ge=1)
score_threshold: float = Field(default=0.0)
trade_cost: float = Field(default=0.001, ge=0, le=0.01)
class PremiumControlConfig(BaseModel):
"""溢价控制配置"""
enabled: bool = Field(default=False)
default_threshold: float = Field(default=0.10)
mode: PremiumMode = Field(default=PremiumMode.FILTER)
penalty_factor: float = Field(default=0.5)
market_overrides: Optional[Dict[str, Dict[str, Any]]] = Field(None)
class DataSourceConfig(BaseModel):
"""数据源配置"""
type: DataSourceType = Field(...)
enabled: bool = Field(default=True)
timeout: int = Field(default=120, ge=10, le=600)
url: Optional[str] = Field(None)
class DataConfig(BaseModel):
"""数据配置"""
sources: List[DataSourceConfig] = Field(...)
@field_validator('sources')
@classmethod
def check_at_least_one(cls, v):
"""至少配置一个数据源"""
if not v:
raise ValueError("必须配置至少一个数据源")
return v
class BenchmarkConfig(BaseModel):
"""基准配置"""
code: str = Field(...)
name: str = Field(...)
class BacktestConfig(BaseModel):
"""回测配置"""
start_date: str = Field(...)
end_date: Optional[str] = Field(None)
class MetadataConfig(BaseModel):
"""配置元数据"""
version: str = Field(default="1.0.0")
strategy: str = Field(default="rotation")
description: str = Field(default="")
last_updated: Optional[str] = Field(None)
class RotationStrategyConfig(BaseModel):
"""ETF轮动策略完整配置"""
metadata: MetadataConfig = Field(default_factory=MetadataConfig)
asset_pools: AssetPool = Field(...)
benchmark: BenchmarkConfig = Field(...)
backtest: BacktestConfig = Field(...)
factor: FactorConfig = Field(default_factory=FactorConfig)
rotation: RotationConfig = Field(default_factory=RotationConfig)
rebalance: RebalanceConfig = Field(default_factory=RebalanceConfig)
premium_control: PremiumControlConfig = Field(default_factory=PremiumControlConfig)
data: DataConfig = Field(...)
# ============================================================
# 配置加载器(独立实现)
# ============================================================
class ConfigLoader:
"""配置加载器(独立实现)"""
def __init__(self, config_dir: str = None):
"""初始化"""
if config_dir is None:
config_dir = Path(__file__).parent
self.config_dir = Path(config_dir)
def load(self, config_file: str) -> RotationStrategyConfig:
"""加载配置文件"""
# 1. 解析路径
config_path = self._resolve_path(config_file)
# 2. 读取 YAML
with open(config_path, 'r', encoding='utf-8') as f:
config_dict = yaml.safe_load(f)
# 3. 环境变量替换
config_dict = self._substitute_env_vars(config_dict)
# 4. Pydantic 验证
config = RotationStrategyConfig(**config_dict)
return config
def _resolve_path(self, config_file: str) -> Path:
"""解析配置文件路径"""
path = Path(config_file)
if path.is_absolute():
if not path.exists():
raise FileNotFoundError(f"配置文件不存在: {path}")
return path
# 相对路径:先在配置目录查找
config_path = self.config_dir / path
if config_path.exists():
return config_path
# 然后在当前工作目录查找
cwd_path = Path.cwd() / path
if cwd_path.exists():
return cwd_path
raise FileNotFoundError(
f"配置文件未找到: {path}\n"
f"搜索路径:\n"
f" - {config_path}\n"
f" - {cwd_path}"
)
def _substitute_env_vars(self, config: Any) -> Any:
"""替换环境变量"""
if isinstance(config, dict):
return {
key: self._substitute_env_vars(value)
for key, value in config.items()
}
elif isinstance(config, list):
return [
self._substitute_env_vars(item)
for item in config
]
elif isinstance(config, str):
# 匹配 ${VAR_NAME} 或 ${VAR_NAME:default}
pattern = r'\$\{([^}]+)\}'
def replace_match(match):
var_expr = match.group(1)
if ':' in var_expr:
var_name, default = var_expr.split(':', 1)
else:
var_name = var_expr
default = None
value = os.getenv(var_name)
if value is None:
if default is not None:
return default
else:
raise ValueError(
f"环境变量未设置: {var_name}\n"
f"请设置环境变量或在配置中使用默认值: ${{{var_name}:default_value}}"
)
return value
return re.sub(pattern, replace_match, config)
else:
return config
# ============================================================
# 快捷函数
# ============================================================
def load_rotation_config(config_file: str = 'config_simple.yaml') -> RotationStrategyConfig:
"""
加载 rotation 配置
Args:
config_file: 配置文件名
Returns:
RotationStrategyConfig: 验证后的配置对象
环境变量:
必须设置 FLASK_API_URL 或提供默认值
"""
loader = ConfigLoader()
return loader.load(config_file)
def get_config_info(config: RotationStrategyConfig) -> dict:
"""获取配置摘要"""
return {
'version': config.metadata.version,
'strategy': config.metadata.strategy,
'description': config.metadata.description,
'asset_count': config.asset_pools.count(),
'groups': config.asset_pools.groups,
'factor_type': config.factor.type.value,
'n_days': config.factor.n_days,
'select_num': config.rotation.select_num,
'start_date': config.backtest.start_date,
'end_date': config.backtest.end_date or '至今',
'threshold_mode': config.rotation.threshold.mode.value,
}
def print_config_summary(config: RotationStrategyConfig) -> None:
"""打印配置摘要"""
info = get_config_info(config)
print("\n" + "=" * 50)
print(" ETF 轮动策略配置摘要")
print("=" * 50)
print(f"\n版本: {info['version']}")
print(f"策略: {info['strategy']}")
print(f"描述: {info['description']}")
print(f"\n资产池:")
print(f" 总数: {info['asset_count']}")
print(f" 分组: {', '.join(info['groups'])}")
print(f"\n因子配置:")
print(f" 类型: {info['factor_type']}")
print(f" 窗口: {info['n_days']}")
print(f"\n轮动配置:")
print(f" 选股数: {info['select_num']}")
print(f" 阈值模式: {info['threshold_mode']}")
print(f"\n回测配置:")
print(f" 起始: {info['start_date']}")
print(f" 结束: {info['end_date']}")
print(f"\n各分组资产:")
for group, assets in config.asset_pools.by_group.items():
print(f"\n [{group}] ({len(assets)} 个):")
for code, asset in assets.items():
cross_market = "✓ 跨市场" if asset.is_cross_market else ""
print(f" - {asset.name} ({code})")
print(f" 信号: {asset.signal_source} → 交易: {asset.trade_source} {cross_market}")
print("\n" + "=" * 50)
# ============================================================
# 测试
# ============================================================
if __name__ == "__main__":
"""测试配置加载器"""
# 设置环境变量(如果没有设置)
if 'FLASK_API_URL' not in os.environ:
print("提示: FLASK_API_URL 未设置,使用默认值")
os.environ['FLASK_API_URL'] = 'https://k3s.tokenpluse.xyz'
try:
# 加载配置
print("\n[测试] 加载 config_simple.yaml...")
config = load_rotation_config()
# 打印摘要
print_config_summary(config)
# 测试基本功能
print("\n[验证] 基本功能测试:")
print(f" ✓ 配置对象类型: {type(config).__name__}")
print(f" ✓ 资产池对象类型: {type(config.asset_pools).__name__}")
print(f" ✓ 资产数量: {config.asset_pools.count()}")
print(f" ✓ 分组数量: {len(config.asset_pools.groups)}")
# 测试数据源
print("\n[验证] 数据源配置:")
for i, source in enumerate(config.data.sources, 1):
print(f" {i}. {source.type.value}")
print(f" URL: {source.url}")
print(f" 启用: {source.enabled}")
print("\n✓ 所有测试通过!")
except Exception as e:
print(f"\n✗ 加载失败: {e}")
import traceback
traceback.print_exc()

186
rotation/config_simple.yaml Normal file
View File

@@ -0,0 +1,186 @@
# ETF轮动策略配置V2 框架)
#
# 配置版本: 2.0.0
# 最后更新: 2024-04-16
# 策略名称: rotation
# 描述: 全球资产大类轮动策略 - 复现 V1 结果
# ============================================================
# 元数据
# ============================================================
metadata:
version: "2.0.0"
strategy: "rotation"
description: "全球资产大类轮动策略 V2 - 复现 V1 结果"
last_updated: "2024-04-16"
# ============================================================
# 资产池配置(扁平化设计:严格对齐 V1 config.yaml
# ============================================================
asset_pools:
assets:
# 中国A股指数
"399006.SZ":
name: "创业板指"
group: "A"
signal_source: "399006.SZ"
trade_source: "159915.SZ"
description: "创业板指数"
"H30269.CSI":
name: "中证红利低波"
group: "A"
signal_source: "H30269.CSI"
trade_source: "512890.SH"
description: "红利低波指数"
# 全球市场
"NDX":
name: "纳指100"
group: "US"
signal_source: "NDX"
trade_source: "513100.SH"
description: "纳斯达克100指数"
"N225":
name: "日经225"
group: "JP"
signal_source: "N225"
trade_source: "513520.SH"
description: "日经225指数"
"GDAXI":
name: "德国DAX"
group: "EU"
signal_source: "GDAXI"
trade_source: "513030.SH"
description: "德国DAX指数"
"HSI":
name: "恒生指数"
group: "HK"
signal_source: "HSI"
trade_source: "159920.SZ"
description: "恒生指数"
"HSTECH.HK":
name: "恒生科技"
group: "HK"
signal_source: "HSTECH.HK"
trade_source: "513130.SH"
description: "恒生科技指数"
# 商品(使用 COMEX/WTI 期货替代上期所主力合约,数据更长)
"GC=F":
name: "黄金"
group: "COMMODITY"
signal_source: "GC=F"
trade_source: "518880.SH"
description: "COMEX黄金期货2000年至今"
"CL=F":
name: "原油"
group: "COMMODITY"
signal_source: "CL=F"
trade_source: "160723.SZ"
description: "WTI原油期货2000年至今"
"HG=F":
name: "有色金属"
group: "COMMODITY"
signal_source: "HG=F"
trade_source: "159980.SZ"
description: "COMEX铜期货2000年至今"
# 防御类资产:短债指数
# 931862.CSI = 中证0-9个月国债指数短债指数
# 数据范围2007-12-31开始约19年数据
# 久期:极短(<1年波动极小熊市防御效果最佳
# 收益归因标的收益约17%决策收益约83%
# 注意无对应ETF可交易直接使用指数数据计算动量和收益
"931862.CSI":
name: "短债指数"
group: "BOND"
signal_source: "931862.CSI"
trade_source: "931862.CSI"
description: "中证0-9个月国债指数久期<1年防御配置"
# ============================================================
# 基准配置
# ============================================================
benchmark:
code: "000300.SH"
name: "沪深300"
# ============================================================
# 回测配置
# ============================================================
backtest:
start_date: "2020-01-10" # 与 V1 保持一致(第一个完整交易日)
# end_date: "2026-05-22" # 与 V1 保持一致
# ============================================================
# 因子配置
# ============================================================
factor:
type: "weighted_momentum" # 加权动量
n_days: 25 # 25 天窗口
# ============================================================
# 轮动配置
# ============================================================
rotation:
select_num: 3 # 选择 Top-3
diversified: true # 强制分散化:每个大类只选 Top 1
# 阈值配置V3 动态阈值)
threshold:
mode: "dynamic" # 动态阈值模式
fixed_value: 0.0 # 固定阈值mode=fixed时使用
# 动态阈值配置(使用短债动量作为阈值)
dynamic:
reference: "931862.CSI" # 阈值参考标的(短债指数)
ratio: 1.0 # 阈值 = 短债动量 × ratio
fallback_enabled: true # 参考不可用时是否回退
fallback_value: 0.0 # 回退值
# ============================================================
# 调仓配置
# ============================================================
rebalance:
min_hold_days: 1
score_threshold: 0.0
trade_cost: 0.001 # 0.1% 交易成本
# ============================================================
# 溢价控制配置
# ============================================================
premium_control:
enabled: false # 启用溢价控制
default_threshold: 0.10 # 默认溢价阈值 10%
mode: "filter" # filter(完全排除) 或 penalize(降权)
penalty_factor: 0.5 # 降权模式下的惩罚系数
# 按市场覆盖配置
market_overrides:
A: # A股 ETF
enabled: false # 不启用(溢价通常 < 0.5%
HK: # 港股 ETF
enabled: true
threshold: 0.10 # 阈值 10%
US: # 美股 ETF
enabled: true
threshold: 0.10 # 阈值 10%
COMMODITY: # 商品 ETF
enabled: false # 不启用
# ============================================================
# 数据配置
# ============================================================
data:
sources:
- type: "flask_api"
enabled: true
url: "${FLASK_API_URL}"
timeout: 120

View File

@@ -9,9 +9,9 @@ ETF轮动策略定时调度器
4. 发送图片链接到钉钉群
用法:
python scripts/daily_scheduler.py --time 15:30 # 后台定时模式
python scripts/daily_scheduler.py --now # 立即执行一次
python scripts/daily_scheduler.py --no-daemon # 非后台模式
python rotation/daily_scheduler.py --time 15:30 # 后台定时模式
python rotation/daily_scheduler.py --now # 立即执行一次
python rotation/daily_scheduler.py --no-daemon # 非后台模式
"""
import warnings
@@ -121,13 +121,13 @@ def run_strategy(config_path: str = "strategies/rotation/config.yaml") -> dict:
logger.info(f"执行命令: {' '.join(cmd)}")
# 执行策略
# 执行策略增加超时到15分钟因为需要获取多市场数据
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=project_root,
timeout=300 # 5分钟超时
timeout=900 # 15分钟超时原5分钟不够数据获取串行需要更长时间
)
if result.returncode != 0:
@@ -154,13 +154,68 @@ def run_strategy(config_path: str = "strategies/rotation/config.yaml") -> dict:
return {"success": False, "error": str(e)}
def send_report_to_dingtalk(chart_path: str, summary_text: str = "") -> bool:
def run_simple_rotation(config_path: str = None) -> dict:
"""
执行 simple_rotation.py 策略回测并生成报告
Args:
config_path: 配置文件路径默认使用 rotation/config_simple.yaml
Returns:
dict: 执行结果包含报告路径等信息
"""
logger.info("开始执行 Simple Rotation 策略回测...")
try:
cmd = [
sys.executable,
str(project_root / "rotation" / "simple_rotation.py")
]
if config_path:
cmd.extend(["--config", config_path])
logger.info(f"执行命令: {' '.join(cmd)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
cwd=project_root,
timeout=900
)
if result.returncode != 0:
logger.error(f"Simple Rotation 执行失败:\n{result.stderr}")
return {"success": False, "error": result.stderr}
logger.info("Simple Rotation 执行成功")
logger.debug(result.stdout)
# simple_rotation.py 生成的报告路径
chart_path = project_root / "rotation" / "results" / "simple_rotation_report.png"
return {
"success": True,
"stdout": result.stdout,
"chart_path": str(chart_path) if chart_path.exists() else None,
}
except subprocess.TimeoutExpired:
logger.error("Simple Rotation 执行超时")
return {"success": False, "error": "timeout"}
except Exception as e:
logger.error(f"Simple Rotation 执行异常: {e}")
return {"success": False, "error": str(e)}
def send_report_to_dingtalk(chart_path: str, summary_text: str = "", title: str = None) -> bool:
"""
上传报告到OSS并发送图片链接到钉钉
Args:
chart_path: 图片文件路径
summary_text: 摘要文本
title: 消息标题默认自动生成
Returns:
bool: 是否发送成功
@@ -173,7 +228,8 @@ def send_report_to_dingtalk(chart_path: str, summary_text: str = "") -> bool:
try:
today_str = datetime.now().strftime("%Y-%m-%d")
title = f"ETF轮动策略调仓日报 ({today_str})"
if title is None:
title = f"ETF轮动策略调仓日报 ({today_str})"
# 使用原有的 send_to_all_groups 发送图片
# 该方法会自动上传到OSS → 发送Markdown消息带图片链接
@@ -197,35 +253,49 @@ def send_report_to_dingtalk(chart_path: str, summary_text: str = "") -> bool:
return False
def setup_schedule(target_time: str = "15:30", config_path: str = "strategies/rotation/config.yaml"):
def setup_schedule(target_time: str = "15:30",
config_path: str = "strategies/rotation/config.yaml",
strategy: str = "all",
simple_config: str = None):
"""
设置定时任务
Args:
target_time: 执行时间 (HH:MM)
config_path: 配置文件路径
config_path: legacy策略配置文件路径
strategy: 策略选择 - "simple" / "legacy" / "all"
simple_config: simple_rotation 配置文件路径
"""
logger.info(f"设置定时任务: 每天 {target_time} 执行")
logger.info(f"设置定时任务: 每天 {target_time} 执行 (策略: {strategy})")
# 清除已有任务
schedule.clear()
# 添加每日任务
schedule.every().day.at(target_time).do(daily_task, config_path=config_path)
schedule.every().day.at(target_time).do(
daily_task,
config_path=config_path,
strategy=strategy,
simple_config=simple_config
)
logger.info("定时任务设置完成,等待执行...")
def daily_task(config_path: str = "strategies/rotation/config.yaml"):
def daily_task(config_path: str = "strategies/rotation/config.yaml",
strategy: str = "all",
simple_config: str = None):
"""
每日任务主流程
Args:
config_path: 配置文件路径
config_path: legacy策略配置文件路径
strategy: 策略选择 - "simple" / "legacy" / "all"两者都执行
simple_config: simple_rotation 配置文件路径
"""
today_str = datetime.now().strftime("%Y-%m-%d")
logger.info("=" * 60)
logger.info(f"开始执行每日任务: {today_str}")
logger.info(f"开始执行每日任务: {today_str} 策略: {strategy}")
logger.info("=" * 60)
# 1. 检查是否为交易日
@@ -233,22 +303,35 @@ def daily_task(config_path: str = "strategies/rotation/config.yaml"):
logger.info("今天不是交易日,跳过执行")
return
# 2. 执行策略
result = run_strategy(config_path)
# 2. 执行 Simple Rotation 策略
if strategy in ("simple", "all"):
result = run_simple_rotation(simple_config)
if result["success"]:
if result.get("chart_path"):
send_report_to_dingtalk(
chart_path=result["chart_path"],
summary_text="",
title=f"Simple Rotation 调仓日报 ({today_str})"
)
else:
logger.warning("Simple Rotation 未找到报告文件")
else:
logger.error(f"Simple Rotation 执行失败: {result.get('error', '未知错误')}")
if not result["success"]:
logger.error(f"策略执行失败: {result.get('error', '未知错误')}")
return
# 3. 发送报告
if result.get("chart_path"):
# 只发送标题和图片,不附带文字摘要
send_report_to_dingtalk(
chart_path=result["chart_path"],
summary_text="" # 空字符串,只显示标题和图片
)
else:
logger.warning("未找到报告文件")
# 3. 执行 Legacy 策略
if strategy in ("legacy", "all"):
result = run_strategy(config_path)
if result["success"]:
if result.get("chart_path"):
send_report_to_dingtalk(
chart_path=result["chart_path"],
summary_text="",
title=f"ETF轮动策略调仓日报 ({today_str})"
)
else:
logger.warning("Legacy 策略未找到报告文件")
else:
logger.error(f"Legacy 策略执行失败: {result.get('error', '未知错误')}")
logger.info("每日任务执行完成")
@@ -276,7 +359,20 @@ def main():
'--config',
type=str,
default='strategies/rotation/config.yaml',
help='配置文件路径'
help='Legacy策略配置文件路径'
)
parser.add_argument(
'--simple-config',
type=str,
default=None,
help='Simple Rotation 配置文件路径(默认 rotation/config_simple.yaml'
)
parser.add_argument(
'--strategy',
type=str,
choices=['simple', 'legacy', 'all'],
default='simple',
help='策略选择: simple=仅Simple Rotation, legacy=仅Legacy策略, all=两者都执行(默认 simple'
)
parser.add_argument(
'--now',
@@ -296,19 +392,19 @@ def main():
if args.now:
# 立即执行一次并退出
daily_task(args.config)
daily_task(args.config, args.strategy, args.simple_config)
elif args.no_daemon:
# 非后台模式:执行一次后进入定时循环
setup_schedule(args.time, args.config)
setup_schedule(args.time, args.config, args.strategy, args.simple_config)
logger.info("执行一次测试...")
daily_task(args.config)
daily_task(args.config, args.strategy, args.simple_config)
logger.info("测试完成启动定时任务循环Ctrl+C 停止)...")
run_scheduler_loop()
else:
# 默认:后台定时模式
setup_schedule(args.time, args.config)
setup_schedule(args.time, args.config, args.strategy, args.simple_config)
run_scheduler_loop()
if __name__ == '__main__':
main()
main()

1255
rotation/simple_rotation.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,348 @@
"""
获取 A 股交易日历脚本
使用 Flask API 交易日历服务获取 A 股交易日历
支持多市场、多年份的交易日查询
用法:
python scripts/get_trading_calendar.py
python scripts/get_trading_calendar.py --year 2024
python scripts/get_trading_calendar.py --start 2024-01-01 --end 2024-12-31
"""
import sys
import argparse
from pathlib import Path
from datetime import datetime, timedelta
import pandas as pd
# 添加项目根目录到路径
project_root = Path(__file__).parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
# 加载环境变量
from dotenv import load_dotenv
load_dotenv()
# 导入 Flask API 数据源
from datasource.flask_api_source import FlaskAPIDataSource
def get_calendar_for_year(source: FlaskAPIDataSource, year: int, market: str = 'A'):
"""
获取指定年份的交易日历
Args:
source: Flask API 数据源实例
year: 年份(如 2024
market: 市场代码('A', 'US', 'HK'
Returns:
pd.DatetimeIndex: 交易日序列
"""
start_date = f"{year}-01-01"
end_date = f"{year}-12-31"
print(f"\n获取 {year}{market} 市场交易日历...")
trading_dates = source.get_trading_calendar(
market=market,
start_date=start_date,
end_date=end_date
)
if trading_dates is None or len(trading_dates) == 0:
print(f"{year}{market} 市场无交易日数据")
return None
return trading_dates
def analyze_calendar(trading_dates: pd.DatetimeIndex, year: int):
"""
分析交易日历统计信息
Args:
trading_dates: 交易日序列
year: 年份
"""
if trading_dates is None or len(trading_dates) == 0:
return
print(f"\n{'=' * 60}")
print(f"{year} 年 A 股交易日历分析")
print(f"{'=' * 60}")
# 基本统计
total_days = len(trading_dates)
print(f"\n基本统计:")
print(f" 总交易日: {total_days}")
print(f" 起始日期: {trading_dates.min().strftime('%Y-%m-%d')}")
print(f" 结束日期: {trading_dates.max().strftime('%Y-%m-%d')}")
# 按月份统计
print(f"\n按月份统计:")
monthly_counts = {}
for date in trading_dates:
month = date.month
monthly_counts[month] = monthly_counts.get(month, 0) + 1
for month in range(1, 13):
count = monthly_counts.get(month, 0)
month_name = datetime(2024, month, 1).strftime('%B')
print(f" {month:02d}月 ({month_name}): {count}")
# 按季度统计
print(f"\n按季度统计:")
quarterly_counts = {1: 0, 2: 0, 3: 0, 4: 0}
for date in trading_dates:
quarter = (date.month - 1) // 3 + 1
quarterly_counts[quarter] += 1
for quarter, count in quarterly_counts.items():
print(f" Q{quarter}: {count}")
# 特殊日期统计
print(f"\n特殊日期:")
first_date = trading_dates.min()
last_date = trading_dates.max()
print(f" 首个交易日: {first_date.strftime('%Y-%m-%d')} ({first_date.strftime('%A')})")
print(f" 最后交易日: {last_date.strftime('%Y-%m-%d')} ({last_date.strftime('%A')})")
# 查找节假日后的首个交易日(通过间隔判断)
gaps = []
for i in range(1, len(trading_dates)):
prev_date = trading_dates[i-1]
curr_date = trading_dates[i]
gap_days = (curr_date - prev_date).days
if gap_days > 3: # 超过3天视为可能节假日
gaps.append({
'prev': prev_date,
'curr': curr_date,
'gap': gap_days
})
if gaps:
print(f"\n可能的节假日(间隔 > 3天:")
for gap_info in gaps[:5]: # 只显示前5个
print(f" {gap_info['prev'].strftime('%Y-%m-%d')}{gap_info['curr'].strftime('%Y-%m-%d')} "
f"(间隔 {gap_info['gap']} 天)")
print(f"\n{'=' * 60}")
def compare_markets(source: FlaskAPIDataSource, year: int):
"""
比较不同市场的交易日历
Args:
source: Flask API 数据源实例
year: 年份
"""
print(f"\n{'=' * 60}")
print(f"{year} 年不同市场交易日历对比")
print(f"{'=' * 60}")
markets = {
'A': 'A股上交所/深交所)',
'US': '美股NYSE',
'HK': '港股HKEX'
}
results = {}
for market_code, market_name in markets.items():
print(f"\n获取 {market_name} 交易日历...")
trading_dates = get_calendar_for_year(source, year, market_code)
if trading_dates is not None and len(trading_dates) > 0:
results[market_code] = {
'name': market_name,
'dates': trading_dates,
'count': len(trading_dates)
}
# 对比统计
print(f"\n交易日对比:")
print(f"{'市场':<20} {'交易日数':<10} {'起始日期':<12} {'结束日期':<12}")
print("-" * 60)
for market_code, data in results.items():
print(f"{data['name']:<20} {data['count']:<10} "
f"{data['dates'].min().strftime('%Y-%m-%d'):<12} "
f"{data['dates'].max().strftime('%Y-%m-%d'):<12}")
# 计算差异
if len(results) >= 2:
print(f"\n交易日差异:")
market_codes = list(results.keys())
for i in range(len(market_codes)):
for j in range(i+1, len(market_codes)):
m1 = market_codes[i]
m2 = market_codes[j]
diff = results[m1]['count'] - results[m2]['count']
print(f" {results[m1]['name']} vs {results[m2]['name']}: "
f"相差 {abs(diff)} 天 ({'+' if diff > 0 else ''}{diff})")
print(f"\n{'=' * 60}")
def show_recent_dates(trading_dates: pd.DatetimeIndex, n: int = 10):
"""
显示最近的交易日
Args:
trading_dates: 交易日序列
n: 显示数量
"""
if trading_dates is None or len(trading_dates) == 0:
return
print(f"\n最近 {n} 个交易日:")
recent_dates = trading_dates[-n:] if len(trading_dates) >= n else trading_dates
for date in recent_dates:
weekday = date.strftime('%A')
print(f" {date.strftime('%Y-%m-%d')} ({weekday})")
def export_calendar(trading_dates: pd.DatetimeIndex, output_path: str, year: int):
"""
导出交易日历到 CSV
Args:
trading_dates: 交易日序列
output_path: 输出路径
year: 年份
"""
if trading_dates is None or len(trading_dates) == 0:
return
# 创建 DataFrame
df = pd.DataFrame({
'date': trading_dates,
'year': trading_dates.year,
'month': trading_dates.month,
'quarter': (trading_dates.month - 1) // 3 + 1,
'weekday': [d.strftime('%A') for d in trading_dates]
})
# 导出到 CSV
filename = f"{output_path}/trading_calendar_A_{year}.csv"
df.to_csv(filename, index=False)
print(f"\n✓ 交易日历已导出到: {filename}")
print(f" 文件包含 {len(df)} 条记录")
def main():
"""主函数"""
parser = argparse.ArgumentParser(description='获取 A 股交易日历')
parser.add_argument(
'--year',
type=int,
default=datetime.now().year,
help='年份(默认当前年份)'
)
parser.add_argument(
'--start',
type=str,
help='起始日期 YYYY-MM-DD'
)
parser.add_argument(
'--end',
type=str,
help='结束日期 YYYY-MM-DD'
)
parser.add_argument(
'--market',
type=str,
default='A',
choices=['A', 'US', 'HK'],
help='市场代码A=A股, US=美股, HK=港股)'
)
parser.add_argument(
'--compare',
action='store_true',
help='对比不同市场交易日历'
)
parser.add_argument(
'--export',
action='store_true',
help='导出交易日历到 CSV'
)
parser.add_argument(
'--output',
type=str,
default='data',
help='导出目录(默认 data'
)
args = parser.parse_args()
# 初始化 Flask API 数据源
print("\n初始化 Flask API 数据源...")
source = FlaskAPIDataSource()
# 检查服务健康状态
health = source.get_health()
if health.get('status') != 'healthy':
print(f"✗ Flask API 服务不可用: {health}")
sys.exit(1)
print(f"✓ Flask API 服务可用 ({source.base_url})")
# 获取交易日历信息
calendar_info = source.get_calendar_info()
if 'error' not in calendar_info:
print(f"\n交易日历服务信息:")
print(f" 支持市场: {', '.join(calendar_info.get('markets', []))}")
print(f" 数据源: {calendar_info.get('source', 'pandas_market_calendars')}")
# 执行不同功能
if args.compare:
# 对比不同市场
compare_markets(source, args.year)
elif args.start and args.end:
# 自定义日期范围
print(f"\n获取 {args.market} 市场交易日历 ({args.start} ~ {args.end})...")
trading_dates = source.get_trading_calendar(
market=args.market,
start_date=args.start,
end_date=args.end
)
if trading_dates is not None:
print(f"✓ 获取到 {len(trading_dates)} 个交易日")
show_recent_dates(trading_dates)
if args.export:
export_calendar(trading_dates, args.output, args.year)
else:
# 获取指定年份交易日历
trading_dates = get_calendar_for_year(source, args.year, args.market)
if trading_dates is not None:
# 分析统计
analyze_calendar(trading_dates, args.year)
# 显示最近交易日
show_recent_dates(trading_dates)
# 导出
if args.export:
export_calendar(trading_dates, args.output, args.year)
print("\n✓ 完成!")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,714 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ETF轮动策略回测回放器</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, 'SF Mono', 'Menlo', monospace; background: #0d1117; color: #c9d1d9; font-size: 13px; }
.loader { display: flex; align-items: center; justify-content: center; height: 100vh; flex-direction: column; gap: 16px; }
.loader input[type="file"] { display: none; }
.loader label { padding: 16px 32px; background: #238636; color: #fff; border-radius: 8px; cursor: pointer; font-size: 16px; }
.loader label:hover { background: #2ea043; }
.loader .hint { color: #8b949e; }
.app { display: none; height: 100vh; flex-direction: column; overflow: hidden; }
.app.active { display: flex; }
/* Header */
.header { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; }
.header .date-display { font-size: 18px; font-weight: 600; color: #58a6ff; min-width: 120px; }
.header .nav-val { font-size: 16px; font-weight: 600; min-width: 100px; }
.header .daily-ret { font-size: 14px; min-width: 80px; font-weight: 600; }
.header .rebal-badge { background: #da3633; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 11px; }
/* Controls */
.controls { display: flex; align-items: center; gap: 8px; padding: 6px 16px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; }
.controls button { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.controls button:hover { background: #30363d; }
.controls button:active { background: #484f58; }
.controls input[type="date"] { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 4px 8px; border-radius: 4px; font-size: 12px; }
.controls input[type="range"] { width: 80px; }
.controls .speed-label { color: #8b949e; font-size: 11px; }
.controls .day-counter { color: #8b949e; font-size: 11px; margin-left: auto; }
/* Nav Chart */
.chart-container { height: 180px; padding: 8px 16px; background: #0d1117; flex-shrink: 0; border-bottom: 1px solid #30363d; position: relative; }
.chart-container canvas { width: 100%; height: 100%; }
/* Stats panel */
.stats-panel { display: flex; gap: 16px; padding: 8px 16px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; flex-wrap: wrap; }
.stat-item { display: flex; flex-direction: column; min-width: 90px; }
.stat-item .stat-label { color: #8b949e; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-item .stat-value { font-size: 14px; font-weight: 600; margin-top: 1px; }
/* Main content */
.main { display: grid; grid-template-columns: 280px 1fr; flex: 1; overflow: hidden; }
/* Holdings panel */
.holdings-panel { padding: 12px; border-right: 1px solid #30363d; overflow-y: auto; background: #161b22; }
.holdings-panel h3 { color: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
.holding-card { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 10px; margin-bottom: 8px; }
.holding-card.positive { border-left: 3px solid #3fb950; }
.holding-card.negative { border-left: 3px solid #da3633; }
.holding-card .code { font-weight: 600; color: #58a6ff; font-size: 14px; }
.holding-card .name { color: #8b949e; font-size: 11px; }
.holding-card .details { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-top: 6px; font-size: 11px; }
.holding-card .details .label { color: #8b949e; }
.holding-card .details .value { text-align: right; }
/* Ranking table */
.ranking-panel { display: flex; flex-direction: column; overflow: hidden; }
.ranking-table-wrap { flex: 1; overflow-y: auto; padding: 0 12px 12px; }
.ranking-panel h3 { color: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; padding: 12px 12px 8px; flex-shrink: 0; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
thead { position: sticky; top: 0; background: #0d1117; z-index: 1; }
th { text-align: left; padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #30363d; font-weight: 500; }
td { padding: 5px 8px; border-bottom: 1px solid #21262d; }
tr.held { background: rgba(56, 139, 253, 0.1); }
tr.held td:first-child { border-left: 3px solid #58a6ff; }
tr.below-threshold { color: #484f58; }
.positive { color: #3fb950; }
.negative { color: #da3633; }
.neutral { color: #8b949e; }
/* Rebalance bar */
.rebalance-bar { padding: 8px 16px; background: #1c1206; border-top: 1px solid #5a3e00; flex-shrink: 0; display: none; font-size: 12px; }
.rebalance-bar.active { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
.rebalance-bar .tag-in { background: #238636; color: #fff; padding: 1px 6px; border-radius: 3px; font-size: 11px; }
.rebalance-bar .tag-out { background: #da3633; color: #fff; padding: 1px 6px; border-radius: 3px; font-size: 11px; }
</style>
</head>
<body>
<div class="loader" id="loader">
<div style="font-size: 20px; font-weight: 600;">ETF 轮动策略回测回放器</div>
<div class="hint">加载 backtest_detail.json 开始回放</div>
<label for="file-input">选择 JSON 文件</label>
<input type="file" id="file-input" accept=".json">
<div class="hint" id="load-status"></div>
</div>
<div class="app" id="app">
<div class="header">
<span class="date-display" id="date-display"></span>
<span class="nav-val" id="nav-display"></span>
<span class="daily-ret" id="ret-display"></span>
<span class="rebal-badge" id="rebal-badge" style="display:none">调仓</span>
</div>
<div class="controls">
<button id="btn-prev" title="上一天 (←)">&#9664; 前一天</button>
<button id="btn-next" title="下一天 (→)">后一天 &#9654;</button>
<button id="btn-prev-rebal" title="上一调仓日">&#9664;&#9664; 上一调仓</button>
<button id="btn-next-rebal" title="下一调仓日">下一调仓 &#9654;&#9654;</button>
<input type="date" id="date-picker">
<button id="btn-play" title="播放/暂停 (Space)">&#9654; 播放</button>
<span class="speed-label">速度:</span>
<input type="range" id="speed-slider" min="1" max="10" value="5">
<span class="day-counter" id="day-counter"></span>
</div>
<div class="chart-container">
<canvas id="nav-chart"></canvas>
</div>
<div class="stats-panel" id="stats-panel"></div>
<div class="main">
<div class="holdings-panel">
<h3>当日持仓</h3>
<div id="holdings-cards"></div>
</div>
<div class="ranking-panel">
<h3>全部标的排名 (按动量排序)</h3>
<div class="ranking-table-wrap">
<table>
<thead>
<tr>
<th>#</th>
<th>代码</th>
<th>名称</th>
<th>大类</th>
<th>动量</th>
<th>阈值</th>
<th>指数价</th>
<th>ETF价</th>
<th>指数收益</th>
<th>ETF收益</th>
<th>溢价率</th>
<th>持仓</th>
</tr>
</thead>
<tbody id="ranking-body"></tbody>
</table>
</div>
</div>
</div>
<div class="rebalance-bar" id="rebalance-bar"></div>
</div>
<script>
let DATA = null;
let currentIdx = 0;
let playing = false;
let playTimer = null;
let rebalanceDays = [];
const $ = id => document.getElementById(id);
// File loading
$('file-input').addEventListener('change', e => {
const file = e.target.files[0];
if (!file) return;
$('load-status').textContent = '加载中...';
const reader = new FileReader();
reader.onload = ev => {
try {
DATA = JSON.parse(ev.target.result);
initApp();
} catch (err) {
$('load-status').textContent = '解析失败: ' + err.message;
}
};
reader.readAsText(file);
});
function initApp() {
$('loader').style.display = 'none';
$('app').classList.add('active');
// Build rebalance day index
rebalanceDays = [];
DATA.days.forEach((d, i) => { if (d.is_rebalance) rebalanceDays.push(i); });
// Set date picker range
$('date-picker').min = DATA.days[0].date;
$('date-picker').max = DATA.days[DATA.days.length - 1].date;
drawChart();
render(0);
}
// Navigation
$('btn-prev').addEventListener('click', () => navigate(-1));
$('btn-next').addEventListener('click', () => navigate(1));
$('btn-prev-rebal').addEventListener('click', () => navigateRebalance(-1));
$('btn-next-rebal').addEventListener('click', () => navigateRebalance(1));
$('date-picker').addEventListener('change', e => {
const target = e.target.value;
const idx = DATA.days.findIndex(d => d.date === target);
if (idx >= 0) { currentIdx = idx; render(currentIdx); drawChartMarker(); }
});
$('btn-play').addEventListener('click', togglePlay);
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT') return;
if (e.key === 'ArrowLeft') { e.preventDefault(); navigate(-1); }
if (e.key === 'ArrowRight') { e.preventDefault(); navigate(1); }
if (e.key === ' ') { e.preventDefault(); togglePlay(); }
});
function navigate(delta) {
const next = currentIdx + delta;
if (next >= 0 && next < DATA.days.length) {
currentIdx = next;
render(currentIdx);
drawChartMarker();
}
}
function navigateRebalance(dir) {
if (dir > 0) {
const next = rebalanceDays.find(i => i > currentIdx);
if (next !== undefined) { currentIdx = next; render(currentIdx); drawChartMarker(); }
} else {
const prev = [...rebalanceDays].reverse().find(i => i < currentIdx);
if (prev !== undefined) { currentIdx = prev; render(currentIdx); drawChartMarker(); }
}
}
function togglePlay() {
playing = !playing;
$('btn-play').innerHTML = playing ? '&#9646;&#9646; 暂停' : '&#9654; 播放';
if (playing) {
playTick();
} else {
clearTimeout(playTimer);
}
}
function playTick() {
if (!playing) return;
if (currentIdx < DATA.days.length - 1) {
currentIdx++;
render(currentIdx);
drawChartMarker();
const speed = parseInt($('speed-slider').value);
const delay = 1100 - speed * 100; // 1000ms ~ 100ms
playTimer = setTimeout(playTick, delay);
} else {
playing = false;
$('btn-play').innerHTML = '&#9654; 播放';
}
}
function fmtPct(v, decimals) {
if (v === null || v === undefined) return '-';
return (v * 100).toFixed(decimals !== undefined ? decimals : 2) + '%';
}
function fmtNum(v, decimals) {
if (v === null || v === undefined) return '-';
return Number(v).toFixed(decimals !== undefined ? decimals : 2);
}
function retClass(v) {
if (v === null || v === undefined) return 'neutral';
return v > 0 ? 'positive' : v < 0 ? 'negative' : 'neutral';
}
function render(idx) {
const day = DATA.days[idx];
const meta = DATA.meta;
// Header
$('date-display').textContent = day.date;
$('nav-display').textContent = '净值 ' + fmtNum(day.nav, 4);
$('nav-display').className = 'nav-val';
const retEl = $('ret-display');
retEl.textContent = (day.daily_return >= 0 ? '+' : '') + fmtPct(day.daily_return);
retEl.className = 'daily-ret ' + retClass(day.daily_return);
const badge = $('rebal-badge');
badge.style.display = day.is_rebalance ? 'inline' : 'none';
$('day-counter').textContent = `${idx + 1} / ${DATA.days.length} 天 | 调仓 ${rebalanceDays.filter(i => i <= idx).length}`;
$('date-picker').value = day.date;
// Stats panel
renderStats(idx);
// Holdings cards
renderHoldings(day, meta);
// Ranking table
renderRanking(day, meta);
// Rebalance bar
renderRebalanceBar(day, meta);
}
function renderStats(idx) {
const panel = $('stats-panel');
const days = DATA.days;
// Slice up to current day
const navs = [];
for (let i = 0; i <= idx; i++) {
if (days[i].nav !== null) navs.push(days[i].nav);
}
if (navs.length < 2) {
panel.innerHTML = '<span class="stat-item"><span class="stat-label">等待数据...</span></span>';
return;
}
const startNav = navs[0];
const curNav = navs[navs.length - 1];
const totalReturn = curNav / startNav - 1;
const tradingDays = navs.length;
const years = tradingDays / 252; // 统一使用 252 天A股标准与 rotation.py 一致
const cagr = years > 0 ? Math.pow(curNav / startNav, 1 / years) - 1 : 0;
// Max drawdown
let maxDD = 0;
let peak = navs[0];
for (let i = 1; i < navs.length; i++) {
if (navs[i] > peak) peak = navs[i];
const dd = navs[i] / peak - 1;
if (dd < maxDD) maxDD = dd;
}
// Volatility & Sharpe使用 252 天,与 rotation.py 一致)
const rets = [];
for (let i = 1; i < navs.length; i++) {
rets.push(navs[i] / navs[i-1] - 1);
}
const meanRet = rets.reduce((a, b) => a + b, 0) / rets.length;
const variance = rets.reduce((a, b) => a + (b - meanRet) ** 2, 0) / rets.length;
const vol = Math.sqrt(variance) * Math.sqrt(252);
const sharpe = vol > 0 ? (meanRet * 252) / vol : 0;
const calmar = maxDD !== 0 ? cagr / Math.abs(maxDD) : 0;
// Rebalance count & avg holding days
const rebalCount = rebalanceDays.filter(i => i <= idx).length;
let avgHoldDays = '-';
if (rebalCount > 1) {
let totalHold = 0;
let prevRebal = rebalanceDays[0];
let cnt = 0;
for (const ri of rebalanceDays) {
if (ri > idx) break;
if (cnt > 0) totalHold += ri - prevRebal;
prevRebal = ri;
cnt++;
}
if (cnt > 1) avgHoldDays = (totalHold / (cnt - 1)).toFixed(1);
}
// Win rate (daily)
const winDays = rets.filter(r => r > 0).length;
const winRate = rets.length > 0 ? winDays / rets.length : 0;
// Year-to-date return
const curYear = days[idx].date.substring(0, 4);
let ytdReturn = null;
for (let i = idx; i >= 0; i--) {
if (days[i].date.substring(0, 4) !== curYear) {
const prevYearEndNav = days[i].nav;
if (prevYearEndNav && curNav) ytdReturn = curNav / prevYearEndNav - 1;
break;
}
}
const items = [
{ label: 'CAGR', value: fmtPct(cagr), cls: retClass(cagr) },
{ label: '累计收益', value: fmtPct(totalReturn), cls: retClass(totalReturn) },
{ label: '最大回撤', value: fmtPct(maxDD), cls: 'negative' },
{ label: '年化波动', value: fmtPct(vol), cls: 'neutral' },
{ label: '夏普', value: fmtNum(sharpe, 3), cls: retClass(sharpe) },
{ label: 'Calmar', value: fmtNum(calmar, 3), cls: retClass(calmar) },
{ label: '胜率', value: fmtPct(winRate), cls: 'neutral' },
{ label: '调仓次数', value: String(rebalCount), cls: 'neutral' },
{ label: '平均持仓', value: avgHoldDays + '天', cls: 'neutral' },
{ label: '本年收益', value: ytdReturn !== null ? fmtPct(ytdReturn) : '-', cls: retClass(ytdReturn) },
{ label: '交易天数', value: String(tradingDays), cls: 'neutral' },
];
panel.innerHTML = items.map(it =>
`<div class="stat-item"><span class="stat-label">${it.label}</span><span class="stat-value ${it.cls}">${it.value}</span></div>`
).join('');
}
function renderHoldings(day, meta) {
const container = $('holdings-cards');
if (!day.holdings || day.holdings.length === 0) {
container.innerHTML = '<div style="color:#8b949e;padding:16px;text-align:center;">无持仓</div>';
return;
}
let html = '';
for (const code of day.holdings) {
const a = day.assets[code];
if (!a) continue;
const info = meta.codes[code] || {};
const cumEtf = a.cum_return_etf;
const cumIdx = a.cum_return_idx;
const mainCum = cumEtf !== null && cumEtf !== undefined ? cumEtf : cumIdx;
const cls = mainCum !== null && mainCum !== undefined ? (mainCum >= 0 ? 'positive' : 'negative') : '';
html += `<div class="holding-card ${cls}">
<span class="code">${code}</span> <span class="name">${info.name || ''}</span>
${info.etf ? `<span class="name"> (${info.etf})</span>` : ''}
<div class="details">
<span class="label">入场日</span><span class="value">${a.entry_date || '-'}</span>
<span class="label">持有天数</span><span class="value">${a.holding_days || 0}</span>
<span class="label">ETF累计</span><span class="value ${retClass(cumEtf)}">${fmtPct(cumEtf)}</span>
<span class="label">指数累计</span><span class="value ${retClass(cumIdx)}">${fmtPct(cumIdx)}</span>
<span class="label">今日ETF</span><span class="value ${retClass(a.etf_return_ctc)}">${fmtPct(a.etf_return_ctc)}</span>
<span class="label">今日指数</span><span class="value ${retClass(a.index_return)}">${fmtPct(a.index_return)}</span>
<span class="label">动量</span><span class="value">${fmtNum(a.momentum, 4)}</span>
<span class="label">大类</span><span class="value">${info.market || '-'}</span>
</div>
</div>`;
}
container.innerHTML = html;
}
function renderRanking(day, meta) {
const tbody = $('ranking-body');
const assets = day.assets;
const codes = Object.keys(assets);
// Sort by momentum descending, nulls last
codes.sort((a, b) => {
const ma = assets[a].momentum;
const mb = assets[b].momentum;
if (ma === null && mb === null) return 0;
if (ma === null) return 1;
if (mb === null) return -1;
return mb - ma;
});
const threshold = codes.length > 0 && assets[codes[0]].threshold !== null
? assets[codes[0]].threshold : 0;
let html = '';
let thresholdDrawn = false;
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
const a = assets[code];
const info = meta.codes[code] || {};
const isHeld = a.is_held;
const belowThreshold = a.momentum !== null && a.momentum < threshold;
// Insert threshold separator row before first below-threshold item
if (!thresholdDrawn && belowThreshold) {
html += `<tr><td colspan="12" style="color:#da3633;font-size:11px;text-align:center;border-bottom:2px dashed #da3633;padding:2px;">--- 动态阈值: ${fmtNum(threshold, 4)} ---</td></tr>`;
thresholdDrawn = true;
}
let rowClass = '';
if (isHeld) rowClass += ' held';
if (belowThreshold) rowClass += ' below-threshold';
// Use rank from data, but show sequential for display
const displayRank = a.rank !== null ? a.rank : '-';
html += `<tr class="${rowClass}">
<td>${displayRank}</td>
<td>${code}</td>
<td>${info.name || ''}</td>
<td>${info.market || ''}</td>
<td>${fmtNum(a.momentum, 4)}</td>
<td>${fmtNum(threshold, 4)}</td>
<td>${fmtNum(a.index_close, 2)}</td>
<td>${a.etf_close !== null ? fmtNum(a.etf_close, 3) : '-'}</td>
<td class="${retClass(a.index_return)}">${fmtPct(a.index_return)}</td>
<td class="${retClass(a.etf_return_ctc)}">${fmtPct(a.etf_return_ctc)}</td>
<td class="${a.premium > 0.05 ? 'negative' : a.premium < -0.02 ? 'positive' : 'neutral'}">${a.premium !== null && a.premium !== undefined ? fmtPct(a.premium) : '-'}</td>
<td>${isHeld ? '<span style="color:#58a6ff">&#9679;</span>' : ''}</td>
</tr>`;
}
// If threshold line not drawn yet (all above), add it at the bottom
if (!thresholdDrawn && threshold > 0) {
html += `<tr class="threshold-line"><td colspan="12" style="color:#da3633;font-size:11px;text-align:center;">--- 动态阈值: ${fmtNum(threshold, 4)} ---</td></tr>`;
}
tbody.innerHTML = html;
}
function renderRebalanceBar(day, meta) {
const bar = $('rebalance-bar');
if (!day.is_rebalance) {
bar.classList.remove('active');
return;
}
bar.classList.add('active');
let html = '<strong>调仓:</strong>';
if (day.added.length > 0) {
html += ' 调入: ';
html += day.added.map(c => {
const name = (meta.codes[c] || {}).name || '';
return `<span class="tag-in">${c} (${name})</span>`;
}).join(' ');
}
if (day.removed.length > 0) {
html += ' 调出: ';
html += day.removed.map(c => {
const name = (meta.codes[c] || {}).name || '';
return `<span class="tag-out">${c} (${name})</span>`;
}).join(' ');
}
// Calculate turnover
const prevHoldings = currentIdx > 0 ? DATA.days[currentIdx - 1].holdings : [];
const swapped = day.removed.length;
const total = Math.max(prevHoldings.length, 1);
const turnover = swapped / total;
const cost = turnover * meta.trade_cost;
html += `<span style="color:#8b949e;margin-left:12px;">换手 ${fmtPct(turnover, 0)} | 成本 ${fmtPct(cost, 2)}</span>`;
bar.innerHTML = html;
}
// ==================== Chart ====================
let chartCanvas, chartCtx;
let chartData = { navs: [], dates: [], rebalIdx: [] };
function drawChart() {
chartCanvas = $('nav-chart');
chartCtx = chartCanvas.getContext('2d');
// Retina support
const rect = chartCanvas.parentElement.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
chartCanvas.width = rect.width * dpr;
chartCanvas.height = rect.height * dpr;
chartCtx.scale(dpr, dpr);
chartCanvas.style.width = rect.width + 'px';
chartCanvas.style.height = rect.height + 'px';
chartData.navs = DATA.days.map(d => d.nav);
chartData.dates = DATA.days.map(d => d.date);
chartData.rebalIdx = rebalanceDays;
drawChartFull();
drawChartMarker();
}
function drawChartFull() {
const ctx = chartCtx;
const W = chartCanvas.width / (window.devicePixelRatio || 1);
const H = chartCanvas.height / (window.devicePixelRatio || 1);
const pad = { top: 20, right: 60, bottom: 20, left: 10 };
const cw = W - pad.left - pad.right;
const ch = H - pad.top - pad.bottom;
const navs = chartData.navs;
const minNav = Math.min(...navs.filter(v => v !== null)) * 0.98;
const maxNav = Math.max(...navs.filter(v => v !== null)) * 1.02;
const n = navs.length;
ctx.clearRect(0, 0, W, H);
// Grid lines
ctx.strokeStyle = '#21262d';
ctx.lineWidth = 0.5;
for (let i = 0; i <= 4; i++) {
const y = pad.top + ch * i / 4;
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(W - pad.right, y);
ctx.stroke();
const val = maxNav - (maxNav - minNav) * i / 4;
ctx.fillStyle = '#484f58';
ctx.font = '10px monospace';
ctx.textAlign = 'left';
ctx.fillText(val.toFixed(2), W - pad.right + 4, y + 3);
}
// Nav line
ctx.strokeStyle = '#58a6ff';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < n; i++) {
const x = pad.left + (i / (n - 1)) * cw;
const y = pad.top + (1 - (navs[i] - minNav) / (maxNav - minNav)) * ch;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Rebalance dots
ctx.fillStyle = 'rgba(218, 54, 51, 0.6)';
for (const ri of chartData.rebalIdx) {
const x = pad.left + (ri / (n - 1)) * cw;
const y = pad.top + (1 - (navs[ri] - minNav) / (maxNav - minNav)) * ch;
ctx.beginPath();
ctx.arc(x, y, 2, 0, Math.PI * 2);
ctx.fill();
}
// Store layout for marker
chartData.layout = { pad, cw, ch, minNav, maxNav, n, W, H };
}
function drawChartMarker() {
if (!chartData.layout) return;
const { pad, cw, ch, minNav, maxNav, n, W, H } = chartData.layout;
// Redraw full chart then add marker
drawChartFull();
const ctx = chartCtx;
const x = pad.left + (currentIdx / (n - 1)) * cw;
// Vertical line
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(x, pad.top);
ctx.lineTo(x, pad.top + ch);
ctx.stroke();
ctx.setLineDash([]);
// Dot
const nav = chartData.navs[currentIdx];
const y = pad.top + (1 - (nav - minNav) / (maxNav - minNav)) * ch;
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fill();
// Label
ctx.fillStyle = '#fff';
ctx.font = '11px monospace';
ctx.textAlign = x > W / 2 ? 'right' : 'left';
const labelX = x > W / 2 ? x - 8 : x + 8;
ctx.fillText(fmtNum(nav, 4), labelX, y - 8);
}
// ==================== Chart interaction (click & drag) ====================
let isDragging = false;
function xToIndex(clientX) {
if (!chartData.layout) return -1;
const rect = chartCanvas.getBoundingClientRect();
const { pad, cw, n } = chartData.layout;
const x = clientX - rect.left - pad.left;
const ratio = Math.max(0, Math.min(1, x / cw));
return Math.round(ratio * (n - 1));
}
function chartNavigateTo(clientX) {
const idx = xToIndex(clientX);
if (idx >= 0 && idx < DATA.days.length && idx !== currentIdx) {
currentIdx = idx;
render(currentIdx);
drawChartMarker();
}
}
$('nav-chart').addEventListener('mousedown', e => {
if (!DATA) return;
isDragging = true;
chartNavigateTo(e.clientX);
});
window.addEventListener('mousemove', e => {
if (!isDragging) return;
chartNavigateTo(e.clientX);
});
window.addEventListener('mouseup', () => {
isDragging = false;
});
// Touch support
$('nav-chart').addEventListener('touchstart', e => {
if (!DATA) return;
isDragging = true;
chartNavigateTo(e.touches[0].clientX);
}, { passive: true });
window.addEventListener('touchmove', e => {
if (!isDragging) return;
chartNavigateTo(e.touches[0].clientX);
}, { passive: true });
window.addEventListener('touchend', () => {
isDragging = false;
});
// Cursor style
$('nav-chart').style.cursor = 'crosshair';
// Resize handler
window.addEventListener('resize', () => {
if (DATA) { drawChart(); }
});
</script>
</body>
</html>