rebuild(stage-11): deliver deterministic heaven workflows
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user