274 lines
9.7 KiB
Python
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
|
|
) |