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)