Files
qlib/backend/app/application/services/selection_service.py
T
Simon 75c5472c31 feat(selection): M6.2 条件选股(method=condition + 财务可见性防护)
- quant/selection.run_condition_selection:结构化条件 AND 求值 —— 字段域
  static.*(行业/市场…)、技术列与派生量(close/volume/ma20/ma60)、已注册因子
  (momentum_60 等)、fundamental.*(announce_date<=as_of 的最新已公告财务值);
  条件支持 value 字面量与 ref 字段比较(如 close > ma60);结果带 filter_status/reason
- SelectionQuery 校验调整:condition 模式为纯过滤(不再强制 top_n/top_pct)
- FinancialRepository 新增 list_announced_many(批量防未来函数读取)+ SQLAlchemy 实现;
  SelectionService 注入 financial_repo 并按 announce_date 取每股最新一版
- tests/test_selection_condition.py:8 例(行业 in/ne、动量>0、close>ma60 ref、阈值、
  ROE 过滤且未来公告不可见、缺财务 repo 报错、更早 as_of 排除);全量 pytest 通过
2026-09-09 00:16:45 +08:00

116 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""选股用例入口(ARCHITECTURE_v2 §14 Selection Engine · 业务层)。
- 输入:SelectionQuery(universe + method + factors/conditions + top_n/pct + as_of)
- 装配:股票池(universe 过滤)→ 行情长表(含预热窗口)→ Selection Engine
(method=score 因子评分 / method=condition 结构化条件)
- 输出:SelectionResult(可解释:factor_values / filter_status / selection_reason)
- 未来函数红线:行情只取 <= as_of;财务条件只取 announce_date <= as_of 的已公告值(v2 §9)
MVP 为同步执行(单日全市场因子/条件计算量轻);如需异步可复用 Job 链路。
"""
from __future__ import annotations
from datetime import date, timedelta
import pandas as pd
from app.domain.entities.market import FinancialIndicator
from app.domain.entities.selection import SelectionQuery, SelectionResult
from app.domain.repositories.market import (
DailyBarRepository,
FinancialRepository,
StockRepository,
)
from app.quant.selection import (
condition_needed_columns,
factor_columns,
run_condition_selection,
run_score_selection,
)
from app.quant.service import filter_stocks, load_daily_df
_FUNDAMENTAL_PREFIX = "fundamental."
class SelectionService:
"""选股用例入口:select(query) → SelectionResult(当前或历史 as_of)。"""
def __init__(
self,
stock_repo: StockRepository,
daily_repo: DailyBarRepository,
financial_repo: FinancialRepository | None = None,
) -> None:
self._stock_repo = stock_repo
self._daily_repo = daily_repo
self._financial_repo = financial_repo
def select(self, query: SelectionQuery) -> SelectionResult:
as_of = query.as_of or date.today()
stocks = filter_stocks(self._stock_repo.list(), query.universe, as_of=as_of)
if not stocks:
return self._run(query, pd.DataFrame(), stocks, as_of, financial={})
symbols = [s.symbol for s in stocks]
if query.method == "score":
columns = sorted(factor_columns(query))
else:
columns = sorted(condition_needed_columns(query))
daily = load_daily_df(
self._daily_repo,
symbols,
as_of - timedelta(days=query.warmup_days),
as_of,
columns,
)
financial: dict[str, FinancialIndicator] = {}
if query.method == "condition" and self._uses_fundamental(query):
financial = self._load_financial(symbols, as_of)
return self._run(query, daily, stocks, as_of, financial)
# ---- 内部 ----
def _run(
self,
query: SelectionQuery,
daily: pd.DataFrame,
stocks: list,
as_of: date,
financial: dict[str, FinancialIndicator],
) -> SelectionResult:
if query.method == "score":
return run_score_selection(daily, query, as_of)
return run_condition_selection(daily, stocks, query, as_of, financial)
@staticmethod
def _uses_fundamental(query: SelectionQuery) -> bool:
for c in query.conditions:
if c.field.startswith(_FUNDAMENTAL_PREFIX) or (
c.ref is not None and c.ref.startswith(_FUNDAMENTAL_PREFIX)
):
return True
return False
def _load_financial(
self, symbols: list[str], as_of: date
) -> dict[str, FinancialIndicator]:
"""按 announce_date <= as_of 批量取财务,每 symbol 保留最新一版。"""
if self._financial_repo is None:
raise ValueError("condition 引用了 fundamental.* 字段,但未注入 FinancialRepository")
getter = getattr(self._financial_repo, "list_announced_many", None)
if getter is not None:
rows = list(getter(symbols, as_of))
else: # 回退逐只
rows = []
for sym in symbols:
rows.extend(self._financial_repo.list_announced(sym, as_of))
by_symbol: dict[str, FinancialIndicator] = {}
for row in rows:
cur = by_symbol.get(row.symbol)
if cur is None or (row.announce_date, row.report_date) > (
cur.announce_date,
cur.report_date,
):
by_symbol[row.symbol] = row
return by_symbol