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