Files
xiaobaifupan/next/backend/data/heaven.py
T

486 lines
18 KiB
Python

from __future__ import annotations
import json
from datetime import datetime, time
from typing import Any
from backend.data.contracts import MarketEntity, ProviderResult, SnapshotState, TradeContext
from backend.data.providers.base import MarketDataProvider
from backend.data.repository import MarketRepository
from backend.data.sentiment import calculate_sentiment
from backend.database.connection import Database
def should_use_realtime(requested_date: str, context: TradeContext, clock: datetime) -> bool:
today = clock.date().isoformat()
if requested_date != today or clock.time() < time(9, 15):
return False
return not (
context.actual_date == today
and context.state in {SnapshotState.FINAL, SnapshotState.ARCHIVE}
)
def historical_payload(
database: Database,
repository: MarketRepository,
stock: MarketEntity,
trade_date: str,
provider: MarketDataProvider,
) -> dict[str, Any]:
raw = provider.heaven_inputs(stock.identifier, trade_date)
daily_rows = _rows(raw, "daily")
basic_rows = _rows(raw, "daily_basic")
member_rows = _rows(raw, "members")
sector_rows = _rows(raw, "sector_daily")
index_rows = _rows(raw, "indices")
quote = next((row for row in daily_rows if row.get("ts_code") == stock.identifier), {})
basic = next((row for row in basic_rows if row.get("ts_code") == stock.identifier), {})
with database.read() as connection:
summary_row = repository.latest_summary(connection, trade_date)
history_rows = repository.summaries(connection, trade_date, 6)
summary = json.loads(str(summary_row["payload_json"])) if summary_row else {}
overview = summary.get("overview") or {}
sentiment = summary.get("sentiment") or {}
event = _stock_event(summary, stock.identifier)
amount = _number(quote.get("amount"))
member_changes = [_number(row.get("change")) for row in member_rows if bool(row.get("quoted"))]
leader = max(
(row for row in member_rows if bool(row.get("quoted"))),
key=lambda row: _number(row.get("change")),
default={},
)
sector_quote = sector_rows[0] if sector_rows else {}
sector_name = str((member_rows[0] if member_rows else {}).get("sector_name") or "")
return {
"trade_date": trade_date,
"mode": "historical",
"stock": {
"identifier": stock.identifier,
"code": stock.code,
"name": stock.name,
"trade_date": _display_date(quote.get("trade_date")),
"quote_kind": "daily",
"change": _optional_number(quote.get("pct_chg")),
"amount_billion": round(amount / 100_000, 4),
"amount_percentile": _percentile_rank(
amount, sorted(_number(row.get("amount")) for row in daily_rows)
),
"turnover_rate": _optional_number(basic.get("turnover_rate")),
"seal_amount_million": _number(event.get("seal_amount")) / 1_000_000,
"open_times": event.get("open_times", 0),
"streak": event.get("streak", 0),
"status": event.get("status", "普通"),
},
"sector": {
"name": sector_name,
"code": str((member_rows[0] if member_rows else {}).get("sector_code") or ""),
"taxonomy": "申万二级" if sector_name else "",
"trade_date": _display_date(sector_quote.get("trade_date")) or trade_date,
"quote_kind": "daily",
"change": _optional_number(sector_quote.get("pct_change")),
"up_count": sum(value > 0 for value in member_changes),
"down_count": sum(value < 0 for value in member_changes),
"member_count": len(member_rows),
"quoted_count": len(member_changes),
"coverage": len(member_changes) / max(len(member_rows), 1),
"member_equal_change": (
sum(member_changes) / len(member_changes) if member_changes else None
),
"leader": str(leader.get("name") or ""),
"leading_pct": _optional_number(leader.get("change")),
},
"market": _market(
trade_date,
"daily",
overview,
sentiment.get("score"),
_history_amounts(history_rows[:-1]),
),
"indices": [
{
"identifier": str(row.get("ts_code") or ""),
"trade_date": _display_date(row.get("trade_date")),
"quote_kind": "daily",
"change": _optional_number(row.get("pct_chg")),
}
for row in index_rows
],
}
def realtime_payload(
database: Database,
repository: MarketRepository,
stock: MarketEntity,
trade_date: str,
previous_trade_date: str,
raw: dict[str, ProviderResult | None],
clock: datetime,
) -> dict[str, Any]:
realtime_rows = [
row
for row in _rows(raw, "realtime")
if _quote_date(row) == trade_date
and _valid_quote(row)
and (clock.time() < time(15) or _quote_time(row) >= "15:00:00")
]
quote_map = {str(row.get("ts_code") or ""): row for row in realtime_rows}
quote = quote_map.get(stock.identifier, {})
members = _rows(raw, "members")
capital = {str(row.get("ts_code") or ""): row for row in _rows(raw, "capital")}
limits = {str(row.get("ts_code") or ""): row for row in _rows(raw, "price_limits")}
suspended = {str(row.get("ts_code") or "") for row in _rows(raw, "suspensions")}
market_rows = [row for row in realtime_rows if str(row.get("ts_code") or "") in capital]
market_turnover = _average(_turnovers(market_rows, capital))
stock_turnover = _turnover(quote, capital.get(stock.identifier, {}))
history_volumes = sorted(
(
(_display_date(row.get("trade_date")), _number(row.get("vol")))
for row in _rows(raw, "stock_history")
if _number(row.get("vol")) > 0
),
key=lambda item: item[0],
)[-5:]
average_volume = _average([value for _, value in history_volumes])
activity = (
_number(quote.get("vol")) / 100 / (average_volume * _session_progress(clock.time()))
if average_volume
else 0
)
with database.read() as connection:
history_rows = repository.summaries(connection, previous_trade_date, 250)
history = [json.loads(str(row["payload_json"])) for row in history_rows]
prior = history[-1] if history else {}
current_limits = _current_limits(market_rows, limits, prior)
overview = _realtime_overview(market_rows, current_limits)
yesterday = _yesterday(prior.get("limits") or [], quote_map, current_limits)
sentiment = calculate_sentiment(
{"overview": overview, "limits": current_limits["up"], "yesterday_limits": yesterday},
history,
)
member_codes = [str(row.get("ts_code") or "") for row in members]
member_quotes = [quote_map[code] for code in member_codes if code in quote_map]
explained = len(member_quotes) + sum(code in suspended for code in member_codes)
changes = [_quote_change(row) for row in member_quotes]
leader = max(member_quotes, key=_quote_change, default={})
sector_quote = next(
(
row
for row in _rows(raw, "sector_realtime")
if _quote_date(row) == trade_date
and (clock.time() < time(15) or _quote_time(row) >= "15:00:00")
),
{},
)
sector_turnover = _average(_turnovers(member_quotes, capital))
sector_name = str((members[0] if members else {}).get("sector_name") or "")
amount = _number(quote.get("amount"))
status, streak = _status(stock.identifier, quote, limits, prior)
return {
"trade_date": trade_date,
"mode": "intraday",
"stock": {
"identifier": stock.identifier,
"code": stock.code,
"name": stock.name,
"trade_date": _quote_date(quote),
"quote_kind": "realtime",
"change": _optional_number(_quote_change(quote)) if quote else None,
"amount_billion": round(amount / 100_000_000, 4),
"amount_percentile": _percentile_rank(
amount, sorted(_number(row.get("amount")) for row in market_rows)
),
"turnover_rate": stock_turnover or None,
"turnover_relative": stock_turnover / market_turnover if market_turnover else None,
"volume_activity_ratio": activity or None,
"seal_amount_million": 0,
"open_times": 0,
"streak": streak,
"status": status,
},
"sector": {
"name": sector_name,
"code": str((members[0] if members else {}).get("sector_code") or ""),
"taxonomy": "申万二级" if sector_name else "",
"trade_date": _quote_date(sector_quote),
"quote_kind": "realtime",
"change": _optional_number(_quote_change(sector_quote)) if sector_quote else None,
"up_count": sum(value > 0 for value in changes),
"down_count": sum(value < 0 for value in changes),
"member_count": len(member_codes),
"quoted_count": explained,
"coverage": explained / max(len(member_codes), 1),
"member_equal_change": _average(changes) if changes else None,
"relative_turnover": (sector_turnover / market_turnover if market_turnover else None),
"leader": str(leader.get("name") or ""),
"leading_pct": _optional_number(_quote_change(leader)) if leader else None,
},
"market": _market(
trade_date,
"realtime",
overview,
sentiment.get("score"),
_history_amounts(history_rows),
),
"indices": [
{
"identifier": identifier,
"trade_date": _quote_date(quote_map.get(identifier, {})),
"quote_kind": "realtime",
"change": (
_optional_number(_quote_change(quote_map[identifier]))
if identifier in quote_map
else None
),
}
for identifier in ("000001.SH", "399001.SZ", "399006.SZ")
],
}
def _rows(values: dict[str, ProviderResult | None], key: str) -> tuple[dict[str, Any], ...]:
result = values.get(key)
return result.rows if isinstance(result, ProviderResult) else ()
def _history_amounts(rows: tuple[Any, ...]) -> list[float]:
return [
_number((json.loads(str(row["payload_json"])).get("overview") or {}).get("amount"))
for row in rows
if row["payload_json"]
]
def _market(
trade_date: str,
quote_kind: str,
overview: dict[str, Any],
sentiment_score: Any,
history_amounts: list[float],
) -> dict[str, Any]:
amount = _number(overview.get("amount"))
average = _average(history_amounts[-5:]) if history_amounts else amount
return {
"trade_date": trade_date,
"quote_kind": quote_kind,
"sentiment_score": _optional_number(sentiment_score),
"seal_rate": _optional_number(overview.get("seal_rate")),
"amount_billion": amount / 100_000_000,
"average_amount_billion": average / 100_000_000,
"up_count": overview.get("up_count"),
"down_count": overview.get("down_count"),
"limit_up_count": overview.get("limit_up"),
"limit_down_count": overview.get("limit_down"),
}
def _stock_event(summary: dict[str, Any], identifier: str) -> dict[str, Any]:
for key in ("limits", "broken", "down_limits"):
for row in summary.get(key) or []:
if str(row.get("identifier") or "") == identifier:
return dict(row)
return {"status": "普通", "streak": 0, "open_times": 0, "seal_amount": 0}
def _quote_date(row: dict[str, Any]) -> str:
return _display_date(row.get("trade_time") or row.get("trade_date"))
def _quote_time(row: dict[str, Any]) -> str:
value = str(row.get("trade_time") or "")
if " " in value:
return value.split(" ", 1)[1][:8]
compact = "".join(character for character in value if character.isdigit())
return f"{compact[8:10]}:{compact[10:12]}:{compact[12:14]}" if len(compact) >= 14 else ""
def _valid_quote(row: dict[str, Any]) -> bool:
return _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0
def _quote_change(row: dict[str, Any]) -> float:
for key in ("pct_chg", "pct_change"):
value = _optional_number(row.get(key))
if value is not None:
return value
close = _number(row.get("close"))
previous = _number(row.get("pre_close"))
return (close / previous - 1) * 100 if close and previous else 0
def _turnover(row: dict[str, Any], capital: dict[str, Any]) -> float:
float_share = _number(capital.get("float_share"))
return _number(row.get("vol")) / float_share / 100 if float_share else 0
def _turnovers(rows: list[dict[str, Any]], capital: dict[str, dict[str, Any]]) -> list[float]:
values = [_turnover(row, capital.get(str(row.get("ts_code") or ""), {})) for row in rows]
return [value for value in values if value > 0]
def _session_progress(current: time) -> float:
if current <= time(9, 30):
return 0.05
if current <= time(11, 30):
return max(0.05, min(0.5, (current.hour * 60 + current.minute - 570) / 240))
if current < time(13):
return 0.5
if current <= time(15):
return max(0.5, min(1.0, 0.5 + (current.hour * 60 + current.minute - 780) / 240))
return 1.0
def _at_price(value: Any, target: Any) -> bool:
price = _number(value)
limit = _number(target)
return bool(limit and abs(price - limit) <= max(0.005, limit * 0.0002))
def _prior_limits(summary: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {
str(row.get("identifier") or ""): row
for row in summary.get("limits") or []
if row.get("identifier")
}
def _current_limits(
market_rows: list[dict[str, Any]],
price_limits: dict[str, dict[str, Any]],
prior: dict[str, Any],
) -> dict[str, list[dict[str, Any]]]:
result: dict[str, list[dict[str, Any]]] = {"up": [], "down": [], "broken": []}
prior_map = _prior_limits(prior)
for quote in market_rows:
identifier = str(quote.get("ts_code") or "")
bounds = price_limits.get(identifier, {})
is_up = _at_price(quote.get("close"), bounds.get("up_limit"))
is_down = _at_price(quote.get("close"), bounds.get("down_limit"))
touched = (
_number(bounds.get("up_limit")) > 0
and _number(quote.get("high")) >= _number(bounds.get("up_limit")) * 0.9998
)
previous_streak = int(_number(prior_map.get(identifier, {}).get("streak")))
row = {
"identifier": identifier,
"code": identifier.split(".")[0],
"name": str(quote.get("name") or "").strip(),
"amount": _number(quote.get("amount")),
"change": _quote_change(quote),
"streak": previous_streak + 1 if is_up and previous_streak else 1 if is_up else 0,
}
if is_up:
result["up"].append(row)
elif is_down:
result["down"].append(row)
elif touched:
result["broken"].append(row)
return result
def _realtime_overview(
market_rows: list[dict[str, Any]], limit_rows: dict[str, list[dict[str, Any]]]
) -> dict[str, Any]:
changes = [_quote_change(row) for row in market_rows]
up_count = sum(value > 0 for value in changes)
down_count = sum(value < 0 for value in changes)
limits = len(limit_rows["up"])
broken = len(limit_rows["broken"])
return {
"up_count": up_count,
"down_count": down_count,
"flat_count": len(changes) - up_count - down_count,
"limit_up": limits,
"limit_down": len(limit_rows["down"]),
"broken": broken,
"seal_rate": round(limits / max(limits + broken, 1) * 100, 1),
"amount": sum(_number(row.get("amount")) for row in market_rows),
}
def _yesterday(
previous_limits: list[dict[str, Any]],
quotes: dict[str, dict[str, Any]],
current: dict[str, list[dict[str, Any]]],
) -> list[dict[str, Any]]:
up_codes = {str(row["identifier"]) for row in current["up"]}
broken_codes = {str(row["identifier"]) for row in current["broken"]}
down_codes = {str(row["identifier"]) for row in current["down"]}
rows = []
for previous in previous_limits:
identifier = str(previous.get("identifier") or "")
quote = quotes.get(identifier)
if not quote:
continue
change = _quote_change(quote)
outcome = (
"晋级"
if identifier in up_codes
else "炸板"
if identifier in broken_codes
else "跌停"
if identifier in down_codes
else "红盘"
if change > 0
else "断板"
)
rows.append({"identifier": identifier, "current_change": change, "outcome": outcome})
return rows
def _status(
identifier: str,
quote: dict[str, Any],
price_limits: dict[str, dict[str, Any]],
prior: dict[str, Any],
) -> tuple[str, int]:
if not quote:
return "普通", 0
bounds = price_limits.get(identifier, {})
if _at_price(quote.get("close"), bounds.get("up_limit")):
return "涨停", int(_number(_prior_limits(prior).get(identifier, {}).get("streak"))) + 1
if _at_price(quote.get("close"), bounds.get("down_limit")):
return "跌停", 0
if (
_number(bounds.get("up_limit"))
and _number(quote.get("high")) >= _number(bounds.get("up_limit")) * 0.9998
):
return "炸板", 0
return "普通", 0
def _average(values: list[float]) -> float:
return sum(values) / len(values) if values else 0
def _number(value: Any) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def _optional_number(value: Any) -> float | None:
if value is None or value == "":
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number == number else None
def _display_date(value: Any) -> str:
compact = str(value or "").replace("-", "")[:8]
if len(compact) != 8 or not compact.isdigit():
return ""
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
def _percentile_rank(value: float, ordered: list[float]) -> float | None:
valid = [item for item in ordered if item > 0]
if value <= 0 or not valid:
return None
return round(sum(item <= value for item in valid) / len(valid) * 100, 2)