add value risk factors with valuation and risk penalty calculations
This commit is contained in:
parent
09ff0080f4
commit
3cef20f6b1
@ -15,6 +15,7 @@ from app.utils.logging import get_logger
|
|||||||
# 导入扩展因子模块
|
# 导入扩展因子模块
|
||||||
from app.features.extended_factors import ExtendedFactors
|
from app.features.extended_factors import ExtendedFactors
|
||||||
from app.features.sentiment_factors import SentimentFactors
|
from app.features.sentiment_factors import SentimentFactors
|
||||||
|
from app.features.value_risk_factors import ValueRiskFactors
|
||||||
# 导入因子验证功能
|
# 导入因子验证功能
|
||||||
from app.features.validation import check_data_sufficiency, detect_outliers
|
from app.features.validation import check_data_sufficiency, detect_outliers
|
||||||
|
|
||||||
@ -83,6 +84,8 @@ DEFAULT_FACTORS: List[FactorSpec] = [
|
|||||||
FactorSpec("sent_impact", 0), # 新闻影响力
|
FactorSpec("sent_impact", 0), # 新闻影响力
|
||||||
FactorSpec("sent_market", 20), # 市场情绪指数
|
FactorSpec("sent_market", 20), # 市场情绪指数
|
||||||
FactorSpec("sent_divergence", 0), # 行业情绪背离度
|
FactorSpec("sent_divergence", 0), # 行业情绪背离度
|
||||||
|
# 风险和估值因子
|
||||||
|
FactorSpec("risk_penalty", 0), # 风险惩罚因子
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -525,6 +528,40 @@ def _compute_security_factors(
|
|||||||
extended_factors = calculator.compute_all_factors(close_series, volume_series)
|
extended_factors = calculator.compute_all_factors(close_series, volume_series)
|
||||||
results.update(extended_factors)
|
results.update(extended_factors)
|
||||||
|
|
||||||
|
# 计算情感因子
|
||||||
|
sentiment_calculator = SentimentFactors()
|
||||||
|
sentiment_factors = sentiment_calculator.compute_stock_factors(broker, ts_code, trade_date)
|
||||||
|
if sentiment_factors:
|
||||||
|
results.update(sentiment_factors)
|
||||||
|
|
||||||
|
# 计算风险和估值因子
|
||||||
|
value_risk_calculator = ValueRiskFactors()
|
||||||
|
|
||||||
|
# 计算val_multiscore
|
||||||
|
val_multiscore = value_risk_calculator.compute_val_multiscore(
|
||||||
|
pe=latest_fields.get("daily_basic.pe"),
|
||||||
|
pb=latest_fields.get("daily_basic.pb"),
|
||||||
|
ps=latest_fields.get("daily_basic.ps"),
|
||||||
|
dv=latest_fields.get("daily_basic.dv_ratio")
|
||||||
|
)
|
||||||
|
if val_multiscore is not None:
|
||||||
|
results["val_multiscore"] = val_multiscore
|
||||||
|
|
||||||
|
# 计算risk_penalty
|
||||||
|
volat_20 = results.get("volat_20")
|
||||||
|
turnover = latest_fields.get("daily_basic.turnover_rate")
|
||||||
|
current_price = latest_fields.get("daily.close")
|
||||||
|
avg_price = rolling_mean(close_series, 20) if len(close_series) >= 20 else None
|
||||||
|
|
||||||
|
risk_penalty = value_risk_calculator.compute_risk_penalty(
|
||||||
|
volatility=volat_20,
|
||||||
|
turnover=turnover,
|
||||||
|
price=current_price,
|
||||||
|
avg_price=avg_price
|
||||||
|
)
|
||||||
|
if risk_penalty is not None:
|
||||||
|
results["risk_penalty"] = risk_penalty
|
||||||
|
|
||||||
# 确保返回结果不为空
|
# 确保返回结果不为空
|
||||||
if not any(v is not None for v in results.values()):
|
if not any(v is not None for v in results.values()):
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
274
app/features/value_risk_factors.py
Normal file
274
app/features/value_risk_factors.py
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
"""Value and risk factor implementations."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict, Optional, Sequence
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app.core.indicators import normalize
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
LOGGER = get_logger(__name__)
|
||||||
|
LOG_EXTRA = {"stage": "value_risk_factors"}
|
||||||
|
|
||||||
|
|
||||||
|
class ValueRiskFactors:
|
||||||
|
"""Value and risk factor calculation implementation.
|
||||||
|
|
||||||
|
This class implements:
|
||||||
|
1. Multi-dimensional valuation score (val_multiscore)
|
||||||
|
2. Risk penalty factor (risk_penalty)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the calculator"""
|
||||||
|
# Weights for different valuation metrics
|
||||||
|
self.valuation_weights = {
|
||||||
|
'pe': 0.3, # PE ratio weight
|
||||||
|
'pb': 0.3, # PB ratio weight
|
||||||
|
'ps': 0.2, # PS ratio weight
|
||||||
|
'dv': 0.2 # Dividend yield weight
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute_val_multiscore(self,
|
||||||
|
pe: Optional[float],
|
||||||
|
pb: Optional[float],
|
||||||
|
ps: Optional[float],
|
||||||
|
dv: Optional[float]) -> Optional[float]:
|
||||||
|
"""Compute multi-dimensional valuation score
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pe: PE ratio
|
||||||
|
pb: PB ratio
|
||||||
|
ps: PS ratio
|
||||||
|
dv: Dividend yield
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized valuation score in [-1, 1] range,
|
||||||
|
where -1 indicates overvalued and 1 indicates undervalued
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
scores = []
|
||||||
|
weights = []
|
||||||
|
|
||||||
|
# PE ratio score (inverted)
|
||||||
|
if pe is not None and pe > 0:
|
||||||
|
pe_score = -normalize(pe, factor=25.0) # Center around PE=25
|
||||||
|
scores.append(pe_score)
|
||||||
|
weights.append(self.valuation_weights['pe'])
|
||||||
|
|
||||||
|
# PB ratio score (inverted)
|
||||||
|
if pb is not None and pb > 0:
|
||||||
|
pb_score = -normalize(pb, factor=2.0) # Center around PB=2
|
||||||
|
scores.append(pb_score)
|
||||||
|
weights.append(self.valuation_weights['pb'])
|
||||||
|
|
||||||
|
# PS ratio score (inverted)
|
||||||
|
if ps is not None and ps > 0:
|
||||||
|
ps_score = -normalize(ps, factor=2.0) # Center around PS=2
|
||||||
|
scores.append(ps_score)
|
||||||
|
weights.append(self.valuation_weights['ps'])
|
||||||
|
|
||||||
|
# Dividend yield score
|
||||||
|
if dv is not None and dv >= 0:
|
||||||
|
dv_score = normalize(dv, factor=0.03) # Center around 3% yield
|
||||||
|
scores.append(dv_score)
|
||||||
|
weights.append(self.valuation_weights['dv'])
|
||||||
|
|
||||||
|
if not scores:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Normalize weights
|
||||||
|
weights = np.array(weights)
|
||||||
|
weights = weights / weights.sum()
|
||||||
|
|
||||||
|
# Weighted average score
|
||||||
|
return float(np.average(scores, weights=weights))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.error(
|
||||||
|
"Error calculating val_multiscore: %s",
|
||||||
|
str(e),
|
||||||
|
exc_info=True,
|
||||||
|
extra=LOG_EXTRA
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def compute_risk_penalty(self,
|
||||||
|
volatility: Optional[float],
|
||||||
|
turnover: Optional[float],
|
||||||
|
price: Optional[float],
|
||||||
|
avg_price: Optional[float]) -> Optional[float]:
|
||||||
|
"""Compute risk penalty factor
|
||||||
|
|
||||||
|
Args:
|
||||||
|
volatility: Historical volatility
|
||||||
|
turnover: Turnover rate
|
||||||
|
price: Current price
|
||||||
|
avg_price: Moving average price (e.g. 20-day MA)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Risk penalty score in [0, 1] range,
|
||||||
|
where 0 indicates low risk and 1 indicates high risk
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
penalties = []
|
||||||
|
|
||||||
|
# Volatility penalty
|
||||||
|
if volatility is not None:
|
||||||
|
vol_penalty = normalize(volatility, factor=0.3) # Baseline 30% annualized vol
|
||||||
|
penalties.append(vol_penalty)
|
||||||
|
|
||||||
|
# Turnover penalty
|
||||||
|
if turnover is not None:
|
||||||
|
turn_penalty = normalize(turnover, factor=5.0) # Baseline 500% turnover
|
||||||
|
penalties.append(turn_penalty)
|
||||||
|
|
||||||
|
# Price deviation penalty
|
||||||
|
if price is not None and avg_price is not None and avg_price > 0:
|
||||||
|
deviation = abs(price / avg_price - 1.0)
|
||||||
|
dev_penalty = normalize(deviation, factor=0.1) # Baseline 10% deviation
|
||||||
|
penalties.append(dev_penalty)
|
||||||
|
|
||||||
|
if not penalties:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Average of all penalties
|
||||||
|
return float(np.mean(penalties))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.error(
|
||||||
|
"Error calculating risk_penalty: %s",
|
||||||
|
str(e),
|
||||||
|
exc_info=True,
|
||||||
|
extra=LOG_EXTRA
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def compute_batch(self,
|
||||||
|
broker,
|
||||||
|
ts_codes: list[str],
|
||||||
|
trade_date: str,
|
||||||
|
batch_size: int = 100) -> None:
|
||||||
|
"""Batch compute factors for multiple stocks
|
||||||
|
|
||||||
|
Args:
|
||||||
|
broker: Data broker instance
|
||||||
|
ts_codes: List of stock codes
|
||||||
|
trade_date: Trading date
|
||||||
|
batch_size: Batch size for processing
|
||||||
|
"""
|
||||||
|
# Prepare SQL statements
|
||||||
|
columns = ['risk_penalty', 'val_multiscore']
|
||||||
|
insert_columns = ['ts_code', 'trade_date', 'updated_at'] + columns
|
||||||
|
|
||||||
|
placeholders = ','.join('?' * len(insert_columns))
|
||||||
|
update_clause = ', '.join(
|
||||||
|
f"{column}=excluded.{column}"
|
||||||
|
for column in ['updated_at'] + columns
|
||||||
|
)
|
||||||
|
|
||||||
|
sql = (
|
||||||
|
f"INSERT INTO factors ({','.join(insert_columns)}) "
|
||||||
|
f"VALUES ({placeholders}) "
|
||||||
|
f"ON CONFLICT (ts_code, trade_date) DO UPDATE SET {update_clause}"
|
||||||
|
)
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
# Process in batches
|
||||||
|
processed = 0
|
||||||
|
for i in range(0, len(ts_codes), batch_size):
|
||||||
|
batch = ts_codes[i:i + batch_size]
|
||||||
|
values = []
|
||||||
|
|
||||||
|
for ts_code in batch:
|
||||||
|
try:
|
||||||
|
# Get required data
|
||||||
|
data = broker.get_stock_data(
|
||||||
|
ts_code,
|
||||||
|
trade_date,
|
||||||
|
fields=[
|
||||||
|
'daily_basic.pe',
|
||||||
|
'daily_basic.pb',
|
||||||
|
'daily_basic.ps',
|
||||||
|
'daily_basic.dv_ratio',
|
||||||
|
'factors.volat_20',
|
||||||
|
'daily_basic.turnover_rate',
|
||||||
|
'daily.close',
|
||||||
|
'daily_basic.pe_ttm'
|
||||||
|
],
|
||||||
|
limit=1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current = data[0]
|
||||||
|
|
||||||
|
# Get 20-day average price
|
||||||
|
hist_data = broker.get_stock_data(
|
||||||
|
ts_code,
|
||||||
|
trade_date,
|
||||||
|
fields=['daily.close'],
|
||||||
|
limit=20
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hist_data or len(hist_data) < 20:
|
||||||
|
continue
|
||||||
|
|
||||||
|
avg_price = np.mean([d['daily.close'] for d in hist_data])
|
||||||
|
|
||||||
|
# Calculate factors
|
||||||
|
risk_penalty = self.compute_risk_penalty(
|
||||||
|
volatility=current.get('factors.volat_20'),
|
||||||
|
turnover=current.get('daily_basic.turnover_rate'),
|
||||||
|
price=current.get('daily.close'),
|
||||||
|
avg_price=avg_price
|
||||||
|
)
|
||||||
|
|
||||||
|
val_multiscore = self.compute_val_multiscore(
|
||||||
|
pe=current.get('daily_basic.pe_ttm'),
|
||||||
|
pb=current.get('daily_basic.pb'),
|
||||||
|
ps=current.get('daily_basic.ps'),
|
||||||
|
dv=current.get('daily_basic.dv_ratio')
|
||||||
|
)
|
||||||
|
|
||||||
|
values.append((
|
||||||
|
ts_code,
|
||||||
|
trade_date,
|
||||||
|
now,
|
||||||
|
risk_penalty,
|
||||||
|
val_multiscore
|
||||||
|
))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.error(
|
||||||
|
"Error processing stock: %s error=%s",
|
||||||
|
ts_code,
|
||||||
|
str(e),
|
||||||
|
exc_info=True,
|
||||||
|
extra=LOG_EXTRA
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if values:
|
||||||
|
try:
|
||||||
|
with broker.db.write_session() as session:
|
||||||
|
session.executemany(sql, values)
|
||||||
|
processed += len(values)
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.error(
|
||||||
|
"Error saving batch results: %s",
|
||||||
|
str(e),
|
||||||
|
exc_info=True,
|
||||||
|
extra=LOG_EXTRA
|
||||||
|
)
|
||||||
|
|
||||||
|
LOGGER.info(
|
||||||
|
"Batch processing completed: processed %d/%d stocks",
|
||||||
|
processed,
|
||||||
|
len(ts_codes),
|
||||||
|
extra=LOG_EXTRA
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user