diff --git a/app/backtest/engine.py b/app/backtest/engine.py index 3c2e6ca..a3fd760 100644 --- a/app/backtest/engine.py +++ b/app/backtest/engine.py @@ -23,6 +23,27 @@ LOGGER = get_logger(__name__) 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 class BtConfig: @@ -129,6 +150,9 @@ class BacktestEngine: ) 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") if mom20 is None and len(close_values) >= 20: mom20 = momentum(close_values, 20) @@ -153,6 +177,9 @@ class BacktestEngine: turn20 = scope_values.get("factors.turn_20") if turn20 is None and turnover_values: 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: mom20 = 0.0 @@ -162,6 +189,10 @@ class BacktestEngine: volat20 = 0.0 if turn20 is None: turn20 = 0.0 + if mom5 is None: + mom5 = 0.0 + if turn5 is None: + turn5 = 0.0 liquidity_score = normalize(turn20, factor=20.0) cost_penalty = normalize( @@ -169,15 +200,32 @@ class BacktestEngine: 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) heat_score = scope_values.get("news.heat_score", 0.0) scope_values.setdefault("news.sentiment_index", sentiment_index) 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_60", mom60) scope_values.setdefault("factors.volat_20", volat20) 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: scope_values["macro.industry_heat"] = 0.5 if scope_values.get("macro.relative_strength") is None: @@ -213,10 +261,12 @@ class BacktestEngine: ) features = { + "mom_5": mom5, "mom_20": mom20, "mom_60": mom60, "volat_20": volat20, "turn_20": turn20, + "turn_5": turn5, "liquidity_score": liquidity_score, "cost_penalty": cost_penalty, "news_heat": heat_score, @@ -227,6 +277,9 @@ class BacktestEngine: scope_values.get("index.performance_peers", 0.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, "limit_up": limit_up, "limit_down": limit_down, diff --git a/app/features/factors.py b/app/features/factors.py index 9b1a0dc..5a6cd3c 100644 --- a/app/features/factors.py +++ b/app/features/factors.py @@ -58,7 +58,7 @@ def compute_factors( 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: return [] diff --git a/tests/test_factors.py b/tests/test_factors.py index 2c3897c..550ce2a 100644 --- a/tests/test_factors.py +++ b/tests/test_factors.py @@ -13,6 +13,8 @@ from app.features.factors import ( FactorSpec, compute_factor_range, compute_factors, + _valuation_score, + _volume_ratio_score, ) from app.utils.config import DataPaths, get_config 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, ), ) + 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( """ INSERT OR REPLACE INTO daily_basic - (ts_code, trade_date, turnover_rate, turnover_rate_f, volume_ratio) - VALUES (?, ?, ?, ?, ?) + (ts_code, trade_date, turnover_rate, turnover_rate_f, volume_ratio, pe, pb, ps) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( ts_code, trade_date, 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) _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) assert results @@ -95,18 +111,29 @@ def test_compute_factors_persists_and_updates(isolated_db): expected_mom5 = momentum(close_series, 5) expected_volat20 = volatility(close_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_60"] == pytest.approx(expected_mom60) assert result.values["mom_5"] == pytest.approx(expected_mom5) assert result.values["volat_20"] == pytest.approx(expected_volat20) 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") with db_session(read_only=True) as conn: 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 = ? """, (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["volat_20"] == pytest.approx(expected_volat20) 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() - 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.turn_20"] == pytest.approx(expected_turn20)