feat(selection): M6.3 选股结果落库 + /api/selections(快照 + 逐候选行)
- domain/repositories/selection.py + SelectionMeta:选股 Repository Protocol
- models/selection.py + migration a6c91d4e7f20:selection_snapshot(查询/统计快照)+
selection_result(逐候选:rank/score/factor_values/filter_status/reason JSON)
- repositories/selection_impl.py:save/get/list_recent(读回重建 SelectionResult)
- api/selections.py:POST /api/selections(同步执行+落库,返回 selection_id+result)、
GET /{id}、GET 列表(as_of/method 过滤);deps 装配 SelectionService/Repo
- config._build_mysql_url:仅编码破坏 URL 结构的字符(修 Alembic configparser 遇 %21 崩)
- 迁移已在 MySQL 应用(alembic head a6c91d4e7f20);tests/test_selections_api.py 4 例
提交→读回一致/404/列表过滤/condition;全量 pytest 通过
This commit is contained in:
+62
@@ -0,0 +1,62 @@
|
||||
"""selection_snapshot / selection_result 表(M6.3 选股落库)
|
||||
|
||||
Revision ID: a6c91d4e7f20
|
||||
Revises: d3f6c9a21b04
|
||||
Create Date: 2026-09-08
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "a6c91d4e7f20"
|
||||
down_revision: str | None = "d3f6c9a21b04"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"selection_snapshot",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("as_of", sa.Date(), nullable=False),
|
||||
sa.Column("method", sa.String(length=16), nullable=False),
|
||||
sa.Column("query_json", sa.Text(), nullable=False),
|
||||
sa.Column("statistics_json", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_selection_snapshot_as_of", "selection_snapshot", ["as_of"])
|
||||
op.create_index("ix_selection_snapshot_created_at", "selection_snapshot", ["created_at"])
|
||||
|
||||
op.create_table(
|
||||
"selection_result",
|
||||
sa.Column(
|
||||
"id",
|
||||
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
|
||||
autoincrement=True,
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("selection_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("symbol", sa.String(length=12), nullable=False),
|
||||
sa.Column("rank", sa.Integer(), nullable=False),
|
||||
sa.Column("score", sa.Numeric(precision=14, scale=6), nullable=False),
|
||||
sa.Column("factor_values_json", sa.Text(), nullable=True),
|
||||
sa.Column("filter_status_json", sa.Text(), nullable=True),
|
||||
sa.Column("reason_json", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_selection_result_selection_id", "selection_result", ["selection_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_selection_result_selection_id", table_name="selection_result")
|
||||
op.drop_table("selection_result")
|
||||
op.drop_index("ix_selection_snapshot_created_at", table_name="selection_snapshot")
|
||||
op.drop_index("ix_selection_snapshot_as_of", table_name="selection_snapshot")
|
||||
op.drop_table("selection_snapshot")
|
||||
@@ -16,3 +16,7 @@ from app.infrastructure.persistence.sqlalchemy.models.market import ( # noqa: F
|
||||
SyncLogModel,
|
||||
TradingCalendarModel,
|
||||
)
|
||||
from app.infrastructure.persistence.sqlalchemy.models.selection import ( # noqa: F401
|
||||
SelectionResultModel,
|
||||
SelectionSnapshotModel,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""选股持久化表(M6.3)。
|
||||
|
||||
selection_snapshot:一次选股运行的查询与统计快照(复现/历史查询用)
|
||||
selection_result:逐候选行(symbol/rank/score + 可解释字段 JSON),
|
||||
回答 v2 §8「某日为什么选这只股票」。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import BigInteger, Date, DateTime, Integer, Numeric, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.infrastructure.persistence.sqlalchemy.base import Base
|
||||
|
||||
PK_INT = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
|
||||
class SelectionSnapshotModel(Base):
|
||||
__tablename__ = "selection_snapshot"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
as_of: Mapped[date] = mapped_column(Date, index=True)
|
||||
method: Mapped[str] = mapped_column(String(16))
|
||||
query_json: Mapped[str] = mapped_column(Text)
|
||||
statistics_json: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
|
||||
|
||||
class SelectionResultModel(Base):
|
||||
__tablename__ = "selection_result"
|
||||
|
||||
id: Mapped[int] = mapped_column(PK_INT, primary_key=True, autoincrement=True)
|
||||
selection_id: Mapped[str] = mapped_column(String(32), index=True)
|
||||
symbol: Mapped[str] = mapped_column(String(12))
|
||||
rank: Mapped[int] = mapped_column(Integer)
|
||||
score: Mapped[float] = mapped_column(Numeric(14, 6))
|
||||
factor_values_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
filter_status_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
reason_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""选股 Repository 的 SQLAlchemy 实现(M6.3)。
|
||||
|
||||
save:snapshot + 候选逐行(同 session,由调用方 commit);
|
||||
get:读回并重建 SelectionResult;list_recent:历史元数据。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.domain.entities.selection import (
|
||||
SelectionCandidate,
|
||||
SelectionMeta,
|
||||
SelectionResult,
|
||||
SelectionStatistics,
|
||||
)
|
||||
from app.infrastructure.persistence.sqlalchemy.models.selection import (
|
||||
SelectionResultModel,
|
||||
SelectionSnapshotModel,
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemySelectionRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self._session = session
|
||||
|
||||
def save(self, selection_id: str, result: SelectionResult) -> None:
|
||||
self._session.add(
|
||||
SelectionSnapshotModel(
|
||||
id=selection_id,
|
||||
as_of=result.as_of_date,
|
||||
method=result.method,
|
||||
query_json=json.dumps(result.config_snapshot, ensure_ascii=False),
|
||||
statistics_json=json.dumps(result.statistics.model_dump(mode="json")),
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
now = datetime.now()
|
||||
for c in result.candidates:
|
||||
self._session.add(
|
||||
SelectionResultModel(
|
||||
selection_id=selection_id,
|
||||
symbol=c.symbol,
|
||||
rank=c.rank,
|
||||
score=c.score,
|
||||
factor_values_json=json.dumps(c.factor_values, ensure_ascii=False),
|
||||
filter_status_json=json.dumps(c.filter_status, ensure_ascii=False),
|
||||
reason_json=json.dumps(c.selection_reason, ensure_ascii=False),
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
self._session.flush()
|
||||
|
||||
def get(self, selection_id: str) -> SelectionResult | None:
|
||||
snap = self._session.get(SelectionSnapshotModel, selection_id)
|
||||
if snap is None:
|
||||
return None
|
||||
rows = self._session.scalars(
|
||||
select(SelectionResultModel)
|
||||
.where(SelectionResultModel.selection_id == selection_id)
|
||||
.order_by(SelectionResultModel.rank)
|
||||
).all()
|
||||
stats = SelectionStatistics.model_validate_json(snap.statistics_json)
|
||||
candidates = [
|
||||
SelectionCandidate(
|
||||
symbol=r.symbol,
|
||||
rank=r.rank,
|
||||
score=float(r.score),
|
||||
factor_values=json.loads(r.factor_values_json or "{}"),
|
||||
filter_status=json.loads(r.filter_status_json or "[]"),
|
||||
selection_reason=json.loads(r.reason_json or "[]"),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
return SelectionResult(
|
||||
as_of_date=snap.as_of,
|
||||
method=snap.method,
|
||||
statistics=stats,
|
||||
candidates=candidates,
|
||||
config_snapshot=json.loads(snap.query_json),
|
||||
)
|
||||
|
||||
def list_recent(
|
||||
self,
|
||||
as_of: date | None = None,
|
||||
method: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[SelectionMeta]:
|
||||
stmt = select(SelectionSnapshotModel).order_by(SelectionSnapshotModel.created_at.desc())
|
||||
if as_of is not None:
|
||||
stmt = stmt.where(SelectionSnapshotModel.as_of == as_of)
|
||||
if method is not None:
|
||||
stmt = stmt.where(SelectionSnapshotModel.method == method)
|
||||
stmt = stmt.limit(limit)
|
||||
metas: list[SelectionMeta] = []
|
||||
for snap in self._session.scalars(stmt).all():
|
||||
stats = SelectionStatistics.model_validate_json(snap.statistics_json)
|
||||
metas.append(
|
||||
SelectionMeta(
|
||||
id=snap.id,
|
||||
as_of=snap.as_of,
|
||||
method=snap.method,
|
||||
universe_size=stats.universe_size,
|
||||
selected=stats.selected,
|
||||
created_at=snap.created_at,
|
||||
)
|
||||
)
|
||||
return metas
|
||||
Reference in New Issue
Block a user