- domain/entities/chart.py:ChartResult/OHLC/Volume/Series/EventMarker/ChartMetadata
(adjust_mode + execution_price_basis 口径元数据)+ SelectionHit
- application/services/chart_service.py:个股 K线/量/MA 指标;显示层 qfq/hfq 折算
(基于主口径 none 行情 × adjust_factor,绝回写研究数据);回测个股视图把实际成交
转 fills 标记并在显示口径不同时做坐标换算(v3 §20.3/§20.5)
- by-symbol 历史查询:SignalRepository/SelectionRepository.list_by_symbol(含溯源 id)
- api/charts.py:/stocks/{symbol}/chart|signals|selections、/backtests/{id}/stocks/{symbol}/chart
|trades|positions
- tests/test_charts.py(指标/qfq-hfq 折算断言/回测 fills/API 集成+404);全量 pytest 通过
129 lines
4.6 KiB
Python
129 lines
4.6 KiB
Python
"""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,
|
|
SignalHit,
|
|
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_by_symbol(self, symbol: str, limit: int = 50) -> list[SignalHit]:
|
|
rows = self._session.scalars(
|
|
select(SignalEventModel)
|
|
.where(SignalEventModel.symbol == symbol)
|
|
.order_by(SignalEventModel.signal_date.desc(), SignalEventModel.id.desc())
|
|
.limit(limit)
|
|
).all()
|
|
return [
|
|
SignalHit(
|
|
signal_id=r.signal_id,
|
|
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
|
|
]
|
|
|
|
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
|