feat(signal): M8.1 交易信号引擎(规则 + signal_event 落库 + /api/signals)
- SignalRules(买入 rank 阈值/趋势 MA/动量 + 卖出区间/破位警示)+ SignalEvent (BUY/WATCH/SELL,score/price/trigger_reason 可解释)+ SignalResult/Meta - quant/signal.generate_signals:与选股同一评分引擎取全市场 rank,按规则分类输出 - signal_snapshot/signal_event 表(migration d8e0b2f3c4d5,MySQL 已应用)+ Repo - SignalService + POST /api/signals(同步+落库)、GET 详情/列表 - tests/test_signals.py(引擎分类/排序/破位不 BUY、service、API 提交读回);全量 pytest 通过
This commit is contained in:
+62
@@ -0,0 +1,62 @@
|
||||
"""signal_snapshot / signal_event 表(M8.1 交易信号)
|
||||
|
||||
Revision ID: d8e0b2f3c4d5
|
||||
Revises: c3e9a0d1f4b5
|
||||
Create Date: 2026-09-09
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "d8e0b2f3c4d5"
|
||||
down_revision: str | None = "c3e9a0d1f4b5"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"signal_snapshot",
|
||||
sa.Column("id", sa.String(length=32), nullable=False),
|
||||
sa.Column("as_of", sa.Date(), nullable=False),
|
||||
sa.Column("query_json", sa.Text(), nullable=False),
|
||||
sa.Column("rules_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_signal_snapshot_as_of", "signal_snapshot", ["as_of"])
|
||||
op.create_index("ix_signal_snapshot_created_at", "signal_snapshot", ["created_at"])
|
||||
|
||||
op.create_table(
|
||||
"signal_event",
|
||||
sa.Column(
|
||||
"id",
|
||||
sa.BigInteger().with_variant(sa.Integer(), "sqlite"),
|
||||
autoincrement=True,
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("signal_id", sa.String(length=32), nullable=False),
|
||||
sa.Column("symbol", sa.String(length=12), nullable=False),
|
||||
sa.Column("signal_date", sa.Date(), nullable=False),
|
||||
sa.Column("signal_type", sa.String(length=8), nullable=False),
|
||||
sa.Column("score", sa.Numeric(precision=14, scale=6), nullable=True),
|
||||
sa.Column("price", sa.Numeric(precision=14, scale=4), 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_signal_event_signal_id", "signal_event", ["signal_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_signal_event_signal_id", table_name="signal_event")
|
||||
op.drop_table("signal_event")
|
||||
op.drop_index("ix_signal_snapshot_created_at", table_name="signal_snapshot")
|
||||
op.drop_index("ix_signal_snapshot_as_of", table_name="signal_snapshot")
|
||||
op.drop_table("signal_snapshot")
|
||||
@@ -26,3 +26,7 @@ from app.infrastructure.persistence.sqlalchemy.models.selection import ( # noqa
|
||||
SelectionResultModel,
|
||||
SelectionSnapshotModel,
|
||||
)
|
||||
from app.infrastructure.persistence.sqlalchemy.models.signal import ( # noqa: F401
|
||||
SignalEventModel,
|
||||
SignalSnapshotModel,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""交易信号表(M8.1)。
|
||||
|
||||
signal_snapshot:一次信号运行的查询/规则/统计快照
|
||||
signal_event:逐信号(signal_type + score/price + trigger_reason JSON)
|
||||
"""
|
||||
|
||||
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 SignalSnapshotModel(Base):
|
||||
__tablename__ = "signal_snapshot"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
as_of: Mapped[date] = mapped_column(Date, index=True)
|
||||
query_json: Mapped[str] = mapped_column(Text)
|
||||
rules_json: Mapped[str] = mapped_column(Text)
|
||||
statistics_json: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
|
||||
|
||||
class SignalEventModel(Base):
|
||||
__tablename__ = "signal_event"
|
||||
|
||||
id: Mapped[int] = mapped_column(PK_INT, primary_key=True, autoincrement=True)
|
||||
signal_id: Mapped[str] = mapped_column(String(32), index=True)
|
||||
symbol: Mapped[str] = mapped_column(String(12))
|
||||
signal_date: Mapped[date] = mapped_column(Date)
|
||||
signal_type: Mapped[str] = mapped_column(String(8))
|
||||
score: Mapped[float | None] = mapped_column(Numeric(14, 6), nullable=True)
|
||||
price: Mapped[float | None] = mapped_column(Numeric(14, 4), nullable=True)
|
||||
reason_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Signal Repository 的 SQLAlchemy 实现(M8.1)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.domain.entities.signal import (
|
||||
SignalEvent,
|
||||
SignalMeta,
|
||||
SignalResult,
|
||||
SignalRules,
|
||||
SignalStatistics,
|
||||
)
|
||||
from app.infrastructure.persistence.sqlalchemy.models.signal import (
|
||||
SignalEventModel,
|
||||
SignalSnapshotModel,
|
||||
)
|
||||
|
||||
|
||||
class SqlAlchemySignalRepository:
|
||||
def __init__(self, session: Session) -> None:
|
||||
self._session = session
|
||||
|
||||
def save(self, signal_id: str, result: SignalResult) -> None:
|
||||
self._session.add(
|
||||
SignalSnapshotModel(
|
||||
id=signal_id,
|
||||
as_of=result.as_of_date,
|
||||
query_json=json.dumps(
|
||||
{
|
||||
"as_of": result.as_of_date.isoformat(),
|
||||
**{k: v for k, v in result.config_snapshot.items() if k != "as_of"},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
rules_json=json.dumps(result.rules.model_dump(mode="json"), ensure_ascii=False),
|
||||
statistics_json=json.dumps(result.statistics.model_dump(mode="json")),
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
now = datetime.now()
|
||||
for e in result.events:
|
||||
self._session.add(
|
||||
SignalEventModel(
|
||||
signal_id=signal_id,
|
||||
symbol=e.symbol,
|
||||
signal_date=e.signal_date,
|
||||
signal_type=e.signal_type,
|
||||
score=e.score,
|
||||
price=e.price,
|
||||
reason_json=json.dumps(e.trigger_reason, ensure_ascii=False),
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
self._session.flush()
|
||||
|
||||
def get(self, signal_id: str) -> SignalResult | None:
|
||||
snap = self._session.get(SignalSnapshotModel, signal_id)
|
||||
if snap is None:
|
||||
return None
|
||||
rows = self._session.scalars(
|
||||
select(SignalEventModel)
|
||||
.where(SignalEventModel.signal_id == signal_id)
|
||||
.order_by(SignalEventModel.signal_type, SignalEventModel.symbol)
|
||||
).all()
|
||||
rules = SignalRules.model_validate_json(snap.rules_json)
|
||||
stats = SignalStatistics.model_validate_json(snap.statistics_json)
|
||||
return SignalResult(
|
||||
as_of_date=snap.as_of,
|
||||
rules=rules,
|
||||
statistics=stats,
|
||||
events=[
|
||||
SignalEvent(
|
||||
symbol=r.symbol,
|
||||
signal_date=r.signal_date,
|
||||
signal_type=r.signal_type,
|
||||
score=float(r.score) if r.score is not None else None,
|
||||
price=float(r.price) if r.price is not None else None,
|
||||
trigger_reason=json.loads(r.reason_json or "[]"),
|
||||
)
|
||||
for r in rows
|
||||
],
|
||||
config_snapshot={"as_of": snap.as_of.isoformat()},
|
||||
)
|
||||
|
||||
def list_recent(self, as_of: date | None = None, limit: int = 20) -> list[SignalMeta]:
|
||||
stmt = select(SignalSnapshotModel).order_by(SignalSnapshotModel.created_at.desc())
|
||||
if as_of is not None:
|
||||
stmt = stmt.where(SignalSnapshotModel.as_of == as_of)
|
||||
stmt = stmt.limit(limit)
|
||||
metas: list[SignalMeta] = []
|
||||
for snap in self._session.scalars(stmt).all():
|
||||
stats = SignalStatistics.model_validate_json(snap.statistics_json)
|
||||
metas.append(
|
||||
SignalMeta(
|
||||
id=snap.id,
|
||||
as_of=snap.as_of,
|
||||
buy=stats.buy,
|
||||
watch=stats.watch,
|
||||
sell=stats.sell,
|
||||
created_at=snap.created_at,
|
||||
)
|
||||
)
|
||||
return metas
|
||||
Reference in New Issue
Block a user