# -*- coding: utf-8 -*- """量化模型单测:因子计算 / 打分 / 权重微调 / 推荐原因""" import sys, os, warnings sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) warnings.filterwarnings('ignore') import numpy as np import pandas as pd import pytest from tests.helpers import gen_kline, gen_trending_up, gen_trending_down from src.quant.model import (compute_factors, cross_section_score, factor_ic_series, WeightStore, state_bucket, FACTOR_NAMES) from src.quant.reason import build_reason class TestFactors: def test_momentum_positive_in_uptrend(self): k = gen_trending_up(60) f = compute_factors(k) assert f.get('mom_20', 0) > 0 def test_momentum_negative_in_downtrend(self): k = gen_trending_down(60) f = compute_factors(k) assert f.get('mom_20', 0) < 0 def test_all_factors_present(self): k = gen_kline(60) f = compute_factors(k) expected = {'mom_20', 'trend_ma20', 'ma_align', 'vol_ratio', 'rsi_inv', 'macd_hist', 'vola_inv'} assert expected.issubset(set(f.keys())) def test_insufficient_data_returns_empty(self): k = gen_kline(10) assert compute_factors(k) == {} class TestCrossSectionScore: def test_ranking_order(self): rows = { 'A': {'mom_20': 0.10, 'trend_ma20': 0.05}, 'B': {'mom_20': -0.05, 'trend_ma20': -0.02}, 'C': {'mom_20': 0.02, 'trend_ma20': 0.01}, } weights = {'mom_20': 1.0, 'trend_ma20': 1.0} scored = cross_section_score(rows, weights) assert scored[0][0] == 'A' assert scored[-1][0] == 'B' def test_single_factor_still_scored(self): """优化后:只要有 ≥1 个有效因子即可参与打分""" rows = { 'A': {'mom_20': 0.10}, 'B': {'mom_20': -0.05}, } scored = cross_section_score(rows, {'mom_20': 1.0}) assert len(scored) == 2 assert scored[0][0] == 'A' class TestWeightStore: def test_load_defaults(self, tmp_path): ws = WeightStore(str(tmp_path / 'test.db')) w = ws.load() assert all(w.get(f, 0) > 0 for f in FACTOR_NAMES) def test_save_and_reload(self, tmp_path): db = str(tmp_path / 'test.db') ws = WeightStore(db) ws.save({'mom_20': 1.5, 'trend_ma20': 0.5}, note='test') w = WeightStore(db).load() assert abs(w['mom_20'] - 1.5) < 0.01 class TestReasonGeneration: def test_reason_contains_numbers(self): k = gen_kline(60) reason, _ = build_reason('test', k) assert any(c.isdigit() for c in reason) def test_reason_not_empty(self): k = gen_kline(60) reason, _ = build_reason('test', k) assert len(reason) > 10