486 lines
18 KiB
Python
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)
|