273 lines
10 KiB
Python
273 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
from backend.data.contracts import ProviderResult
|
|
from backend.data.repository import MarketRepository
|
|
from backend.database.connection import Database
|
|
|
|
|
|
def assemble_screener_inputs(
|
|
database: Database,
|
|
repository: MarketRepository,
|
|
trade_date: str,
|
|
trade_dates: tuple[str, ...],
|
|
raw: dict[str, ProviderResult | None],
|
|
) -> tuple[dict[str, Any], dict[str, float]]:
|
|
inputs = {
|
|
key: [_normalize_row(row) for row in value.rows]
|
|
for key, value in raw.items()
|
|
if isinstance(value, ProviderResult)
|
|
}
|
|
inputs["earnings"] = _earnings_events(
|
|
inputs.pop("forecast", []), inputs.pop("express", []), trade_date
|
|
)
|
|
popularity, popularity_coverage = _popularity(database, repository, trade_date)
|
|
institutions, institution_coverage = _institutions(database, repository, trade_date)
|
|
local_limits, local_limit_coverage = _local_limit_events(
|
|
database, repository, trade_dates[-80:]
|
|
)
|
|
inputs["popularity"] = popularity
|
|
inputs["institutions"] = institutions
|
|
inputs["limit_events"] = _merge_events(inputs.get("limit_events", []), local_limits)
|
|
|
|
directory_count = len(inputs.get("directory", []))
|
|
daily_current = _on_date(inputs.get("daily", []), trade_date)
|
|
current_count = len({str(row.get("ts_code") or "") for row in daily_current})
|
|
expected = max(directory_count, current_count, 1)
|
|
basic_current = _on_date(inputs.get("daily_basic", []), trade_date)
|
|
fundamentals = _latest_announced(inputs.get("fundamentals", []), trade_date)
|
|
moneyflow_counts = _code_date_counts(inputs.get("moneyflow", []))
|
|
industry_codes = {
|
|
str(row.get("ts_code") or "")
|
|
for row in inputs.get("industry", [])
|
|
if _active_member(row, trade_date)
|
|
}
|
|
auction_count = len({str(row.get("ts_code") or "") for row in inputs.get("auction", [])})
|
|
limit_result = raw.get("limit_events")
|
|
provider_limit_coverage = (
|
|
limit_result.metadata.coverage if isinstance(limit_result, ProviderResult) else 0
|
|
)
|
|
forecast = raw.get("forecast")
|
|
express = raw.get("express")
|
|
earnings_coverage = min(
|
|
forecast.metadata.coverage if isinstance(forecast, ProviderResult) else 0,
|
|
express.metadata.coverage if isinstance(express, ProviderResult) else 0,
|
|
)
|
|
coverage = {
|
|
"market": min(current_count / expected, 1),
|
|
"valuation": min(
|
|
len({str(row.get("ts_code") or "") for row in basic_current}) / expected, 1
|
|
),
|
|
"financial": min(len(fundamentals) / expected, 1),
|
|
"moneyflow": min(
|
|
sum(count >= min(5, len(trade_dates)) for count in moneyflow_counts.values())
|
|
/ expected,
|
|
1,
|
|
),
|
|
"industry": min(len(industry_codes) / expected, 1),
|
|
"auction": min(auction_count / expected, 1),
|
|
"popularity": popularity_coverage,
|
|
"institutions": institution_coverage,
|
|
"earnings": earnings_coverage,
|
|
"limit_events": max(provider_limit_coverage, local_limit_coverage),
|
|
}
|
|
return inputs, coverage
|
|
|
|
|
|
def _popularity(
|
|
database: Database, repository: MarketRepository, trade_date: str
|
|
) -> tuple[list[dict[str, Any]], float]:
|
|
with database.read() as connection:
|
|
row = repository.insight_snapshot(connection, "popularity", trade_date)
|
|
if row is None:
|
|
return [], 0.0
|
|
payload = json.loads(str(row["payload_json"]))
|
|
return [
|
|
{
|
|
"ts_code": str(item.get("identifier") or ""),
|
|
"combined_score": item.get("score"),
|
|
"rank_change": item.get("rank_change"),
|
|
"dual_source": bool(item.get("dual_source")),
|
|
}
|
|
for item in payload.get("combined") or []
|
|
if item.get("identifier")
|
|
], float(row["coverage"])
|
|
|
|
|
|
def _institutions(
|
|
database: Database, repository: MarketRepository, trade_date: str
|
|
) -> tuple[list[dict[str, Any]], float]:
|
|
with database.read() as connection:
|
|
row = repository.insight_snapshot(connection, "dragon-list", trade_date)
|
|
if row is None:
|
|
return [], 0.0
|
|
payload = json.loads(str(row["payload_json"]))
|
|
seats = payload.get("seats")
|
|
if seats is None:
|
|
return [], 0.0
|
|
grouped: dict[str, dict[str, float]] = {}
|
|
for item in seats:
|
|
identifier = str(item.get("ts_code") or "")
|
|
if not identifier or "机构" not in str(item.get("exalter") or ""):
|
|
continue
|
|
target = grouped.setdefault(identifier, {"net": 0.0, "count": 0})
|
|
target["net"] += float(item.get("net_buy") or 0) / 1_000_000
|
|
target["count"] += 1
|
|
return [
|
|
{
|
|
"ts_code": identifier,
|
|
"net_buy_million": values["net"],
|
|
"seat_count": int(values["count"]),
|
|
}
|
|
for identifier, values in grouped.items()
|
|
], float(row["coverage"])
|
|
|
|
|
|
def _local_limit_events(
|
|
database: Database,
|
|
repository: MarketRepository,
|
|
trade_dates: tuple[str, ...],
|
|
) -> tuple[list[dict[str, Any]], float]:
|
|
if not trade_dates:
|
|
return [], 0.0
|
|
with database.read() as connection:
|
|
rows = repository.summaries(connection, trade_dates[-1], len(trade_dates))
|
|
expected = set(trade_dates)
|
|
covered: set[str] = set()
|
|
events = []
|
|
for row in rows:
|
|
trade_date = str(row["trade_date"])
|
|
if trade_date not in expected:
|
|
continue
|
|
covered.add(trade_date)
|
|
payload = json.loads(str(row["payload_json"]))
|
|
for key, event in (("limits", "U"), ("broken", "Z"), ("down_limits", "D")):
|
|
events.extend(
|
|
{
|
|
"trade_date": trade_date,
|
|
"ts_code": str(item["identifier"]),
|
|
"limit_type": event,
|
|
}
|
|
for item in payload.get(key) or []
|
|
if item.get("identifier")
|
|
)
|
|
return events, len(covered) / len(expected)
|
|
|
|
|
|
def _normalize_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
result = dict(row)
|
|
for field in ("trade_date", "ann_date", "end_date", "ex_date", "in_date", "out_date"):
|
|
value = str(result.get(field) or "")
|
|
if len(value) == 8 and value.isdigit():
|
|
result[field] = f"{value[:4]}-{value[4:6]}-{value[6:]}"
|
|
if result.get("price") is not None and result.get("pre_close") is not None:
|
|
price = _number(result.get("price"))
|
|
previous = _number(result.get("pre_close"))
|
|
result["change"] = (price / previous - 1) * 100 if price is not None and previous else None
|
|
amount = _number(result.get("amount"))
|
|
result["amount_million"] = amount / 1_000_000 if amount is not None else None
|
|
return result
|
|
|
|
|
|
def _earnings_events(
|
|
forecasts: list[dict[str, Any]], expresses: list[dict[str, Any]], through: str
|
|
) -> list[dict[str, Any]]:
|
|
forecast_map: dict[tuple[str, str], dict[str, Any]] = {}
|
|
for row in forecasts:
|
|
key = (str(row.get("ts_code") or ""), str(row.get("end_date") or ""))
|
|
announced = str(row.get("ann_date") or "")
|
|
current = forecast_map.get(key)
|
|
if (
|
|
all(key)
|
|
and announced
|
|
and announced <= through
|
|
and (current is None or announced > str(current.get("ann_date") or ""))
|
|
):
|
|
forecast_map[key] = row
|
|
result = []
|
|
for row in expresses:
|
|
key = (str(row.get("ts_code") or ""), str(row.get("end_date") or ""))
|
|
announced = str(row.get("ann_date") or "")
|
|
forecast = forecast_map.get(key)
|
|
if forecast is None or not announced or announced > through:
|
|
continue
|
|
values = [
|
|
value
|
|
for value in (
|
|
_number(forecast.get("net_profit_min")),
|
|
_number(forecast.get("net_profit_max")),
|
|
)
|
|
if value is not None
|
|
]
|
|
expected = sum(values) / len(values) if values else None
|
|
actual = _number(row.get("n_income"))
|
|
if expected in (None, 0) or actual is None:
|
|
continue
|
|
if abs(actual) > max(abs(expected), 1) * 100:
|
|
actual /= 10000
|
|
result.append(
|
|
{
|
|
"ts_code": key[0],
|
|
"end_date": key[1],
|
|
"ann_date": announced,
|
|
"surprise_pct": (actual / expected - 1) * 100,
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def _on_date(rows: list[dict[str, Any]], trade_date: str) -> list[dict[str, Any]]:
|
|
return [row for row in rows if str(row.get("trade_date") or "") == trade_date]
|
|
|
|
|
|
def _latest_announced(rows: list[dict[str, 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 "")
|
|
current = result.get(identifier)
|
|
if (
|
|
identifier
|
|
and announced
|
|
and announced <= through
|
|
and (current is None or announced > str(current.get("ann_date") or ""))
|
|
):
|
|
result[identifier] = row
|
|
return result
|
|
|
|
|
|
def _code_date_counts(rows: list[dict[str, Any]]) -> dict[str, int]:
|
|
values: dict[str, set[str]] = {}
|
|
for row in rows:
|
|
identifier = str(row.get("ts_code") or "")
|
|
if identifier:
|
|
values.setdefault(identifier, set()).add(str(row.get("trade_date") or ""))
|
|
return {identifier: len(dates) for identifier, dates in values.items()}
|
|
|
|
|
|
def _active_member(row: dict[str, Any], trade_date: str) -> bool:
|
|
start = str(row.get("in_date") or "")
|
|
end = str(row.get("out_date") or "")
|
|
return (not start or start <= trade_date) and (not end or end > trade_date)
|
|
|
|
|
|
def _merge_events(
|
|
provider: list[dict[str, Any]], local: list[dict[str, Any]]
|
|
) -> list[dict[str, Any]]:
|
|
merged = {
|
|
(str(row.get("trade_date") or ""), str(row.get("ts_code") or "")): row for row in local
|
|
}
|
|
for row in provider:
|
|
merged[(str(row.get("trade_date") or ""), str(row.get("ts_code") or ""))] = row
|
|
return list(merged.values())
|
|
|
|
|
|
def _number(value: Any) -> float | None:
|
|
try:
|
|
result = float(value)
|
|
return result if result == result else None
|
|
except (TypeError, ValueError):
|
|
return None
|