feat(composite): M7.2b 因子组合落库 + /api/composites CRUD

- factor_composite 表(migration c3e9a0d1f4b5,MySQL 已应用;name 唯一)
- CompositeDefinition/Component 实体 + CompositeRepository Protocol + SQLAlchemy 实现
- /api/composites:POST(注册表自动填充组件 direction;未注册因子 400)、GET 列表/详情、DELETE
- tests/test_composites_api.py(repo CRUD/同名拒绝/删除、API 方向填充/404/400);全量 pytest 通过
This commit is contained in:
Simon
2026-09-09 00:30:37 +08:00
parent 273aee2772
commit 4fa2bb748e
10 changed files with 412 additions and 1 deletions
@@ -0,0 +1,37 @@
"""factor_composite 表(M7.2b 因子组合保存/复用)
Revision ID: c3e9a0d1f4b5
Revises: b7f2a5e81c33
Create Date: 2026-09-09
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "c3e9a0d1f4b5"
down_revision: str | None = "b7f2a5e81c33"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"factor_composite",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("method", sa.String(length=16), nullable=False),
sa.Column("description", sa.String(length=300), nullable=False),
sa.Column("components_json", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name", name="uq_factor_composite_name"),
)
def downgrade() -> None:
op.drop_table("factor_composite")
@@ -4,6 +4,9 @@
模型统一继承 infra.persistence.sqlalchemy.base.Base。
"""
from app.infrastructure.persistence.sqlalchemy.models.composite import ( # noqa: F401
FactorCompositeModel,
)
from app.infrastructure.persistence.sqlalchemy.models.factor import ( # noqa: F401
FactorDefinitionModel,
)
@@ -0,0 +1,25 @@
"""因子组合表(M7.2b)。
factor_composite:可保存/复用的因子组合定义;components 以 JSON 存
(组件方向冗余快照自因子注册表,计算时以注册表为准)。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.infrastructure.persistence.sqlalchemy.base import Base
class FactorCompositeModel(Base):
__tablename__ = "factor_composite"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True)
method: Mapped[str] = mapped_column(String(16), default="fixed")
description: Mapped[str] = mapped_column(String(300), default="")
components_json: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime)
@@ -0,0 +1,80 @@
"""因子组合 Repository 的 SQLAlchemy 实现(M7.2b)。"""
from __future__ import annotations
import json
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.domain.entities.composite import CompositeComponent, CompositeDefinition
from app.infrastructure.persistence.sqlalchemy.models.composite import FactorCompositeModel
class SqlAlchemyCompositeRepository:
def __init__(self, session: Session) -> None:
self._session = session
def save(self, definition: CompositeDefinition) -> CompositeDefinition:
if not definition.id:
raise ValueError("需要 id(由调用方生成)")
exists = self._session.get(FactorCompositeModel, definition.id)
dup = self._session.scalar(
select(FactorCompositeModel)
.where(FactorCompositeModel.name == definition.name)
.limit(1)
)
if dup is not None and dup.id != definition.id:
raise ValueError(f"组合名已存在:{definition.name}")
now = definition.created_at or datetime.now()
if exists is None:
self._session.add(
FactorCompositeModel(
id=definition.id,
name=definition.name,
method=definition.method,
description=definition.description,
components_json=json.dumps(
[c.model_dump() for c in definition.components], ensure_ascii=False
),
created_at=now,
)
)
else:
exists.name = definition.name
exists.method = definition.method
exists.description = definition.description
exists.components_json = json.dumps(
[c.model_dump() for c in definition.components], ensure_ascii=False
)
self._session.flush()
return definition
def get(self, composite_id: str) -> CompositeDefinition | None:
row = self._session.get(FactorCompositeModel, composite_id)
return _to_entity(row) if row else None
def list(self) -> list[CompositeDefinition]:
rows = self._session.scalars(
select(FactorCompositeModel).order_by(FactorCompositeModel.name)
).all()
return [_to_entity(r) for r in rows]
def delete(self, composite_id: str) -> bool:
row = self._session.get(FactorCompositeModel, composite_id)
if row is None:
return False
self._session.delete(row)
return True
def _to_entity(row: FactorCompositeModel) -> CompositeDefinition:
return CompositeDefinition(
id=row.id,
name=row.name,
method=row.method,
description=row.description,
components=[CompositeComponent(**c) for c in json.loads(row.components_json)],
created_at=row.created_at,
)