"""M7.1 因子目录测试:factor_definition 落库(幂等 upsert)+ /api/factors 读库 + seed。 repo 测试走 tmp SQLite;API 测试 override get_session 到 tmp sqlite 种子库。 """ from __future__ import annotations import pytest from app.api import deps from app.domain.entities.factor import FactorDefinition from app.infrastructure.persistence.sqlalchemy.base import Base from app.infrastructure.persistence.sqlalchemy.repositories.factor_impl import ( SqlAlchemyFactorRepository, ) from app.main import app from app.quant.factors import list_factors from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @pytest.fixture() def session(tmp_path): engine = create_engine(f"sqlite:///{tmp_path / 'factor.db'}", future=True) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine, expire_on_commit=False) with Session() as s: yield s class TestFactorRepository: def test_upsert_idempotent_and_roundtrip(self, session) -> None: repo = SqlAlchemyFactorRepository(session) d = FactorDefinition( name="test_momentum", description="测试", formula="x", brief="b", lookback=10, requires=["close", "high"], ) assert repo.upsert_many([d]) == 1 session.commit() assert len(repo.list()) == 1 got = repo.get("test_momentum") assert got is not None and got.requires == ["close", "high"] # 幂等更新 repo.upsert_many([d.model_copy(update={"description": "更新"})]) session.commit() assert repo.get("test_momentum").description == "更新" assert len(repo.list()) == 1 def test_seed_from_registry(self, session) -> None: repo = SqlAlchemyFactorRepository(session) defs = [FactorDefinition.from_registry_def(d) for d in list_factors()] assert repo.upsert_many(defs) == len(defs) session.commit() names = {f.name for f in repo.list()} assert len(names) == len(defs) # 与注册表一致 assert names == {d.name for d in list_factors()} assert repo.get("momentum_60").direction == "higher_is_better" @pytest.fixture() def client(tmp_path): engine = create_engine(f"sqlite:///{tmp_path / 'api.db'}", future=True) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine, expire_on_commit=False) def _session_override(): with Session() as s: yield s app.dependency_overrides[deps.get_session] = _session_override with TestClient(app) as c: yield c app.dependency_overrides.clear() class TestFactorsApi: def test_list_seeds_and_reads_db(self, client) -> None: resp = client.get("/api/factors") assert resp.status_code == 200 rows = resp.json() assert isinstance(rows, list) and len(rows) >= 9 first = next(r for r in rows if r["name"] == "momentum_60") # DB 契约源字段齐全(与前端 FactorMeta 匹配 + version) assert set(first.keys()) >= { "name", "description", "brief", "formula", "frequency", "lookback", "direction", "requires", "version", } assert "close" in first["requires"] def test_list_matches_registry_after_seed(self, client) -> None: """seed 后目录 == 代码注册表集合(无额外未知项)。""" client.get("/api/factors") # 首次访问触发 seed client.get("/api/factors") # 幂等:二次访问不报错、不重复 resp = client.get("/api/factors") names = {r["name"] for r in resp.json()} assert names == {d.name for d in list_factors()} class TestRegistrySyncRegression: """回归:表非空时也必须补齐「注册表有、库里没有」的因子。 历史 bug:seed 只在表为空时触发,导致 `dividend_yield` 等后加的因子永远不进目录 (实测真实库表里 9 条、注册表 11 条),前端因子下拉与归档说明都取不到它们。 """ def test_missing_registry_factor_is_seeded_when_table_not_empty(self, client, tmp_path) -> None: from sqlalchemy import text # 1) 先正常读一次 → 目录完整(含股息率因子) full = {r["name"] for r in client.get("/api/factors").json()} assert "dividend_yield" in full, "注册表里的股息率因子必须出现在目录中" # 2) 人为删除一行,复现「表非空但缺因子」的历史状态 engine = create_engine(f"sqlite:///{tmp_path / 'api.db'}", future=True) with engine.begin() as conn: conn.execute(text("DELETE FROM factor_definition WHERE name = 'dividend_yield'")) rows = client.get("/api/factors").json() # 3) 再读 → 缺失因子被当场补齐 names = {r["name"] for r in rows} assert "dividend_yield" in names, "表非空时也必须补齐缺失的注册表因子" assert names == {d.name for d in list_factors()} # 4) 用户登记的自定义因子元数据不被覆盖/删除(只补不删) Session = sessionmaker(bind=engine, expire_on_commit=False) with Session() as s2: SqlAlchemyFactorRepository(s2).upsert_many( [ FactorDefinition( name="my_custom_factor", description="自定义因子", formula="x", brief="b", frequency="daily", lookback=5, direction="higher_is_better", requires=[], version="1", ) ] ) s2.commit() names2 = {r["name"] for r in client.get("/api/factors").json()} assert "my_custom_factor" in names2, "只补不删:自定义因子必须保留" assert "dividend_yield" in names2 class TestCatalogIsRegistryProjection: """目录是注册表的投影(2026-10 明确语义):口径字段按代码纠正,自定义行不碰。 原先是「只在缺名字时才 upsert 全量」,于是手改内置因子文案能存活到「代码里出现 新因子」那一刻再被无声覆盖 —— 行为不确定。现在改成按字段差集确定性收敛。 """ def test_hand_edited_registry_row_converges_back_to_code(self, client, tmp_path) -> None: from app.quant.factors import get_factor from sqlalchemy import create_engine, text engine = create_engine(f"sqlite:///{tmp_path / 'api.db'}", future=True) with engine.begin() as conn: conn.execute( text( "UPDATE factor_definition SET description = '手改的假口径', " "lookback = 999, direction = 'lower_is_better' " "WHERE name = 'dividend_yield'" ) ) rows = {r["name"]: r for r in client.get("/api/factors").json()} defn, _ = get_factor("dividend_yield") got = rows["dividend_yield"] assert got["description"] == defn.description, "口径文案必须按代码改回(否则说明书会撒谎)" assert got["lookback"] == defn.lookback assert got["direction"] == defn.direction, "方向被手改会让读者以为越大越差" def test_steady_state_writes_nothing(self, session) -> None: from app.application.services.factor_catalog import sync_registry_factors repo = SqlAlchemyFactorRepository(session) assert sync_registry_factors(repo, session) == len(list_factors()), "首次:全部补齐" assert sync_registry_factors(repo, session) == 0, "稳态:目录 == 注册表 → 零写入" def test_custom_row_untouched_and_never_deleted(self, session) -> None: from app.application.services.factor_catalog import sync_registry_factors repo = SqlAlchemyFactorRepository(session) sync_registry_factors(repo, session) repo.upsert_many( [ FactorDefinition( name="my_note_factor", description="人工登记的备注", formula="x", brief="b", lookback=5, requires=[], ) ] ) session.commit() assert sync_registry_factors(repo, session) == 0, "自定义行不算 stale,不触发写入" got = repo.get("my_note_factor") assert got is not None and got.description == "人工登记的备注" assert got.name not in {d.name for d in list_factors()}, "前提:它确实不在注册表里"