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 → 合理范围
This commit is contained in:
@@ -78,12 +78,18 @@ class DataCache:
|
|||||||
cache_dir = PROJECT_ROOT / 'data' / 'simple_rotation_cache'
|
cache_dir = PROJECT_ROOT / 'data' / 'simple_rotation_cache'
|
||||||
self.cache_dir = Path(cache_dir)
|
self.cache_dir = Path(cache_dir)
|
||||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# premium data cache: {trade_code: {date_str: premium_ratio}}
|
||||||
|
self.premium_data: Dict[str, Dict[str, float]] = {}
|
||||||
|
|
||||||
def _cache_path(self, code: str, adj: str) -> Path:
|
def _cache_path(self, code: str, adj: str) -> Path:
|
||||||
prefix = 'index' if adj == 'raw' else 'etf'
|
prefix = 'index' if adj == 'raw' else 'etf'
|
||||||
safe_code = code.replace('=', '_').replace('^', '_')
|
safe_code = code.replace('=', '_').replace('^', '_')
|
||||||
return self.cache_dir / f"{prefix}_{safe_code}.csv"
|
return self.cache_dir / f"{prefix}_{safe_code}.csv"
|
||||||
|
|
||||||
|
def _premium_cache_path(self, code: str) -> Path:
|
||||||
|
safe_code = code.replace('=', '_').replace('^', '_')
|
||||||
|
return self.cache_dir / f"premium_{safe_code}.csv"
|
||||||
|
|
||||||
def preload(self, code: str, start_date: str, end_date: str, adj: str = 'raw') -> Optional[pd.DataFrame]:
|
def preload(self, code: str, start_date: str, end_date: str, adj: str = 'raw') -> Optional[pd.DataFrame]:
|
||||||
"""Preload full history and cache to CSV"""
|
"""Preload full history and cache to CSV"""
|
||||||
cache_path = self._cache_path(code, adj)
|
cache_path = self._cache_path(code, adj)
|
||||||
@@ -111,7 +117,7 @@ class DataCache:
|
|||||||
return df
|
return df
|
||||||
|
|
||||||
def _fetch_api(self, code: str, start_date: str, end_date: str, adj: str) -> Optional[pd.DataFrame]:
|
def _fetch_api(self, code: str, start_date: str, end_date: str, adj: str) -> Optional[pd.DataFrame]:
|
||||||
"""Fetch from Flask API"""
|
"""Fetch from Flask API, also extracts premium_series for ETFs"""
|
||||||
url = f"{self.base_url}{self.api_path}"
|
url = f"{self.base_url}{self.api_path}"
|
||||||
params = {'code': code, 'start': start_date, 'end': end_date, 'adj': adj}
|
params = {'code': code, 'start': start_date, 'end': end_date, 'adj': adj}
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
@@ -136,6 +142,11 @@ class DataCache:
|
|||||||
df = df.set_index('date').sort_index()
|
df = df.set_index('date').sort_index()
|
||||||
keep = [c for c in ['open', 'high', 'low', 'close', 'volume'] if c in df.columns]
|
keep = [c for c in ['open', 'high', 'low', 'close', 'volume'] if c in df.columns]
|
||||||
df = df[keep]
|
df = df[keep]
|
||||||
|
# Extract and cache premium_series (ETF only)
|
||||||
|
premium_series = data.get('premium_series', [])
|
||||||
|
if premium_series:
|
||||||
|
df.attrs['premium_series'] = {item['date']: item['premium'] for item in premium_series}
|
||||||
|
self._save_premium_cache(code, df.attrs['premium_series'])
|
||||||
print(f" + {code}: {len(df)} rows ({adj})")
|
print(f" + {code}: {len(df)} rows ({adj})")
|
||||||
return df
|
return df
|
||||||
except requests.exceptions.Timeout:
|
except requests.exceptions.Timeout:
|
||||||
@@ -148,6 +159,61 @@ class DataCache:
|
|||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _save_premium_cache(self, code: str, premium_dict: Dict[str, float]):
|
||||||
|
"""Save premium data to CSV cache"""
|
||||||
|
try:
|
||||||
|
cache_path = self._premium_cache_path(code)
|
||||||
|
pd.DataFrame(
|
||||||
|
[{'date': k, 'premium': v} for k, v in premium_dict.items()]
|
||||||
|
).to_csv(cache_path, index=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def preload_premium(self, code: str, start_date: str = '2000-01-01', end_date: str = None) -> Optional[Dict[str, float]]:
|
||||||
|
"""Load premium data for an ETF code from cache, or fetch from API if not available"""
|
||||||
|
if code in self.premium_data:
|
||||||
|
return self.premium_data[code]
|
||||||
|
cache_path = self._premium_cache_path(code)
|
||||||
|
if cache_path.exists():
|
||||||
|
try:
|
||||||
|
df = pd.read_csv(cache_path)
|
||||||
|
if len(df) > 0 and 'date' in df.columns and 'premium' in df.columns:
|
||||||
|
self.premium_data[code] = dict(zip(df['date'].astype(str), df['premium']))
|
||||||
|
return self.premium_data[code]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# No cache: fetch premium_series directly from API (returns full history)
|
||||||
|
if end_date is None:
|
||||||
|
end_date = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
url = f"{self.base_url}{self.api_path}"
|
||||||
|
params = {'code': code, 'start': start_date, 'end': end_date, 'adj': 'raw'}
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, params=params, timeout=self.timeout)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
if attempt < 2:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
if 'error' in data:
|
||||||
|
return None
|
||||||
|
premium_series = data.get('premium_series', [])
|
||||||
|
if premium_series:
|
||||||
|
premium_dict = {item['date']: item['premium'] for item in premium_series}
|
||||||
|
self.premium_data[code] = premium_dict
|
||||||
|
self._save_premium_cache(code, premium_dict)
|
||||||
|
print(f" + premium {code}: {len(premium_dict)} days")
|
||||||
|
return premium_dict
|
||||||
|
return None
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
if attempt < 2:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
def get_trading_calendar(self, market: str, start_date: str, end_date: str) -> Optional[pd.DatetimeIndex]:
|
def get_trading_calendar(self, market: str, start_date: str, end_date: str) -> Optional[pd.DatetimeIndex]:
|
||||||
"""Fetch trading calendar from API"""
|
"""Fetch trading calendar from API"""
|
||||||
url = f"{self.base_url}/api/v1/trading-calendar"
|
url = f"{self.base_url}/api/v1/trading-calendar"
|
||||||
@@ -218,10 +284,12 @@ class SimpleRotationStrategy:
|
|||||||
self.signal_codes = []
|
self.signal_codes = []
|
||||||
self.signal_to_trade = {}
|
self.signal_to_trade = {}
|
||||||
self.code_to_group = {}
|
self.code_to_group = {}
|
||||||
|
self.trade_code_to_group = {}
|
||||||
for code, asset in self.config.asset_pools.assets.items():
|
for code, asset in self.config.asset_pools.assets.items():
|
||||||
self.signal_codes.append(asset.signal_source)
|
self.signal_codes.append(asset.signal_source)
|
||||||
self.signal_to_trade[asset.signal_source] = asset.trade_source
|
self.signal_to_trade[asset.signal_source] = asset.trade_source
|
||||||
self.code_to_group[asset.signal_source] = asset.group
|
self.code_to_group[asset.signal_source] = asset.group
|
||||||
|
self.trade_code_to_group[asset.trade_source] = asset.group
|
||||||
|
|
||||||
# Data source
|
# Data source
|
||||||
data_source = self.config.data.sources[0]
|
data_source = self.config.data.sources[0]
|
||||||
@@ -260,7 +328,10 @@ class SimpleRotationStrategy:
|
|||||||
df = self.data_cache.preload(code, preload_start, end_date, adj=adj)
|
df = self.data_cache.preload(code, preload_start, end_date, adj=adj)
|
||||||
if df is not None:
|
if df is not None:
|
||||||
self.etf_data[code] = df
|
self.etf_data[code] = df
|
||||||
print(f"\n Trade: {len(self.etf_data)}/{len(trade_codes)} OK")
|
# Load premium data cache for all ETF trade codes
|
||||||
|
for code in trade_codes:
|
||||||
|
self.data_cache.preload_premium(code)
|
||||||
|
print(f"\n Trade: {len(self.etf_data)}/{len(trade_codes)} OK, premium: {len(self.data_cache.premium_data)} loaded")
|
||||||
|
|
||||||
def _compute_momentum(self, signal_code: str, date: pd.Timestamp) -> Optional[float]:
|
def _compute_momentum(self, signal_code: str, date: pd.Timestamp) -> Optional[float]:
|
||||||
"""Compute momentum for a single code on a given date"""
|
"""Compute momentum for a single code on a given date"""
|
||||||
@@ -620,15 +691,20 @@ class SimpleRotationStrategy:
|
|||||||
|
|
||||||
return idx_ret, etf_ret
|
return idx_ret, etf_ret
|
||||||
|
|
||||||
def _compute_premium(self, code: str, idx_close: float, etf_close: float) -> Optional[float]:
|
def _compute_premium(self, trade_code: str, date: pd.Timestamp) -> Optional[float]:
|
||||||
"""Compute premium = (etf_close - index_close) / index_close.
|
"""Get real premium from API data cache: (ETF_price - NAV) / NAV.
|
||||||
Only meaningful for ETFs that track an index (not bonds)."""
|
Returns None for BOND or when premium data is unavailable."""
|
||||||
group = self.code_to_group.get(code, '')
|
group = self.trade_code_to_group.get(trade_code, '')
|
||||||
if group == 'BOND':
|
if group == 'BOND':
|
||||||
return None
|
return None
|
||||||
if idx_close is None or etf_close is None or idx_close == 0:
|
premium_dict = self.data_cache.premium_data.get(trade_code)
|
||||||
|
if not premium_dict:
|
||||||
return None
|
return None
|
||||||
return round((etf_close - idx_close) / idx_close, 6)
|
date_str = date.strftime('%Y-%m-%d')
|
||||||
|
val = premium_dict.get(date_str)
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
return round(float(val), 6)
|
||||||
|
|
||||||
def _build_day_assets(self, record: dict, date: pd.Timestamp,
|
def _build_day_assets(self, record: dict, date: pd.Timestamp,
|
||||||
entry_info: Dict[str, dict]) -> dict:
|
entry_info: Dict[str, dict]) -> dict:
|
||||||
@@ -652,7 +728,7 @@ class SimpleRotationStrategy:
|
|||||||
idx_close = self._get_index_close(code, date)
|
idx_close = self._get_index_close(code, date)
|
||||||
etf_close = self._get_etf_close(trade_code, date)
|
etf_close = self._get_etf_close(trade_code, date)
|
||||||
idx_ret, etf_ret_ctc = self._get_daily_returns(code, date)
|
idx_ret, etf_ret_ctc = self._get_daily_returns(code, date)
|
||||||
premium = self._compute_premium(code, idx_close, etf_close)
|
premium = self._compute_premium(trade_code, date)
|
||||||
|
|
||||||
# Entry / holding info
|
# Entry / holding info
|
||||||
ei = entry_info.get(code) if is_held else None
|
ei = entry_info.get(code) if is_held else None
|
||||||
|
|||||||
Reference in New Issue
Block a user