rebuild(stage-9): deliver deterministic intelligent screening

This commit is contained in:
leefer
2026-07-30 05:15:17 +08:00
parent 6cb52e864a
commit 158257ebb8
46 changed files with 7322 additions and 34 deletions
+12
View File
@@ -65,3 +65,15 @@ def require_smart_access(
SmartAccessPrincipal = Annotated[Principal, Depends(require_smart_access)]
def require_smart_write(
request: Request,
principal: CsrfPrincipal,
) -> Principal:
if not request.app.state.container.memberships.can_use_smart_features(principal):
raise AppError("membership_required", "该功能仅对会员开放。", 403)
return principal
SmartWritePrincipal = Annotated[Principal, Depends(require_smart_write)]
@@ -0,0 +1 @@
__all__: list[str] = []
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
import json
import math
from functools import lru_cache
from pathlib import Path
from typing import Any
CONFIG_ROOT = Path(__file__).resolve().parents[3] / "config"
ALLOWED_OPERATORS = frozenset({">", ">=", "<", "<=", "==", "!=", "between", "in"})
class CatalogError(RuntimeError):
pass
@lru_cache(maxsize=1)
def factor_catalog() -> dict[str, Any]:
payload = _read("screener-factors.json")
factors = payload.get("factors")
groups = payload.get("groups")
if payload.get("schema_version") != 1 or not isinstance(factors, dict):
raise CatalogError("选股因子目录无效")
grouped = [field for values in (groups or {}).values() for field in values]
if len(grouped) != len(set(grouped)) or set(grouped) != set(factors):
raise CatalogError("选股因子分组与目录不一致")
return payload
@lru_cache(maxsize=1)
def strategy_catalog() -> tuple[dict[str, Any], ...]:
payload = _read("screener-strategies.json")
items = payload.get("strategies")
if payload.get("schema_version") != 1 or not isinstance(items, list):
raise CatalogError("选股策略目录无效")
identifiers: set[str] = set()
names: set[str] = set()
factors = set(factor_catalog()["factors"])
for item in items:
identifier = str(item.get("id") or "")
name = str(item.get("name") or "")
if not identifier or identifier in identifiers or not name or name in names:
raise CatalogError("选股策略标识或名称重复")
identifiers.add(identifier)
names.add(name)
validate_formula(item.get("formula"), factors)
if sum(item.get("kind") == "stage" for item in items) != 7:
raise CatalogError("阶段策略必须为7套")
if sum(item.get("kind") == "curated" for item in items) != 29:
raise CatalogError("精选策略必须为29套")
return tuple(items)
def strategy_by_id(identifier: str) -> dict[str, Any] | None:
return next((item for item in strategy_catalog() if item["id"] == identifier), None)
def validate_formula(formula: Any, factors: set[str] | None = None) -> dict[str, Any]:
if not isinstance(formula, dict):
raise CatalogError("选股公式必须是对象")
known = factors or set(factor_catalog()["factors"])
universe = formula.get("universe") or {}
listed_days = universe.get("listed_days_min", 120)
if not isinstance(listed_days, int) or not 0 <= listed_days <= 5000:
raise CatalogError("上市天数范围无效")
filters = formula.get("filters")
scores = formula.get("score")
if not isinstance(filters, list) or len(filters) > 20:
raise CatalogError("筛选条件必须为不超过20项的列表")
if not isinstance(scores, list) or not 1 <= len(scores) <= 12:
raise CatalogError("评分因子必须为1至12项")
for condition in filters:
if condition.get("field") not in known:
raise CatalogError(f"未知筛选因子:{condition.get('field')}")
if condition.get("op") not in ALLOWED_OPERATORS or "value" not in condition:
raise CatalogError("筛选运算符或比较值无效")
_validate_comparison(condition["op"], condition["value"])
total = 0.0
score_fields: set[str] = set()
for score in scores:
if score.get("field") not in known:
raise CatalogError(f"未知评分因子:{score.get('field')}")
field = str(score["field"])
if field in score_fields:
raise CatalogError("评分因子不能重复")
score_fields.add(field)
raw_weight = score.get("weight")
if not isinstance(raw_weight, (int, float)) or isinstance(raw_weight, bool):
raise CatalogError("评分权重必须是数值")
weight = float(raw_weight)
if weight <= 0 or score.get("direction", "desc") not in {"asc", "desc"}:
raise CatalogError("评分权重或方向无效")
total += weight
if abs(total - 1) > 0.000001:
raise CatalogError("评分权重总和必须为100%")
limit = formula.get("limit")
minimum = formula.get("min_score")
if not isinstance(limit, int) or not 1 <= limit <= 50:
raise CatalogError("输出数量必须为1至50")
if not isinstance(minimum, (int, float)) or not 0 <= minimum <= 1:
raise CatalogError("最低综合分必须在0至1之间")
return formula
def _validate_comparison(operator: str, value: Any) -> None:
if operator == "between":
if not isinstance(value, list) or len(value) != 2:
raise CatalogError("区间条件必须包含两个边界")
if not all(_comparable(item) for item in value) or value[0] > value[1]:
raise CatalogError("区间条件边界无效")
return
if operator == "in":
if not isinstance(value, list) or not 1 <= len(value) <= 20:
raise CatalogError("集合条件必须包含1至20个值")
if not all(_comparable(item) for item in value):
raise CatalogError("集合条件包含无效值")
return
if not _comparable(value):
raise CatalogError("比较值必须是有限数值或布尔值")
def _comparable(value: Any) -> bool:
return isinstance(value, bool) or (
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(float(value))
)
def _read(filename: str) -> dict[str, Any]:
try:
return json.loads((CONFIG_ROOT / filename).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise CatalogError(f"无法读取{filename}") from exc
@@ -0,0 +1,288 @@
from __future__ import annotations
from collections import defaultdict
from statistics import fmean, median
from typing import Any
from backend.features.screener.catalog import factor_catalog
from backend.features.screener.factor_math import mean, number, pearson, percentile_map, rounded
def finalize_factor_rows(
rows: list[dict[str, Any]], dataset_ready: dict[str, bool]
) -> list[dict[str, Any]]:
market_returns = [number(row.get("return_5d")) for row in rows]
valid_market = [value for value in market_returns if value is not None]
market_mean = fmean(valid_market) if valid_market else None
for row in rows:
stock_return = number(row.get("return_5d"))
row["relative_strength"] = (
rounded(stock_return - market_mean, 2)
if stock_return is not None and market_mean is not None
else None
)
_rank(rows, "return_5d", "return_5d_rank", "desc")
_rank(rows, "momentum_60_5", "momentum_60_5_rank", "desc")
_sector_factors(rows, dataset_ready)
_composite_factors(rows)
_style_factors(rows)
_market_height(rows)
known = factor_catalog()["factors"]
for row in rows:
for field in known:
row.setdefault(field, None)
return rows
def _sector_factors(rows: list[dict[str, Any]], dataset_ready: dict[str, bool]) -> None:
fields = (
"sector_strength",
"sector_return_5d",
"sector_return_20d",
"sector_momentum_rank",
"sector_stock_momentum_rank",
"sector_net_flow_5d_million",
"sector_flow_rank",
"sector_prosperity_rank",
"sector_trend_rank",
"sector_crowding_rank",
"sector_composite_score",
"sector_limit_count",
"sector_up_count",
"sector_breadth_ma20",
)
if not dataset_ready.get("industry"):
for row in rows:
row.update(dict.fromkeys(fields))
return
sectors: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
if row.get("sector"):
sectors[str(row["sector"])].append(row)
market_amount = sum(float(number(row.get("amount_billion")) or 0) for row in rows)
metrics = []
for name, members in sectors.items():
returns_5 = [number(row.get("return_5d")) for row in members]
returns_20 = [number(row.get("return_20d")) for row in members]
average_5 = mean(returns_5)
average_20 = mean(returns_20)
flows = [number(row.get("net_flow_5d_million")) for row in members]
sector_flow = (
sum(float(value) for value in flows)
if all(value is not None for value in flows)
else None
)
limit_values = [row.get("is_limit_up_today") for row in members]
limit_count = (
sum(bool(value) for value in limit_values)
if all(value is not None for value in limit_values)
else None
)
changes = [number(row.get("pct_chg")) for row in members]
up_count = (
sum(float(value) >= 5 for value in changes if value is not None)
if all(value is not None for value in changes)
else None
)
above = [row.get("above_ma20") for row in members]
breadth = (
sum(bool(value) for value in above) / len(above) * 100
if above and all(value is not None for value in above)
else None
)
growth = [
mean([number(row.get("revenue_yoy")), number(row.get("netprofit_yoy"))])
for row in members
]
valid_growth = [value for value in growth if value is not None]
prosperity = median(valid_growth) if valid_growth else None
turnovers = [number(row.get("turnover_rate")) for row in members]
average_turnover = mean(turnovers)
amount_share = (
sum(float(number(row.get("amount_billion")) or 0) for row in members)
/ market_amount
* 100
if market_amount
else None
)
crowding = (
average_turnover + amount_share
if average_turnover is not None and amount_share is not None
else None
)
trend = (
average_20 + breadth / 10 if average_20 is not None and breadth is not None else None
)
strength = (
min(
100,
max(
0,
50 + average_5 * 4 + (limit_count or 0) * 3 + (up_count or 0) * 0.6,
),
)
if average_5 is not None
else None
)
metrics.append(
{
"identifier": name,
"sector": name,
"return_20": average_20,
"flow": sector_flow,
"prosperity": prosperity,
"trend": trend,
"crowding": crowding,
}
)
stock_ranks = percentile_map(members, "return_20d", "desc")
for row in members:
row.update(
{
"sector_strength": rounded(strength, 1),
"sector_return_5d": rounded(average_5, 2),
"sector_return_20d": rounded(average_20, 2),
"sector_stock_momentum_rank": rounded(
stock_ranks.get(str(row["identifier"])), 4
),
"sector_net_flow_5d_million": rounded(sector_flow, 2),
"sector_limit_count": limit_count,
"sector_up_count": up_count,
"sector_breadth_ma20": rounded(breadth, 1),
}
)
rank_specs = {
"sector_momentum_rank": ("return_20", "desc"),
"sector_flow_rank": ("flow", "desc"),
"sector_prosperity_rank": ("prosperity", "desc"),
"sector_trend_rank": ("trend", "desc"),
"sector_crowding_rank": ("crowding", "desc"),
}
maps = {
output: percentile_map(metrics, source, direction)
for output, (source, direction) in rank_specs.items()
}
for name, members in sectors.items():
values = {field: mapping.get(name) for field, mapping in maps.items()}
composite = (
values["sector_prosperity_rank"] * 0.4
+ values["sector_trend_rank"] * 0.3
+ (1 - values["sector_crowding_rank"]) * 0.3
if all(value is not None for value in values.values())
else None
)
for row in members:
row.update({field: rounded(value, 4) for field, value in values.items()})
row["sector_composite_score"] = rounded(composite, 4)
for row in rows:
if not row.get("sector"):
row.update(dict.fromkeys(fields))
def _composite_factors(rows: list[dict[str, Any]]) -> None:
specs = {
"factor_value_score": (("pe_ttm", "asc"), ("pb", "asc"), ("dividend_yield_ttm", "desc")),
"factor_growth_score": (("revenue_yoy", "desc"), ("netprofit_yoy", "desc")),
"factor_quality_score": (("roe", "desc"), ("roic", "desc"), ("gross_margin", "desc")),
"factor_momentum_score": (("momentum_60_5", "desc"), ("relative_strength", "desc")),
"factor_sentiment_score": (("turnover_rate", "desc"), ("volume_ratio_5d", "desc")),
}
for output, factor_specs in specs.items():
maps = [percentile_map(rows, field, direction) for field, direction in factor_specs]
for row in rows:
values = [mapping.get(str(row["identifier"])) for mapping in maps]
row[output] = rounded(mean(values), 4)
future_rank = percentile_map(rows, "return_20d", "desc")
weights = {}
for output in specs:
pairs = [(number(row.get(output)), future_rank.get(str(row["identifier"]))) for row in rows]
valid = [(left, right) for left, right in pairs if left is not None and right is not None]
correlation = pearson(
[float(left) for left, _ in valid],
[float(right) for _, right in valid],
)
weights[output] = max(0.05, correlation)
for row in rows:
available = [
(number(row.get(field)), weight)
for field, weight in weights.items()
if number(row.get(field)) is not None
]
row["multi_factor_composite"] = (
rounded(
sum(float(value) * weight for value, weight in available)
/ sum(weight for _, weight in available),
4,
)
if available
else None
)
def _style_factors(rows: list[dict[str, Any]]) -> None:
size = percentile_map(rows, "total_mv_billion", "desc")
large = [row for row in rows if (size.get(str(row["identifier"])) or 0) >= 0.7]
small = [row for row in rows if (size.get(str(row["identifier"])) or 1) <= 0.3]
large_return = mean([number(row.get("return_20d")) for row in large])
small_return = mean([number(row.get("return_20d")) for row in small])
prefer_large = (
large_return >= small_return
if large_return is not None and small_return is not None
else None
)
growth = [row for row in rows if (number(row.get("factor_growth_score")) or 0) >= 0.7]
value = [row for row in rows if (number(row.get("factor_value_score")) or 0) >= 0.7]
growth_return = mean([number(row.get("return_20d")) for row in growth])
value_return = mean([number(row.get("return_20d")) for row in value])
prefer_growth = (
growth_return >= value_return
if growth_return is not None and value_return is not None
else None
)
for row in rows:
size_rank = size.get(str(row["identifier"]))
row["style_size_fit"] = (
rounded(size_rank if prefer_large else 1 - size_rank, 4)
if size_rank is not None and prefer_large is not None
else None
)
row["style_growth_fit"] = (
row.get("factor_growth_score")
if prefer_growth
else row.get("factor_value_score")
if prefer_growth is not None
else None
)
row["style_fit_score"] = rounded(
mean([number(row.get("style_size_fit")), number(row.get("style_growth_fit"))]),
4,
)
def _market_height(rows: list[dict[str, Any]]) -> None:
current = [int(row["limit_streak"]) for row in rows if row.get("limit_streak") is not None]
previous = [
int(row["previous_limit_streak"])
for row in rows
if row.get("previous_limit_streak") is not None
]
current_height = max(current, default=0)
previous_height = max(previous, default=0)
for row in rows:
streak = row.get("limit_streak")
prior = row.get("previous_limit_streak")
if streak is None or prior is None:
row["is_market_height"] = None
row["new_space_board"] = None
continue
is_height = current_height >= 2 and int(streak) == current_height
row["is_market_height"] = is_height
row["new_space_board"] = is_height and not (
previous_height >= 2 and int(prior) == previous_height
)
def _rank(rows: list[dict[str, Any]], source: str, target: str, direction: str) -> None:
mapping = percentile_map(rows, source, direction)
for row in rows:
row[target] = rounded(mapping.get(str(row["identifier"])), 4)
+260
View File
@@ -0,0 +1,260 @@
from __future__ import annotations
from typing import Any
from backend.features.screener.catalog import factor_catalog, validate_formula
FACTOR_MINIMUM_COVERAGE = {
"valuation": 0.95,
"financial": 0.90,
"moneyflow": 0.90,
"industry": 0.95,
"auction": 0.90,
"popularity": 0.95,
"institutions": 0.95,
"earnings": 0.95,
"market": 0.98,
}
FACTOR_DATASET = {
**dict.fromkeys(
{
"pe_ttm",
"pb",
"ps_ttm",
"dividend_yield_ttm",
"circ_mv_billion",
"total_mv_billion",
"turnover_rate",
"turnover_5d",
},
"valuation",
),
**dict.fromkeys(
{
"dividend_years",
"roe",
"roa",
"roic",
"gross_margin",
"netprofit_yoy",
"revenue_yoy",
"ocf_to_opincome",
"financial_risk",
"factor_value_score",
"factor_growth_score",
"factor_quality_score",
"multi_factor_composite",
},
"financial",
),
**dict.fromkeys(
{
"earnings_surprise_pct",
"earnings_days_since_announce",
"earnings_event_quality",
},
"earnings",
),
**dict.fromkeys(
{
"net_flow_million",
"large_flow_million",
"net_flow_5d_million",
"flow_to_circ_mv_5d",
"sector_net_flow_5d_million",
"sector_flow_rank",
},
"moneyflow",
),
**dict.fromkeys(
{
"sector_strength",
"sector_return_5d",
"sector_return_20d",
"sector_momentum_rank",
"sector_stock_momentum_rank",
"sector_prosperity_rank",
"sector_trend_rank",
"sector_crowding_rank",
"sector_composite_score",
"sector_limit_count",
"sector_up_count",
"sector_breadth_ma20",
},
"industry",
),
**dict.fromkeys(
{
"auction_change",
"auction_amount_million",
"auction_turnover_rate",
"auction_volume_ratio",
},
"auction",
),
**dict.fromkeys(
{"popularity_score", "popularity_rank_change", "popularity_dual_source"},
"popularity",
),
**dict.fromkeys(
{"institution_net_buy_million", "institution_seat_count"},
"institutions",
),
}
def execute_formula(
rows: list[dict[str, Any]],
formula: dict[str, Any],
coverage: dict[str, float],
) -> dict[str, Any]:
validate_formula(formula)
required = sorted({str(item["field"]) for item in [*formula["filters"], *formula["score"]]})
universe = formula.get("universe") or {}
universe_rows = [
row
for row in rows
if not (universe.get("exclude_st", True) and bool(row.get("is_st")))
and int(row.get("listed_days") or 0) >= int(universe.get("listed_days_min") or 0)
]
missing_datasets = sorted(
{
dataset
for field in required
if (dataset := FACTOR_DATASET.get(field, "market"))
and coverage.get(dataset, 0) < FACTOR_MINIMUM_COVERAGE[dataset]
}
)
field_coverage = {
field: (
sum(row.get(field) is not None for row in universe_rows) / len(universe_rows)
if universe_rows
else 0.0
)
for field in required
}
missing_fields = sorted(
field
for field in required
if field_coverage[field] < FACTOR_MINIMUM_COVERAGE[FACTOR_DATASET.get(field, "market")]
)
if missing_datasets or missing_fields:
return {
"status": "data_incomplete",
"items": [],
"missing_datasets": missing_datasets,
"missing_fields": missing_fields,
"field_coverage": field_coverage,
"eligible_count": 0,
}
eligible = []
for row in universe_rows:
if any(row.get(field) is None for field in required):
continue
if all(
_matches(row[item["field"]], item["op"], item["value"]) for item in formula["filters"]
):
eligible.append(row)
if not eligible:
return {
"status": "no_signal",
"items": [],
"missing_datasets": [],
"missing_fields": [],
"field_coverage": field_coverage,
"eligible_count": 0,
}
percentiles = {
item["field"]: _percentiles(eligible, item["field"], item["direction"])
for item in formula["score"]
}
labels = factor_catalog()["factors"]
results = []
for row in eligible:
contributions = []
total = 0.0
identifier = str(row["identifier"])
for item in formula["score"]:
points = percentiles[item["field"]][identifier] * float(item["weight"])
total += points
contributions.append(
{
"field": item["field"],
"label": labels[item["field"]],
"value": row[item["field"]],
"points": round(points * 100, 1),
}
)
if total < float(formula["min_score"]):
continue
contributions.sort(key=lambda item: (-item["points"], item["field"]))
results.append(
{
"identifier": identifier,
"code": row["code"],
"name": row["name"],
"sector": row.get("sector") or "",
"close": row.get("close"),
"pct_chg": row.get("pct_chg"),
"amount_billion": row.get("amount_billion"),
"score": round(total, 6),
"score_display": round(total * 100, 1),
"contributions": contributions,
"reason": "".join(item["label"] for item in contributions[:3]),
"risk_flags": _risk_flags(row),
}
)
results.sort(key=lambda item: (-item["score"], item["identifier"]))
results = results[: int(formula["limit"])]
return {
"status": "completed" if results else "no_signal",
"items": results,
"missing_datasets": [],
"missing_fields": [],
"field_coverage": field_coverage,
"eligible_count": len(eligible),
}
def _percentiles(rows: list[dict[str, Any]], field: str, direction: str) -> dict[str, float]:
ordered = sorted(
rows,
key=lambda row: (
float(row[field]) if direction == "asc" else -float(row[field]),
str(row["identifier"]),
),
)
if len(ordered) == 1:
return {str(ordered[0]["identifier"]): 1.0}
return {
str(row["identifier"]): 1 - index / (len(ordered) - 1) for index, row in enumerate(ordered)
}
def _matches(value: Any, operator: str, expected: Any) -> bool:
if operator == "between":
return expected[0] <= value <= expected[1]
if operator == "in":
return value in expected
return {
">": value > expected,
">=": value >= expected,
"<": value < expected,
"<=": value <= expected,
"==": value == expected,
"!=": value != expected,
}[operator]
def _risk_flags(row: dict[str, Any]) -> list[str]:
flags = []
if row.get("financial_risk"):
flags.append("存在已确认财务风险")
if row.get("volatility_10d") is not None and float(row["volatility_10d"]) >= 6:
flags.append("近期波动偏高")
if row.get("pct_chg") is not None and float(row["pct_chg"]) >= 9:
flags.append("当日涨幅较高")
return flags
@@ -0,0 +1,144 @@
from __future__ import annotations
import math
from statistics import fmean
from typing import Any
def number(value: Any) -> float | None:
try:
result = float(value)
return result if math.isfinite(result) else None
except (TypeError, ValueError):
return None
def change(current: float | None, previous: float | None) -> float | None:
if current is None or previous in (None, 0):
return None
return (current / previous - 1) * 100
def mean(values: list[float | None]) -> float | None:
valid = [value for value in values if value is not None]
return fmean(valid) if valid else None
def ratio(numerator: float | None, denominator: float | None) -> float | None:
if numerator is None or denominator in (None, 0):
return None
return numerator / denominator
def rsi(closes: list[float], period: int = 6) -> float | None:
if len(closes) <= period:
return None
differences = [closes[index] - closes[index - 1] for index in range(1, len(closes))]
recent = differences[-period:]
gains = sum(max(value, 0) for value in recent) / period
losses = sum(max(-value, 0) for value in recent) / period
if losses == 0:
return 100.0 if gains > 0 else 50.0
return 100 - 100 / (1 + gains / losses)
def ema(values: list[float], period: int) -> list[float]:
if not values:
return []
alpha = 2 / (period + 1)
result = [values[0]]
for value in values[1:]:
result.append(value * alpha + result[-1] * (1 - alpha))
return result
def macd(values: list[float]) -> tuple[list[float], list[float]]:
fast = ema(values, 12)
slow = ema(values, 26)
difference = [left - right for left, right in zip(fast, slow, strict=True)]
return difference, ema(difference, 9)
def weekly_series(rows: list[dict[str, Any]]) -> tuple[list[float], list[float]]:
weeks: dict[str, dict[str, float]] = {}
for row in rows:
date = str(row.get("trade_date") or "")
if len(date) != 10:
continue
from datetime import date as date_type
parsed = date_type.fromisoformat(date)
key = f"{parsed.isocalendar().year}-{parsed.isocalendar().week:02d}"
weeks.setdefault(key, {"close": 0.0, "amount": 0.0})
close = number(row.get("close"))
amount = number(row.get("amount"))
if close is not None:
weeks[key]["close"] = close
if amount is not None:
weeks[key]["amount"] += amount
values = list(weeks.values())
return [item["close"] for item in values], [item["amount"] for item in values]
def percentile_map(rows: list[dict[str, Any]], field: str, direction: str) -> dict[str, float]:
valid = [row for row in rows if number(row.get(field)) is not None]
ordered = sorted(
valid,
key=lambda row: (
number(row[field]) if direction == "asc" else -float(number(row[field]) or 0),
str(row["identifier"]),
),
)
if len(ordered) == 1:
return {str(ordered[0]["identifier"]): 1.0}
return {
str(row["identifier"]): 1 - index / (len(ordered) - 1) for index, row in enumerate(ordered)
}
def pearson(left: list[float], right: list[float]) -> float:
if len(left) < 3 or len(left) != len(right):
return 0.0
left_mean = fmean(left)
right_mean = fmean(right)
numerator = sum(
(first - left_mean) * (second - right_mean)
for first, second in zip(left, right, strict=True)
)
left_scale = math.sqrt(sum((value - left_mean) ** 2 for value in left))
right_scale = math.sqrt(sum((value - right_mean) ** 2 for value in right))
return numerator / (left_scale * right_scale) if left_scale and right_scale else 0.0
def rounded(value: float | None, digits: int = 4) -> float | None:
return round(value, digits) if value is not None else None
def calculate_earnings_quality(bars: list[dict[str, Any]], announcement_date: str) -> bool | None:
index = next(
(
offset
for offset, row in enumerate(bars)
if str(row.get("trade_date") or "") == announcement_date
),
-1,
)
if index < 0:
return None
prior = [
number(row.get("vol"))
for row in bars[max(0, index - 5) : index]
if number(row.get("vol")) is not None
]
baseline = mean(prior)
current = bars[index]
volume_ratio = ratio(number(current.get("vol")), baseline)
bad = (
number(current.get("close")) is not None
and number(current.get("open")) is not None
and float(number(current["close"]) or 0) < float(number(current["open"]) or 0)
and float(number(current.get("pct_chg")) or 0) < 0
and volume_ratio is not None
and volume_ratio >= 1.8
)
return not bad
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
from backend.features.screener.cross_section import finalize_factor_rows
from backend.features.screener.engine import FACTOR_MINIMUM_COVERAGE
from backend.features.screener.technical import build_technical_rows
SHANGHAI = ZoneInfo("Asia/Shanghai")
def build_factor_snapshot(
trade_date: str,
inputs: dict[str, Any],
coverage: dict[str, float],
sources: list[str],
) -> dict[str, Any]:
ready = {
dataset: coverage.get(dataset, 0) >= minimum
for dataset, minimum in FACTOR_MINIMUM_COVERAGE.items()
}
ready["limit_events"] = coverage.get("limit_events", 0) >= 1
rows = finalize_factor_rows(build_technical_rows(trade_date, inputs, ready), ready)
canonical = json.dumps(
{"trade_date": trade_date, "coverage": coverage, "rows": rows},
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
)
version = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
return {
"trade_date": trade_date,
"version": version,
"observed_at": datetime.now(SHANGHAI).isoformat(timespec="seconds"),
"coverage": coverage,
"sources": sorted(set(sources)),
"rows": rows,
}
@@ -0,0 +1,448 @@
from __future__ import annotations
import json
import sqlite3
from datetime import datetime
from typing import Any
class ScreenerRepository:
def save_factor_snapshot(
self,
connection: sqlite3.Connection,
*,
trade_date: str,
version: str,
observed_at: str,
state: str,
sources: list[str],
coverage: dict[str, float],
rows: list[dict[str, Any]],
) -> int:
connection.execute(
"""
INSERT OR IGNORE INTO screener_factor_snapshots (
trade_date, version, observed_at, state, source_set_json,
coverage_json, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
trade_date,
version,
observed_at,
state,
_json(sources),
_json(coverage),
_now(),
),
)
snapshot = connection.execute(
"""
SELECT id FROM screener_factor_snapshots
WHERE trade_date = ? AND version = ?
""",
(trade_date, version),
).fetchone()
if snapshot is None:
raise RuntimeError("因子快照写入失败")
snapshot_id = int(snapshot["id"])
connection.executemany(
"""
INSERT OR REPLACE INTO screener_factor_values (
snapshot_id, identifier, code, name, sector,
listed_days, is_st, payload_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
(
snapshot_id,
row["identifier"],
row["code"],
row["name"],
row.get("sector"),
int(row.get("listed_days") or 0),
int(bool(row.get("is_st"))),
_json(row),
)
for row in rows
),
)
return snapshot_id
def latest_factor_snapshot(
self, connection: sqlite3.Connection, through: str
) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT * FROM screener_factor_snapshots
WHERE trade_date <= ? ORDER BY trade_date DESC, id DESC LIMIT 1
""",
(through,),
).fetchone()
def factor_snapshot(
self, connection: sqlite3.Connection, snapshot_id: int
) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM screener_factor_snapshots WHERE id = ?",
(snapshot_id,),
).fetchone()
def factor_rows(self, connection: sqlite3.Connection, snapshot_id: int) -> list[dict[str, Any]]:
return [
json.loads(str(row["payload_json"]))
for row in connection.execute(
"""
SELECT payload_json FROM screener_factor_values
WHERE snapshot_id = ? ORDER BY identifier
""",
(snapshot_id,),
).fetchall()
]
def begin_run(
self,
connection: sqlite3.Connection,
*,
owner_user_id: int | None,
mode: str,
strategy_id: str,
strategy_name: str,
strategy_version: int,
selection_date: str,
factor_snapshot_id: int,
) -> sqlite3.Row:
connection.execute(
"""
INSERT OR IGNORE INTO screener_runs (
owner_user_id, mode, strategy_id, strategy_name,
strategy_version, selection_date, factor_snapshot_id,
status, started_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'running', ?)
""",
(
owner_user_id,
mode,
strategy_id,
strategy_name,
strategy_version,
selection_date,
factor_snapshot_id,
_now(),
),
)
row = connection.execute(
"""
SELECT * FROM screener_runs
WHERE mode = ? AND strategy_id = ? AND selection_date = ?
AND strategy_version = ? AND factor_snapshot_id = ?
AND COALESCE(owner_user_id, 0) = COALESCE(?, 0)
""",
(
mode,
strategy_id,
selection_date,
strategy_version,
factor_snapshot_id,
owner_user_id,
),
).fetchone()
if row is None:
raise RuntimeError("选股任务写入失败")
return row
def finish_run(
self,
connection: sqlite3.Connection,
run_id: int,
*,
status: str,
coverage: float,
missing_fields: list[str],
result: list[dict[str, Any]],
error_message: str = "",
) -> None:
connection.execute(
"""
UPDATE screener_runs SET
status = ?, completed_at = ?, coverage = ?,
missing_fields_json = ?, result_json = ?, error_message = ?
WHERE id = ?
""",
(
status,
_now(),
max(0, min(coverage, 1)),
_json(missing_fields),
_json(result),
error_message,
run_id,
),
)
def latest_runs(
self,
connection: sqlite3.Connection,
mode: str,
through: str,
owner_user_id: int | None = None,
) -> tuple[sqlite3.Row, ...]:
return tuple(
connection.execute(
"""
SELECT run.* FROM screener_runs run
JOIN (
SELECT strategy_id, MAX(id) AS latest_id
FROM screener_runs
WHERE mode = ? AND selection_date = ?
AND COALESCE(owner_user_id, 0) = COALESCE(?, 0)
GROUP BY strategy_id
) latest ON latest.latest_id = run.id
ORDER BY run.strategy_id
""",
(mode, through, owner_user_id),
).fetchall()
)
def run_for_user(
self, connection: sqlite3.Connection, run_id: int, user_id: int
) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT * FROM screener_runs
WHERE id = ? AND (owner_user_id IS NULL OR owner_user_id = ?)
""",
(run_id, user_id),
).fetchone()
def save_custom_strategy(
self,
connection: sqlite3.Connection,
user_id: int,
name: str,
formula: dict[str, Any],
) -> sqlite3.Row:
now = _now()
connection.execute(
"""
INSERT INTO custom_screener_strategies (
user_id, name, version, formula_json, created_at, updated_at
) VALUES (?, ?, 1, ?, ?, ?)
ON CONFLICT(user_id, name) DO UPDATE SET
version = version + 1,
formula_json = excluded.formula_json,
updated_at = excluded.updated_at
""",
(user_id, name, _json(formula), now, now),
)
row = connection.execute(
"""
SELECT * FROM custom_screener_strategies
WHERE user_id = ? AND name = ?
""",
(user_id, name),
).fetchone()
if row is None:
raise RuntimeError("自定义策略写入失败")
return row
def custom_strategies(
self, connection: sqlite3.Connection, user_id: int
) -> tuple[sqlite3.Row, ...]:
return tuple(
connection.execute(
"""
SELECT * FROM custom_screener_strategies
WHERE user_id = ? ORDER BY updated_at DESC, id DESC
""",
(user_id,),
).fetchall()
)
def custom_strategy(
self, connection: sqlite3.Connection, user_id: int, strategy_id: int
) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT * FROM custom_screener_strategies
WHERE id = ? AND user_id = ?
""",
(strategy_id, user_id),
).fetchone()
def delete_custom_strategy(
self, connection: sqlite3.Connection, user_id: int, strategy_id: int
) -> bool:
cursor = connection.execute(
"DELETE FROM custom_screener_strategies WHERE id = ? AND user_id = ?",
(strategy_id, user_id),
)
return cursor.rowcount > 0
def add_track(
self,
connection: sqlite3.Connection,
*,
user_id: int,
run: sqlite3.Row,
candidate: dict[str, Any],
) -> int:
connection.execute(
"""
INSERT OR IGNORE INTO strategy_tracks (
user_id, run_id, identifier, code, name, sector,
selection_date, strategy_name, entry_price, added_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
int(run["id"]),
candidate["identifier"],
candidate["code"],
candidate["name"],
candidate.get("sector"),
str(run["selection_date"]),
str(run["strategy_name"]),
float(candidate["close"]),
_now(),
),
)
row = connection.execute(
"""
SELECT id FROM strategy_tracks
WHERE user_id = ? AND run_id = ? AND identifier = ?
""",
(user_id, int(run["id"]), candidate["identifier"]),
).fetchone()
if row is None:
raise RuntimeError("策略跟踪写入失败")
return int(row["id"])
def tracks(self, connection: sqlite3.Connection, user_id: int) -> tuple[sqlite3.Row, ...]:
return tuple(
connection.execute(
"""
SELECT track.*, run.mode, run.strategy_id
FROM strategy_tracks track
JOIN screener_runs run ON run.id = track.run_id
WHERE track.user_id = ? ORDER BY track.added_at DESC, track.id DESC
""",
(user_id,),
).fetchall()
)
def track_bars(self, connection: sqlite3.Connection, track_id: int) -> tuple[sqlite3.Row, ...]:
return tuple(
connection.execute(
"""
SELECT * FROM strategy_track_bars
WHERE track_id = ? ORDER BY trade_date
""",
(track_id,),
).fetchall()
)
def tracked_before(
self, connection: sqlite3.Connection, trade_date: str
) -> tuple[sqlite3.Row, ...]:
return tuple(
connection.execute(
"""
SELECT * FROM strategy_tracks
WHERE selection_date < ? ORDER BY id
""",
(trade_date,),
).fetchall()
)
def save_track_bar(
self,
connection: sqlite3.Connection,
track_id: int,
trade_date: str,
row: dict[str, Any],
) -> None:
connection.execute(
"""
INSERT INTO strategy_track_bars (
track_id, trade_date, open, high, low, close
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(track_id, trade_date) DO UPDATE SET
open = excluded.open,
high = excluded.high,
low = excluded.low,
close = excluded.close
""",
(
track_id,
trade_date,
float(row["open"]),
float(row["high"]),
float(row["low"]),
float(row["close"]),
),
)
def record_track_event(
self, connection: sqlite3.Connection, track_id: int, milestone: str
) -> bool:
cursor = connection.execute(
"""
INSERT OR IGNORE INTO strategy_track_events (track_id, milestone, created_at)
VALUES (?, ?, ?)
""",
(track_id, milestone, _now()),
)
return cursor.rowcount > 0
def remove_track(self, connection: sqlite3.Connection, user_id: int, track_id: int) -> bool:
cursor = connection.execute(
"DELETE FROM strategy_tracks WHERE id = ? AND user_id = ?",
(track_id, user_id),
)
return cursor.rowcount > 0
def decode_run(row: sqlite3.Row | None) -> dict[str, Any] | None:
if row is None:
return None
result = dict(row)
result["missing_fields"] = json.loads(str(row["missing_fields_json"]))
result["items"] = json.loads(str(row["result_json"]))
result.pop("missing_fields_json", None)
result.pop("result_json", None)
return result
def decode_custom(row: sqlite3.Row) -> dict[str, Any]:
result = dict(row)
result["formula"] = json.loads(str(row["formula_json"]))
result.pop("formula_json", None)
return result
def decode_track(row: sqlite3.Row, bars: tuple[sqlite3.Row, ...]) -> dict[str, Any]:
result = dict(row)
entry = float(row["entry_price"])
closes = [float(item["close"]) for item in bars]
highs = [float(item["high"]) for item in bars]
lows = [float(item["low"]) for item in bars]
result["t1_open_return"] = (
round((float(bars[0]["open"]) / entry - 1) * 100, 2) if bars else None
)
for index in (1, 3, 5):
result[f"t{index}_return"] = (
round((closes[index - 1] / entry - 1) * 100, 2) if len(closes) >= index else None
)
result["max_gain"] = round((max(highs) / entry - 1) * 100, 2) if highs else None
result["max_drawdown"] = round((min(lows) / entry - 1) * 100, 2) if lows else None
result["observed_days"] = len(bars)
return result
def _json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
def _now() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Path, Query, Request
from backend.data.gateway import MarketDataUnavailable
from backend.data.providers.base import ProviderError
from backend.data.quality import DataQualityError
from backend.features.accounts.auth import (
AdminWritePrincipal,
AuthenticatedPrincipal,
SmartAccessPrincipal,
SmartWritePrincipal,
)
from backend.features.screener.schemas import (
CustomStrategyInput,
IdentifierResponse,
MessageResponse,
ScreenerCatalogResponse,
ScreenerSyncResponse,
ScreenerWorkspaceResponse,
TrackInput,
)
from backend.features.screener.service import ScreenerError
from backend.http.errors import AppError
router = APIRouter(prefix="/screener", tags=["screener"])
@router.get("/catalog", response_model=ScreenerCatalogResponse)
def catalog(request: Request, _principal: AuthenticatedPrincipal) -> dict:
return request.app.state.container.screener.catalog()
@router.get("", response_model=ScreenerWorkspaceResponse)
def workspace(
request: Request,
principal: SmartAccessPrincipal,
requested_date: Annotated[str, Query(alias="date")],
) -> dict:
return _call(request, "workspace", requested_date, principal.user.id)
@router.post("/sync", response_model=ScreenerSyncResponse)
def sync(
request: Request,
_principal: AdminWritePrincipal,
requested_date: Annotated[str, Query(alias="date")],
) -> dict:
return _call(request, "sync_and_run", requested_date)
@router.put("/custom", response_model=dict)
def save_custom(
payload: CustomStrategyInput,
request: Request,
principal: SmartWritePrincipal,
) -> dict:
return _call(request, "save_custom", principal.user.id, payload.name, payload.formula)
@router.delete("/custom/{strategy_id}", response_model=MessageResponse)
def delete_custom(
request: Request,
principal: SmartWritePrincipal,
strategy_id: Annotated[int, Path(gt=0)],
) -> MessageResponse:
_call(request, "delete_custom", principal.user.id, strategy_id)
return MessageResponse(message="自定义策略已删除。")
@router.post("/custom/{strategy_id}/run", response_model=dict)
def run_custom(
request: Request,
principal: SmartWritePrincipal,
strategy_id: Annotated[int, Path(gt=0)],
requested_date: Annotated[str, Query(alias="date")],
) -> dict:
return _call(request, "run_custom", principal.user.id, strategy_id, requested_date)
@router.get("/tracks", response_model=list[dict])
def tracks(request: Request, principal: SmartAccessPrincipal) -> list[dict]:
return _call(request, "tracks", principal.user.id)
@router.post("/tracks", response_model=IdentifierResponse)
def add_track(
payload: TrackInput,
request: Request,
principal: SmartWritePrincipal,
) -> IdentifierResponse:
identifier = _call(request, "add_track", principal.user.id, payload.run_id, payload.identifier)
return IdentifierResponse(id=identifier)
@router.delete("/tracks/{track_id}", response_model=MessageResponse)
def remove_track(
request: Request,
principal: SmartWritePrincipal,
track_id: Annotated[int, Path(gt=0)],
) -> MessageResponse:
_call(request, "remove_track", principal.user.id, track_id)
return MessageResponse(message="已停止跟踪。")
def _call(request: Request, method: str, *args):
try:
return getattr(request.app.state.container.screener, method)(*args)
except (ScreenerError, MarketDataUnavailable, ProviderError, DataQualityError) as exc:
raise AppError("screener_unavailable", str(exc), 409) from exc
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class ScreenerCatalogResponse(BaseModel):
factor_groups: dict[str, list[str]]
factors: dict[str, str]
stage: list[dict[str, Any]]
curated: list[dict[str, Any]]
class ScreenerWorkspaceResponse(BaseModel):
trade_date: str | None
message: str
catalog: ScreenerCatalogResponse
stage_runs: list[dict[str, Any]]
curated_runs: list[dict[str, Any]]
custom_strategies: list[dict[str, Any]]
custom_runs: list[dict[str, Any]] = Field(default_factory=list)
class ScreenerSyncResponse(BaseModel):
trade_date: str
factor_version: str
factor_count: int
phase: str
stage_runs: int
curated_runs: int
completed: int
failed: int
class CustomStrategyInput(BaseModel):
name: str = Field(min_length=1, max_length=30)
formula: dict[str, Any]
class TrackInput(BaseModel):
run_id: int = Field(gt=0)
identifier: str = Field(min_length=1, max_length=40)
class IdentifierResponse(BaseModel):
id: int
class MessageResponse(BaseModel):
message: str
+328
View File
@@ -0,0 +1,328 @@
from __future__ import annotations
import json
from datetime import datetime, time
from typing import Any
from zoneinfo import ZoneInfo
from backend.data.contracts import SnapshotState
from backend.data.gateway import DataGateway
from backend.data.repository import MarketRepository
from backend.database.connection import Database
from backend.features.screener.catalog import (
CatalogError,
factor_catalog,
strategy_catalog,
validate_formula,
)
from backend.features.screener.engine import execute_formula
from backend.features.screener.factors import build_factor_snapshot
from backend.features.screener.repository import (
ScreenerRepository,
decode_custom,
decode_run,
decode_track,
)
SHANGHAI = ZoneInfo("Asia/Shanghai")
PHASE_REGIMES = {
"冰点": "ice",
"修复": "repair",
"发酵": "fermentation",
"高潮": "climax",
"分化": "divergence",
"退潮": "retreat",
}
FINISHED = frozenset({"completed", "no_signal", "data_incomplete"})
class ScreenerError(RuntimeError):
pass
class ScreenerService:
def __init__(
self,
database: Database,
repository: ScreenerRepository,
market_repository: MarketRepository,
gateway: DataGateway,
) -> None:
self._database = database
self._repository = repository
self._market_repository = market_repository
self._gateway = gateway
def catalog(self) -> dict[str, Any]:
factors = factor_catalog()
strategies = strategy_catalog()
return {
"factor_groups": factors["groups"],
"factors": factors["factors"],
"stage": [_public_strategy(item) for item in strategies if item["kind"] == "stage"],
"curated": [_public_strategy(item) for item in strategies if item["kind"] == "curated"],
}
def workspace(self, requested_date: str, user_id: int) -> dict[str, Any]:
through = self._gateway.trade_context(requested_date).actual_date
with self._database.read() as connection:
custom = [
decode_custom(row)
for row in self._repository.custom_strategies(connection, user_id)
]
if through is None:
return {
"trade_date": None,
"message": "等待管理员首次同步真实收盘行情",
"catalog": self.catalog(),
"stage_runs": [],
"curated_runs": [],
"custom_strategies": custom,
"custom_runs": [],
}
with self._database.read() as connection:
stage = [
decode_run(row)
for row in self._repository.latest_runs(connection, "stage", through)
]
curated = [
decode_run(row)
for row in self._repository.latest_runs(connection, "curated", through)
]
custom_runs = [
decode_run(row)
for row in self._repository.latest_runs(connection, "custom", through, user_id)
]
return {
"trade_date": through,
"message": "",
"catalog": self.catalog(),
"stage_runs": stage,
"curated_runs": curated,
"custom_strategies": custom,
"custom_runs": custom_runs,
}
def sync_and_run(self, trade_date: str) -> dict[str, Any]:
market = self._market_snapshot(trade_date)
inputs, coverage, sources = self._gateway.screener_inputs(trade_date)
snapshot = build_factor_snapshot(trade_date, inputs, coverage, sources)
state = str(market["state"])
with self._database.transaction() as connection:
snapshot_id = self._repository.save_factor_snapshot(
connection,
trade_date=trade_date,
version=snapshot["version"],
observed_at=snapshot["observed_at"],
state=state,
sources=snapshot["sources"],
coverage=snapshot["coverage"],
rows=snapshot["rows"],
)
phase = str((market["payload"].get("sentiment") or {}).get("phase") or "")
regime = PHASE_REGIMES.get(phase)
stage_strategies, curated_strategies = automatic_strategies(regime)
runs = [
self._run(snapshot_id, trade_date, strategy, None)
for strategy in [*stage_strategies, *curated_strategies]
]
self._update_tracks(trade_date, snapshot["rows"])
return {
"trade_date": trade_date,
"factor_version": snapshot["version"],
"factor_count": len(snapshot["rows"]),
"phase": phase,
"stage_runs": len(stage_strategies),
"curated_runs": len(curated_strategies),
"completed": sum(run and run["status"] in FINISHED for run in runs),
"failed": sum(run and run["status"] == "failed" for run in runs),
}
def run_after_close(self, now: datetime | None = None) -> dict[str, Any] | None:
clock = now or datetime.now(SHANGHAI)
if clock.time() < time(15, 10):
return None
trade_date = clock.date().isoformat()
market = self._market_snapshot(trade_date)
if market["trade_date"] != trade_date or market["state"] != SnapshotState.FINAL.value:
return None
return self.sync_and_run(trade_date)
def save_custom(self, user_id: int, name: str, formula: dict[str, Any]) -> dict[str, Any]:
normalized = " ".join(name.split())
if not normalized or len(normalized) > 30:
raise ScreenerError("自定义策略名称应为1至30个字符")
try:
validate_formula(formula)
except CatalogError as exc:
raise ScreenerError(str(exc)) from exc
with self._database.transaction() as connection:
return decode_custom(
self._repository.save_custom_strategy(connection, user_id, normalized, formula)
)
def delete_custom(self, user_id: int, strategy_id: int) -> None:
with self._database.transaction() as connection:
if not self._repository.delete_custom_strategy(connection, user_id, strategy_id):
raise ScreenerError("未找到该自定义策略")
def run_custom(self, user_id: int, strategy_id: int, through: str) -> dict[str, Any]:
with self._database.read() as connection:
custom = self._repository.custom_strategy(connection, user_id, strategy_id)
snapshot = self._repository.latest_factor_snapshot(connection, through)
if custom is None:
raise ScreenerError("未找到该自定义策略")
if snapshot is None:
raise ScreenerError("当前日期尚未生成完整因子快照")
strategy = {
"id": f"custom-{custom['id']}",
"name": str(custom["name"]),
"version": int(custom["version"]),
"kind": "custom",
"formula": json.loads(str(custom["formula_json"])),
}
result = self._run(int(snapshot["id"]), str(snapshot["trade_date"]), strategy, user_id)
if result is None:
raise ScreenerError("自定义策略执行失败")
return result
def tracks(self, user_id: int) -> list[dict[str, Any]]:
with self._database.read() as connection:
return [
decode_track(row, self._repository.track_bars(connection, int(row["id"])))
for row in self._repository.tracks(connection, user_id)
]
def add_track(self, user_id: int, run_id: int, identifier: str) -> int:
with self._database.transaction() as connection:
run = self._repository.run_for_user(connection, run_id, user_id)
if run is None:
raise ScreenerError("未找到可访问的选股结果")
items = json.loads(str(run["result_json"]))
candidate = next(
(item for item in items if str(item.get("identifier")) == identifier), None
)
if (
candidate is None
or not isinstance(candidate.get("close"), (int, float))
or float(candidate["close"]) <= 0
):
raise ScreenerError("该候选无法加入持续跟踪")
return self._repository.add_track(
connection, user_id=user_id, run=run, candidate=candidate
)
def remove_track(self, user_id: int, track_id: int) -> None:
with self._database.transaction() as connection:
if not self._repository.remove_track(connection, user_id, track_id):
raise ScreenerError("未找到该跟踪记录")
def _run(
self,
snapshot_id: int,
trade_date: str,
strategy: dict[str, Any],
owner_user_id: int | None,
) -> dict[str, Any] | None:
mode = str(strategy["kind"])
with self._database.transaction() as connection:
row = self._repository.begin_run(
connection,
owner_user_id=owner_user_id,
mode=mode,
strategy_id=str(strategy["id"]),
strategy_name=str(strategy["name"]),
strategy_version=int(strategy["version"]),
selection_date=trade_date,
factor_snapshot_id=snapshot_id,
)
existing = decode_run(row)
if existing and existing["status"] in FINISHED:
return existing
snapshot = self._repository.factor_snapshot(connection, snapshot_id)
rows = self._repository.factor_rows(connection, snapshot_id)
if snapshot is None:
raise ScreenerError("因子快照不存在")
coverage = json.loads(str(snapshot["coverage_json"]))
try:
outcome = execute_formula(rows, strategy["formula"], coverage)
status = str(outcome["status"])
missing = [*outcome["missing_datasets"], *outcome["missing_fields"]]
score_coverage = min(outcome["field_coverage"].values(), default=0)
error = ""
except (CatalogError, KeyError, TypeError, ValueError) as exc:
status, missing, score_coverage, outcome, error = (
"failed",
[],
0,
{"items": []},
str(exc),
)
with self._database.transaction() as connection:
self._repository.finish_run(
connection,
int(row["id"]),
status=status,
coverage=score_coverage,
missing_fields=missing,
result=outcome["items"],
error_message=error,
)
return decode_run(
connection.execute(
"SELECT * FROM screener_runs WHERE id = ?", (row["id"],)
).fetchone()
)
def _market_snapshot(self, trade_date: str) -> dict[str, Any]:
with self._database.read() as connection:
row = self._market_repository.latest_summary(connection, trade_date)
if row is None or str(row["trade_date"]) != trade_date:
raise ScreenerError("当日收盘行情尚未完成,选股任务未启动")
if str(row["state"]) not in {SnapshotState.FINAL.value, SnapshotState.ARCHIVE.value}:
raise ScreenerError("行情快照尚未收盘定稿")
return {
"trade_date": str(row["trade_date"]),
"state": str(row["state"]),
"payload": json.loads(str(row["payload_json"])),
}
def _update_tracks(self, trade_date: str, rows: list[dict[str, Any]]) -> None:
current = {str(row["identifier"]): row for row in rows}
with self._database.transaction() as connection:
for track in self._repository.tracked_before(connection, trade_date):
row = current.get(str(track["identifier"]))
if row is None or any(
row.get(field) is None for field in ("open", "high", "low", "close")
):
continue
self._repository.save_track_bar(connection, int(track["id"]), trade_date, row)
days = len(self._repository.track_bars(connection, int(track["id"])))
if days in {1, 5}:
self._repository.record_track_event(connection, int(track["id"]), f"t{days}")
def _public_strategy(strategy: dict[str, Any]) -> dict[str, Any]:
return {
"id": strategy["id"],
"version": strategy["version"],
"kind": strategy["kind"],
"name": strategy["name"],
"display_name": strategy.get("display_name") or strategy["name"],
"description": strategy["description"],
"regimes": strategy.get("regimes") or [],
"formula": strategy["formula"],
}
def automatic_strategies(
regime: str | None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
strategies = strategy_catalog()
stage = [
item
for item in strategies
if item["kind"] == "stage" and regime in (item.get("regimes") or [])
]
curated = [item for item in strategies if item["kind"] == "curated"]
return stage, curated
+434
View File
@@ -0,0 +1,434 @@
from __future__ import annotations
from statistics import fmean, pstdev
from typing import Any
from backend.features.screener.factor_math import (
calculate_earnings_quality,
change,
macd,
mean,
number,
ratio,
rounded,
rsi,
weekly_series,
)
from backend.features.screener.technical_support import (
broken_metrics,
dividend_years,
ending_streak,
group,
large_flow,
latest_by_code,
max_streak,
point_in_time,
)
from backend.features.screener.technical_support import (
limit_events as map_limit_events,
)
from backend.features.screener.technical_support import (
listed_days as calculate_listed_days,
)
def build_technical_rows(
trade_date: str,
inputs: dict[str, Any],
dataset_ready: dict[str, bool],
) -> list[dict[str, Any]]:
daily = group(inputs.get("daily") or (), "ts_code")
basics = latest_by_code(inputs.get("daily_basic") or (), trade_date)
basic_history = group(inputs.get("daily_basic") or (), "ts_code")
flows = group(inputs.get("moneyflow") or (), "ts_code")
fundamentals = point_in_time(inputs.get("fundamentals") or (), trade_date)
dividends = group(inputs.get("dividends") or (), "ts_code")
auctions = latest_by_code(inputs.get("auction") or (), trade_date)
earnings = point_in_time(inputs.get("earnings") or (), trade_date)
popularity = {str(row["ts_code"]): row for row in inputs.get("popularity") or ()}
institutions = {str(row["ts_code"]): row for row in inputs.get("institutions") or ()}
directory = {str(row["ts_code"]): row for row in inputs.get("directory") or ()}
industries = {
str(row["ts_code"]): str(row.get("l2_name") or "")
for row in inputs.get("industry") or ()
if row.get("ts_code")
}
benchmark = {
str(row["trade_date"]): float(row["close"])
for row in inputs.get("benchmark") or ()
if number(row.get("close")) is not None
}
limit_event_map = map_limit_events(inputs.get("limit_events") or ())
rows = []
for identifier, bars in daily.items():
bars.sort(key=lambda row: str(row.get("trade_date") or ""))
if not bars or str(bars[-1].get("trade_date") or "") != trade_date:
continue
info = directory.get(identifier)
if info is None:
continue
closes = [number(row.get("close")) for row in bars]
if any(value is None for value in closes) or not closes:
continue
close_values = [float(value) for value in closes if value is not None]
row = _stock_row(
trade_date=trade_date,
identifier=identifier,
info=info,
bars=bars,
closes=close_values,
basic=basics.get(identifier, {}),
basic_history=basic_history.get(identifier, []),
flows=flows.get(identifier, []),
fundamental=fundamentals.get(identifier, {}),
dividends=dividends.get(identifier, []),
auction=auctions.get(identifier, {}),
earnings=earnings.get(identifier, {}),
popularity=popularity.get(identifier),
institution=institutions.get(identifier),
sector=industries.get(identifier) if dataset_ready.get("industry") else None,
benchmark=benchmark,
limit_events=limit_event_map,
dataset_ready=dataset_ready,
)
rows.append(row)
return rows
def _stock_row(
*,
trade_date: str,
identifier: str,
info: dict[str, Any],
bars: list[dict[str, Any]],
closes: list[float],
basic: dict[str, Any],
basic_history: list[dict[str, Any]],
flows: list[dict[str, Any]],
fundamental: dict[str, Any],
dividends: list[dict[str, Any]],
auction: dict[str, Any],
earnings: dict[str, Any],
popularity: dict[str, Any] | None,
institution: dict[str, Any] | None,
sector: str | None,
benchmark: dict[str, float],
limit_events: dict[str, dict[str, str]],
dataset_ready: dict[str, bool],
) -> dict[str, Any]:
current = bars[-1]
previous = bars[-2] if len(bars) >= 2 else {}
highs = [number(item.get("high")) for item in bars]
lows = [number(item.get("low")) for item in bars]
volumes = [number(item.get("vol")) for item in bars]
changes = [number(item.get("pct_chg")) for item in bars]
open_price = number(current.get("open"))
close_price = closes[-1]
code = str(info.get("symbol") or info.get("code") or identifier.split(".")[0])
name = str(info.get("name") or "")
is_st = "ST" in name.upper() or "退" in name
listed_days = calculate_listed_days(info.get("list_date"), trade_date)
ma20 = mean(closes[-20:]) if len(closes) >= 20 else None
ma60 = mean(closes[-60:]) if len(closes) >= 60 else None
prior_ma20 = mean(closes[-25:-5]) if len(closes) >= 25 else None
prior_ma60 = mean(closes[-65:-5]) if len(closes) >= 65 else None
ma_values = [
mean(closes[-window:]) if len(closes) >= window else None for window in (5, 10, 20, 60)
]
high_values = [float(value) for value in highs if value is not None]
low_values = [float(value) for value in lows if value is not None]
event_flags = [
limit_events.get(str(item.get("trade_date") or ""), {}).get(identifier) for item in bars
]
up_flags = [value == "U" for value in event_flags]
down_flags = [value == "D" for value in event_flags]
event_known = dataset_ready.get("limit_events", False)
benchmark_60 = [benchmark.get(str(item.get("trade_date") or "")) for item in bars[-61:]]
rs_values = [
float(item["close"]) / benchmark[str(item["trade_date"])]
for item in bars[-120:]
if number(item.get("close")) is not None and benchmark.get(str(item.get("trade_date")))
]
weekly_closes, weekly_amounts = weekly_series(bars)
weekly_dif, weekly_dea = macd(weekly_closes)
daily_dif, daily_dea = macd(closes)
daily_cross = (
len(daily_dif) >= 2 and daily_dif[-1] > daily_dea[-1] and daily_dif[-2] <= daily_dea[-2]
)
pullback = (
ma20 is not None
and open_price is not None
and close_price >= ma20
and open_price <= ma20 * 1.02
and close_price > open_price
)
turnover_history = sorted(basic_history, key=lambda item: str(item.get("trade_date") or ""))
flow_history = sorted(flows, key=lambda item: str(item.get("trade_date") or ""))[-5:]
net_flows = [number(item.get("net_mf_amount")) for item in flow_history]
current_flow = flow_history[-1] if flow_history else {}
circ_mv = number(basic.get("circ_mv"))
net_5d_raw = sum(value for value in net_flows if value is not None) if net_flows else None
broken = broken_metrics(bars, up_flags)
previous_signal = event_flags[-2] if len(event_flags) >= 2 else None
prior_three = event_flags[max(0, len(event_flags) - 4) : -2]
previous_streak = ending_streak(up_flags, len(up_flags) - 2) if event_known else None
current_streak = ending_streak(up_flags) if event_known else None
current_low = number(current.get("low"))
previous_close = number(previous.get("close"))
body = abs(close_price - open_price) if open_price is not None else None
lower_shadow = (
max(0.0, min(open_price, close_price) - current_low)
if open_price is not None and current_low is not None
else None
)
lower_shadow_ratio = (
lower_shadow / body
if lower_shadow is not None and body not in (None, 0)
else 10.0
if lower_shadow and body == 0
else None
)
earnings_date = str(earnings.get("ann_date") or "")
earnings_days = (
sum(earnings_date < str(item.get("trade_date") or "") <= trade_date for item in bars)
if earnings_date
else None
)
earnings_ready = dataset_ready.get("earnings", False)
earnings_quality = (
calculate_earnings_quality(bars, earnings_date)
if earnings_date
else True
if earnings_ready
else None
)
netprofit = number(fundamental.get("netprofit_yoy"))
financial_risk = (
True
if is_st or (netprofit is not None and netprofit <= -100)
else False
if dataset_ready.get("financial")
else None
)
row = {
"identifier": identifier,
"code": code,
"name": name,
"sector": sector,
"listed_days": listed_days,
"is_st": is_st,
"close": rounded(close_price, 2),
"pct_chg": rounded(number(current.get("pct_chg")), 2),
"return_5d": rounded(change(close_price, closes[-6]), 2) if len(closes) >= 6 else None,
"return_10d": rounded(change(close_price, closes[-11]), 2) if len(closes) >= 11 else None,
"return_20d": rounded(change(close_price, closes[-21]), 2) if len(closes) >= 21 else None,
"return_60d": rounded(change(close_price, closes[-61]), 2) if len(closes) >= 61 else None,
"momentum_60_5": rounded(change(closes[-6], closes[-61]), 2) if len(closes) >= 61 else None,
"above_ma20": close_price > ma20 if ma20 is not None else None,
"rsi_6": rounded(rsi(closes, 6), 2),
"ma60_slope": rounded(change(ma60, prior_ma60), 3),
"ma20_slope_5d": rounded(change(ma20, prior_ma20), 3),
"ma_bull_alignment": (
bool(ma_values[0] > ma_values[1] > ma_values[2] > ma_values[3])
if all(value is not None for value in ma_values)
else None
),
"drawdown_from_high_250": (
rounded((1 - close_price / max(high_values[-250:])) * 100, 2)
if len(high_values) >= 250 and max(high_values[-250:]) > 0
else None
),
"donchian_breakout_pct": (
rounded(change(close_price, max(high_values[-21:-1])), 2)
if len(high_values) >= 21
else None
),
"range_20d": (
rounded(change(max(high_values[-21:-1]), min(low_values[-21:-1])), 2)
if len(high_values) >= 21 and len(low_values) >= 21
else None
),
"rs_high_120": len(rs_values) >= 120 and rs_values[-1] >= max(rs_values)
if benchmark
else None,
"excess_return_60d": (
rounded(
float(change(close_price, closes[-61]) or 0)
- float(change(benchmark_60[-1], benchmark_60[0]) or 0),
2,
)
if len(closes) >= 61 and len(benchmark_60) == 61 and all(benchmark_60)
else None
),
"weekly_trend_signal": (
weekly_dif[-1] > 0 and weekly_dea[-1] > 0 if len(weekly_closes) >= 30 else None
),
"daily_buy_trigger": daily_cross or pullback if len(closes) >= 26 else None,
"weekly_amount_trend": (
weekly_amounts[-1] >= fmean(weekly_amounts[-5:-1]) if len(weekly_amounts) >= 5 else None
),
"volume_ratio_5d": (
rounded(ratio(number(current.get("vol")), mean(volumes[-6:-1])), 2)
if len(volumes) >= 6
else None
),
"turnover_5d": (
rounded(
sum(
float(number(item.get("turnover_rate")) or 0) for item in turnover_history[-5:]
),
2,
)
if dataset_ready.get("valuation") and len(turnover_history) >= 5
else None
),
"volatility_10d": (
rounded(pstdev(float(value) for value in changes[-10:] if value is not None), 2)
if len(changes) >= 10 and all(value is not None for value in changes[-10:])
else None
),
"amount_billion": rounded((number(current.get("amount")) or 0) / 100000, 2),
"turnover_rate": rounded(number(basic.get("turnover_rate")), 2),
"circ_mv_billion": rounded(circ_mv / 10000, 2) if circ_mv is not None else None,
"total_mv_billion": rounded((number(basic.get("total_mv")) or 0) / 10000, 2)
if number(basic.get("total_mv")) is not None
else None,
"pe_ttm": rounded(number(basic.get("pe_ttm")), 2),
"pb": rounded(number(basic.get("pb")), 2),
"ps_ttm": rounded(number(basic.get("ps_ttm")), 2),
"dividend_yield_ttm": rounded(number(basic.get("dv_ttm")), 2),
"dividend_years": dividend_years(dividends, trade_date)
if dataset_ready.get("financial")
else None,
"roe": rounded(number(fundamental.get("roe")), 2),
"roa": rounded(number(fundamental.get("roa")), 2),
"roic": rounded(number(fundamental.get("roic")), 2),
"gross_margin": rounded(number(fundamental.get("grossprofit_margin")), 2),
"netprofit_yoy": rounded(netprofit, 2),
"revenue_yoy": rounded(number(fundamental.get("or_yoy")), 2),
"ocf_to_opincome": rounded(number(fundamental.get("ocf_to_or")), 2),
"earnings_surprise_pct": (
rounded(number(earnings.get("surprise_pct")), 2)
if earnings
else 0.0
if earnings_ready
else None
),
"earnings_days_since_announce": (
earnings_days if earnings_days is not None else 999 if earnings_ready else None
),
"earnings_event_quality": earnings_quality,
"popularity_score": (
rounded(number((popularity or {}).get("combined_score")), 2)
if popularity
else 0.0
if dataset_ready.get("popularity")
else None
),
"popularity_rank_change": (
number((popularity or {}).get("rank_change"))
if popularity
else 0.0
if dataset_ready.get("popularity")
else None
),
"popularity_dual_source": (
bool((popularity or {}).get("dual_source"))
if popularity
else False
if dataset_ready.get("popularity")
else None
),
"institution_net_buy_million": (
rounded(number((institution or {}).get("net_buy_million")), 2)
if institution
else 0.0
if dataset_ready.get("institutions")
else None
),
"institution_seat_count": (
number((institution or {}).get("seat_count"))
if institution
else 0
if dataset_ready.get("institutions")
else None
),
"net_flow_million": rounded((number(current_flow.get("net_mf_amount")) or 0) / 100, 2)
if current_flow
else None,
"large_flow_million": large_flow(current_flow),
"net_flow_5d_million": rounded(net_5d_raw / 100, 2)
if net_5d_raw is not None and len(flow_history) >= 5
else None,
"flow_to_circ_mv_5d": rounded(net_5d_raw / circ_mv * 100, 4)
if net_5d_raw is not None and circ_mv
else None,
"limit_streak": current_streak,
"previous_limit_streak": previous_streak,
"previous_first_limit": previous_signal == "U" and "U" not in prior_three
if event_known
else None,
"previous_limit_signal": previous_signal in {"U", "Z"}
and not any(value in {"U", "Z"} for value in prior_three)
if event_known
else None,
"is_limit_up_today": up_flags[-1] if event_known else None,
"is_limit_down_today": down_flags[-1] if event_known else None,
"no_limit_30d": not any(up_flags[-30:]) if event_known and len(up_flags) >= 30 else None,
"had_limit_80d": any(up_flags[-80:-30]) if event_known and len(up_flags) >= 80 else None,
"no_limit_down_20d": not any(down_flags[-20:])
if event_known and len(down_flags) >= 20
else None,
"financial_risk": financial_risk,
"max_continuous_board_10d": max_streak(up_flags[-10:])
if event_known and len(up_flags) >= 10
else None,
"dragon_first_yin": (
previous_streak is not None
and previous_streak >= 3
and not up_flags[-1]
and open_price is not None
and close_price < open_price
)
if event_known
else None,
"yin_day_pct": rounded(number(current.get("pct_chg")), 2)
if event_known and previous_streak and previous_streak >= 3 and not up_flags[-1]
else None,
"broken_reversal": broken["signal"] if event_known else None,
"days_since_broken": broken["days"] if event_known else None,
"close_above_broken_high": broken["recovered"] if event_known else None,
"vol_vs_broken_day": broken["volume_ratio"] if event_known else None,
"recent_limit_up_5d": sum(up_flags[-5:]) if event_known and len(up_flags) >= 5 else None,
"intraday_min_pct": rounded(change(current_low, previous_close), 2),
"lower_shadow_ratio": rounded(lower_shadow_ratio, 2),
"vol_vs_previous": rounded(
ratio(number(current.get("vol")), number(previous.get("vol"))), 3
),
"previous_amount_billion": rounded((number(previous.get("amount")) or 0) / 100000, 2)
if previous
else None,
"auction_change": rounded(number(auction.get("change")), 2),
"auction_amount_million": rounded(number(auction.get("amount_million")), 2),
"auction_turnover_rate": rounded(number(auction.get("turnover_rate")), 4),
"auction_volume_ratio": rounded(number(auction.get("volume_ratio")), 2),
"relative_position_60": (
rounded(
(close_price - min(low_values[-60:]))
/ (max(high_values[-60:]) - min(low_values[-60:])),
4,
)
if len(high_values) >= 60 and max(high_values[-60:]) > min(low_values[-60:])
else None
),
"max_abs_change_15d": max(abs(float(value)) for value in changes[-15:] if value is not None)
if len(changes) >= 15
else None,
"close_to_high_15d": rounded(ratio(close_price, max(high_values[-15:])), 4)
if len(high_values) >= 15
else None,
"close_to_high_60d": rounded(ratio(close_price, max(high_values[-60:])), 4)
if len(high_values) >= 60
else None,
}
return row
@@ -0,0 +1,137 @@
from __future__ import annotations
from collections import defaultdict
from datetime import date
from typing import Any
from backend.features.screener.factor_math import number, ratio, rounded
def group(rows: Any, field: str) -> dict[str, list[dict[str, Any]]]:
result: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
key = str(row.get(field) or "")
if key:
result[key].append(dict(row))
return result
def latest_by_code(rows: Any, through: str) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for row in rows:
identifier = str(row.get("ts_code") or "")
row_date = str(row.get("trade_date") or "")
if (
identifier
and row_date <= through
and (
identifier not in result
or row_date > str(result[identifier].get("trade_date") or "")
)
):
result[identifier] = dict(row)
return result
def point_in_time(rows: Any, through: str) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
for row in rows:
identifier = str(row.get("ts_code") or "")
announced = str(row.get("ann_date") or "")
if (
identifier
and announced
and announced <= through
and (
identifier not in result
or announced > str(result[identifier].get("ann_date") or "")
)
):
result[identifier] = dict(row)
return result
def limit_events(rows: Any) -> dict[str, dict[str, str]]:
result: dict[str, dict[str, str]] = defaultdict(dict)
for row in rows:
date_value = str(row.get("trade_date") or "")
identifier = str(row.get("ts_code") or "")
event = str(row.get("limit_type") or "")
if date_value and identifier and event in {"U", "D", "Z"}:
result[date_value][identifier] = event
return result
def listed_days(value: Any, through: str) -> int:
try:
listed = str(value or "").replace("-", "")
start = date(int(listed[:4]), int(listed[4:6]), int(listed[6:]))
return (date.fromisoformat(through) - start).days
except (ValueError, TypeError):
return 0
def dividend_years(rows: list[dict[str, Any]], through: str) -> int:
return len(
{
str(row.get("end_date") or "")[:4]
for row in rows
if str(row.get("ann_date") or row.get("ex_date") or "") <= through
and (number(row.get("cash_div_tax")) or 0) > 0
}
)
def large_flow(row: dict[str, Any]) -> float | None:
if not row:
return None
direct = number(row.get("large_net_amount"))
if direct is not None:
return rounded(direct / 100, 2)
buys = [number(row.get(field)) for field in ("buy_lg_amount", "buy_elg_amount")]
sells = [number(row.get(field)) for field in ("sell_lg_amount", "sell_elg_amount")]
if any(value is None for value in buys + sells):
return None
return rounded(
(sum(float(value) for value in buys) - sum(float(value) for value in sells)) / 100,
2,
)
def ending_streak(flags: list[bool], end: int | None = None) -> int:
index = len(flags) - 1 if end is None else end
count = 0
while index >= 0 and flags[index]:
count += 1
index -= 1
return count
def max_streak(flags: list[bool]) -> int:
best = current = 0
for flag in flags:
current = current + 1 if flag else 0
best = max(best, current)
return best
def broken_metrics(bars: list[dict[str, Any]], up_flags: list[bool]) -> dict[str, Any]:
if len(bars) < 3:
return {"signal": None, "days": None, "recovered": None, "volume_ratio": None}
last_limit = next((index for index in range(len(up_flags) - 2, -1, -1) if up_flags[index]), -1)
if last_limit < 0:
return {"signal": False, "days": None, "recovered": False, "volume_ratio": None}
days = len(bars) - 1 - last_limit
broken_high = number(bars[last_limit].get("high"))
recovered = (
number(bars[-1].get("close")) is not None
and broken_high is not None
and float(number(bars[-1]["close"]) or 0) > broken_high
)
volume_ratio = ratio(number(bars[-1].get("vol")), number(bars[last_limit].get("vol")))
return {
"signal": 1 <= days <= 3 and recovered and volume_ratio is not None and volume_ratio >= 1,
"days": days,
"recovered": recovered,
"volume_ratio": rounded(volume_ratio, 3),
}