This commit is contained in:
sam 2025-10-02 10:53:17 +08:00
parent 70dce8c58c
commit 6a7c20db91
3 changed files with 102 additions and 7 deletions

View File

@ -23,6 +23,27 @@ LOGGER = get_logger(__name__)
LOG_EXTRA = {"stage": "backtest"} LOG_EXTRA = {"stage": "backtest"}
def _valuation_score(value: object, scale: float) -> float:
try:
numeric = float(value)
except (TypeError, ValueError):
return 0.0
if numeric <= 0:
return 0.0
score = scale / (scale + numeric)
return max(0.0, min(1.0, score))
def _volume_ratio_score(value: object) -> float:
try:
numeric = float(value)
except (TypeError, ValueError):
return 0.0
if numeric < 0:
numeric = 0.0
return max(0.0, min(1.0, numeric / 10.0))
@dataclass @dataclass
class BtConfig: class BtConfig:
@ -129,6 +150,9 @@ class BacktestEngine:
) )
close_values = [value for _date, value in closes if value is not None] close_values = [value for _date, value in closes if value is not None]
mom5 = scope_values.get("factors.mom_5")
if mom5 is None and len(close_values) >= 5:
mom5 = momentum(close_values, 5)
mom20 = scope_values.get("factors.mom_20") mom20 = scope_values.get("factors.mom_20")
if mom20 is None and len(close_values) >= 20: if mom20 is None and len(close_values) >= 20:
mom20 = momentum(close_values, 20) mom20 = momentum(close_values, 20)
@ -153,6 +177,9 @@ class BacktestEngine:
turn20 = scope_values.get("factors.turn_20") turn20 = scope_values.get("factors.turn_20")
if turn20 is None and turnover_values: if turn20 is None and turnover_values:
turn20 = rolling_mean(turnover_values, 20) turn20 = rolling_mean(turnover_values, 20)
turn5 = scope_values.get("factors.turn_5")
if turn5 is None and len(turnover_values) >= 5:
turn5 = rolling_mean(turnover_values, 5)
if mom20 is None: if mom20 is None:
mom20 = 0.0 mom20 = 0.0
@ -162,6 +189,10 @@ class BacktestEngine:
volat20 = 0.0 volat20 = 0.0
if turn20 is None: if turn20 is None:
turn20 = 0.0 turn20 = 0.0
if mom5 is None:
mom5 = 0.0
if turn5 is None:
turn5 = 0.0
liquidity_score = normalize(turn20, factor=20.0) liquidity_score = normalize(turn20, factor=20.0)
cost_penalty = normalize( cost_penalty = normalize(
@ -169,15 +200,32 @@ class BacktestEngine:
factor=50.0, factor=50.0,
) )
val_pe = scope_values.get("factors.val_pe_score")
if val_pe is None:
val_pe = _valuation_score(scope_values.get("daily_basic.pe"), scale=12.0)
val_pb = scope_values.get("factors.val_pb_score")
if val_pb is None:
val_pb = _valuation_score(scope_values.get("daily_basic.pb"), scale=2.5)
volume_ratio_score = scope_values.get("factors.volume_ratio_score")
if volume_ratio_score is None:
volume_ratio_score = _volume_ratio_score(scope_values.get("daily_basic.volume_ratio"))
sentiment_index = scope_values.get("news.sentiment_index", 0.0) sentiment_index = scope_values.get("news.sentiment_index", 0.0)
heat_score = scope_values.get("news.heat_score", 0.0) heat_score = scope_values.get("news.heat_score", 0.0)
scope_values.setdefault("news.sentiment_index", sentiment_index) scope_values.setdefault("news.sentiment_index", sentiment_index)
scope_values.setdefault("news.heat_score", heat_score) scope_values.setdefault("news.heat_score", heat_score)
scope_values.setdefault("factors.mom_5", mom5)
scope_values.setdefault("factors.mom_20", mom20) scope_values.setdefault("factors.mom_20", mom20)
scope_values.setdefault("factors.mom_60", mom60) scope_values.setdefault("factors.mom_60", mom60)
scope_values.setdefault("factors.volat_20", volat20) scope_values.setdefault("factors.volat_20", volat20)
scope_values.setdefault("factors.turn_20", turn20) scope_values.setdefault("factors.turn_20", turn20)
scope_values.setdefault("factors.turn_5", turn5)
scope_values.setdefault("factors.val_pe_score", val_pe)
scope_values.setdefault("factors.val_pb_score", val_pb)
scope_values.setdefault("factors.volume_ratio_score", volume_ratio_score)
if scope_values.get("macro.industry_heat") is None: if scope_values.get("macro.industry_heat") is None:
scope_values["macro.industry_heat"] = 0.5 scope_values["macro.industry_heat"] = 0.5
if scope_values.get("macro.relative_strength") is None: if scope_values.get("macro.relative_strength") is None:
@ -213,10 +261,12 @@ class BacktestEngine:
) )
features = { features = {
"mom_5": mom5,
"mom_20": mom20, "mom_20": mom20,
"mom_60": mom60, "mom_60": mom60,
"volat_20": volat20, "volat_20": volat20,
"turn_20": turn20, "turn_20": turn20,
"turn_5": turn5,
"liquidity_score": liquidity_score, "liquidity_score": liquidity_score,
"cost_penalty": cost_penalty, "cost_penalty": cost_penalty,
"news_heat": heat_score, "news_heat": heat_score,
@ -227,6 +277,9 @@ class BacktestEngine:
scope_values.get("index.performance_peers", 0.0), scope_values.get("index.performance_peers", 0.0),
), ),
"risk_penalty": min(1.0, volat20 * 5.0), "risk_penalty": min(1.0, volat20 * 5.0),
"valuation_pe_score": val_pe,
"valuation_pb_score": val_pb,
"volume_ratio_score": volume_ratio_score,
"is_suspended": is_suspended, "is_suspended": is_suspended,
"limit_up": limit_up, "limit_up": limit_up,
"limit_down": limit_down, "limit_down": limit_down,

View File

@ -58,7 +58,7 @@ def compute_factors(
entry for ``trade_date`` will be ignored. entry for ``trade_date`` will be ignored.
""" """
specs = [spec for spec in factors if spec.window > 0] specs = [spec for spec in factors if spec.window >= 0]
if not specs: if not specs:
return [] return []

View File

@ -13,6 +13,8 @@ from app.features.factors import (
FactorSpec, FactorSpec,
compute_factor_range, compute_factor_range,
compute_factors, compute_factors,
_valuation_score,
_volume_ratio_score,
) )
from app.utils.config import DataPaths, get_config from app.utils.config import DataPaths, get_config
from app.utils.data_access import DataBroker from app.utils.data_access import DataBroker
@ -58,18 +60,25 @@ def _populate_sample_data(ts_code: str, as_of: date) -> None:
1_000_000.0, 1_000_000.0,
), ),
) )
pe = 10.0 + (offset % 5)
pb = 1.5 + (offset % 3) * 0.1
ps = 2.0 + (offset % 4) * 0.1
volume_ratio = 0.5 + (offset % 4) * 0.5
conn.execute( conn.execute(
""" """
INSERT OR REPLACE INTO daily_basic INSERT OR REPLACE INTO daily_basic
(ts_code, trade_date, turnover_rate, turnover_rate_f, volume_ratio) (ts_code, trade_date, turnover_rate, turnover_rate_f, volume_ratio, pe, pb, ps)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
ts_code, ts_code,
trade_date, trade_date,
turnover, turnover,
turnover, turnover,
1.0, volume_ratio,
pe,
pb,
ps,
), ),
) )
@ -79,7 +88,14 @@ def test_compute_factors_persists_and_updates(isolated_db):
trade_day = date(2025, 1, 30) trade_day = date(2025, 1, 30)
_populate_sample_data(ts_code, trade_day) _populate_sample_data(ts_code, trade_day)
specs = [*DEFAULT_FACTORS, FactorSpec("mom_5", 5)] specs = [
*DEFAULT_FACTORS,
FactorSpec("mom_5", 5),
FactorSpec("turn_5", 5),
FactorSpec("val_pe_score", 0),
FactorSpec("val_pb_score", 0),
FactorSpec("volume_ratio_score", 0),
]
results = compute_factors(trade_day, specs) results = compute_factors(trade_day, specs)
assert results assert results
@ -95,18 +111,29 @@ def test_compute_factors_persists_and_updates(isolated_db):
expected_mom5 = momentum(close_series, 5) expected_mom5 = momentum(close_series, 5)
expected_volat20 = volatility(close_series, 20) expected_volat20 = volatility(close_series, 20)
expected_turn20 = rolling_mean(turnover_series, 20) expected_turn20 = rolling_mean(turnover_series, 20)
expected_turn5 = rolling_mean(turnover_series, 5)
latest_pe = 10.0 + (0 % 5)
latest_pb = 1.5 + (0 % 3) * 0.1
latest_volume_ratio = 0.5 + (0 % 4) * 0.5
expected_val_pe = _valuation_score(latest_pe, scale=12.0)
expected_val_pb = _valuation_score(latest_pb, scale=2.5)
expected_vol_ratio_score = _volume_ratio_score(latest_volume_ratio)
assert result.values["mom_20"] == pytest.approx(expected_mom20) assert result.values["mom_20"] == pytest.approx(expected_mom20)
assert result.values["mom_60"] == pytest.approx(expected_mom60) assert result.values["mom_60"] == pytest.approx(expected_mom60)
assert result.values["mom_5"] == pytest.approx(expected_mom5) assert result.values["mom_5"] == pytest.approx(expected_mom5)
assert result.values["volat_20"] == pytest.approx(expected_volat20) assert result.values["volat_20"] == pytest.approx(expected_volat20)
assert result.values["turn_20"] == pytest.approx(expected_turn20) assert result.values["turn_20"] == pytest.approx(expected_turn20)
assert result.values["turn_5"] == pytest.approx(expected_turn5)
assert result.values["val_pe_score"] == pytest.approx(expected_val_pe)
assert result.values["val_pb_score"] == pytest.approx(expected_val_pb)
assert result.values["volume_ratio_score"] == pytest.approx(expected_vol_ratio_score)
trade_date_str = trade_day.strftime("%Y%m%d") trade_date_str = trade_day.strftime("%Y%m%d")
with db_session(read_only=True) as conn: with db_session(read_only=True) as conn:
row = conn.execute( row = conn.execute(
""" """
SELECT mom_20, mom_60, mom_5, volat_20, turn_20 SELECT mom_20, mom_60, mom_5, volat_20, turn_20, turn_5, val_pe_score, val_pb_score, volume_ratio_score
FROM factors WHERE ts_code = ? AND trade_date = ? FROM factors WHERE ts_code = ? AND trade_date = ?
""", """,
(ts_code, trade_date_str), (ts_code, trade_date_str),
@ -117,9 +144,24 @@ def test_compute_factors_persists_and_updates(isolated_db):
assert row["mom_5"] == pytest.approx(expected_mom5) assert row["mom_5"] == pytest.approx(expected_mom5)
assert row["volat_20"] == pytest.approx(expected_volat20) assert row["volat_20"] == pytest.approx(expected_volat20)
assert row["turn_20"] == pytest.approx(expected_turn20) assert row["turn_20"] == pytest.approx(expected_turn20)
assert row["turn_5"] == pytest.approx(expected_turn5)
assert row["val_pe_score"] == pytest.approx(expected_val_pe)
assert row["val_pb_score"] == pytest.approx(expected_val_pb)
assert row["volume_ratio_score"] == pytest.approx(expected_vol_ratio_score)
broker = DataBroker() broker = DataBroker()
latest = broker.fetch_latest(ts_code, trade_date_str, ["factors.mom_5", "factors.turn_20"]) latest = broker.fetch_latest(
ts_code,
trade_date_str,
[
"factors.mom_5",
"factors.turn_20",
"factors.turn_5",
"factors.val_pe_score",
"factors.val_pb_score",
"factors.volume_ratio_score",
],
)
assert latest["factors.mom_5"] == pytest.approx(expected_mom5) assert latest["factors.mom_5"] == pytest.approx(expected_mom5)
assert latest["factors.turn_20"] == pytest.approx(expected_turn20) assert latest["factors.turn_20"] == pytest.approx(expected_turn20)