rebuild(stage-11): deliver deterministic heaven workflows

This commit is contained in:
leefer
2026-07-30 07:08:13 +08:00
parent aa3f02bd59
commit 35ae079de7
49 changed files with 7208 additions and 39 deletions
+59 -2
View File
@@ -16,6 +16,7 @@ from backend.data.contracts import (
SnapshotState,
TradeContext,
)
from backend.data.heaven import historical_payload, realtime_payload, should_use_realtime
from backend.data.policy import DataSourcePolicy
from backend.data.providers.base import MarketDataProvider, ProviderError
from backend.data.quality import DataQualityError, require_quality
@@ -217,6 +218,34 @@ class DataGateway:
)
return payload
def heaven_trend_inputs(
self, query: str, requested_date: str, now: datetime | None = None
) -> dict[str, Any]:
clock = now or datetime.now(SHANGHAI)
context = self.trade_context(requested_date, clock)
if context.actual_date is None:
raise MarketDataUnavailable("等待管理员首次同步真实收盘行情")
stock = self._resolve_stock_query(query)
provider = self._provider(DataSource.TUSHARE)
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
if should_use_realtime(requested_date, context, clock):
dates = self.trading_dates(requested_date, 2)
if len(dates) < 2 or dates[0] != requested_date:
raise MarketDataUnavailable("目标日期不是有效交易日")
raw = provider.heaven_realtime_inputs(stock.identifier, dates[0], dates[1])
return realtime_payload(
self._database,
self._repository,
stock,
dates[0],
dates[1],
raw,
clock,
)
return historical_payload(
self._database, self._repository, stock, context.actual_date, provider
)
def search(self, query: str) -> tuple[MarketEntity, ...]:
with self._database.read() as connection:
return self._repository.search(connection, query)
@@ -310,6 +339,29 @@ class DataGateway:
return MarketEntity("stock", f"{normalized}.{suffix}", normalized, normalized)
raise MarketDataUnavailable("未找到该行情标的")
def _resolve_stock_query(self, query: str) -> MarketEntity:
normalized = query.strip()
if not normalized:
raise MarketDataUnavailable("请输入股票代码或股票名称")
with self._database.read() as connection:
matches = tuple(
item
for item in self._repository.search(connection, normalized, 16)
if item.entity_type == "stock"
)
exact = [
item
for item in matches
if item.code.casefold() == normalized.casefold()
or item.identifier.casefold() == normalized.casefold()
or item.name.casefold() == normalized.casefold()
]
if len(exact) == 1:
return exact[0]
if len(exact) > 1:
raise MarketDataUnavailable("股票名称存在重名,请输入六位代码")
raise MarketDataUnavailable("未找到该股票,请检查代码或名称")
def _save_chart(self, series: ChartSeries) -> None:
payload = {
"previous_close": series.previous_close,
@@ -485,5 +537,10 @@ def _number(value: Any) -> float:
def _optional_number(value: Any) -> float | None:
number = _number(value)
return number if number > 0 else 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
+485
View File
@@ -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)
+8
View File
@@ -29,6 +29,14 @@ class MarketDataProvider(Protocol):
def sector_members(self, representative: str, trade_date: str) -> ProviderResult: ...
def heaven_inputs(
self, representative: str, trade_date: str
) -> dict[str, ProviderResult | None]: ...
def heaven_realtime_inputs(
self, representative: str, trade_date: str, previous_trade_date: str
) -> dict[str, ProviderResult | None]: ...
def market_insight(
self,
kind: str,
+10
View File
@@ -102,6 +102,16 @@ class EastmoneyProvider:
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
raise ProviderError("The display provider is not the constituent authority")
def heaven_inputs(
self, representative: str, trade_date: str
) -> dict[str, ProviderResult | None]:
raise ProviderError("The display provider cannot supply deterministic Heaven inputs")
def heaven_realtime_inputs(
self, representative: str, trade_date: str, previous_trade_date: str
) -> dict[str, ProviderResult | None]:
raise ProviderError("The display provider cannot supply deterministic Heaven inputs")
def market_insight(
self,
kind: str,
+10
View File
@@ -92,6 +92,16 @@ class IfindProvider:
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
raise ProviderError("iFinD is not the Shenwan constituent authority")
def heaven_inputs(
self, representative: str, trade_date: str
) -> dict[str, ProviderResult | None]:
raise ProviderError("iFinD deterministic Heaven inputs are not enabled")
def heaven_realtime_inputs(
self, representative: str, trade_date: str, previous_trade_date: str
) -> dict[str, ProviderResult | None]:
raise ProviderError("iFinD deterministic Heaven inputs are not enabled")
def market_insight(
self,
kind: str,
+145 -29
View File
@@ -132,36 +132,9 @@ class TushareProvider:
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
target = _compact(trade_date)
memberships = self._membership_rows({"ts_code": representative})
active = [row for row in memberships if _active_on(row, target)]
if not active:
raise ProviderError("未找到该股票在目标日期的申万行业")
industry = max(
active,
key=lambda row: (
str(row.get("in_date") or ""),
str(row.get("l2_code") or ""),
),
)
industry, members = self._sector_memberships(representative, target)
sector_code = str(industry.get("l2_code") or "")
sector_name = str(industry.get("l2_name") or "").strip()
if not sector_code:
raise ProviderError("该股票缺少申万二级行业")
members = [
row
for row in self._membership_rows({"l2_code": sector_code})
if _active_on(row, target)
]
deduplicated: dict[str, dict[str, Any]] = {}
for row in members:
code = str(row.get("ts_code") or "")
current = deduplicated.get(code)
if code and (
current is None or str(row.get("in_date") or "") > str(current.get("in_date") or "")
):
deduplicated[code] = row
if not deduplicated:
raise ProviderError("该申万行业没有有效成分股")
daily = self._query(
"daily",
{"trade_date": target},
@@ -170,7 +143,8 @@ class TushareProvider:
)
quote_map = {str(row.get("ts_code") or ""): row for row in daily.rows}
rows = []
for code, member in deduplicated.items():
for member in members:
code = str(member.get("ts_code") or "")
quote = quote_map.get(code) or {}
rows.append(
{
@@ -188,6 +162,114 @@ class TushareProvider:
coverage = sum(bool(row["quoted"]) for row in rows) / len(rows)
return ProviderResult(tuple(rows), _metadata(self.source, "mixed", coverage))
def heaven_inputs(
self, representative: str, trade_date: str
) -> dict[str, ProviderResult | None]:
target = _compact(trade_date)
membership = self.sector_members(representative, trade_date)
sector_code = str(membership.rows[0].get("sector_code") or "") if membership.rows else ""
return {
"members": membership,
"daily": self._optional_query(
"daily",
{"trade_date": target},
"ts_code,trade_date,open,high,low,close,pre_close,pct_chg,vol,amount",
),
"daily_basic": self._optional_query(
"daily_basic",
{"trade_date": target},
"ts_code,trade_date,turnover_rate,volume_ratio,total_mv,circ_mv",
),
"sector_daily": self._optional_query(
"sw_daily",
{"ts_code": sector_code, "trade_date": target},
"ts_code,trade_date,name,open,high,low,close,pct_change,vol,amount,pe,pb,float_mv,total_mv",
),
"indices": self._index_rows(target),
}
def heaven_realtime_inputs(
self, representative: str, trade_date: str, previous_trade_date: str
) -> dict[str, ProviderResult | None]:
target = _compact(trade_date)
previous = _compact(previous_trade_date)
industry, members = self._sector_memberships(representative, target)
sector_code = str(industry.get("l2_code") or "")
directory = self._optional_query(
"stock_basic",
{"exchange": "", "list_status": "L"},
"ts_code,name,industry,market,list_date",
)
active_codes = tuple(
str(row.get("ts_code") or "")
for row in (directory.rows if directory else ())
if row.get("ts_code")
)
realtime_codes = (*active_codes, "000001.SH", "399001.SZ", "399006.SZ")
member_rows = tuple(
{
"sector_code": sector_code,
"sector_name": str(industry.get("l2_name") or "").strip(),
"ts_code": str(row.get("ts_code") or ""),
"name": str(row.get("name") or "").strip(),
}
for row in members
)
start = (datetime.strptime(target, "%Y%m%d") - timedelta(days=35)).strftime("%Y%m%d")
return {
"directory": directory,
"members": ProviderResult(
member_rows,
_metadata(self.source, "membership", 1 if member_rows else 0),
),
"realtime": self._optional_query(
"rt_k",
{"ts_code": ",".join(realtime_codes)},
"ts_code,name,trade_time,open,high,low,close,pre_close,vol,amount,num,pct_chg",
),
"capital": self._optional_query(
"daily_basic",
{"trade_date": previous},
"ts_code,trade_date,total_share,float_share,free_share,total_mv,circ_mv",
),
"stock_history": self._optional_query(
"daily",
{"ts_code": representative, "start_date": start, "end_date": previous},
"ts_code,trade_date,vol,amount",
),
"price_limits": self._optional_query(
"stk_limit",
{"trade_date": target},
"ts_code,trade_date,up_limit,down_limit",
),
"suspensions": self._optional_query(
"suspend_d",
{"suspend_date": target},
"ts_code,suspend_date,resume_date,suspend_timing,suspend_type",
),
"sector_realtime": self._optional_query(
"rt_sw_k",
{"ts_code": sector_code},
"ts_code,name,trade_time,close,pre_close,high,open,low,vol,amount,pct_change",
),
}
def _index_rows(self, trade_date: str) -> ProviderResult | None:
rows: list[dict[str, Any]] = []
completed = 0
for identifier in ("000001.SH", "399001.SZ", "399006.SZ"):
result = self._optional_query(
"index_daily",
{"ts_code": identifier, "trade_date": trade_date},
"ts_code,trade_date,close,pre_close,pct_chg",
)
if result is not None and result.rows:
rows.extend(result.rows)
completed += 1
if not rows:
return None
return ProviderResult(tuple(rows), _metadata(self.source, "percent", completed / 3))
def market_insight(
self,
kind: str,
@@ -443,6 +525,40 @@ class TushareProvider:
rows.extend(result.rows)
return rows
def _sector_memberships(
self, representative: str, trade_date: str
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
active = [
row
for row in self._membership_rows({"ts_code": representative})
if _active_on(row, trade_date)
]
if not active:
raise ProviderError("未找到该股票在目标日期的申万行业")
industry = max(
active,
key=lambda row: (str(row.get("in_date") or ""), str(row.get("l2_code") or "")),
)
sector_code = str(industry.get("l2_code") or "")
if not sector_code:
raise ProviderError("该股票缺少申万二级行业")
rows = [
row
for row in self._membership_rows({"l2_code": sector_code})
if _active_on(row, trade_date)
]
deduplicated: dict[str, dict[str, Any]] = {}
for row in rows:
code = str(row.get("ts_code") or "")
current = deduplicated.get(code)
if code and (
current is None or str(row.get("in_date") or "") > str(current.get("in_date") or "")
):
deduplicated[code] = row
if not deduplicated:
raise ProviderError("该申万行业没有有效成分股")
return industry, list(deduplicated.values())
def _query(
self,
api_name: str,
+270
View File
@@ -0,0 +1,270 @@
from __future__ import annotations
from statistics import mean, median
from typing import Any
WEIGHTS = {
"breadth": 20,
"limit_ecology": 25,
"profit_effect": 30,
"ladder_structure": 15,
"liquidity": 10,
}
def calculate_sentiment(snapshot: dict[str, Any], history: list[dict[str, Any]]) -> dict[str, Any]:
stats = _stats(snapshot)
historical = [_stats(item) for item in history[-250:]]
breadth = _clamp(stats["breadth_ratio"])
limit_strength = _adaptive(
stats["limit_up"], _linear(stats["limit_up"], 10, 100), _series(historical, "limit_up")
)
down_pressure = _adaptive(
stats["limit_down"], _linear(stats["limit_down"], 0, 50), _series(historical, "limit_down")
)
down_relief = 100 - down_pressure
seal_quality = _linear(stats["seal_rate"], 35, 90)
ecology = limit_strength * 0.35 + seal_quality * 0.35 + down_relief * 0.30
systemic_health = breadth * 0.60 + down_relief * 0.40
gate = 1 if systemic_health >= 35 else 0.35 + systemic_health / 35 * 0.65
profit = _profit(stats)
max_height = _adaptive(
stats["max_height"],
_linear(stats["max_height"], 1, 7),
_series(historical, "max_height"),
)
continuation = (stats["second_board"] + stats["three_plus"]) / max(stats["limit_up"], 1) * 100
three_density = stats["three_plus"] / max(stats["limit_up"], 1) * 100
three_score = _adaptive(
stats["three_plus"], _clamp(three_density * 5), _series(historical, "three_plus")
)
ladder = (
max_height * 0.30
+ _clamp(continuation * 3) * 0.25
+ three_score * 0.25
+ stats["ladder_completeness"] * 0.20
)
prior_amounts = [item["amount"] for item in historical[-20:] if item["amount"] > 0]
baseline = mean(prior_amounts) if prior_amounts else stats["amount"] or 1
amount_ratio = stats["amount"] / max(baseline, 1)
amount_score = _clamp(50 + (amount_ratio - 1) * 100)
limit_share = stats["limit_amount"] / max(stats["amount"], 1) * 100
liquidity = amount_score * 0.70 + _clamp(limit_share * 20) * 0.30
components = {
"breadth": breadth,
"limit_ecology": ecology,
"profit_effect": profit,
"ladder_structure": ladder,
"liquidity": liquidity,
}
score = round(sum(components[key] * weight / 100 for key, weight in WEIGHTS.items()) * gate)
extreme = stats["breadth_ratio"] <= 15 and stats["limit_down"] >= 100
if extreme:
score = min(score, 15)
elif stats["breadth_ratio"] <= 25 and stats["limit_down"] >= 50:
score = min(score, 24)
prior_sentiments = [item.get("sentiment") or {} for item in history[-3:]]
prior_scores = [
float(item["score"]) for item in prior_sentiments if item.get("score") is not None
]
momentum = score - mean(prior_scores) if prior_scores else 0
direction = "升温" if momentum > 3 else "降温" if momentum < -3 else "持平"
previous = prior_sentiments[-1] if prior_sentiments else None
day_change = (
score - float(previous["score"]) if previous and previous.get("score") is not None else 0
)
signal = _phase_signal(score, momentum, profit)
phase, reason = _phase(
previous, score, day_change, systemic_health, profit, ecology, signal, extreme
)
fermentation_ready = signal == "发酵" and score >= 45 and profit >= 45 and systemic_health >= 35
previous_count = int(previous.get("fermentation_signal_count") or 0) if previous else 0
fermentation_count = previous_count + 1 if fermentation_ready else 0
history_days = len(history)
confidence = min(95, round(55 + min(history_days, 20) * 1.25 + (15 if phase == signal else 7)))
labels = {
"breadth": "市场宽度",
"limit_ecology": "涨停生态",
"profit_effect": "赚钱效应",
"ladder_structure": "连板结构",
"liquidity": "成交活跃度",
}
return {
"score": score,
"label": _label(score),
"direction": direction,
"momentum": round(momentum, 1),
"day_change": round(day_change, 1),
"phase": phase,
"phase_signal": signal,
"transition_reason": reason,
"fermentation_signal_count": fermentation_count,
"confidence": confidence,
"history_days": history_days,
"systemic_health": round(systemic_health, 1),
"components": [
{
"key": key,
"label": labels[key],
"score": round(value, 1),
"weight": WEIGHTS[key],
}
for key, value in components.items()
],
"stats": stats,
}
def _stats(snapshot: dict[str, Any]) -> dict[str, float]:
overview = snapshot.get("overview") or {}
limits = snapshot.get("limits") or []
yesterday = snapshot.get("yesterday_limits") or []
streaks = [max(1, int(_number(row.get("streak"), 1))) for row in limits]
levels = set(streaks)
max_height = max(streaks, default=0)
active = _number(overview.get("up_count")) + _number(overview.get("down_count"))
changes = [_number(row.get("current_change")) for row in yesterday]
previous_count = len(yesterday)
return {
"breadth_ratio": _number(overview.get("up_count")) / max(active, 1) * 100,
"limit_up": _number(overview.get("limit_up")),
"limit_down": _number(overview.get("limit_down")),
"broken": _number(overview.get("broken")),
"seal_rate": _number(overview.get("seal_rate")),
"amount": _number(overview.get("amount")),
"limit_amount": sum(_number(row.get("amount")) for row in limits),
"second_board": sum(streak == 2 for streak in streaks),
"three_plus": sum(streak >= 3 for streak in streaks),
"max_height": max_height,
"ladder_completeness": (
sum(level in levels for level in range(1, max_height + 1)) / max_height * 100
if max_height
else 0
),
"previous_count": previous_count,
"positive_rate": sum(change > 0 for change in changes) / max(previous_count, 1) * 100,
"advance_rate": sum(row.get("outcome") == "晋级" for row in yesterday)
/ max(previous_count, 1)
* 100,
"average_change": mean(changes) if changes else 0,
"median_change": median(changes) if changes else 0,
"severe_loss_rate": sum(change <= -5 for change in changes) / max(previous_count, 1) * 100,
"previous_down_rate": sum(row.get("outcome") == "跌停" for row in yesterday)
/ max(previous_count, 1)
* 100,
}
def _profit(stats: dict[str, float]) -> float:
if not stats["previous_count"]:
return 50
median_score = _clamp(50 + stats["median_change"] * 7)
average_score = _clamp(50 + stats["average_change"] * 6)
advance_score = _clamp(stats["advance_rate"] * 2.5)
loss_safety = _clamp(100 - stats["severe_loss_rate"] * 3)
down_safety = _clamp(100 - stats["previous_down_rate"] * 7)
tail = loss_safety * 0.70 + down_safety * 0.30
return (
stats["positive_rate"] * 0.30
+ median_score * 0.25
+ average_score * 0.10
+ advance_score * 0.20
+ tail * 0.15
)
def _phase_signal(score: float, momentum: float, profit: float) -> str:
if score < 25:
return "修复" if momentum > 3 else "冰点"
if score < 45:
return "修复" if momentum > 3 else "退潮"
if score >= 80:
return "高潮" if momentum >= -2 and profit >= 60 else "分化"
if score >= 65:
return "分化" if momentum < -3 or profit < 50 else "发酵"
if momentum < -5:
return "退潮"
return "发酵" if momentum >= 0 and profit >= 45 else "分化"
def _phase(previous, score, change, health, profit, ecology, signal, extreme):
if not previous:
return signal, "首个连续交易日,采用原始阶段信号"
prior = str(previous.get("phase") or signal)
if extreme:
return "冰点", "市场宽度与跌停数量触发极端冰点"
recovery = change >= 6 and score >= 25 and health >= 24
climax = score >= 80 and profit >= 60 and health >= 60 and ecology >= 70
if prior in {"冰点", "退潮"}:
if score < 25:
return "冰点", "市场仍处于冰点区间"
return ("修复", "出现有效回升") if recovery else (prior, "尚未形成有效修复")
if prior == "修复":
if score < 25:
return "冰点", "修复失败并跌入冰点"
if change <= -6 and score < 45:
return "退潮", "修复失败且显著降温"
prior_signal = int(previous.get("fermentation_signal_count") or 0)
if signal == "发酵" and prior_signal >= 1:
return "发酵", "发酵条件连续两个交易日成立"
return "修复", "修复延续,等待发酵确认"
if prior == "发酵":
if score < 45 and (change < 0 or health < 35):
return "退潮", "温度与系统健康度转弱"
if climax:
return "高潮", "温度、赚钱效应与涨停生态达到高潮条件"
return ("分化", "发酵阶段出现降温") if signal in {"分化", "退潮"} else ("发酵", "发酵延续")
if prior == "高潮":
if climax:
return "高潮", "高潮条件继续成立"
return ("退潮", "风险快速释放") if score < 45 or health < 30 else ("分化", "高潮条件消退")
if score < 25:
return "冰点", "分化继续恶化至冰点"
if score < 45 or health < 30:
return "退潮", "分化后继续转弱"
return "分化", "分化延续,等待方向确认"
def _label(score: float) -> str:
if score >= 80:
return "情绪高涨"
if score >= 60:
return "情绪偏强"
if score >= 40:
return "情绪中性"
if score >= 20:
return "情绪偏弱"
return "情绪冰点"
def _series(rows: list[dict[str, float]], key: str) -> list[float]:
return [row[key] for row in rows]
def _adaptive(value: float, fixed: float, history: list[float]) -> float:
if len(history) < 20:
return fixed
below = sum(item < value for item in history[-250:])
equal = sum(item == value for item in history[-250:])
percentile = (below + equal * 0.5) / len(history[-250:]) * 100
return fixed * 0.25 + percentile * 0.75
def _linear(value: float, low: float, high: float) -> float:
return _clamp((value - low) / (high - low) * 100) if high > low else 50
def _clamp(value: float) -> float:
return min(100, max(0, value))
def _number(value: Any, default: float = 0.0) -> float:
try:
number = float(value)
return number if number == number else default
except (TypeError, ValueError):
return default