feat(factor): M7.1 因子定义入库 + /api/factors 读库(目录契约源)

- factor_definition 表(migration b7f2a5e81c33,MySQL 已应用):name 主键 + 元数据
  (formula/brief/frequency/lookback/direction/requires JSON/version)+ FactorDefinition
  entity(from_registry_def 由代码注册表构造)
- FactorRepository Protocol + SQLAlchemy 实现(幂等 upsert/list/get)
- /api/factors 改读 DB;目录为空自动 seed 注册表(幂等)—— 保留自定义因子登记能力
  (计算仍须代码注册,引用未注册因子照常 FactorError,防伪因子)
- tests/test_factor_catalog.py(repo 幂等/roundtrip/registry seed、API seed+字段齐全);
  test_api 的 client fixture 补 tmp sqlite session(factors 读库);全量 pytest 通过
This commit is contained in:
Simon
2026-09-09 00:28:16 +08:00
parent 0ab9038570
commit 8f47b5b603
11 changed files with 360 additions and 18 deletions
@@ -0,0 +1,40 @@
"""factor_definition 表(M7.1 因子定义入库)
Revision ID: b7f2a5e81c33
Revises: a6c91d4e7f20
Create Date: 2026-09-09
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "b7f2a5e81c33"
down_revision: str | None = "a6c91d4e7f20"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"factor_definition",
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("description", sa.String(length=500), nullable=False),
sa.Column("formula", sa.String(length=500), nullable=False),
sa.Column("brief", sa.String(length=500), nullable=False),
sa.Column("frequency", sa.String(length=16), nullable=False),
sa.Column("lookback", sa.Integer(), nullable=False),
sa.Column("direction", sa.String(length=32), nullable=False),
sa.Column("requires_json", sa.Text(), nullable=False),
sa.Column("version", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("name"),
)
def downgrade() -> None:
op.drop_table("factor_definition")
@@ -4,6 +4,9 @@
模型统一继承 infra.persistence.sqlalchemy.base.Base。
"""
from app.infrastructure.persistence.sqlalchemy.models.factor import ( # noqa: F401
FactorDefinitionModel,
)
from app.infrastructure.persistence.sqlalchemy.models.jobs import ( # noqa: F401
ExperimentModel,
JobModel,
@@ -0,0 +1,28 @@
"""因子目录表(M7.1)。
factor_definition:因子元数据契约源(name 主键幂等);requires 以 JSON 存。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.infrastructure.persistence.sqlalchemy.base import Base
class FactorDefinitionModel(Base):
__tablename__ = "factor_definition"
name: Mapped[str] = mapped_column(String(64), primary_key=True)
description: Mapped[str] = mapped_column(String(500), default="")
formula: Mapped[str] = mapped_column(String(500), default="")
brief: Mapped[str] = mapped_column(String(500), default="")
frequency: Mapped[str] = mapped_column(String(16), default="daily")
lookback: Mapped[int] = mapped_column(Integer, default=20)
direction: Mapped[str] = mapped_column(String(32), default="higher_is_better")
requires_json: Mapped[str] = mapped_column(Text, default="[]")
version: Mapped[str] = mapped_column(String(16), default="1")
created_at: Mapped[datetime] = mapped_column(DateTime)
@@ -0,0 +1,78 @@
"""因子目录 Repository 的 SQLAlchemy 实现(M7.1)。"""
from __future__ import annotations
import json
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.domain.entities.factor import FactorDefinition
from app.infrastructure.persistence.sqlalchemy.models.factor import FactorDefinitionModel
def _to_entity(row: FactorDefinitionModel) -> FactorDefinition:
return FactorDefinition(
name=row.name,
description=row.description,
formula=row.formula,
brief=row.brief,
frequency=row.frequency,
lookback=row.lookback,
direction=row.direction,
requires=json.loads(row.requires_json or "[]"),
version=row.version,
created_at=row.created_at,
)
class SqlAlchemyFactorRepository:
def __init__(self, session: Session) -> None:
self._session = session
def upsert_many(self, definitions: list[FactorDefinition]) -> int:
if not definitions:
return 0
existing = {
r.name: r
for r in self._session.scalars(
select(FactorDefinitionModel).where(
FactorDefinitionModel.name.in_([d.name for d in definitions])
)
)
}
now = datetime.now()
for d in definitions:
row = existing.get(d.name)
if row is None:
self._session.add(
FactorDefinitionModel(
name=d.name,
description=d.description,
formula=d.formula,
brief=d.brief,
frequency=d.frequency,
lookback=d.lookback,
direction=d.direction,
requires_json=json.dumps(d.requires),
version=d.version,
created_at=now,
)
)
else:
for k, v in d.model_dump(exclude={"created_at"}).items():
if k == "requires":
v = json.dumps(v)
setattr(row, k, v)
return len(definitions)
def list(self) -> list[FactorDefinition]:
rows = self._session.scalars(
select(FactorDefinitionModel).order_by(FactorDefinitionModel.name)
).all()
return [_to_entity(r) for r in rows]
def get(self, name: str) -> FactorDefinition | None:
row = self._session.get(FactorDefinitionModel, name)
return _to_entity(row) if row else None