86 lines
2.8 KiB
Python
86 lines
2.8 KiB
Python
# -*- 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
|