- 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 通过
135 lines
4.9 KiB
Python
135 lines
4.9 KiB
Python
"""选股 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.chart import SelectionHit
|
||
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_by_symbol(self, symbol: str, limit: int = 50) -> list[SelectionHit]:
|
||
rows = self._session.execute(
|
||
select(SelectionResultModel, SelectionSnapshotModel.as_of, SelectionSnapshotModel.method)
|
||
.join(SelectionSnapshotModel, SelectionSnapshotModel.id == SelectionResultModel.selection_id)
|
||
.where(SelectionResultModel.symbol == symbol)
|
||
.order_by(SelectionSnapshotModel.as_of.desc(), SelectionResultModel.rank)
|
||
.limit(limit)
|
||
).all()
|
||
return [
|
||
SelectionHit(
|
||
selection_id=r[0].selection_id,
|
||
as_of=r[1],
|
||
method=r[2],
|
||
symbol=r[0].symbol,
|
||
rank=r[0].rank,
|
||
score=float(r[0].score),
|
||
selection_reason=json.loads(r[0].reason_json or "[]"),
|
||
)
|
||
for r in rows
|
||
]
|
||
|
||
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
|