llm-quant/app/features/value_risk_factors.py

274 lines
9.7 KiB
Python

"""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
)