migration: preserve market data and search slice

This commit is contained in:
leefer
2026-07-31 01:03:43 +08:00
parent 982af4ba92
commit ac94f5285e
22 changed files with 4956 additions and 4628 deletions
+4 -927
View File
@@ -18,7 +18,7 @@ from backend.bootstrap.container import build_application_container
from backend.bootstrap.settings import load_runtime_settings
from backend.http import HttpTransportMixin
from backend.llm import LLMGateway, LLMGatewayError
from chart_data_provider import ChartDataError
from backend.features.market import ChartDataError, MarketServiceMixin
from backend.bootstrap.config import (
DATA_DIR,
MENTOR_SKILLS_DIR,
@@ -39,7 +39,7 @@ from heaven_engine import (
build_personal_field,
hexagram_from_lines,
)
from ifind_client import IfindError
from backend.data.providers.ifind_client import IfindError
from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection
from mentor_agent import MentorAgentError, stream_with_mentor
from market_insights import MarketInsightsService
@@ -61,7 +61,7 @@ from sentiment_engine import (
build_sentiment_history,
latest_contiguous_history,
)
from tushare_client import TushareClient, TushareError, _sector_coverage_issue
from backend.data.providers.tushare_client import TushareClient, TushareError, _sector_coverage_issue
SCREENER_LIBRARY_VERSION = 8
@@ -103,23 +103,6 @@ LEGACY_SECRET_KEYS = {
"LLM_FALLBACK_MODEL",
}
SEARCH_INDEXES = (
{"id": "000001.SH", "code": "000001.SH", "name": "上证指数", "type": "index", "subtitle": "沪市综合指数"},
{"id": "399001.SZ", "code": "399001.SZ", "name": "深证成指", "type": "index", "subtitle": "深市成份指数"},
{"id": "399006.SZ", "code": "399006.SZ", "name": "创业板指", "type": "index", "subtitle": "创业板核心指数"},
)
SEARCH_TYPE_LABELS = {
"stock": "股票",
"sector": "板块",
"theme": "题材",
"index": "指数",
}
THS_SEARCH_TYPES = {
"I": ("sector", "行业板块"),
"R": ("sector", "地域板块"),
"N": ("theme", "概念题材"),
}
MENTOR_DATA_PROFILES = {
"emotion": {
"kobe92-perspective", "niepanchongsheng-perspective",
@@ -158,7 +141,7 @@ MENTOR_ETF_UNIVERSE = (
)
class DashboardService:
class DashboardService(MarketServiceMixin):
def __init__(self) -> None:
runtime = load_runtime_settings()
self.vault = SecretVault(runtime.encryption_key)
@@ -213,12 +196,6 @@ class DashboardService:
initial_delay_seconds=3,
)
def _tushare_client(self) -> TushareClient:
gateway = getattr(self, "data_gateway", None)
if gateway is not None:
return gateway.tushare()
# Compatibility for isolated legacy unit-test service stubs.
return TushareClient(self.token)
def _load_system_credentials(self, environment: dict[str, str]) -> dict[str, Any]:
encrypted = self.database.get_system_setting("credentials")
@@ -756,181 +733,6 @@ class DashboardService:
def _public_personal_profile(personal: dict[str, Any]) -> dict[str, Any]:
return AccountService.public_personal_profile(personal)
def get_dashboard(self, trade_date: str, force: bool = False) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
now = datetime.now().astimezone()
if (
normalized_date == now.strftime("%Y%m%d")
and now.time().replace(tzinfo=None) < datetime.strptime("09:15", "%H:%M").time()
):
previous = self.database.get_latest_real_snapshot(normalized_date, strictly_before=True)
if previous:
carried = self._carry_dashboard(previous, normalized_date, "盘前沿用最近交易日收盘行情")
return self._apply_reason_overrides(self._with_storage(carried, cached=True))
if not force:
snapshot = self.database.get_snapshot(normalized_date)
if snapshot and str((snapshot.get("meta") or {}).get("source") or "") != "demo":
snapshot = copy.deepcopy(snapshot)
if normalized_date != now.strftime("%Y%m%d"):
snapshot.setdefault("meta", {}).update(
{"realtime": False, "market_status": "closed"}
)
if not self._dashboard_sentiment_ready(snapshot):
snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date)
self.database.save_snapshot(
normalized_date,
str((snapshot.get("meta") or {}).get("source") or "tushare"),
snapshot,
)
snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date)
return self._apply_reason_overrides(self._with_storage(snapshot, cached=True))
resolved = self.database.get_data_snapshot(
"dashboard_request_v1", normalized_date
)
if resolved and str((resolved.get("meta") or {}).get("source") or "") != "demo":
resolved = copy.deepcopy(resolved)
resolved.setdefault("meta", {})["requested_date"] = self._display_compact_date(
normalized_date
)
return self._apply_reason_overrides(
self._with_storage(resolved, cached=True)
)
if datetime.strptime(normalized_date, "%Y%m%d").weekday() >= 5:
previous = self.database.get_latest_real_snapshot(normalized_date)
if previous:
carried = self._carry_dashboard(
previous,
normalized_date,
"非交易日沿用最近交易日收盘行情",
)
self.database.save_data_snapshot(
"dashboard_request_v1", normalized_date, "sqlite", carried
)
return self._apply_reason_overrides(
self._with_storage(carried, cached=True)
)
return self.sync_dashboard(normalized_date)
@staticmethod
def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool:
overview = dashboard.get("overview") or {}
return int(overview.get("sentiment_engine_version") or 0) == SENTIMENT_ENGINE_VERSION and all(
key in overview
for key in (
"sentiment_score",
"sentiment_label",
"sentiment_phase",
"sentiment_direction",
"sentiment_components",
)
)
@staticmethod
def _display_compact_date(compact: str) -> str:
return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}"
def _carry_dashboard(
self, snapshot: dict[str, Any], requested_date: str, reason: str
) -> dict[str, Any]:
carried = copy.deepcopy(snapshot)
meta = carried.setdefault("meta", {})
meta.update(
{
"requested_date": self._display_compact_date(requested_date),
"carried_forward": True,
"realtime": False,
"market_status": "closed",
"notice": reason,
}
)
return carried
def _realtime_snapshot_due(
self,
normalized_date: str,
snapshot: dict[str, Any],
) -> bool:
if not self.configured or normalized_date != date.today().strftime("%Y%m%d"):
return False
now = datetime.now().astimezone()
local_time = now.time().replace(tzinfo=None)
realtime_start = datetime.strptime("09:15", "%H:%M").time()
morning_end = datetime.strptime("11:35", "%H:%M").time()
afternoon_start = datetime.strptime("12:55", "%H:%M").time()
realtime_end = datetime.strptime("15:05", "%H:%M").time()
in_session = (
realtime_start <= local_time < morning_end
or afternoon_start <= local_time < realtime_end
)
if not in_session:
return False
meta = snapshot.get("meta") or {}
snapshot_trade_date = str(meta.get("trade_date") or "").replace("-", "")
if snapshot_trade_date and snapshot_trade_date != normalized_date:
return False
if not meta.get("realtime"):
return True
try:
updated_at = datetime.fromisoformat(str(meta.get("updated_at") or ""))
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=now.tzinfo)
except ValueError:
return True
age_seconds = (now - updated_at.astimezone(now.tzinfo)).total_seconds()
return age_seconds >= 8
def sync_dashboard(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
source = "tushare"
with self.sync_lock:
sync_id = self.database.start_sync(normalized_date, source)
try:
if not self.configured:
raise TushareError("公共行情尚未配置")
dashboard = self._tushare_client().dashboard(normalized_date)
dashboard["meta"]["source"] = source
dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date)
dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date)
record_count = self._record_count(dashboard)
actual_date = normalize_date(
str(dashboard.get("meta", {}).get("trade_date") or normalized_date)
)
self.database.save_snapshot(actual_date, source, dashboard)
if actual_date != normalized_date:
dashboard.setdefault("meta", {}).update(
{
"carried_forward": True,
"realtime": False,
"market_status": "closed",
}
)
self.database.save_data_snapshot(
"dashboard_request_v1", normalized_date, source, dashboard
)
self.database.finish_sync(
sync_id,
"success",
record_count,
dashboard.get("meta", {}).get("notice", ""),
source,
)
return self._apply_reason_overrides(self._with_storage(dashboard, cached=False))
except TushareError as exc:
fallback = self.database.get_latest_real_snapshot(normalized_date)
if fallback:
carried = self._carry_dashboard(
fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}"
)
self.database.finish_sync(
sync_id, "fallback", self._record_count(carried), str(exc), "tushare"
)
return self._apply_reason_overrides(self._with_storage(carried, cached=True))
self.database.finish_sync(sync_id, "failed", message=str(exc))
raise ValueError("暂无可用的真实行情快照,请等待后台完成首次同步。") from exc
except Exception as exc:
self.database.finish_sync(sync_id, "failed", message=str(exc))
raise
def _enrich_dashboard_sentiment(
self,
@@ -1126,9 +928,6 @@ class DashboardService:
**self.database.status(),
}
def realtime_aggregate_health(self, sector: str = "") -> dict[str, Any]:
sector = validate_text(sector, "板块名称", 50)
return self.realtime_aggregator.health_snapshot(sector)
def _market_insights(self) -> MarketInsightsService:
if not self.configured:
@@ -3580,663 +3379,6 @@ class DashboardService:
"rows": [],
}
def _search_market_directory(self) -> list[dict[str, Any]]:
cached = self.database.get_data_snapshot("search_directory", "ths") or {}
cached_items = list(cached.get("items") or [])
if cached_items and int(cached.get("schema_version") or 0) >= 2:
return cached_items
if not self.configured:
return cached_items
try:
rows = self._tushare_client().query(
"ths_index",
{},
"ts_code,name,count,exchange,list_date,type",
)
except TushareError:
return cached_items
items = []
for row in rows:
mapping = THS_SEARCH_TYPES.get(str(row.get("type") or "").upper())
code = str(row.get("ts_code") or "").strip().upper()
name = str(row.get("name") or "").strip()
if not mapping or not code or not name or str(row.get("exchange") or "").upper() != "A":
continue
entity_type, subtitle = mapping
items.append(
{
"id": code,
"code": code,
"name": name,
"type": entity_type,
"subtitle": subtitle,
"member_count": int(float(row.get("count") or 0)),
}
)
if items:
self.database.save_data_snapshot(
"search_directory", "ths", "tushare", {"schema_version": 2, "items": items}
)
return items
@staticmethod
def _search_match_score(item: dict[str, Any], query: str) -> tuple[int, int, str]:
name = str(item.get("name") or "").casefold()
code = str(item.get("code") or item.get("id") or "").casefold()
needle = query.casefold()
if code == needle:
rank = 0
elif name == needle:
rank = 1
elif code.startswith(needle):
rank = 2
elif name.startswith(needle):
rank = 3
else:
rank = 4
return rank, len(name), code
def search_entities(self, query: str, trade_date: str) -> dict[str, Any]:
needle = str(query or "").strip()
normalized_date = normalize_date(trade_date)
groups: dict[str, list[dict[str, Any]]] = {
"stocks": [],
"sectors": [],
"themes": [],
"indices": [],
}
if not needle:
return {"query": "", "trade_date": normalized_date, "groups": groups}
stocks = []
for row in self.database.search_stock_master(needle, 12):
stocks.append(
{
"id": str(row.get("code") or ""),
"code": str(row.get("code") or ""),
"name": str(row.get("name") or "--"),
"type": "stock",
"type_label": SEARCH_TYPE_LABELS["stock"],
"industry": str(row.get("industry") or "其他"),
"market": str(row.get("market") or ""),
"subtitle": " · ".join(
part for part in (str(row.get("industry") or ""), str(row.get("market") or "")) if part
) or "A股",
}
)
groups["stocks"] = stocks[:8]
market_items = list(self._search_market_directory()) + [dict(item) for item in SEARCH_INDEXES]
matched = [
item for item in market_items
if needle.casefold() in str(item.get("name") or "").casefold()
or needle.casefold() in str(item.get("code") or "").casefold()
]
matched.sort(key=lambda item: self._search_match_score(item, needle))
group_keys = {"sector": "sectors", "theme": "themes", "index": "indices"}
for item in matched:
group_key = group_keys.get(str(item.get("type") or ""))
if not group_key or len(groups[group_key]) >= 8:
continue
groups[group_key].append(
{
**item,
"type_label": SEARCH_TYPE_LABELS[str(item["type"])],
}
)
return {"query": needle, "trade_date": normalized_date, "groups": groups}
def get_search_detail(
self, entity_type: str, identifier: str, trade_date: str
) -> dict[str, Any]:
entity_type = str(entity_type or "").strip().lower()
identifier = str(identifier or "").strip().upper()
normalized_date = normalize_date(trade_date)
if entity_type not in {"sector", "theme", "index"}:
raise ValueError("搜索详情类型不支持。")
if not re.fullmatch(r"[A-Z0-9.]{3,24}", identifier):
raise ValueError("搜索详情标识无效。")
if not self.configured:
raise ValueError("行情数据源尚未配置。")
if entity_type == "index":
index_basic = next((item for item in SEARCH_INDEXES if item["id"] == identifier), None)
if not index_basic:
raise ValueError("暂不支持该指数详情。")
return self._index_search_detail(index_basic, normalized_date)
directory = self._search_market_directory()
basic = next(
(
item for item in directory
if item.get("id") == identifier and item.get("type") == entity_type
),
None,
)
if not basic:
raise ValueError("未找到对应的板块或题材。")
return self._ths_search_detail(basic, normalized_date)
def get_intraday_chart(
self, entity_type: str, identifier: str
) -> dict[str, Any]:
entity_type = str(entity_type or "").strip().lower()
identifier = str(identifier or "").strip().upper()
if entity_type == "stock":
code = validate_stock_code(identifier)
chart = self.chart_data.stock_intraday(code)
type_label = SEARCH_TYPE_LABELS["stock"]
elif entity_type == "index":
basic = next((item for item in SEARCH_INDEXES if item["id"] == identifier), None)
if not basic:
raise ValueError("暂不支持该指数分时行情。")
chart = self.chart_data.index_intraday(identifier)
type_label = SEARCH_TYPE_LABELS["index"]
elif entity_type in {"sector", "theme"}:
basic = next(
(
item for item in self._search_market_directory()
if item.get("id") == identifier and item.get("type") == entity_type
),
None,
)
if not basic:
raise ValueError("未找到对应的板块或题材。")
chart = self.chart_data.board_intraday(identifier, str(basic.get("name") or ""))
type_label = SEARCH_TYPE_LABELS[entity_type]
else:
raise ValueError("分时行情类型不支持。")
return {
"meta": {
"trade_date": str(chart.get("trade_date") or ""),
"previous_close": float(chart.get("previous_close") or 0),
},
"entity": {
"id": identifier,
"code": str(chart.get("code") or identifier),
"name": str(chart.get("name") or ""),
"type": entity_type,
"type_label": type_label,
},
"points": list(chart.get("points") or []),
}
def _ths_search_detail(
self, basic: dict[str, Any], trade_date: str
) -> dict[str, Any]:
client = self._tushare_client()
resolved_date, _ = client.resolve_trade_context(trade_date)
end = datetime.strptime(resolved_date, "%Y%m%d")
start_date = (end - timedelta(days=190)).strftime("%Y%m%d")
identifier = str(basic["id"])
snapshot = client.sector_snapshot(identifier, resolved_date)
rows = client.query(
"ths_daily",
{"ts_code": identifier, "start_date": start_date, "end_date": resolved_date},
"ts_code,trade_date,open,high,low,close,pct_change,vol,turnover_rate,total_mv,float_mv",
)
rows.sort(key=lambda item: str(item.get("trade_date") or ""))
series = [
{
"trade_date": self._display_compact_date(str(row.get("trade_date") or "")),
"open": float(row.get("open") or 0),
"high": float(row.get("high") or 0),
"low": float(row.get("low") or 0),
"close": float(row.get("close") or 0),
"change": float(row.get("pct_change") or 0),
"volume": float(row.get("vol") or 0),
"turnover_rate": float(row.get("turnover_rate") or 0),
}
for row in rows[-90:]
]
try:
chart_series = self.chart_data.board_daily(identifier, resolved_date, 90)
if chart_series:
series = chart_series
except (AttributeError, ChartDataError):
pass
latest = series[-1] if series else {}
snapshot_is_current = str(snapshot.get("trade_date") or "").replace("-", "") == resolved_date
change = float(
snapshot.get("change")
if snapshot_is_current and snapshot.get("change") is not None
else latest.get("change") or 0
)
if latest.get("realtime"):
change = float(latest.get("change") or 0)
turnover_rate = float(
snapshot.get("turnover_rate")
if snapshot_is_current and snapshot.get("turnover_rate") is not None
else latest.get("turnover_rate") or 0
)
metrics = [
{"label": "涨跌幅", "value": round(change, 2), "unit": "%", "tone": "change"},
{"label": "换手率", "value": round(turnover_rate, 2), "unit": "%"},
{"label": "成份数量", "value": int(float(basic.get("member_count") or 0)), "unit": ""},
]
up_count = int(float(snapshot.get("up_count") or 0))
down_count = int(float(snapshot.get("down_count") or 0))
if up_count or down_count:
metrics.extend(
[
{"label": "上涨家数", "value": up_count, "unit": ""},
{"label": "下跌家数", "value": down_count, "unit": ""},
]
)
leader = str(snapshot.get("leader") or "").strip()
if leader and leader != "--":
metrics.extend(
[
{"label": "领涨标的", "value": leader, "unit": ""},
{"label": "领涨幅", "value": round(float(snapshot.get("leading_pct") or 0), 2), "unit": "%", "tone": "change"},
]
)
return {
"meta": {
"trade_date": self._display_compact_date(resolved_date),
"realtime": bool(snapshot.get("realtime")),
},
"entity": {
"id": identifier,
"code": identifier,
"name": str(snapshot.get("name") or basic.get("name") or "--"),
"type": str(basic.get("type") or "sector"),
"type_label": SEARCH_TYPE_LABELS[str(basic.get("type") or "sector")],
"subtitle": str(basic.get("subtitle") or ""),
"value": float(latest.get("close") or 0),
"change": change,
},
"series": series,
"metrics": metrics,
}
def _index_search_detail(
self, basic: dict[str, Any], trade_date: str
) -> dict[str, Any]:
client = self._tushare_client()
resolved_date, _ = client.resolve_trade_context(trade_date)
payload = (
client.realtime_market_indices(resolved_date)
if client.should_use_realtime(trade_date, resolved_date)
else client.market_indices(resolved_date, 90)
)
current = next(
(item for item in payload.get("indices") or [] if item.get("ts_code") == basic["id"]),
None,
)
if not current:
raise ValueError("该指数暂无可用行情。")
end = datetime.strptime(resolved_date, "%Y%m%d")
rows = client.query(
"index_daily",
{
"ts_code": basic["id"],
"start_date": (end - timedelta(days=190)).strftime("%Y%m%d"),
"end_date": resolved_date,
},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
rows.sort(key=lambda item: str(item.get("trade_date") or ""))
series = [
{
"trade_date": self._display_compact_date(str(row.get("trade_date") or "")),
"open": float(row.get("open") or 0),
"high": float(row.get("high") or 0),
"low": float(row.get("low") or 0),
"close": float(row.get("close") or 0),
"change": float(row.get("pct_chg") or 0),
"volume": float(row.get("vol") or 0),
}
for row in rows[-90:]
]
try:
chart_series = self.chart_data.index_daily(str(basic["id"]), resolved_date, 90)
if chart_series:
series = chart_series
except (AttributeError, ChartDataError):
pass
latest = series[-1] if series else {}
latest_close = float(latest.get("close") or current.get("close") or 0)
latest_change = float(latest.get("change") or current.get("pct_chg") or 0)
def series_return(days: int) -> float:
if len(series) <= days:
return 0.0
previous = float(series[-days - 1].get("close") or 0)
return (latest_close / previous - 1) * 100 if previous > 0 else 0.0
return {
"meta": {
"trade_date": self._display_compact_date(str(current.get("trade_date") or resolved_date)),
"realtime": bool(payload.get("realtime")),
},
"entity": {
**basic,
"type_label": SEARCH_TYPE_LABELS["index"],
"value": latest_close,
"change": latest_change,
},
"series": series,
"metrics": [
{"label": "涨跌幅", "value": round(latest_change, 2), "unit": "%", "tone": "change"},
{"label": "近5日", "value": round(series_return(5), 2), "unit": "%", "tone": "change"},
{"label": "近20日", "value": round(series_return(20), 2), "unit": "%", "tone": "change"},
{"label": "成交额", "value": round(float(current.get("amount_billion") or 0), 2), "unit": "亿"},
],
}
def get_stock_detail(
self, code: str, trade_date: str, force: bool = False
) -> dict[str, Any]:
code = validate_stock_code(code)
normalized_date = normalize_date(trade_date)
cache_key = f"{code}:{normalized_date}"
if not force:
cached = self.database.get_data_snapshot("stock_detail", cache_key)
if cached and str((cached.get("meta") or {}).get("source") or "") != "demo":
if not self._stock_detail_cache_needs_refresh(cached, normalized_date):
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return self._prepare_stock_detail(cached, code, normalized_date)
name, sector = self._stock_identity(code, normalized_date)
source = "tushare"
if self.configured:
try:
payload = self._tushare_client().stock_detail(
tushare_code(code), normalized_date
)
if not payload.get("prices"):
raise TushareError("No price history returned")
except TushareError as exc:
payload = self.database.get_latest_data_snapshot(
"stock_detail", f"{code}:", cache_key, exclude_source="demo"
)
if not payload:
raise ValueError(f"暂无 {code} 的真实行情数据:{exc}") from exc
payload = copy.deepcopy(payload)
payload["meta"] = {
**payload.get("meta", {}),
"cached": True,
"notice": "最新行情暂不可用,已沿用最近真实收盘数据。",
}
return self._prepare_stock_detail(payload, code, normalized_date)
else:
payload = self.database.get_latest_data_snapshot(
"stock_detail", f"{code}:", cache_key, exclude_source="demo"
)
if not payload:
raise ValueError(f"暂无 {code} 的真实行情数据,请等待后台完成首次同步。")
payload = copy.deepcopy(payload)
payload["meta"] = {
**payload.get("meta", {}),
"cached": True,
"notice": "公共行情尚未配置,已沿用最近真实收盘数据。",
}
return self._prepare_stock_detail(payload, code, normalized_date)
payload["meta"]["source"] = source
payload["meta"]["cached"] = False
self.database.save_data_snapshot("stock_detail", cache_key, source, payload)
return self._prepare_stock_detail(payload, code, normalized_date)
@staticmethod
def _stock_detail_bar_date(payload: dict[str, Any]) -> str:
prices = list(payload.get("prices") or [])
return str((prices[-1] if prices else {}).get("trade_date") or "").replace("-", "")
def _stock_detail_cache_needs_refresh(
self, payload: dict[str, Any], requested_date: str
) -> bool:
now = datetime.now().astimezone()
return (
requested_date == now.strftime("%Y%m%d")
and now.time().replace(tzinfo=None) >= dt_time(15, 0)
and self._stock_detail_bar_date(payload) < requested_date
)
def _prepare_stock_detail(
self, payload: dict[str, Any], code: str, requested_date: str
) -> dict[str, Any]:
result = copy.deepcopy(payload)
now = datetime.now().astimezone()
try:
result["prices"] = self.chart_data.stock_daily(code, requested_date, 90)
result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"}
except (AttributeError, ChartDataError):
pass
result = self._sanitize_stock_detail_prices(result, now)
actual_date = self._stock_detail_bar_date(result)
if actual_date:
result["meta"] = {
**(result.get("meta") or {}),
"trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}",
}
today = now.strftime("%Y%m%d")
should_merge = (
requested_date == today
and actual_date <= today
and now.weekday() < 5
and now.time().replace(tzinfo=None) >= dt_time(9, 30)
)
if should_merge:
quote = self._ifind_realtime_stock_quote(code)
if quote and self._valid_realtime_stock_quote(quote, today):
self._merge_realtime_stock_detail(result, quote, requested_date)
elif self.configured and actual_date < today:
client = self._tushare_client()
try:
resolved_date, _ = client.resolve_trade_context(requested_date)
if resolved_date == today:
quote = client.realtime_stock_quote(tushare_code(code), requested_date)
if self._valid_realtime_stock_quote(quote, today):
self._merge_realtime_stock_detail(result, quote, requested_date)
except TushareError:
pass
return self._enrich_stock_detail(result)
@staticmethod
def _sanitize_stock_detail_prices(
payload: dict[str, Any], market_now: datetime
) -> dict[str, Any]:
result = copy.deepcopy(payload)
raw_prices = list(result.get("prices") or [])
raw_latest_date = str(
(raw_prices[-1] if raw_prices else {}).get("trade_date") or ""
).replace("-", "")
prices = []
for bar in raw_prices:
open_price = float(bar.get("open") or 0)
high = float(bar.get("high") or 0)
low = float(bar.get("low") or 0)
close = float(bar.get("close") or 0)
if (
open_price > 0
and high >= max(open_price, close)
and 0 < low <= min(open_price, close)
and close > 0
):
prices.append(bar)
today = market_now.strftime("%Y%m%d")
market_open = (
market_now.weekday() < 5
and market_now.time().replace(tzinfo=None) >= dt_time(9, 30)
)
if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == today:
current = prices[-1]
has_market_activity = (
float(current.get("volume") or 0) > 0
or float(current.get("amount_billion") or 0) > 0
)
if not market_open or not has_market_activity:
prices.pop()
if raw_latest_date == today and (
not prices
or str(prices[-1].get("trade_date") or "").replace("-", "") != today
):
result["meta"] = {**(result.get("meta") or {}), "realtime": False}
result["prices"] = prices
if prices:
latest = prices[-1]
stock = dict(result.get("stock") or {})
stock.update(
{
"price": float(latest.get("close") or 0),
"change": float(latest.get("change") or 0),
"amount_billion": float(latest.get("amount_billion") or 0),
}
)
result["stock"] = stock
return result
@staticmethod
def _valid_realtime_stock_quote(quote: dict[str, Any], trade_date: str) -> bool:
price = float(quote.get("price") or 0)
open_price = float(quote.get("open") or 0)
high = float(quote.get("high") or 0)
low = float(quote.get("low") or 0)
volume = float(quote.get("volume") or 0)
amount = float(quote.get("amount_billion") or 0)
quote_date = str(quote.get("quote_time") or "")[:10].replace("-", "")
return (
price > 0
and open_price > 0
and high >= max(open_price, price)
and 0 < low <= min(open_price, price)
and (volume > 0 or amount > 0)
and (not quote_date or quote_date == trade_date)
)
def _ifind_realtime_stock_quote(self, code: str) -> dict[str, Any] | None:
ifind = getattr(self, "ifind", None)
if not ifind or not ifind.configured:
return None
try:
rows = ifind.real_time(
tushare_code(code),
[
"open", "high", "low", "latest", "preClose",
"volume", "amount", "turnoverRatio",
],
cache_ttl=10,
)
except IfindError:
return None
row = rows[0] if rows else {}
price = float(row.get("latest") or 0)
previous_close = float(row.get("preClose") or 0)
if price <= 0:
return None
change = (price / previous_close - 1) * 100 if previous_close > 0 else 0.0
stock = self._stock_identity(code, date.today().strftime("%Y%m%d"))
return {
"name": stock[0],
"sector": stock[1],
"price": price,
"open": float(row.get("open") or price),
"high": float(row.get("high") or price),
"low": float(row.get("low") or price),
"change": round(change, 4),
"volume": float(row.get("volume") or 0),
"volume_unit": "lots",
"amount_billion": float(row.get("amount") or 0) / 100_000_000,
"turnover_rate": float(row.get("turnoverRatio") or 0),
"quote_time": str(row.get("time") or ""),
}
@staticmethod
def _merge_realtime_stock_detail(
payload: dict[str, Any], quote: dict[str, Any], trade_date: str
) -> None:
display_date = f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:]}"
realtime_bar = {
"trade_date": display_date,
"open": quote["open"],
"high": quote["high"],
"low": quote["low"],
"close": quote["price"],
"change": quote["change"],
"volume": quote["volume"] if quote.get("volume_unit") == "lots" else quote["volume"] / 100,
"amount_billion": quote["amount_billion"],
"realtime": True,
}
prices = list(payload.get("prices") or [])
if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == trade_date:
prices[-1] = realtime_bar
else:
prices.append(realtime_bar)
payload["prices"] = prices[-90:]
stock = dict(payload.get("stock") or {})
stock.update(
{
"name": quote["name"],
"industry": quote["sector"],
"price": quote["price"],
"change": quote["change"],
"amount_billion": quote["amount_billion"],
"turnover_rate": quote["turnover_rate"],
}
)
payload["stock"] = stock
payload["meta"] = {
**(payload.get("meta") or {}),
"trade_date": display_date,
"realtime": True,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
def get_stock_preview(
self, code: str, trade_date: str, force: bool = False
) -> dict[str, Any]:
code = validate_stock_code(code)
# Hover previews deliberately follow the latest market day, independent
# from the review date selected by the page.
detail = self.get_stock_detail(code, date.today().strftime("%Y%m%d"), force)
detail_meta = detail.get("meta") or {}
resolved_date = str(detail_meta.get("trade_date") or trade_date)
intraday_points: list[dict[str, Any]] = []
intraday_status = "unavailable"
intraday_notice = "分时行情暂不可用。"
intraday_trade_date = ""
intraday_previous_close = 0.0
try:
intraday = self.chart_data.stock_intraday(code)
intraday_points = list(intraday.get("points") or [])
intraday_trade_date = str(intraday.get("trade_date") or "")
intraday_previous_close = float(intraday.get("previous_close") or 0)
if intraday_points:
intraday_status = "available"
intraday_notice = ""
else:
intraday_status = "empty"
intraday_notice = "最近交易日暂无分时数据。"
except ChartDataError:
intraday_status = "unavailable"
intraday_notice = "分时行情暂不可用,请稍后重试。"
prices = list(detail.get("prices") or [])[-60:]
stock = dict(detail.get("stock") or {"code": code})
realtime = bool(detail_meta.get("realtime"))
return {
"meta": {
"trade_date": resolved_date,
"source": detail_meta.get("source") or "unavailable",
"notice": detail_meta.get("notice") or "",
"intraday_status": intraday_status,
"intraday_notice": intraday_notice,
"intraday_trade_date": intraday_trade_date,
"intraday_previous_close": intraday_previous_close,
"realtime": realtime,
"refresh_interval_seconds": 10 if realtime else 0,
},
"stock": stock,
"prices": prices,
"intraday": intraday_points,
}
def save_reason(self, trade_date: str, code: str, reason: str) -> None:
normalized_date = normalize_date(trade_date)
@@ -4246,55 +3388,6 @@ class DashboardService:
raise ValueError("涨停原因应为 1 至 200 个字符。")
self.database.save_reason_override(normalized_date, code, reason)
def backfill(self, start_date: str, end_date: str) -> list[dict[str, Any]]:
start = datetime.strptime(normalize_date(start_date), "%Y%m%d").date()
end = datetime.strptime(normalize_date(end_date), "%Y%m%d").date()
if start > end:
raise ValueError("开始日期不能晚于结束日期。")
weekdays = []
current = start
while current <= end:
if current.weekday() < 5:
weekdays.append(current)
current += timedelta(days=1)
if len(weekdays) > 15:
raise ValueError("单次最多回补 15 个工作日。")
results = []
for day in weekdays:
dashboard = self.sync_dashboard(day.strftime("%Y%m%d"))
results.append(
{
"requested_date": day.isoformat(),
"trade_date": dashboard["meta"]["trade_date"],
"source": dashboard["meta"]["source"],
"records": self._record_count(dashboard),
}
)
return results
def _stock_identity(self, code: str, trade_date: str) -> tuple[str, str]:
snapshot = self.database.get_snapshot(trade_date) or {}
for key in ("limits", "broken", "down_limits"):
for row in snapshot.get(key) or []:
if str(row.get("code")) == code:
return row.get("name") or "--", row.get("sector") or "其他"
for item in self.database.list_watchlist(self.current_user_id):
if item["code"] == code:
return item["name"], item["sector"] or "其他"
return "--", "其他"
def _enrich_stock_detail(self, payload: dict[str, Any]) -> dict[str, Any]:
result = dict(payload)
stock = dict(payload.get("stock") or {})
code = str(stock.get("code") or "")
watched = {
item["code"]: item
for item in self.database.list_watchlist(self.current_user_id)
}
stock["watchlist"] = watched.get(code)
result["stock"] = stock
result["notes"] = self.database.list_notes(self.current_user_id, code=code)
return result
def _apply_reason_overrides(self, dashboard: dict[str, Any]) -> dict[str, Any]:
trade_date = str(dashboard.get("meta", {}).get("trade_date", "")).replace("-", "")
@@ -4578,22 +3671,6 @@ class DashboardService:
}
return result
def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]:
result = dict(dashboard)
result["meta"] = {
**dashboard.get("meta", {}),
"storage": "sqlite",
"cached": cached,
}
return result
@staticmethod
def _record_count(dashboard: dict[str, Any]) -> int:
return sum(
len(dashboard.get(key) or [])
for key in ("limits", "broken", "down_limits", "yesterday_limits")
)
SERVICE = DashboardService()
+3 -3
View File
@@ -10,12 +10,12 @@ from backend.features.alerts import AlertService
from backend.features.review import TradeJournalService
from backend.features.screener import StrategyTrackingService
from backend.jobs import InProcessJobRunner, JobRegistry, SQLiteJobRunRepository
from chart_data_provider import MarketChartClient
from database import ReviewDatabase
from ifind_client import IfindHttpClient
from mentor_agent import MentorSkillRegistry
from realtime_aggregator import WebRealtimeAggregator
from screener import ScreenerEngine
from backend.data.providers.ifind_client import IfindHttpClient
from backend.data.realtime import WebRealtimeAggregator
from backend.features.market.charts import MarketChartClient
@dataclass(frozen=True)
+8 -1
View File
@@ -1,4 +1,3 @@
from .gateway import DataGateway, build_data_gateway
from .policy import DataPolicyError, DataSourcePolicy
from .quality import DataQualityError, DataQualityGate, QualityEvidence, QualityReport
@@ -12,3 +11,11 @@ __all__ = [
"QualityReport",
"build_data_gateway",
]
def __getattr__(name: str):
if name in {"DataGateway", "build_data_gateway"}:
from .gateway import DataGateway, build_data_gateway
return {"DataGateway": DataGateway, "build_data_gateway": build_data_gateway}[name]
raise AttributeError(name)
+4 -4
View File
@@ -8,10 +8,10 @@ from backend.data.contracts import DataUsage
from backend.data.policy import DataSourcePolicy
from backend.data.providers import IfindProvider, TushareProvider
from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport
from chart_data_provider import EastmoneyChartClient, MarketChartClient
from ifind_client import IfindHttpClient
from realtime_aggregator import WebRealtimeAggregator
from tushare_client import TushareClient
from backend.data.providers.ifind_client import IfindHttpClient
from backend.data.providers.tushare_client import TushareClient
from backend.data.realtime import WebRealtimeAggregator
from backend.features.market.charts import EastmoneyChartClient, MarketChartClient
@dataclass(frozen=True)
+1 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from ifind_client import IfindHttpClient
from backend.data.providers.ifind_client import IfindHttpClient
class IfindProvider:
+385
View File
@@ -0,0 +1,385 @@
from __future__ import annotations
import copy
import json
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timedelta
from typing import Any
class IfindError(RuntimeError):
pass
class IfindHttpClient:
BASE_URL = "https://quantapi.51ifind.com/api/v1"
AUTH_ENDPOINT = "get_access_token"
AUTH_ERROR_CODES = {-1302, -1303, -1304, -4302, -4303}
def __init__(
self,
refresh_token: str = "",
access_token: str = "",
timeout: int = 15,
) -> None:
self.timeout = max(3, int(timeout))
self._refresh_token = str(refresh_token or "").strip()
self._access_token = str(access_token or "").strip()
self._access_expires_at: datetime | None = None
self._token_lock = threading.Lock()
self._cache_lock = threading.Lock()
self._cache: dict[str, dict[str, Any]] = {}
@property
def configured(self) -> bool:
return bool(self._refresh_token or self._access_token)
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
refresh_token = str(refresh_token or "").strip()
access_token = str(access_token or "").strip()
with self._token_lock:
refresh_changed = refresh_token != self._refresh_token
self._refresh_token = refresh_token
if access_token or refresh_changed:
self._access_token = access_token
self._access_expires_at = None
if refresh_changed:
with self._cache_lock:
self._cache.clear()
def status(self) -> dict[str, Any]:
return {
"configured": self.configured,
"access_ready": bool(self._access_token),
"access_expires_at": (
self._access_expires_at.isoformat(timespec="seconds")
if self._access_expires_at
else ""
),
}
def test_connection(self) -> dict[str, Any]:
payload = self.real_time(
"000001.SH",
["open", "high", "low", "latest", "preClose"],
cache_ttl=0,
)
return {
"ok": bool(payload),
"sample_time": str(payload[0].get("time") or "") if payload else "",
}
def real_time(
self,
codes: str | list[str],
indicators: list[str],
cache_ttl: int = 10,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"real_time_quotation",
{"codes": code_text, "indicators": ",".join(indicators)},
cache_key=f"rq:{code_text}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def history(
self,
codes: str | list[str],
indicators: list[str],
start_date: str,
end_date: str,
cache_ttl: int = 300,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"cmd_history_quotation",
{
"codes": code_text,
"indicators": ",".join(indicators),
"startdate": self._display_date(start_date),
"enddate": self._display_date(end_date),
"functionpara": {"CPS": "forward1", "Fill": "Omit"},
},
cache_key=f"hq:{code_text}:{start_date}:{end_date}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def intraday(
self,
code: str,
start_time: str,
end_time: str,
cache_ttl: int = 20,
) -> list[dict[str, Any]]:
indicators = ["open", "high", "low", "close", "volume", "amount", "avgPrice"]
payload = self._request(
"high_frequency",
{
"codes": self._codes(code),
"indicators": ",".join(indicators),
"starttime": start_time,
"endtime": end_time,
"functionpara": {
"CPS": "forward1",
"Fill": "Previous",
"Timeformat": "LocalTime",
"Interval": "1",
"Limitstart": "09:30:00",
"Limitend": "15:00:00",
},
},
cache_key=f"hf:{code}:{start_time}:{end_time}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def snapshots(
self,
codes: str | list[str],
indicators: list[str],
start_time: str,
end_time: str,
cache_ttl: int = 8,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"snap_shot",
{
"codes": code_text,
"indicators": ",".join(indicators),
"starttime": start_time,
"endtime": end_time,
},
cache_key=f"ss:{code_text}:{start_time}:{end_time}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def wencai(self, query: str, search_type: str = "stock", cache_ttl: int = 300) -> list[dict[str, Any]]:
normalized = " ".join(str(query or "").split())
if not normalized:
raise IfindError("问财查询不能为空。")
payload = self._request(
"smart_stock_picking",
{"searchstring": normalized, "searchtype": search_type},
cache_key=f"wc:{search_type}:{normalized}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def report_query(
self,
codes: str | list[str],
begin_date: str,
end_date: str,
cache_ttl: int = 300,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"report_query",
{
"codes": code_text,
"beginrDate": self._display_date(begin_date),
"endrDate": self._display_date(end_date),
"outputpara": (
"reportDate:Y,thscode:Y,secName:Y,ctime:Y,"
"reportTitle:Y,pdfURL:Y,seq:Y"
),
},
cache_key=f"report:{code_text}:{begin_date}:{end_date}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def _request(
self,
endpoint: str,
body: dict[str, Any],
cache_key: str = "",
cache_ttl: int = 0,
) -> dict[str, Any]:
if not self.configured:
raise IfindError("iFinD 尚未配置。")
if cache_key and cache_ttl > 0:
cached = self._cached(cache_key, cache_ttl)
if cached is not None:
return cached
payload = self._post(endpoint, body, self._ensure_access_token())
if self._is_auth_error(payload) and self._refresh_token:
self._invalidate_access_token()
payload = self._post(endpoint, body, self._ensure_access_token(force=True))
self._validate_payload(payload)
if cache_key and cache_ttl > 0:
with self._cache_lock:
self._cache[cache_key] = {
"created_at": time.time(),
"payload": copy.deepcopy(payload),
}
return payload
def _ensure_access_token(self, force: bool = False) -> str:
with self._token_lock:
now = datetime.now().astimezone().replace(tzinfo=None)
token_valid = bool(self._access_token) and (
self._access_expires_at is None
or self._access_expires_at > now + timedelta(minutes=2)
)
if token_valid and not force:
return self._access_token
if not self._refresh_token:
if self._access_token:
return self._access_token
raise IfindError("iFinD Refresh Token 尚未配置。")
payload = self._post(self.AUTH_ENDPOINT, {}, "", self._refresh_token)
self._validate_payload(payload)
data = payload.get("data") or {}
token = str(data.get("access_token") or "").strip()
if not token:
raise IfindError("iFinD 未返回 Access Token。")
expires_at = self._parse_datetime(data.get("expired_time"))
self._access_token = token
self._access_expires_at = expires_at
return token
def _post(
self,
endpoint: str,
body: dict[str, Any],
access_token: str,
refresh_token: str = "",
) -> dict[str, Any]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "XiaobaiReviewWeb/1.0",
"ifindlang": "cn",
}
if access_token:
headers["access_token"] = access_token
if refresh_token:
headers["refresh_token"] = refresh_token
request = urllib.request.Request(
f"{self.BASE_URL}/{endpoint}",
data=json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = ""
try:
detail_payload = json.loads(exc.read().decode("utf-8", errors="replace"))
detail = str(detail_payload.get("errmsg") or detail_payload.get("message") or "")
except (json.JSONDecodeError, OSError):
pass
raise IfindError(f"iFinD HTTP {exc.code}{f'{detail[:160]}' if detail else ''}") from exc
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
raise IfindError("iFinD 数据请求失败。") from exc
if not isinstance(payload, dict):
raise IfindError("iFinD 返回格式不正确。")
return payload
def _cached(self, key: str, ttl: int) -> dict[str, Any] | None:
with self._cache_lock:
cached = self._cache.get(key)
if not cached:
return None
if time.time() - float(cached.get("created_at") or 0) > ttl:
self._cache.pop(key, None)
return None
return copy.deepcopy(cached["payload"])
def _invalidate_access_token(self) -> None:
with self._token_lock:
self._access_token = ""
self._access_expires_at = None
@classmethod
def _validate_payload(cls, payload: dict[str, Any]) -> None:
try:
error_code = int(payload.get("errorcode") or 0)
except (TypeError, ValueError):
error_code = -1
if error_code != 0:
message = str(payload.get("errmsg") or "未知错误")
raise IfindError(f"iFinD 返回错误:{message[:200]}")
@classmethod
def _is_auth_error(cls, payload: dict[str, Any]) -> bool:
try:
error_code = int(payload.get("errorcode") or 0)
except (TypeError, ValueError):
error_code = 0
message = str(payload.get("errmsg") or "").casefold()
return error_code in cls.AUTH_ERROR_CODES or "token" in message or "鉴权" in message
@staticmethod
def _table_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
tables = payload.get("tables") or []
if isinstance(tables, dict):
tables = [tables]
rows: list[dict[str, Any]] = []
for block in tables if isinstance(tables, list) else []:
if not isinstance(block, dict):
continue
table = block.get("table") or {}
if not isinstance(table, dict):
continue
times = block.get("time") or []
codes = block.get("thscode") or block.get("thscodes") or []
if isinstance(codes, str):
codes = [codes]
lengths = [len(value) for value in table.values() if isinstance(value, list)]
row_count = max(lengths or [len(times) if isinstance(times, list) else 0, 1 if table else 0])
for index in range(row_count):
row: dict[str, Any] = {}
if isinstance(times, list) and index < len(times):
row["time"] = times[index]
if codes:
row["thscode"] = codes[index] if index < len(codes) else codes[0]
for field, values in table.items():
if isinstance(values, list):
row[field] = values[index] if index < len(values) else None
elif index == 0:
row[field] = values
rows.append(row)
return rows
@staticmethod
def _codes(codes: str | list[str]) -> str:
if isinstance(codes, list):
values = [str(code or "").strip().upper() for code in codes]
else:
values = [part.strip().upper() for part in str(codes or "").split(",")]
values = [value for value in values if value]
if not values:
raise IfindError("iFinD 证券代码不能为空。")
if len(values) > 100:
raise IfindError("iFinD 单次证券代码过多。")
return ",".join(values)
@staticmethod
def _display_date(value: str) -> str:
compact = str(value or "").replace("-", "")
if len(compact) != 8 or not compact.isdigit():
raise IfindError("iFinD 日期格式不正确。")
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
@staticmethod
def _parse_datetime(value: Any) -> datetime | None:
text = str(value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text)
except ValueError:
return None
+1 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Callable
from tushare_client import TushareClient
from backend.data.providers.tushare_client import TushareClient
class TushareProvider:
File diff suppressed because it is too large Load Diff
+426
View File
@@ -0,0 +1,426 @@
from __future__ import annotations
import copy
import http.client
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime
from threading import Lock
from typing import Any, ClassVar
class RealtimeAggregateError(RuntimeError):
pass
EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
EASTMONEY_SECTOR_URL = "https://push2.eastmoney.com/api/qt/clist/get"
TENCENT_INDEX_URL = "https://qt.gtimg.cn/q=sh000001,sz399001,sz399006"
THS_LIMIT_URL = "https://data.10jqka.com.cn/dataapi/limit_up/limit_up_pool"
XGB_POOL_URL = "https://flash-api.xuangubao.cn/api/pool/detail"
BROWSER_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/138.0.0.0 Safari/537.36"
)
@dataclass
class WebRealtimeAggregator:
timeout: int = 8
retry_attempts: int = 3
retry_delay_seconds: float = 0.2
response_cache_ttl_seconds: int = 90
_sector_cache: ClassVar[dict[str, Any]] = {}
_sector_cache_lock: ClassVar[Lock] = Lock()
_response_cache: ClassVar[dict[str, dict[str, Any]]] = {}
_response_cache_lock: ClassVar[Lock] = Lock()
def health_snapshot(self, sector: str = "") -> dict[str, Any]:
started = time.perf_counter()
sources: dict[str, dict[str, Any]] = {}
indices: list[dict[str, Any]] = []
sector_payload: dict[str, Any] | None = None
indices, sources["eastmoney_indices"] = self._capture(self.eastmoney_indices)
if sector.strip():
sector_payload, sources["eastmoney_sector"] = self._capture(
lambda: self.eastmoney_sector(sector)
)
ths_observation, sources["ths_limit_pool"] = self._capture(self.ths_limit_pool)
xgb_observation, sources["xgb_limit_pool"] = self._capture(self.xgb_limit_pool)
index_times = [int(item.get("quote_time_epoch") or 0) for item in indices or []]
now = datetime.now().astimezone()
max_skew = 120 if now.hour >= 15 else 15
index_consistent = bool(index_times) and max(index_times) - min(index_times) <= max_skew
ready = (
bool(indices)
and len(indices) == 3
and index_consistent
and (not sector.strip() or bool(sector_payload))
)
return {
"ready": ready,
"isolated": True,
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"elapsed_ms": round((time.perf_counter() - started) * 1000),
"indices": indices or [],
"index_consistent": index_consistent,
"sector": sector_payload,
"sources": sources,
"observations": {
"ths_limit_pool": ths_observation,
"xgb_limit_pool": xgb_observation,
},
"policy": {
"integration": "heaven_realtime_fallback",
"max_index_time_skew_seconds": max_skew,
"notice": "聚合源仅作为盘中观势的实时指数与板块外显,主行情快照仍由Tushare维护。",
},
}
def eastmoney_indices(self) -> list[dict[str, Any]]:
try:
payload = self._get_json(
EASTMONEY_INDEX_URL,
{
"secids": "1.000001,0.399001,0.399006",
"fltt": "2",
"invt": "2",
"fields": "f12,f14,f2,f3,f4,f15,f16,f17,f18,f6,f124",
},
referer="https://quote.eastmoney.com/",
)
except RealtimeAggregateError:
return self.tencent_indices()
cache_meta = payload.get("_aggregate_cache") or {}
rows = list((payload.get("data") or {}).get("diff") or [])
result = []
for row in rows:
code = str(row.get("f12") or "")
if code not in {"000001", "399001", "399006"}:
continue
epoch = int(_number(row.get("f124")))
result.append(
{
"code": code,
"name": row.get("f14") or code,
"price": _number(row.get("f2")),
"change": _number(row.get("f3")),
"change_amount": _number(row.get("f4")),
"open": _number(row.get("f17")),
"high": _number(row.get("f15")),
"low": _number(row.get("f16")),
"previous_close": _number(row.get("f18")),
"amount_billion": round(_number(row.get("f6")) / 100000000, 2),
"quote_time_epoch": epoch,
"quote_time": (
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
if epoch else ""
),
"source": (
"eastmoney_push2_cache" if cache_meta else "eastmoney_push2"
),
"cache_age_seconds": cache_meta.get("age_seconds", 0),
}
)
if len(result) != 3:
raise RealtimeAggregateError(f"Eastmoney returned {len(result)}/3 indices")
return result
def tencent_indices(self) -> list[dict[str, Any]]:
raw, cache_age = self._get_text(
TENCENT_INDEX_URL,
referer="https://gu.qq.com/",
encoding="gb18030",
)
result = []
for line in raw.splitlines():
if '="' not in line:
continue
fields = line.split('="', 1)[1].rsplit('";', 1)[0].split("~")
if len(fields) < 38:
continue
code = fields[2]
if code not in {"000001", "399001", "399006"}:
continue
try:
quote_time = datetime.strptime(fields[30], "%Y%m%d%H%M%S").astimezone()
except ValueError as exc:
raise RealtimeAggregateError(
f"Tencent returned invalid quote time for {code}"
) from exc
result.append(
{
"code": code,
"name": fields[1] or code,
"price": _number(fields[3]),
"change": _number(fields[32]),
"change_amount": _number(fields[31]),
"open": _number(fields[5]),
"high": _number(fields[33]),
"low": _number(fields[34]),
"previous_close": _number(fields[4]),
"amount_billion": round(_number(fields[37]) / 10000, 2),
"quote_time_epoch": int(quote_time.timestamp()),
"quote_time": quote_time.isoformat(timespec="seconds"),
"source": "tencent_qt_cache" if cache_age else "tencent_qt",
"cache_age_seconds": cache_age,
}
)
if len(result) != 3:
raise RealtimeAggregateError(f"Tencent returned {len(result)}/3 indices")
return result
def eastmoney_sector(self, query: str) -> dict[str, Any]:
target = _normalize_sector(query)
candidates = self._eastmoney_sector_catalog()
matched = _match_sector(candidates, target)
if not matched:
raise RealtimeAggregateError(f"Eastmoney sector not found: {query}")
epoch = int(_number(matched.get("f124")))
return {
"code": matched.get("f12") or "",
"name": matched.get("f14") or query,
"price": _number(matched.get("f2")),
"change": _number(matched.get("f3")),
"change_amount": _number(matched.get("f4")),
"turnover_rate": _number(matched.get("f8")),
"up_count": int(_number(matched.get("f104"))),
"down_count": int(_number(matched.get("f105"))),
"leader": matched.get("f128") or "--",
"leader_code": matched.get("f140") or "",
"leading_pct": _number(matched.get("f136")),
"quote_time_epoch": epoch,
"quote_time": (
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
if epoch else ""
),
"source": "eastmoney_push2",
"match_query": query,
}
def _eastmoney_sector_catalog(self) -> list[dict[str, Any]]:
now = time.time()
with self._sector_cache_lock:
cached = self._sector_cache.get("eastmoney")
if cached and now - float(cached.get("created_at") or 0) < 600:
return list(cached.get("rows") or [])
def load_page(page: int) -> list[dict[str, Any]]:
payload = self._get_json(
EASTMONEY_SECTOR_URL,
{
"pn": str(page),
"pz": "100",
"po": "1",
"np": "1",
"fltt": "2",
"invt": "2",
"fid": "f3",
"fs": "m:90+t:2",
"fields": "f12,f14,f2,f3,f4,f8,f104,f105,f128,f136,f140,f124",
},
referer="https://quote.eastmoney.com/center/boardlist.html",
)
return list((payload.get("data") or {}).get("diff") or [])
with ThreadPoolExecutor(max_workers=5) as executor:
pages = list(executor.map(load_page, range(1, 6)))
rows = [row for page in pages for row in page]
if not rows:
raise RealtimeAggregateError("Eastmoney sector catalog is empty")
with self._sector_cache_lock:
self._sector_cache["eastmoney"] = {"created_at": now, "rows": rows}
return rows
def ths_limit_pool(self) -> dict[str, Any]:
payload = self._get_json(
THS_LIMIT_URL,
{"page": "1", "limit": "3", "field": "199112"},
referer="https://data.10jqka.com.cn/limit_up/",
)
data = payload.get("data") or payload
return {
"available": True,
"keys": sorted(str(key) for key in data.keys()) if isinstance(data, dict) else [],
"source": "ths_web_dataapi",
}
def xgb_limit_pool(self) -> dict[str, Any]:
payload = self._get_json(
XGB_POOL_URL,
{"pool_name": "limit_up"},
referer="https://xuangubao.cn/",
)
data = payload.get("data") or {}
rows = data if isinstance(data, list) else data.get("pool") or data.get("list") or []
return {
"available": True,
"count": len(rows) if isinstance(rows, list) else 0,
"source": "xuangubao_web_api",
}
def _capture(self, operation):
started = time.perf_counter()
try:
value = operation()
return value, {
"ok": True,
"elapsed_ms": round((time.perf_counter() - started) * 1000),
"error": "",
}
except Exception as exc:
return None, {
"ok": False,
"elapsed_ms": round((time.perf_counter() - started) * 1000),
"error": str(exc)[:500],
}
def _get_json(
self,
url: str,
params: dict[str, str],
referer: str,
) -> dict[str, Any]:
request_url = f"{url}?{urllib.parse.urlencode(params)}"
last_error: Exception | None = None
attempts = max(1, int(self.retry_attempts))
for attempt in range(attempts):
request = urllib.request.Request(
request_url,
headers={
"Accept": "application/json,text/plain,*/*",
"Connection": "close",
"Referer": referer,
"User-Agent": BROWSER_USER_AGENT,
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
content_type = response.headers.get("Content-Type", "")
raw = response.read().decode("utf-8", errors="replace")
if "json" not in content_type.lower() and not raw.lstrip().startswith(("{", "[")):
raise RealtimeAggregateError(
f"non-JSON response: {raw[:120].strip()}"
)
payload = json.loads(raw)
if not isinstance(payload, dict):
raise RealtimeAggregateError("unexpected response shape")
if payload.get("rc") not in (None, 0):
raise RealtimeAggregateError(f"provider rc={payload.get('rc')}")
with self._response_cache_lock:
self._response_cache[request_url] = {
"created_at": time.time(),
"payload": copy.deepcopy(payload),
}
return payload
except (
urllib.error.URLError,
TimeoutError,
ConnectionError,
OSError,
http.client.HTTPException,
json.JSONDecodeError,
RealtimeAggregateError,
) as exc:
last_error = exc
if attempt + 1 < attempts and self.retry_delay_seconds > 0:
time.sleep(self.retry_delay_seconds * (attempt + 1))
now = time.time()
with self._response_cache_lock:
cached = self._response_cache.get(request_url)
cache_age = now - float((cached or {}).get("created_at") or 0)
if cached and cache_age <= self.response_cache_ttl_seconds:
payload = copy.deepcopy(cached.get("payload") or {})
payload["_aggregate_cache"] = {"age_seconds": round(cache_age, 1)}
return payload
raise RealtimeAggregateError(f"request failed after {attempts} attempts: {last_error}") from last_error
def _get_text(
self,
request_url: str,
referer: str,
encoding: str = "utf-8",
) -> tuple[str, float]:
cache_key = f"text:{request_url}"
last_error: Exception | None = None
attempts = max(1, int(self.retry_attempts))
for attempt in range(attempts):
request = urllib.request.Request(
request_url,
headers={
"Accept": "text/plain,*/*",
"Connection": "close",
"Referer": referer,
"User-Agent": BROWSER_USER_AGENT,
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
raw = response.read().decode(encoding, errors="replace")
if not raw.strip():
raise RealtimeAggregateError("empty text response")
with self._response_cache_lock:
self._response_cache[cache_key] = {
"created_at": time.time(),
"payload": raw,
}
return raw, 0
except (
urllib.error.URLError,
TimeoutError,
ConnectionError,
OSError,
http.client.HTTPException,
RealtimeAggregateError,
) as exc:
last_error = exc
if attempt + 1 < attempts and self.retry_delay_seconds > 0:
time.sleep(self.retry_delay_seconds * (attempt + 1))
now = time.time()
with self._response_cache_lock:
cached = self._response_cache.get(cache_key)
cache_age = now - float((cached or {}).get("created_at") or 0)
if cached and cache_age <= self.response_cache_ttl_seconds:
return str(cached.get("payload") or ""), round(cache_age, 1)
raise RealtimeAggregateError(
f"text request failed after {attempts} attempts: {last_error}"
) from last_error
def _normalize_sector(value: Any) -> str:
text = str(value or "").strip().replace(" ", "")
for suffix in ("板块", "概念", "行业", "", "", "(A股)", "A股)"):
text = text.replace(suffix, "")
aliases = {"元器件": "元件", "电子元器件": "元件"}
return aliases.get(text, text)
def _match_sector(rows: list[dict[str, Any]], target: str) -> dict[str, Any] | None:
exact = [row for row in rows if _normalize_sector(row.get("f14")) == target]
if exact:
return min(exact, key=lambda row: len(str(row.get("f14") or "")))
fuzzy = [
row for row in rows
if target and (
target in _normalize_sector(row.get("f14"))
or _normalize_sector(row.get("f14")) in target
)
]
return min(fuzzy, key=lambda row: len(_normalize_sector(row.get("f14")))) if fuzzy else None
def _number(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
+13
View File
@@ -0,0 +1,13 @@
"""Public market data, search, detail and chart feature."""
from .charts import ChartDataError, EastmoneyChartClient, MarketChartClient
from .repository import MarketRepositoryMixin
from .service import MarketServiceMixin
__all__ = [
"ChartDataError",
"EastmoneyChartClient",
"MarketChartClient",
"MarketRepositoryMixin",
"MarketServiceMixin",
]
+497
View File
@@ -0,0 +1,497 @@
from __future__ import annotations
import http.client
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, time as dt_time, timedelta
from threading import Lock
from typing import Any, ClassVar
from backend.data.providers.ifind_client import IfindError, IfindHttpClient
class ChartDataError(RuntimeError):
pass
TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get"
BOARD_LIST_URL = "https://push2delay.eastmoney.com/api/qt/clist/get"
BROWSER_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/138.0.0.0 Safari/537.36"
)
INDEX_SECIDS = {
"000001.SH": "1.000001",
"399001.SZ": "0.399001",
"399006.SZ": "0.399006",
}
class MarketChartClient:
"""Prefer iFinD for display charts and retain Eastmoney as a last resort."""
def __init__(self, ifind: IfindHttpClient, fallback: "EastmoneyChartClient") -> None:
self.ifind = ifind
self.fallback = fallback
def stock_intraday(self, code: str) -> dict[str, Any]:
normalized = str(code or "").strip()
if not re.fullmatch(r"\d{6}", normalized):
raise ChartDataError("Invalid stock code")
ifind_code = _stock_market_code(normalized)
try:
return self._ifind_intraday(ifind_code, "stock", normalized)
except (IfindError, ChartDataError):
return self.fallback.stock_intraday(normalized)
def stock_daily(self, code: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
normalized = str(code or "").strip()
if not re.fullmatch(r"\d{6}", normalized):
raise ChartDataError("Invalid stock code")
return self._ifind_daily(_stock_market_code(normalized), end_date, limit)
def index_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
normalized = str(identifier or "").strip().upper()
if normalized not in INDEX_SECIDS:
raise ChartDataError("Unsupported index")
return self._ifind_daily(normalized, end_date, limit)
def board_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
normalized = str(identifier or "").strip().upper()
if not normalized:
raise ChartDataError("Invalid board code")
return self._ifind_daily(normalized, end_date, limit)
def index_intraday(self, identifier: str) -> dict[str, Any]:
normalized = str(identifier or "").strip().upper()
if normalized not in INDEX_SECIDS:
raise ChartDataError("Unsupported index")
try:
return self._ifind_intraday(normalized, "index", normalized)
except (IfindError, ChartDataError):
return self.fallback.index_intraday(normalized)
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
normalized = str(identifier or "").strip().upper()
try:
return self._ifind_intraday(normalized, "board", normalized, name)
except (IfindError, ChartDataError):
return self.fallback.board_intraday(normalized, name)
def _ifind_intraday(
self,
ifind_code: str,
entity_type: str,
identifier: str,
name: str = "",
) -> dict[str, Any]:
if not self.ifind.configured:
raise ChartDataError("iFinD is not configured")
now = datetime.now().astimezone()
rows: list[dict[str, Any]] = []
for offset in range(0, 8):
candidate = now.date() - timedelta(days=offset)
if candidate.weekday() >= 5:
continue
display_date = candidate.isoformat()
rows = self.ifind.intraday(
ifind_code,
f"{display_date} 09:30:00",
f"{display_date} 15:00:00",
cache_ttl=20 if offset == 0 else 6 * 60 * 60,
)
if rows:
break
points = [point for row in rows if (point := _ifind_point(row))]
if not points:
raise ChartDataError("No iFinD intraday chart data returned")
latest_date = points[-1]["date"]
points = [point for point in points if point["date"] == latest_date]
previous_close = self._previous_close(ifind_code, latest_date, points[0]["open"])
return {
"entity_type": entity_type,
"identifier": identifier,
"name": name,
"code": identifier,
"trade_date": latest_date,
"previous_close": previous_close,
"points": points,
"source": "ifind",
}
def _ifind_daily(
self, ifind_code: str, end_date: str, limit: int
) -> list[dict[str, Any]]:
if not self.ifind.configured:
raise ChartDataError("iFinD is not configured")
compact_end = str(end_date or "").replace("-", "")
if not re.fullmatch(r"\d{8}", compact_end):
raise ChartDataError("Invalid chart end date")
end = datetime.strptime(compact_end, "%Y%m%d")
start = (end - timedelta(days=max(190, limit * 3))).strftime("%Y%m%d")
try:
rows = self.ifind.history(
ifind_code,
["open", "high", "low", "close", "volume", "amount"],
start,
compact_end,
cache_ttl=300,
)
except IfindError as exc:
raise ChartDataError("No iFinD daily chart data returned") from exc
normalized = []
for row in rows:
stamp = str(row.get("time") or "").strip()
trade_date = stamp[:10]
close = _number(row.get("close"))
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", trade_date) or close <= 0:
continue
normalized.append(
{
"trade_date": trade_date,
"open": _number(row.get("open")),
"high": _number(row.get("high")),
"low": _number(row.get("low")),
"close": close,
"volume": _number(row.get("volume")),
"amount_billion": _number(row.get("amount")) / 100_000_000,
}
)
normalized.sort(key=lambda row: row["trade_date"])
for index, row in enumerate(normalized):
previous = normalized[index - 1]["close"] if index > 0 else 0
row["change"] = round((row["close"] / previous - 1) * 100, 4) if previous else 0.0
market_now = datetime.now().astimezone()
today = market_now.strftime("%Y%m%d")
market_open = (
market_now.weekday() < 5
and market_now.time().replace(tzinfo=None) >= dt_time(9, 30)
)
today_display = market_now.date().isoformat()
if normalized and normalized[-1]["trade_date"] == today_display:
current_bar = normalized[-1]
current_bar_is_valid = (
current_bar["open"] > 0
and current_bar["high"] >= max(current_bar["open"], current_bar["close"])
and 0 < current_bar["low"] <= min(current_bar["open"], current_bar["close"])
and (current_bar["volume"] > 0 or current_bar["amount_billion"] > 0)
)
if not market_open or not current_bar_is_valid:
normalized.pop()
if compact_end == today and market_open:
try:
quote_rows = self.ifind.real_time(
ifind_code,
["open", "high", "low", "latest", "preClose", "volume", "amount"],
cache_ttl=10,
)
quote = quote_rows[0] if quote_rows else {}
latest = _number(quote.get("latest"))
previous = _number(quote.get("preClose"))
open_price = _number(quote.get("open"))
high = _number(quote.get("high"))
low = _number(quote.get("low"))
volume = _number(quote.get("volume"))
amount = _number(quote.get("amount"))
quote_date = str(quote.get("time") or "")[:10].replace("-", "")
quote_is_current = not quote_date or quote_date == today
has_market_activity = volume > 0 or amount > 0
if (
latest > 0
and open_price > 0
and high >= max(open_price, latest)
and 0 < low <= min(open_price, latest)
and has_market_activity
and quote_is_current
):
realtime = {
"trade_date": end.strftime("%Y-%m-%d"),
"open": open_price,
"high": high,
"low": low,
"close": latest,
"change": round((latest / previous - 1) * 100, 4) if previous else 0.0,
"volume": volume,
"amount_billion": amount / 100_000_000,
"realtime": True,
}
if normalized and normalized[-1]["trade_date"] == realtime["trade_date"]:
normalized[-1] = realtime
else:
normalized.append(realtime)
except IfindError:
pass
if not normalized:
raise ChartDataError("No iFinD daily chart data returned")
return normalized[-max(20, min(180, int(limit))):]
def _previous_close(self, code: str, trade_date: str, fallback: float) -> float:
today = datetime.now().astimezone().date().isoformat()
if trade_date == today:
try:
quote = self.ifind.real_time(code, ["preClose"], cache_ttl=20)
value = _number((quote[0] if quote else {}).get("preClose"))
if value > 0:
return value
except IfindError:
pass
end = datetime.strptime(trade_date, "%Y-%m-%d")
try:
rows = self.ifind.history(
code,
["close"],
(end - timedelta(days=12)).strftime("%Y%m%d"),
end.strftime("%Y%m%d"),
cache_ttl=6 * 60 * 60,
)
closes = [_number(row.get("close")) for row in rows if _number(row.get("close")) > 0]
if len(closes) >= 2:
return closes[-2]
except IfindError:
pass
return fallback
@dataclass
class EastmoneyChartClient:
"""Isolated display-only minute chart source.
The returned data must not be used by market snapshots, scoring, screening,
or divination. Its only consumer is a chart-rendering endpoint.
"""
timeout: int = 6
cache_ttl_seconds: int = 20
retry_attempts: int = 2
_cache: ClassVar[dict[str, dict[str, Any]]] = {}
_cache_lock: ClassVar[Lock] = Lock()
_board_catalog: ClassVar[dict[str, dict[str, str]]] = {}
_board_catalog_at: ClassVar[float] = 0.0
_board_catalog_lock: ClassVar[Lock] = Lock()
def stock_intraday(self, code: str) -> dict[str, Any]:
normalized = str(code or "").strip()
if not re.fullmatch(r"\d{6}", normalized):
raise ChartDataError("Invalid stock code")
market = "1" if normalized.startswith(("5", "6", "9")) else "0"
return self._intraday(f"{market}.{normalized}", "stock", normalized)
def index_intraday(self, identifier: str) -> dict[str, Any]:
normalized = str(identifier or "").strip().upper()
secid = INDEX_SECIDS.get(normalized)
if not secid:
raise ChartDataError("Unsupported index")
return self._intraday(secid, "index", normalized)
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
normalized = str(identifier or "").strip().upper()
if re.fullmatch(r"BK\d{4}", normalized):
board_code = normalized
else:
board_code = self._resolve_board_code(name or identifier)
return self._intraday(f"90.{board_code}", "board", board_code)
def _intraday(self, secid: str, entity_type: str, identifier: str) -> dict[str, Any]:
cache_key = f"{entity_type}:{identifier}"
cached = self._get_cached(cache_key)
if cached is not None:
return cached
payload = self._request_json(
TRENDS_URL,
{
"secid": secid,
"fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13",
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
"iscr": "0",
"ndays": "1",
},
"https://quote.eastmoney.com/",
)
data = payload.get("data") or {}
points = [point for raw in data.get("trends") or [] if (point := _parse_trend(raw))]
if not points:
raise ChartDataError("No intraday chart data returned")
result = {
"entity_type": entity_type,
"identifier": identifier,
"name": str(data.get("name") or ""),
"code": str(data.get("code") or identifier),
"trade_date": points[-1]["date"],
"previous_close": _number(data.get("preClose")),
"points": points,
}
with self._cache_lock:
self._cache[cache_key] = {"created_at": time.time(), "payload": result}
return result
def _get_cached(self, cache_key: str) -> dict[str, Any] | None:
with self._cache_lock:
cached = self._cache.get(cache_key)
if not cached:
return None
if time.time() - float(cached.get("created_at") or 0) > self.cache_ttl_seconds:
with self._cache_lock:
self._cache.pop(cache_key, None)
return None
return dict(cached["payload"])
def _resolve_board_code(self, name: str) -> str:
normalized = _normalize_name(name)
if not normalized:
raise ChartDataError("Board name is required")
catalog = self._load_board_catalog()
item = catalog.get(normalized)
if not item:
raise ChartDataError("No matching chart board")
return item["code"]
def _load_board_catalog(self) -> dict[str, dict[str, str]]:
now = time.time()
with self._board_catalog_lock:
if self._board_catalog and now - self._board_catalog_at < 6 * 60 * 60:
return dict(self._board_catalog)
rows: list[dict[str, Any]] = []
for board_type in ("1", "2", "3"):
for page in range(1, 6):
payload = self._request_json(
BOARD_LIST_URL,
{
"pn": str(page),
"pz": "100",
"po": "1",
"np": "1",
"fltt": "2",
"invt": "2",
"fid": "f3",
"fs": f"m:90+t:{board_type}",
"fields": "f12,f14",
},
"https://quote.eastmoney.com/center/boardlist.html",
)
page_rows = (payload.get("data") or {}).get("diff") or []
rows.extend(page_rows)
if len(page_rows) < 100:
break
catalog: dict[str, dict[str, str]] = {}
for row in rows:
code = str(row.get("f12") or "").strip().upper()
board_name = str(row.get("f14") or "").strip()
if re.fullmatch(r"BK\d{4}", code) and board_name:
catalog.setdefault(_normalize_name(board_name), {"code": code, "name": board_name})
if not catalog:
raise ChartDataError("Board chart directory is unavailable")
with self._board_catalog_lock:
type(self)._board_catalog = catalog
type(self)._board_catalog_at = now
return dict(catalog)
def _request_json(
self, url: str, params: dict[str, str], referer: str
) -> dict[str, Any]:
request_url = f"{url}?{urllib.parse.urlencode(params)}"
last_error: Exception | None = None
for attempt in range(max(1, int(self.retry_attempts))):
request = urllib.request.Request(
request_url,
headers={
"Accept": "application/json,text/plain,*/*",
"Connection": "close",
"Referer": referer,
"User-Agent": BROWSER_USER_AGENT,
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
if not isinstance(payload, dict):
raise ChartDataError("Invalid intraday chart response")
return payload
except (
urllib.error.URLError,
TimeoutError,
ConnectionError,
OSError,
http.client.HTTPException,
json.JSONDecodeError,
ChartDataError,
) as exc:
last_error = exc
if attempt + 1 < self.retry_attempts:
time.sleep(0.12)
raise ChartDataError("Intraday chart request failed") from last_error
def _parse_trend(raw: Any) -> dict[str, Any] | None:
fields = str(raw or "").split(",")
if len(fields) < 8 or " " not in fields[0]:
return None
stamp = fields[0].strip()
trade_date, trade_time = stamp.split(" ", 1)
close = _number(fields[2])
if close <= 0:
return None
return {
"date": trade_date,
"time": trade_time[:5],
"open": _number(fields[1]),
"close": close,
"high": _number(fields[3]),
"low": _number(fields[4]),
"volume": _number(fields[5]),
"amount": _number(fields[6]),
"average": _number(fields[7]),
}
def _ifind_point(row: dict[str, Any]) -> dict[str, Any] | None:
stamp = str(row.get("time") or "").strip()
if " " not in stamp:
return None
trade_date, trade_time = stamp.split(" ", 1)
close = _number(row.get("close"))
if close <= 0:
return None
return {
"date": trade_date,
"time": trade_time[:5],
"open": _number(row.get("open")),
"close": close,
"high": _number(row.get("high")),
"low": _number(row.get("low")),
"volume": _number(row.get("volume")),
"amount": _number(row.get("amount")),
"average": _number(row.get("avgPrice")),
}
def _stock_market_code(code: str) -> str:
if code.startswith(("4", "8", "9")):
suffix = "BJ"
elif code.startswith("6"):
suffix = "SH"
else:
suffix = "SZ"
return f"{code}.{suffix}"
def _number(value: Any) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def _normalize_name(value: Any) -> str:
normalized = re.sub(r"[\s·・()()\-_/]", "", str(value or "")).casefold()
return re.sub(r"(?:概念|行业|[ⅠⅡⅢ])$", "", normalized)
+222
View File
@@ -0,0 +1,222 @@
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
class MarketRepositoryMixin:
def get_snapshot(self, trade_date: str) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
"SELECT payload FROM dashboard_snapshots WHERE trade_date = ?",
(trade_date,),
).fetchone()
if not row:
return None
try:
return json.loads(row["payload"])
except json.JSONDecodeError:
return None
def get_latest_real_snapshot(
self, trade_date: str, strictly_before: bool = False
) -> dict[str, Any] | None:
operator = "<" if strictly_before else "<="
with self.connect() as connection:
row = connection.execute(
f"""
SELECT payload FROM dashboard_snapshots
WHERE trade_date {operator} ? AND source != 'demo'
ORDER BY trade_date DESC LIMIT 1
""",
(trade_date,),
).fetchone()
if not row:
return None
try:
return json.loads(row["payload"])
except json.JSONDecodeError:
return None
def save_snapshot(self, trade_date: str, source: str, payload: dict[str, Any]) -> None:
updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
record_count = sum(
len(payload.get(key) or [])
for key in ("limits", "broken", "down_limits", "yesterday_limits")
)
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
with self.connect() as connection:
connection.execute(
"""
INSERT INTO dashboard_snapshots
(trade_date, source, payload, record_count, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(trade_date) DO UPDATE SET
source = excluded.source,
payload = excluded.payload,
record_count = excluded.record_count,
updated_at = excluded.updated_at
""",
(trade_date, source, content, record_count, updated_at),
)
def get_data_snapshot(self, kind: str, cache_key: str) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
"SELECT payload FROM data_snapshots WHERE kind = ? AND cache_key = ?",
(kind, cache_key),
).fetchone()
if not row:
return None
try:
return json.loads(row["payload"])
except json.JSONDecodeError:
return None
def get_latest_data_snapshot(
self,
kind: str,
cache_key_prefix: str,
maximum_cache_key: str,
exclude_source: str = "",
) -> dict[str, Any] | None:
source_clause = " AND source != ?" if exclude_source else ""
parameters: list[Any] = [kind, f"{cache_key_prefix}%", maximum_cache_key]
if exclude_source:
parameters.append(exclude_source)
with self.connect() as connection:
row = connection.execute(
f"""
SELECT payload FROM data_snapshots
WHERE kind = ? AND cache_key LIKE ? AND cache_key <= ?{source_clause}
ORDER BY cache_key DESC LIMIT 1
""",
parameters,
).fetchone()
if not row:
return None
try:
return json.loads(row["payload"])
except json.JSONDecodeError:
return None
def save_data_snapshot(
self, kind: str, cache_key: str, source: str, payload: dict[str, Any]
) -> None:
updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
with self.connect() as connection:
connection.execute(
"""
INSERT INTO data_snapshots (kind, cache_key, source, payload, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(kind, cache_key) DO UPDATE SET
source = excluded.source,
payload = excluded.payload,
updated_at = excluded.updated_at
""",
(kind, cache_key, source, content, updated_at),
)
def search_stock_master(self, query: str, limit: int = 12) -> list[dict[str, Any]]:
text = str(query or "").strip()
if not text:
return []
escaped = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
with self.connect() as connection:
rows = connection.execute(
"""
SELECT ts_code, code, name, industry, market, list_date
FROM stock_master
WHERE code = ? OR name = ? OR name LIKE ? ESCAPE '\\'
ORDER BY
CASE WHEN code = ? THEN 0 WHEN name = ? THEN 1 ELSE 2 END,
list_date DESC,
code
LIMIT ?
""",
(text, text, f"%{escaped}%", text, text, max(1, min(30, int(limit)))),
).fetchall()
return [dict(row) for row in rows]
def list_snapshot_payloads(self, end_date: str, limit: int = 260) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT trade_date, payload FROM dashboard_snapshots
WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT ?
""",
(end_date, limit),
).fetchall()
result: list[dict[str, Any]] = []
for row in reversed(rows):
try:
payload = json.loads(row["payload"])
except json.JSONDecodeError:
continue
payload["_snapshot_date"] = row["trade_date"]
result.append(payload)
return result
def start_sync(self, trade_date: str, source: str) -> int:
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
cursor = connection.execute(
"""
INSERT INTO sync_runs (trade_date, source, status, started_at)
VALUES (?, ?, 'running', ?)
""",
(trade_date, source, started_at),
)
return int(cursor.lastrowid)
def finish_sync(
self,
sync_id: int,
status: str,
record_count: int = 0,
message: str = "",
source: str | None = None,
) -> None:
finished_at = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.execute(
"""
UPDATE sync_runs
SET status = ?, finished_at = ?, record_count = ?, message = ?,
source = COALESCE(?, source)
WHERE id = ?
""",
(status, finished_at, record_count, message[:1000], source, sync_id),
)
def status(self) -> dict[str, Any]:
with self.connect() as connection:
last_sync = connection.execute(
"""
SELECT id, trade_date, source, status, started_at, finished_at,
record_count, message
FROM sync_runs ORDER BY id DESC LIMIT 1
"""
).fetchone()
snapshot_stats = connection.execute(
"""
SELECT COUNT(*) AS dates, COALESCE(SUM(record_count), 0) AS records,
MAX(updated_at) AS updated_at
FROM dashboard_snapshots
"""
).fetchone()
watchlist_count = connection.execute("SELECT COUNT(*) FROM watchlist").fetchone()[0]
note_count = connection.execute("SELECT COUNT(*) FROM review_notes").fetchone()[0]
return {
"database": str(self.path.name),
"snapshot_dates": int(snapshot_stats["dates"]),
"snapshot_records": int(snapshot_stats["records"]),
"updated_at": snapshot_stats["updated_at"],
"last_sync": dict(last_sync) if last_sync else None,
"watchlist_count": int(watchlist_count),
"note_count": int(note_count),
}
+949
View File
@@ -0,0 +1,949 @@
from __future__ import annotations
import copy
import re
from datetime import date, datetime, time as dt_time, timedelta
from typing import Any
from backend.bootstrap.config import (
normalize_date,
tushare_code,
validate_stock_code,
validate_text,
)
from backend.data.providers.ifind_client import IfindError
from backend.data.providers.tushare_client import TushareClient, TushareError
from backend.features.market.charts import ChartDataError
from sentiment_engine import SENTIMENT_ENGINE_VERSION
SEARCH_INDEXES = (
{"id": "000001.SH", "code": "000001.SH", "name": "上证指数", "type": "index", "subtitle": "沪市综合指数"},
{"id": "399001.SZ", "code": "399001.SZ", "name": "深证成指", "type": "index", "subtitle": "深市成份指数"},
{"id": "399006.SZ", "code": "399006.SZ", "name": "创业板指", "type": "index", "subtitle": "创业板核心指数"},
)
SEARCH_TYPE_LABELS = {
"stock": "股票",
"sector": "板块",
"theme": "题材",
"index": "指数",
}
THS_SEARCH_TYPES = {
"I": ("sector", "行业板块"),
"R": ("sector", "地域板块"),
"N": ("theme", "概念题材"),
}
class MarketServiceMixin:
def _tushare_client(self) -> TushareClient:
gateway = getattr(self, "data_gateway", None)
if gateway is not None:
return gateway.tushare()
# Compatibility for isolated legacy unit-test service stubs.
return TushareClient(self.token)
def get_dashboard(self, trade_date: str, force: bool = False) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
now = datetime.now().astimezone()
if (
normalized_date == now.strftime("%Y%m%d")
and now.time().replace(tzinfo=None) < datetime.strptime("09:15", "%H:%M").time()
):
previous = self.database.get_latest_real_snapshot(normalized_date, strictly_before=True)
if previous:
carried = self._carry_dashboard(previous, normalized_date, "盘前沿用最近交易日收盘行情")
return self._apply_reason_overrides(self._with_storage(carried, cached=True))
if not force:
snapshot = self.database.get_snapshot(normalized_date)
if snapshot and str((snapshot.get("meta") or {}).get("source") or "") != "demo":
snapshot = copy.deepcopy(snapshot)
if normalized_date != now.strftime("%Y%m%d"):
snapshot.setdefault("meta", {}).update(
{"realtime": False, "market_status": "closed"}
)
if not self._dashboard_sentiment_ready(snapshot):
snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date)
self.database.save_snapshot(
normalized_date,
str((snapshot.get("meta") or {}).get("source") or "tushare"),
snapshot,
)
snapshot.setdefault("meta", {})["requested_date"] = self._display_compact_date(normalized_date)
return self._apply_reason_overrides(self._with_storage(snapshot, cached=True))
resolved = self.database.get_data_snapshot(
"dashboard_request_v1", normalized_date
)
if resolved and str((resolved.get("meta") or {}).get("source") or "") != "demo":
resolved = copy.deepcopy(resolved)
resolved.setdefault("meta", {})["requested_date"] = self._display_compact_date(
normalized_date
)
return self._apply_reason_overrides(
self._with_storage(resolved, cached=True)
)
if datetime.strptime(normalized_date, "%Y%m%d").weekday() >= 5:
previous = self.database.get_latest_real_snapshot(normalized_date)
if previous:
carried = self._carry_dashboard(
previous,
normalized_date,
"非交易日沿用最近交易日收盘行情",
)
self.database.save_data_snapshot(
"dashboard_request_v1", normalized_date, "sqlite", carried
)
return self._apply_reason_overrides(
self._with_storage(carried, cached=True)
)
return self.sync_dashboard(normalized_date)
@staticmethod
def _dashboard_sentiment_ready(dashboard: dict[str, Any]) -> bool:
overview = dashboard.get("overview") or {}
return int(overview.get("sentiment_engine_version") or 0) == SENTIMENT_ENGINE_VERSION and all(
key in overview
for key in (
"sentiment_score",
"sentiment_label",
"sentiment_phase",
"sentiment_direction",
"sentiment_components",
)
)
@staticmethod
def _display_compact_date(compact: str) -> str:
return f"{compact[:4]}-{compact[4:6]}-{compact[6:8]}"
def _carry_dashboard(
self, snapshot: dict[str, Any], requested_date: str, reason: str
) -> dict[str, Any]:
carried = copy.deepcopy(snapshot)
meta = carried.setdefault("meta", {})
meta.update(
{
"requested_date": self._display_compact_date(requested_date),
"carried_forward": True,
"realtime": False,
"market_status": "closed",
"notice": reason,
}
)
return carried
def _realtime_snapshot_due(
self,
normalized_date: str,
snapshot: dict[str, Any],
) -> bool:
if not self.configured or normalized_date != date.today().strftime("%Y%m%d"):
return False
now = datetime.now().astimezone()
local_time = now.time().replace(tzinfo=None)
realtime_start = datetime.strptime("09:15", "%H:%M").time()
morning_end = datetime.strptime("11:35", "%H:%M").time()
afternoon_start = datetime.strptime("12:55", "%H:%M").time()
realtime_end = datetime.strptime("15:05", "%H:%M").time()
in_session = (
realtime_start <= local_time < morning_end
or afternoon_start <= local_time < realtime_end
)
if not in_session:
return False
meta = snapshot.get("meta") or {}
snapshot_trade_date = str(meta.get("trade_date") or "").replace("-", "")
if snapshot_trade_date and snapshot_trade_date != normalized_date:
return False
if not meta.get("realtime"):
return True
try:
updated_at = datetime.fromisoformat(str(meta.get("updated_at") or ""))
if updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=now.tzinfo)
except ValueError:
return True
age_seconds = (now - updated_at.astimezone(now.tzinfo)).total_seconds()
return age_seconds >= 8
def sync_dashboard(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
source = "tushare"
with self.sync_lock:
sync_id = self.database.start_sync(normalized_date, source)
try:
if not self.configured:
raise TushareError("公共行情尚未配置")
dashboard = self._tushare_client().dashboard(normalized_date)
dashboard["meta"]["source"] = source
dashboard["meta"]["requested_date"] = self._display_compact_date(normalized_date)
dashboard = self._enrich_dashboard_sentiment(dashboard, normalized_date)
record_count = self._record_count(dashboard)
actual_date = normalize_date(
str(dashboard.get("meta", {}).get("trade_date") or normalized_date)
)
self.database.save_snapshot(actual_date, source, dashboard)
if actual_date != normalized_date:
dashboard.setdefault("meta", {}).update(
{
"carried_forward": True,
"realtime": False,
"market_status": "closed",
}
)
self.database.save_data_snapshot(
"dashboard_request_v1", normalized_date, source, dashboard
)
self.database.finish_sync(
sync_id,
"success",
record_count,
dashboard.get("meta", {}).get("notice", ""),
source,
)
return self._apply_reason_overrides(self._with_storage(dashboard, cached=False))
except TushareError as exc:
fallback = self.database.get_latest_real_snapshot(normalized_date)
if fallback:
carried = self._carry_dashboard(
fallback, normalized_date, f"最新行情暂不可用,沿用最近收盘快照:{exc}"
)
self.database.finish_sync(
sync_id, "fallback", self._record_count(carried), str(exc), "tushare"
)
return self._apply_reason_overrides(self._with_storage(carried, cached=True))
self.database.finish_sync(sync_id, "failed", message=str(exc))
raise ValueError("暂无可用的真实行情快照,请等待后台完成首次同步。") from exc
except Exception as exc:
self.database.finish_sync(sync_id, "failed", message=str(exc))
raise
def realtime_aggregate_health(self, sector: str = "") -> dict[str, Any]:
sector = validate_text(sector, "板块名称", 50)
return self.realtime_aggregator.health_snapshot(sector)
def _search_market_directory(self) -> list[dict[str, Any]]:
cached = self.database.get_data_snapshot("search_directory", "ths") or {}
cached_items = list(cached.get("items") or [])
if cached_items and int(cached.get("schema_version") or 0) >= 2:
return cached_items
if not self.configured:
return cached_items
try:
rows = self._tushare_client().query(
"ths_index",
{},
"ts_code,name,count,exchange,list_date,type",
)
except TushareError:
return cached_items
items = []
for row in rows:
mapping = THS_SEARCH_TYPES.get(str(row.get("type") or "").upper())
code = str(row.get("ts_code") or "").strip().upper()
name = str(row.get("name") or "").strip()
if not mapping or not code or not name or str(row.get("exchange") or "").upper() != "A":
continue
entity_type, subtitle = mapping
items.append(
{
"id": code,
"code": code,
"name": name,
"type": entity_type,
"subtitle": subtitle,
"member_count": int(float(row.get("count") or 0)),
}
)
if items:
self.database.save_data_snapshot(
"search_directory", "ths", "tushare", {"schema_version": 2, "items": items}
)
return items
@staticmethod
def _search_match_score(item: dict[str, Any], query: str) -> tuple[int, int, str]:
name = str(item.get("name") or "").casefold()
code = str(item.get("code") or item.get("id") or "").casefold()
needle = query.casefold()
if code == needle:
rank = 0
elif name == needle:
rank = 1
elif code.startswith(needle):
rank = 2
elif name.startswith(needle):
rank = 3
else:
rank = 4
return rank, len(name), code
def search_entities(self, query: str, trade_date: str) -> dict[str, Any]:
needle = str(query or "").strip()
normalized_date = normalize_date(trade_date)
groups: dict[str, list[dict[str, Any]]] = {
"stocks": [],
"sectors": [],
"themes": [],
"indices": [],
}
if not needle:
return {"query": "", "trade_date": normalized_date, "groups": groups}
stocks = []
for row in self.database.search_stock_master(needle, 12):
stocks.append(
{
"id": str(row.get("code") or ""),
"code": str(row.get("code") or ""),
"name": str(row.get("name") or "--"),
"type": "stock",
"type_label": SEARCH_TYPE_LABELS["stock"],
"industry": str(row.get("industry") or "其他"),
"market": str(row.get("market") or ""),
"subtitle": " · ".join(
part for part in (str(row.get("industry") or ""), str(row.get("market") or "")) if part
) or "A股",
}
)
groups["stocks"] = stocks[:8]
market_items = list(self._search_market_directory()) + [dict(item) for item in SEARCH_INDEXES]
matched = [
item for item in market_items
if needle.casefold() in str(item.get("name") or "").casefold()
or needle.casefold() in str(item.get("code") or "").casefold()
]
matched.sort(key=lambda item: self._search_match_score(item, needle))
group_keys = {"sector": "sectors", "theme": "themes", "index": "indices"}
for item in matched:
group_key = group_keys.get(str(item.get("type") or ""))
if not group_key or len(groups[group_key]) >= 8:
continue
groups[group_key].append(
{
**item,
"type_label": SEARCH_TYPE_LABELS[str(item["type"])],
}
)
return {"query": needle, "trade_date": normalized_date, "groups": groups}
def get_search_detail(
self, entity_type: str, identifier: str, trade_date: str
) -> dict[str, Any]:
entity_type = str(entity_type or "").strip().lower()
identifier = str(identifier or "").strip().upper()
normalized_date = normalize_date(trade_date)
if entity_type not in {"sector", "theme", "index"}:
raise ValueError("搜索详情类型不支持。")
if not re.fullmatch(r"[A-Z0-9.]{3,24}", identifier):
raise ValueError("搜索详情标识无效。")
if not self.configured:
raise ValueError("行情数据源尚未配置。")
if entity_type == "index":
index_basic = next((item for item in SEARCH_INDEXES if item["id"] == identifier), None)
if not index_basic:
raise ValueError("暂不支持该指数详情。")
return self._index_search_detail(index_basic, normalized_date)
directory = self._search_market_directory()
basic = next(
(
item for item in directory
if item.get("id") == identifier and item.get("type") == entity_type
),
None,
)
if not basic:
raise ValueError("未找到对应的板块或题材。")
return self._ths_search_detail(basic, normalized_date)
def get_intraday_chart(
self, entity_type: str, identifier: str
) -> dict[str, Any]:
entity_type = str(entity_type or "").strip().lower()
identifier = str(identifier or "").strip().upper()
if entity_type == "stock":
code = validate_stock_code(identifier)
chart = self.chart_data.stock_intraday(code)
type_label = SEARCH_TYPE_LABELS["stock"]
elif entity_type == "index":
basic = next((item for item in SEARCH_INDEXES if item["id"] == identifier), None)
if not basic:
raise ValueError("暂不支持该指数分时行情。")
chart = self.chart_data.index_intraday(identifier)
type_label = SEARCH_TYPE_LABELS["index"]
elif entity_type in {"sector", "theme"}:
basic = next(
(
item for item in self._search_market_directory()
if item.get("id") == identifier and item.get("type") == entity_type
),
None,
)
if not basic:
raise ValueError("未找到对应的板块或题材。")
chart = self.chart_data.board_intraday(identifier, str(basic.get("name") or ""))
type_label = SEARCH_TYPE_LABELS[entity_type]
else:
raise ValueError("分时行情类型不支持。")
return {
"meta": {
"trade_date": str(chart.get("trade_date") or ""),
"previous_close": float(chart.get("previous_close") or 0),
},
"entity": {
"id": identifier,
"code": str(chart.get("code") or identifier),
"name": str(chart.get("name") or ""),
"type": entity_type,
"type_label": type_label,
},
"points": list(chart.get("points") or []),
}
def _ths_search_detail(
self, basic: dict[str, Any], trade_date: str
) -> dict[str, Any]:
client = self._tushare_client()
resolved_date, _ = client.resolve_trade_context(trade_date)
end = datetime.strptime(resolved_date, "%Y%m%d")
start_date = (end - timedelta(days=190)).strftime("%Y%m%d")
identifier = str(basic["id"])
snapshot = client.sector_snapshot(identifier, resolved_date)
rows = client.query(
"ths_daily",
{"ts_code": identifier, "start_date": start_date, "end_date": resolved_date},
"ts_code,trade_date,open,high,low,close,pct_change,vol,turnover_rate,total_mv,float_mv",
)
rows.sort(key=lambda item: str(item.get("trade_date") or ""))
series = [
{
"trade_date": self._display_compact_date(str(row.get("trade_date") or "")),
"open": float(row.get("open") or 0),
"high": float(row.get("high") or 0),
"low": float(row.get("low") or 0),
"close": float(row.get("close") or 0),
"change": float(row.get("pct_change") or 0),
"volume": float(row.get("vol") or 0),
"turnover_rate": float(row.get("turnover_rate") or 0),
}
for row in rows[-90:]
]
try:
chart_series = self.chart_data.board_daily(identifier, resolved_date, 90)
if chart_series:
series = chart_series
except (AttributeError, ChartDataError):
pass
latest = series[-1] if series else {}
snapshot_is_current = str(snapshot.get("trade_date") or "").replace("-", "") == resolved_date
change = float(
snapshot.get("change")
if snapshot_is_current and snapshot.get("change") is not None
else latest.get("change") or 0
)
if latest.get("realtime"):
change = float(latest.get("change") or 0)
turnover_rate = float(
snapshot.get("turnover_rate")
if snapshot_is_current and snapshot.get("turnover_rate") is not None
else latest.get("turnover_rate") or 0
)
metrics = [
{"label": "涨跌幅", "value": round(change, 2), "unit": "%", "tone": "change"},
{"label": "换手率", "value": round(turnover_rate, 2), "unit": "%"},
{"label": "成份数量", "value": int(float(basic.get("member_count") or 0)), "unit": ""},
]
up_count = int(float(snapshot.get("up_count") or 0))
down_count = int(float(snapshot.get("down_count") or 0))
if up_count or down_count:
metrics.extend(
[
{"label": "上涨家数", "value": up_count, "unit": ""},
{"label": "下跌家数", "value": down_count, "unit": ""},
]
)
leader = str(snapshot.get("leader") or "").strip()
if leader and leader != "--":
metrics.extend(
[
{"label": "领涨标的", "value": leader, "unit": ""},
{"label": "领涨幅", "value": round(float(snapshot.get("leading_pct") or 0), 2), "unit": "%", "tone": "change"},
]
)
return {
"meta": {
"trade_date": self._display_compact_date(resolved_date),
"realtime": bool(snapshot.get("realtime")),
},
"entity": {
"id": identifier,
"code": identifier,
"name": str(snapshot.get("name") or basic.get("name") or "--"),
"type": str(basic.get("type") or "sector"),
"type_label": SEARCH_TYPE_LABELS[str(basic.get("type") or "sector")],
"subtitle": str(basic.get("subtitle") or ""),
"value": float(latest.get("close") or 0),
"change": change,
},
"series": series,
"metrics": metrics,
}
def _index_search_detail(
self, basic: dict[str, Any], trade_date: str
) -> dict[str, Any]:
client = self._tushare_client()
resolved_date, _ = client.resolve_trade_context(trade_date)
payload = (
client.realtime_market_indices(resolved_date)
if client.should_use_realtime(trade_date, resolved_date)
else client.market_indices(resolved_date, 90)
)
current = next(
(item for item in payload.get("indices") or [] if item.get("ts_code") == basic["id"]),
None,
)
if not current:
raise ValueError("该指数暂无可用行情。")
end = datetime.strptime(resolved_date, "%Y%m%d")
rows = client.query(
"index_daily",
{
"ts_code": basic["id"],
"start_date": (end - timedelta(days=190)).strftime("%Y%m%d"),
"end_date": resolved_date,
},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
)
rows.sort(key=lambda item: str(item.get("trade_date") or ""))
series = [
{
"trade_date": self._display_compact_date(str(row.get("trade_date") or "")),
"open": float(row.get("open") or 0),
"high": float(row.get("high") or 0),
"low": float(row.get("low") or 0),
"close": float(row.get("close") or 0),
"change": float(row.get("pct_chg") or 0),
"volume": float(row.get("vol") or 0),
}
for row in rows[-90:]
]
try:
chart_series = self.chart_data.index_daily(str(basic["id"]), resolved_date, 90)
if chart_series:
series = chart_series
except (AttributeError, ChartDataError):
pass
latest = series[-1] if series else {}
latest_close = float(latest.get("close") or current.get("close") or 0)
latest_change = float(latest.get("change") or current.get("pct_chg") or 0)
def series_return(days: int) -> float:
if len(series) <= days:
return 0.0
previous = float(series[-days - 1].get("close") or 0)
return (latest_close / previous - 1) * 100 if previous > 0 else 0.0
return {
"meta": {
"trade_date": self._display_compact_date(str(current.get("trade_date") or resolved_date)),
"realtime": bool(payload.get("realtime")),
},
"entity": {
**basic,
"type_label": SEARCH_TYPE_LABELS["index"],
"value": latest_close,
"change": latest_change,
},
"series": series,
"metrics": [
{"label": "涨跌幅", "value": round(latest_change, 2), "unit": "%", "tone": "change"},
{"label": "近5日", "value": round(series_return(5), 2), "unit": "%", "tone": "change"},
{"label": "近20日", "value": round(series_return(20), 2), "unit": "%", "tone": "change"},
{"label": "成交额", "value": round(float(current.get("amount_billion") or 0), 2), "unit": "亿"},
],
}
def get_stock_detail(
self, code: str, trade_date: str, force: bool = False
) -> dict[str, Any]:
code = validate_stock_code(code)
normalized_date = normalize_date(trade_date)
cache_key = f"{code}:{normalized_date}"
if not force:
cached = self.database.get_data_snapshot("stock_detail", cache_key)
if cached and str((cached.get("meta") or {}).get("source") or "") != "demo":
if not self._stock_detail_cache_needs_refresh(cached, normalized_date):
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return self._prepare_stock_detail(cached, code, normalized_date)
name, sector = self._stock_identity(code, normalized_date)
source = "tushare"
if self.configured:
try:
payload = self._tushare_client().stock_detail(
tushare_code(code), normalized_date
)
if not payload.get("prices"):
raise TushareError("No price history returned")
except TushareError as exc:
payload = self.database.get_latest_data_snapshot(
"stock_detail", f"{code}:", cache_key, exclude_source="demo"
)
if not payload:
raise ValueError(f"暂无 {code} 的真实行情数据:{exc}") from exc
payload = copy.deepcopy(payload)
payload["meta"] = {
**payload.get("meta", {}),
"cached": True,
"notice": "最新行情暂不可用,已沿用最近真实收盘数据。",
}
return self._prepare_stock_detail(payload, code, normalized_date)
else:
payload = self.database.get_latest_data_snapshot(
"stock_detail", f"{code}:", cache_key, exclude_source="demo"
)
if not payload:
raise ValueError(f"暂无 {code} 的真实行情数据,请等待后台完成首次同步。")
payload = copy.deepcopy(payload)
payload["meta"] = {
**payload.get("meta", {}),
"cached": True,
"notice": "公共行情尚未配置,已沿用最近真实收盘数据。",
}
return self._prepare_stock_detail(payload, code, normalized_date)
payload["meta"]["source"] = source
payload["meta"]["cached"] = False
self.database.save_data_snapshot("stock_detail", cache_key, source, payload)
return self._prepare_stock_detail(payload, code, normalized_date)
@staticmethod
def _stock_detail_bar_date(payload: dict[str, Any]) -> str:
prices = list(payload.get("prices") or [])
return str((prices[-1] if prices else {}).get("trade_date") or "").replace("-", "")
def _stock_detail_cache_needs_refresh(
self, payload: dict[str, Any], requested_date: str
) -> bool:
now = datetime.now().astimezone()
return (
requested_date == now.strftime("%Y%m%d")
and now.time().replace(tzinfo=None) >= dt_time(15, 0)
and self._stock_detail_bar_date(payload) < requested_date
)
def _prepare_stock_detail(
self, payload: dict[str, Any], code: str, requested_date: str
) -> dict[str, Any]:
result = copy.deepcopy(payload)
now = datetime.now().astimezone()
try:
result["prices"] = self.chart_data.stock_daily(code, requested_date, 90)
result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"}
except (AttributeError, ChartDataError):
pass
result = self._sanitize_stock_detail_prices(result, now)
actual_date = self._stock_detail_bar_date(result)
if actual_date:
result["meta"] = {
**(result.get("meta") or {}),
"trade_date": f"{actual_date[:4]}-{actual_date[4:6]}-{actual_date[6:]}",
}
today = now.strftime("%Y%m%d")
should_merge = (
requested_date == today
and actual_date <= today
and now.weekday() < 5
and now.time().replace(tzinfo=None) >= dt_time(9, 30)
)
if should_merge:
quote = self._ifind_realtime_stock_quote(code)
if quote and self._valid_realtime_stock_quote(quote, today):
self._merge_realtime_stock_detail(result, quote, requested_date)
elif self.configured and actual_date < today:
client = self._tushare_client()
try:
resolved_date, _ = client.resolve_trade_context(requested_date)
if resolved_date == today:
quote = client.realtime_stock_quote(tushare_code(code), requested_date)
if self._valid_realtime_stock_quote(quote, today):
self._merge_realtime_stock_detail(result, quote, requested_date)
except TushareError:
pass
return self._enrich_stock_detail(result)
@staticmethod
def _sanitize_stock_detail_prices(
payload: dict[str, Any], market_now: datetime
) -> dict[str, Any]:
result = copy.deepcopy(payload)
raw_prices = list(result.get("prices") or [])
raw_latest_date = str(
(raw_prices[-1] if raw_prices else {}).get("trade_date") or ""
).replace("-", "")
prices = []
for bar in raw_prices:
open_price = float(bar.get("open") or 0)
high = float(bar.get("high") or 0)
low = float(bar.get("low") or 0)
close = float(bar.get("close") or 0)
if (
open_price > 0
and high >= max(open_price, close)
and 0 < low <= min(open_price, close)
and close > 0
):
prices.append(bar)
today = market_now.strftime("%Y%m%d")
market_open = (
market_now.weekday() < 5
and market_now.time().replace(tzinfo=None) >= dt_time(9, 30)
)
if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == today:
current = prices[-1]
has_market_activity = (
float(current.get("volume") or 0) > 0
or float(current.get("amount_billion") or 0) > 0
)
if not market_open or not has_market_activity:
prices.pop()
if raw_latest_date == today and (
not prices
or str(prices[-1].get("trade_date") or "").replace("-", "") != today
):
result["meta"] = {**(result.get("meta") or {}), "realtime": False}
result["prices"] = prices
if prices:
latest = prices[-1]
stock = dict(result.get("stock") or {})
stock.update(
{
"price": float(latest.get("close") or 0),
"change": float(latest.get("change") or 0),
"amount_billion": float(latest.get("amount_billion") or 0),
}
)
result["stock"] = stock
return result
@staticmethod
def _valid_realtime_stock_quote(quote: dict[str, Any], trade_date: str) -> bool:
price = float(quote.get("price") or 0)
open_price = float(quote.get("open") or 0)
high = float(quote.get("high") or 0)
low = float(quote.get("low") or 0)
volume = float(quote.get("volume") or 0)
amount = float(quote.get("amount_billion") or 0)
quote_date = str(quote.get("quote_time") or "")[:10].replace("-", "")
return (
price > 0
and open_price > 0
and high >= max(open_price, price)
and 0 < low <= min(open_price, price)
and (volume > 0 or amount > 0)
and (not quote_date or quote_date == trade_date)
)
def _ifind_realtime_stock_quote(self, code: str) -> dict[str, Any] | None:
ifind = getattr(self, "ifind", None)
if not ifind or not ifind.configured:
return None
try:
rows = ifind.real_time(
tushare_code(code),
[
"open", "high", "low", "latest", "preClose",
"volume", "amount", "turnoverRatio",
],
cache_ttl=10,
)
except IfindError:
return None
row = rows[0] if rows else {}
price = float(row.get("latest") or 0)
previous_close = float(row.get("preClose") or 0)
if price <= 0:
return None
change = (price / previous_close - 1) * 100 if previous_close > 0 else 0.0
stock = self._stock_identity(code, date.today().strftime("%Y%m%d"))
return {
"name": stock[0],
"sector": stock[1],
"price": price,
"open": float(row.get("open") or price),
"high": float(row.get("high") or price),
"low": float(row.get("low") or price),
"change": round(change, 4),
"volume": float(row.get("volume") or 0),
"volume_unit": "lots",
"amount_billion": float(row.get("amount") or 0) / 100_000_000,
"turnover_rate": float(row.get("turnoverRatio") or 0),
"quote_time": str(row.get("time") or ""),
}
@staticmethod
def _merge_realtime_stock_detail(
payload: dict[str, Any], quote: dict[str, Any], trade_date: str
) -> None:
display_date = f"{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:]}"
realtime_bar = {
"trade_date": display_date,
"open": quote["open"],
"high": quote["high"],
"low": quote["low"],
"close": quote["price"],
"change": quote["change"],
"volume": quote["volume"] if quote.get("volume_unit") == "lots" else quote["volume"] / 100,
"amount_billion": quote["amount_billion"],
"realtime": True,
}
prices = list(payload.get("prices") or [])
if prices and str(prices[-1].get("trade_date") or "").replace("-", "") == trade_date:
prices[-1] = realtime_bar
else:
prices.append(realtime_bar)
payload["prices"] = prices[-90:]
stock = dict(payload.get("stock") or {})
stock.update(
{
"name": quote["name"],
"industry": quote["sector"],
"price": quote["price"],
"change": quote["change"],
"amount_billion": quote["amount_billion"],
"turnover_rate": quote["turnover_rate"],
}
)
payload["stock"] = stock
payload["meta"] = {
**(payload.get("meta") or {}),
"trade_date": display_date,
"realtime": True,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
def get_stock_preview(
self, code: str, trade_date: str, force: bool = False
) -> dict[str, Any]:
code = validate_stock_code(code)
# Hover previews deliberately follow the latest market day, independent
# from the review date selected by the page.
detail = self.get_stock_detail(code, date.today().strftime("%Y%m%d"), force)
detail_meta = detail.get("meta") or {}
resolved_date = str(detail_meta.get("trade_date") or trade_date)
intraday_points: list[dict[str, Any]] = []
intraday_status = "unavailable"
intraday_notice = "分时行情暂不可用。"
intraday_trade_date = ""
intraday_previous_close = 0.0
try:
intraday = self.chart_data.stock_intraday(code)
intraday_points = list(intraday.get("points") or [])
intraday_trade_date = str(intraday.get("trade_date") or "")
intraday_previous_close = float(intraday.get("previous_close") or 0)
if intraday_points:
intraday_status = "available"
intraday_notice = ""
else:
intraday_status = "empty"
intraday_notice = "最近交易日暂无分时数据。"
except ChartDataError:
intraday_status = "unavailable"
intraday_notice = "分时行情暂不可用,请稍后重试。"
prices = list(detail.get("prices") or [])[-60:]
stock = dict(detail.get("stock") or {"code": code})
realtime = bool(detail_meta.get("realtime"))
return {
"meta": {
"trade_date": resolved_date,
"source": detail_meta.get("source") or "unavailable",
"notice": detail_meta.get("notice") or "",
"intraday_status": intraday_status,
"intraday_notice": intraday_notice,
"intraday_trade_date": intraday_trade_date,
"intraday_previous_close": intraday_previous_close,
"realtime": realtime,
"refresh_interval_seconds": 10 if realtime else 0,
},
"stock": stock,
"prices": prices,
"intraday": intraday_points,
}
def backfill(self, start_date: str, end_date: str) -> list[dict[str, Any]]:
start = datetime.strptime(normalize_date(start_date), "%Y%m%d").date()
end = datetime.strptime(normalize_date(end_date), "%Y%m%d").date()
if start > end:
raise ValueError("开始日期不能晚于结束日期。")
weekdays = []
current = start
while current <= end:
if current.weekday() < 5:
weekdays.append(current)
current += timedelta(days=1)
if len(weekdays) > 15:
raise ValueError("单次最多回补 15 个工作日。")
results = []
for day in weekdays:
dashboard = self.sync_dashboard(day.strftime("%Y%m%d"))
results.append(
{
"requested_date": day.isoformat(),
"trade_date": dashboard["meta"]["trade_date"],
"source": dashboard["meta"]["source"],
"records": self._record_count(dashboard),
}
)
return results
def _stock_identity(self, code: str, trade_date: str) -> tuple[str, str]:
snapshot = self.database.get_snapshot(trade_date) or {}
for key in ("limits", "broken", "down_limits"):
for row in snapshot.get(key) or []:
if str(row.get("code")) == code:
return row.get("name") or "--", row.get("sector") or "其他"
for item in self.database.list_watchlist(self.current_user_id):
if item["code"] == code:
return item["name"], item["sector"] or "其他"
return "--", "其他"
def _enrich_stock_detail(self, payload: dict[str, Any]) -> dict[str, Any]:
result = dict(payload)
stock = dict(payload.get("stock") or {})
code = str(stock.get("code") or "")
watched = {
item["code"]: item
for item in self.database.list_watchlist(self.current_user_id)
}
stock["watchlist"] = watched.get(code)
result["stock"] = stock
result["notes"] = self.database.list_notes(self.current_user_id, code=code)
return result
def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]:
result = dict(dashboard)
result["meta"] = {
**dashboard.get("meta", {}),
"storage": "sqlite",
"cached": cached,
}
return result
@staticmethod
def _record_count(dashboard: dict[str, Any]) -> int:
return sum(
len(dashboard.get(key) or [])
for key in ("limits", "broken", "down_limits", "yesterday_limits")
)
+4 -494
View File
@@ -1,497 +1,7 @@
from __future__ import annotations
"""Compatibility alias for the canonical market chart clients."""
import http.client
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, time as dt_time, timedelta
from threading import Lock
from typing import Any, ClassVar
import sys
from ifind_client import IfindError, IfindHttpClient
from backend.features.market import charts as _implementation
class ChartDataError(RuntimeError):
pass
TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get"
BOARD_LIST_URL = "https://push2delay.eastmoney.com/api/qt/clist/get"
BROWSER_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/138.0.0.0 Safari/537.36"
)
INDEX_SECIDS = {
"000001.SH": "1.000001",
"399001.SZ": "0.399001",
"399006.SZ": "0.399006",
}
class MarketChartClient:
"""Prefer iFinD for display charts and retain Eastmoney as a last resort."""
def __init__(self, ifind: IfindHttpClient, fallback: "EastmoneyChartClient") -> None:
self.ifind = ifind
self.fallback = fallback
def stock_intraday(self, code: str) -> dict[str, Any]:
normalized = str(code or "").strip()
if not re.fullmatch(r"\d{6}", normalized):
raise ChartDataError("Invalid stock code")
ifind_code = _stock_market_code(normalized)
try:
return self._ifind_intraday(ifind_code, "stock", normalized)
except (IfindError, ChartDataError):
return self.fallback.stock_intraday(normalized)
def stock_daily(self, code: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
normalized = str(code or "").strip()
if not re.fullmatch(r"\d{6}", normalized):
raise ChartDataError("Invalid stock code")
return self._ifind_daily(_stock_market_code(normalized), end_date, limit)
def index_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
normalized = str(identifier or "").strip().upper()
if normalized not in INDEX_SECIDS:
raise ChartDataError("Unsupported index")
return self._ifind_daily(normalized, end_date, limit)
def board_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
normalized = str(identifier or "").strip().upper()
if not normalized:
raise ChartDataError("Invalid board code")
return self._ifind_daily(normalized, end_date, limit)
def index_intraday(self, identifier: str) -> dict[str, Any]:
normalized = str(identifier or "").strip().upper()
if normalized not in INDEX_SECIDS:
raise ChartDataError("Unsupported index")
try:
return self._ifind_intraday(normalized, "index", normalized)
except (IfindError, ChartDataError):
return self.fallback.index_intraday(normalized)
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
normalized = str(identifier or "").strip().upper()
try:
return self._ifind_intraday(normalized, "board", normalized, name)
except (IfindError, ChartDataError):
return self.fallback.board_intraday(normalized, name)
def _ifind_intraday(
self,
ifind_code: str,
entity_type: str,
identifier: str,
name: str = "",
) -> dict[str, Any]:
if not self.ifind.configured:
raise ChartDataError("iFinD is not configured")
now = datetime.now().astimezone()
rows: list[dict[str, Any]] = []
for offset in range(0, 8):
candidate = now.date() - timedelta(days=offset)
if candidate.weekday() >= 5:
continue
display_date = candidate.isoformat()
rows = self.ifind.intraday(
ifind_code,
f"{display_date} 09:30:00",
f"{display_date} 15:00:00",
cache_ttl=20 if offset == 0 else 6 * 60 * 60,
)
if rows:
break
points = [point for row in rows if (point := _ifind_point(row))]
if not points:
raise ChartDataError("No iFinD intraday chart data returned")
latest_date = points[-1]["date"]
points = [point for point in points if point["date"] == latest_date]
previous_close = self._previous_close(ifind_code, latest_date, points[0]["open"])
return {
"entity_type": entity_type,
"identifier": identifier,
"name": name,
"code": identifier,
"trade_date": latest_date,
"previous_close": previous_close,
"points": points,
"source": "ifind",
}
def _ifind_daily(
self, ifind_code: str, end_date: str, limit: int
) -> list[dict[str, Any]]:
if not self.ifind.configured:
raise ChartDataError("iFinD is not configured")
compact_end = str(end_date or "").replace("-", "")
if not re.fullmatch(r"\d{8}", compact_end):
raise ChartDataError("Invalid chart end date")
end = datetime.strptime(compact_end, "%Y%m%d")
start = (end - timedelta(days=max(190, limit * 3))).strftime("%Y%m%d")
try:
rows = self.ifind.history(
ifind_code,
["open", "high", "low", "close", "volume", "amount"],
start,
compact_end,
cache_ttl=300,
)
except IfindError as exc:
raise ChartDataError("No iFinD daily chart data returned") from exc
normalized = []
for row in rows:
stamp = str(row.get("time") or "").strip()
trade_date = stamp[:10]
close = _number(row.get("close"))
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", trade_date) or close <= 0:
continue
normalized.append(
{
"trade_date": trade_date,
"open": _number(row.get("open")),
"high": _number(row.get("high")),
"low": _number(row.get("low")),
"close": close,
"volume": _number(row.get("volume")),
"amount_billion": _number(row.get("amount")) / 100_000_000,
}
)
normalized.sort(key=lambda row: row["trade_date"])
for index, row in enumerate(normalized):
previous = normalized[index - 1]["close"] if index > 0 else 0
row["change"] = round((row["close"] / previous - 1) * 100, 4) if previous else 0.0
market_now = datetime.now().astimezone()
today = market_now.strftime("%Y%m%d")
market_open = (
market_now.weekday() < 5
and market_now.time().replace(tzinfo=None) >= dt_time(9, 30)
)
today_display = market_now.date().isoformat()
if normalized and normalized[-1]["trade_date"] == today_display:
current_bar = normalized[-1]
current_bar_is_valid = (
current_bar["open"] > 0
and current_bar["high"] >= max(current_bar["open"], current_bar["close"])
and 0 < current_bar["low"] <= min(current_bar["open"], current_bar["close"])
and (current_bar["volume"] > 0 or current_bar["amount_billion"] > 0)
)
if not market_open or not current_bar_is_valid:
normalized.pop()
if compact_end == today and market_open:
try:
quote_rows = self.ifind.real_time(
ifind_code,
["open", "high", "low", "latest", "preClose", "volume", "amount"],
cache_ttl=10,
)
quote = quote_rows[0] if quote_rows else {}
latest = _number(quote.get("latest"))
previous = _number(quote.get("preClose"))
open_price = _number(quote.get("open"))
high = _number(quote.get("high"))
low = _number(quote.get("low"))
volume = _number(quote.get("volume"))
amount = _number(quote.get("amount"))
quote_date = str(quote.get("time") or "")[:10].replace("-", "")
quote_is_current = not quote_date or quote_date == today
has_market_activity = volume > 0 or amount > 0
if (
latest > 0
and open_price > 0
and high >= max(open_price, latest)
and 0 < low <= min(open_price, latest)
and has_market_activity
and quote_is_current
):
realtime = {
"trade_date": end.strftime("%Y-%m-%d"),
"open": open_price,
"high": high,
"low": low,
"close": latest,
"change": round((latest / previous - 1) * 100, 4) if previous else 0.0,
"volume": volume,
"amount_billion": amount / 100_000_000,
"realtime": True,
}
if normalized and normalized[-1]["trade_date"] == realtime["trade_date"]:
normalized[-1] = realtime
else:
normalized.append(realtime)
except IfindError:
pass
if not normalized:
raise ChartDataError("No iFinD daily chart data returned")
return normalized[-max(20, min(180, int(limit))):]
def _previous_close(self, code: str, trade_date: str, fallback: float) -> float:
today = datetime.now().astimezone().date().isoformat()
if trade_date == today:
try:
quote = self.ifind.real_time(code, ["preClose"], cache_ttl=20)
value = _number((quote[0] if quote else {}).get("preClose"))
if value > 0:
return value
except IfindError:
pass
end = datetime.strptime(trade_date, "%Y-%m-%d")
try:
rows = self.ifind.history(
code,
["close"],
(end - timedelta(days=12)).strftime("%Y%m%d"),
end.strftime("%Y%m%d"),
cache_ttl=6 * 60 * 60,
)
closes = [_number(row.get("close")) for row in rows if _number(row.get("close")) > 0]
if len(closes) >= 2:
return closes[-2]
except IfindError:
pass
return fallback
@dataclass
class EastmoneyChartClient:
"""Isolated display-only minute chart source.
The returned data must not be used by market snapshots, scoring, screening,
or divination. Its only consumer is a chart-rendering endpoint.
"""
timeout: int = 6
cache_ttl_seconds: int = 20
retry_attempts: int = 2
_cache: ClassVar[dict[str, dict[str, Any]]] = {}
_cache_lock: ClassVar[Lock] = Lock()
_board_catalog: ClassVar[dict[str, dict[str, str]]] = {}
_board_catalog_at: ClassVar[float] = 0.0
_board_catalog_lock: ClassVar[Lock] = Lock()
def stock_intraday(self, code: str) -> dict[str, Any]:
normalized = str(code or "").strip()
if not re.fullmatch(r"\d{6}", normalized):
raise ChartDataError("Invalid stock code")
market = "1" if normalized.startswith(("5", "6", "9")) else "0"
return self._intraday(f"{market}.{normalized}", "stock", normalized)
def index_intraday(self, identifier: str) -> dict[str, Any]:
normalized = str(identifier or "").strip().upper()
secid = INDEX_SECIDS.get(normalized)
if not secid:
raise ChartDataError("Unsupported index")
return self._intraday(secid, "index", normalized)
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
normalized = str(identifier or "").strip().upper()
if re.fullmatch(r"BK\d{4}", normalized):
board_code = normalized
else:
board_code = self._resolve_board_code(name or identifier)
return self._intraday(f"90.{board_code}", "board", board_code)
def _intraday(self, secid: str, entity_type: str, identifier: str) -> dict[str, Any]:
cache_key = f"{entity_type}:{identifier}"
cached = self._get_cached(cache_key)
if cached is not None:
return cached
payload = self._request_json(
TRENDS_URL,
{
"secid": secid,
"fields1": "f1,f2,f3,f4,f5,f6,f7,f8,f9,f10,f11,f12,f13",
"fields2": "f51,f52,f53,f54,f55,f56,f57,f58",
"iscr": "0",
"ndays": "1",
},
"https://quote.eastmoney.com/",
)
data = payload.get("data") or {}
points = [point for raw in data.get("trends") or [] if (point := _parse_trend(raw))]
if not points:
raise ChartDataError("No intraday chart data returned")
result = {
"entity_type": entity_type,
"identifier": identifier,
"name": str(data.get("name") or ""),
"code": str(data.get("code") or identifier),
"trade_date": points[-1]["date"],
"previous_close": _number(data.get("preClose")),
"points": points,
}
with self._cache_lock:
self._cache[cache_key] = {"created_at": time.time(), "payload": result}
return result
def _get_cached(self, cache_key: str) -> dict[str, Any] | None:
with self._cache_lock:
cached = self._cache.get(cache_key)
if not cached:
return None
if time.time() - float(cached.get("created_at") or 0) > self.cache_ttl_seconds:
with self._cache_lock:
self._cache.pop(cache_key, None)
return None
return dict(cached["payload"])
def _resolve_board_code(self, name: str) -> str:
normalized = _normalize_name(name)
if not normalized:
raise ChartDataError("Board name is required")
catalog = self._load_board_catalog()
item = catalog.get(normalized)
if not item:
raise ChartDataError("No matching chart board")
return item["code"]
def _load_board_catalog(self) -> dict[str, dict[str, str]]:
now = time.time()
with self._board_catalog_lock:
if self._board_catalog and now - self._board_catalog_at < 6 * 60 * 60:
return dict(self._board_catalog)
rows: list[dict[str, Any]] = []
for board_type in ("1", "2", "3"):
for page in range(1, 6):
payload = self._request_json(
BOARD_LIST_URL,
{
"pn": str(page),
"pz": "100",
"po": "1",
"np": "1",
"fltt": "2",
"invt": "2",
"fid": "f3",
"fs": f"m:90+t:{board_type}",
"fields": "f12,f14",
},
"https://quote.eastmoney.com/center/boardlist.html",
)
page_rows = (payload.get("data") or {}).get("diff") or []
rows.extend(page_rows)
if len(page_rows) < 100:
break
catalog: dict[str, dict[str, str]] = {}
for row in rows:
code = str(row.get("f12") or "").strip().upper()
board_name = str(row.get("f14") or "").strip()
if re.fullmatch(r"BK\d{4}", code) and board_name:
catalog.setdefault(_normalize_name(board_name), {"code": code, "name": board_name})
if not catalog:
raise ChartDataError("Board chart directory is unavailable")
with self._board_catalog_lock:
type(self)._board_catalog = catalog
type(self)._board_catalog_at = now
return dict(catalog)
def _request_json(
self, url: str, params: dict[str, str], referer: str
) -> dict[str, Any]:
request_url = f"{url}?{urllib.parse.urlencode(params)}"
last_error: Exception | None = None
for attempt in range(max(1, int(self.retry_attempts))):
request = urllib.request.Request(
request_url,
headers={
"Accept": "application/json,text/plain,*/*",
"Connection": "close",
"Referer": referer,
"User-Agent": BROWSER_USER_AGENT,
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
if not isinstance(payload, dict):
raise ChartDataError("Invalid intraday chart response")
return payload
except (
urllib.error.URLError,
TimeoutError,
ConnectionError,
OSError,
http.client.HTTPException,
json.JSONDecodeError,
ChartDataError,
) as exc:
last_error = exc
if attempt + 1 < self.retry_attempts:
time.sleep(0.12)
raise ChartDataError("Intraday chart request failed") from last_error
def _parse_trend(raw: Any) -> dict[str, Any] | None:
fields = str(raw or "").split(",")
if len(fields) < 8 or " " not in fields[0]:
return None
stamp = fields[0].strip()
trade_date, trade_time = stamp.split(" ", 1)
close = _number(fields[2])
if close <= 0:
return None
return {
"date": trade_date,
"time": trade_time[:5],
"open": _number(fields[1]),
"close": close,
"high": _number(fields[3]),
"low": _number(fields[4]),
"volume": _number(fields[5]),
"amount": _number(fields[6]),
"average": _number(fields[7]),
}
def _ifind_point(row: dict[str, Any]) -> dict[str, Any] | None:
stamp = str(row.get("time") or "").strip()
if " " not in stamp:
return None
trade_date, trade_time = stamp.split(" ", 1)
close = _number(row.get("close"))
if close <= 0:
return None
return {
"date": trade_date,
"time": trade_time[:5],
"open": _number(row.get("open")),
"close": close,
"high": _number(row.get("high")),
"low": _number(row.get("low")),
"volume": _number(row.get("volume")),
"amount": _number(row.get("amount")),
"average": _number(row.get("avgPrice")),
}
def _stock_market_code(code: str) -> str:
if code.startswith(("4", "8", "9")):
suffix = "BJ"
elif code.startswith("6"):
suffix = "SH"
else:
suffix = "SZ"
return f"{code}.{suffix}"
def _number(value: Any) -> float:
try:
return float(value or 0)
except (TypeError, ValueError):
return 0.0
def _normalize_name(value: Any) -> str:
normalized = re.sub(r"[\s·・()()\-_/]", "", str(value or "")).casefold()
return re.sub(r"(?:概念|行业|[ⅠⅡⅢ])$", "", normalized)
sys.modules[__name__] = _implementation
+6 -213
View File
@@ -8,6 +8,7 @@ from typing import Any
from backend.database import MIGRATIONS, MigrationRunner, SQLiteConnectionFactory
from backend.features.accounts.repository import AccountRepositoryMixin
from backend.features.market.repository import MarketRepositoryMixin
from backend.features.system.repository import SystemSettingsRepositoryMixin
@@ -20,7 +21,11 @@ def _optional_float(value: Any) -> float | None:
return None
class ReviewDatabase(AccountRepositoryMixin, SystemSettingsRepositoryMixin):
class ReviewDatabase(
AccountRepositoryMixin,
MarketRepositoryMixin,
SystemSettingsRepositoryMixin,
):
def __init__(self, path: Path) -> None:
self.path = path
self.path.parent.mkdir(parents=True, exist_ok=True)
@@ -690,118 +695,6 @@ class ReviewDatabase(AccountRepositoryMixin, SystemSettingsRepositoryMixin):
).fetchone()
return int(row["total"] if row else 0)
def get_snapshot(self, trade_date: str) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
"SELECT payload FROM dashboard_snapshots WHERE trade_date = ?",
(trade_date,),
).fetchone()
if not row:
return None
try:
return json.loads(row["payload"])
except json.JSONDecodeError:
return None
def get_latest_real_snapshot(
self, trade_date: str, strictly_before: bool = False
) -> dict[str, Any] | None:
operator = "<" if strictly_before else "<="
with self.connect() as connection:
row = connection.execute(
f"""
SELECT payload FROM dashboard_snapshots
WHERE trade_date {operator} ? AND source != 'demo'
ORDER BY trade_date DESC LIMIT 1
""",
(trade_date,),
).fetchone()
if not row:
return None
try:
return json.loads(row["payload"])
except json.JSONDecodeError:
return None
def save_snapshot(self, trade_date: str, source: str, payload: dict[str, Any]) -> None:
updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
record_count = sum(
len(payload.get(key) or [])
for key in ("limits", "broken", "down_limits", "yesterday_limits")
)
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
with self.connect() as connection:
connection.execute(
"""
INSERT INTO dashboard_snapshots
(trade_date, source, payload, record_count, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(trade_date) DO UPDATE SET
source = excluded.source,
payload = excluded.payload,
record_count = excluded.record_count,
updated_at = excluded.updated_at
""",
(trade_date, source, content, record_count, updated_at),
)
def get_data_snapshot(self, kind: str, cache_key: str) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
"SELECT payload FROM data_snapshots WHERE kind = ? AND cache_key = ?",
(kind, cache_key),
).fetchone()
if not row:
return None
try:
return json.loads(row["payload"])
except json.JSONDecodeError:
return None
def get_latest_data_snapshot(
self,
kind: str,
cache_key_prefix: str,
maximum_cache_key: str,
exclude_source: str = "",
) -> dict[str, Any] | None:
source_clause = " AND source != ?" if exclude_source else ""
parameters: list[Any] = [kind, f"{cache_key_prefix}%", maximum_cache_key]
if exclude_source:
parameters.append(exclude_source)
with self.connect() as connection:
row = connection.execute(
f"""
SELECT payload FROM data_snapshots
WHERE kind = ? AND cache_key LIKE ? AND cache_key <= ?{source_clause}
ORDER BY cache_key DESC LIMIT 1
""",
parameters,
).fetchone()
if not row:
return None
try:
return json.loads(row["payload"])
except json.JSONDecodeError:
return None
def save_data_snapshot(
self, kind: str, cache_key: str, source: str, payload: dict[str, Any]
) -> None:
updated_at = datetime.now().astimezone().isoformat(timespec="seconds")
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
with self.connect() as connection:
connection.execute(
"""
INSERT INTO data_snapshots (kind, cache_key, source, payload, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(kind, cache_key) DO UPDATE SET
source = excluded.source,
payload = excluded.payload,
updated_at = excluded.updated_at
""",
(kind, cache_key, source, content, updated_at),
)
def list_watchlist(self, user_id: int) -> list[dict[str, Any]]:
with self.connect() as connection:
@@ -869,7 +762,6 @@ class ReviewDatabase(AccountRepositoryMixin, SystemSettingsRepositoryMixin):
(int(user_id), code),
)
return cursor.rowcount > 0
def list_notes(
self,
user_id: int,
@@ -1042,26 +934,6 @@ class ReviewDatabase(AccountRepositoryMixin, SystemSettingsRepositoryMixin):
)
return len(values)
def search_stock_master(self, query: str, limit: int = 12) -> list[dict[str, Any]]:
text = str(query or "").strip()
if not text:
return []
escaped = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
with self.connect() as connection:
rows = connection.execute(
"""
SELECT ts_code, code, name, industry, market, list_date
FROM stock_master
WHERE code = ? OR name = ? OR name LIKE ? ESCAPE '\\'
ORDER BY
CASE WHEN code = ? THEN 0 WHEN name = ? THEN 1 ELSE 2 END,
list_date DESC,
code
LIMIT ?
""",
(text, text, f"%{escaped}%", text, text, max(1, min(30, int(limit)))),
).fetchall()
return [dict(row) for row in rows]
def list_stock_master(self) -> list[dict[str, Any]]:
with self.connect() as connection:
@@ -1666,24 +1538,6 @@ class ReviewDatabase(AccountRepositoryMixin, SystemSettingsRepositoryMixin):
for row in series[-limit:]
]
def list_snapshot_payloads(self, end_date: str, limit: int = 260) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"""
SELECT trade_date, payload FROM dashboard_snapshots
WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT ?
""",
(end_date, limit),
).fetchall()
result: list[dict[str, Any]] = []
for row in reversed(rows):
try:
payload = json.loads(row["payload"])
except json.JSONDecodeError:
continue
payload["_snapshot_date"] = row["trade_date"]
result.append(payload)
return result
def save_screener_strategy(
self, user_id: int | None, name: str, description: str, regimes: list[str], formula: dict[str, Any],
@@ -2529,64 +2383,3 @@ class ReviewDatabase(AccountRepositoryMixin, SystemSettingsRepositoryMixin):
(int(reading_id), int(user_id)),
)
return cursor.rowcount > 0
def start_sync(self, trade_date: str, source: str) -> int:
started_at = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
cursor = connection.execute(
"""
INSERT INTO sync_runs (trade_date, source, status, started_at)
VALUES (?, ?, 'running', ?)
""",
(trade_date, source, started_at),
)
return int(cursor.lastrowid)
def finish_sync(
self,
sync_id: int,
status: str,
record_count: int = 0,
message: str = "",
source: str | None = None,
) -> None:
finished_at = datetime.now().astimezone().isoformat(timespec="seconds")
with self.connect() as connection:
connection.execute(
"""
UPDATE sync_runs
SET status = ?, finished_at = ?, record_count = ?, message = ?,
source = COALESCE(?, source)
WHERE id = ?
""",
(status, finished_at, record_count, message[:1000], source, sync_id),
)
def status(self) -> dict[str, Any]:
with self.connect() as connection:
last_sync = connection.execute(
"""
SELECT id, trade_date, source, status, started_at, finished_at,
record_count, message
FROM sync_runs ORDER BY id DESC LIMIT 1
"""
).fetchone()
snapshot_stats = connection.execute(
"""
SELECT COUNT(*) AS dates, COALESCE(SUM(record_count), 0) AS records,
MAX(updated_at) AS updated_at
FROM dashboard_snapshots
"""
).fetchone()
watchlist_count = connection.execute("SELECT COUNT(*) FROM watchlist").fetchone()[0]
note_count = connection.execute("SELECT COUNT(*) FROM review_notes").fetchone()[0]
return {
"database": str(self.path.name),
"snapshot_dates": int(snapshot_stats["dates"]),
"snapshot_records": int(snapshot_stats["records"]),
"updated_at": snapshot_stats["updated_at"],
"last_sync": dict(last_sync) if last_sync else None,
"watchlist_count": int(watchlist_count),
"note_count": int(note_count),
}
+4 -382
View File
@@ -1,385 +1,7 @@
from __future__ import annotations
"""Compatibility alias for the canonical iFinD provider implementation."""
import copy
import json
import threading
import time
import urllib.error
import urllib.request
from datetime import datetime, timedelta
from typing import Any
import sys
from backend.data.providers import ifind_client as _implementation
class IfindError(RuntimeError):
pass
class IfindHttpClient:
BASE_URL = "https://quantapi.51ifind.com/api/v1"
AUTH_ENDPOINT = "get_access_token"
AUTH_ERROR_CODES = {-1302, -1303, -1304, -4302, -4303}
def __init__(
self,
refresh_token: str = "",
access_token: str = "",
timeout: int = 15,
) -> None:
self.timeout = max(3, int(timeout))
self._refresh_token = str(refresh_token or "").strip()
self._access_token = str(access_token or "").strip()
self._access_expires_at: datetime | None = None
self._token_lock = threading.Lock()
self._cache_lock = threading.Lock()
self._cache: dict[str, dict[str, Any]] = {}
@property
def configured(self) -> bool:
return bool(self._refresh_token or self._access_token)
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
refresh_token = str(refresh_token or "").strip()
access_token = str(access_token or "").strip()
with self._token_lock:
refresh_changed = refresh_token != self._refresh_token
self._refresh_token = refresh_token
if access_token or refresh_changed:
self._access_token = access_token
self._access_expires_at = None
if refresh_changed:
with self._cache_lock:
self._cache.clear()
def status(self) -> dict[str, Any]:
return {
"configured": self.configured,
"access_ready": bool(self._access_token),
"access_expires_at": (
self._access_expires_at.isoformat(timespec="seconds")
if self._access_expires_at
else ""
),
}
def test_connection(self) -> dict[str, Any]:
payload = self.real_time(
"000001.SH",
["open", "high", "low", "latest", "preClose"],
cache_ttl=0,
)
return {
"ok": bool(payload),
"sample_time": str(payload[0].get("time") or "") if payload else "",
}
def real_time(
self,
codes: str | list[str],
indicators: list[str],
cache_ttl: int = 10,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"real_time_quotation",
{"codes": code_text, "indicators": ",".join(indicators)},
cache_key=f"rq:{code_text}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def history(
self,
codes: str | list[str],
indicators: list[str],
start_date: str,
end_date: str,
cache_ttl: int = 300,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"cmd_history_quotation",
{
"codes": code_text,
"indicators": ",".join(indicators),
"startdate": self._display_date(start_date),
"enddate": self._display_date(end_date),
"functionpara": {"CPS": "forward1", "Fill": "Omit"},
},
cache_key=f"hq:{code_text}:{start_date}:{end_date}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def intraday(
self,
code: str,
start_time: str,
end_time: str,
cache_ttl: int = 20,
) -> list[dict[str, Any]]:
indicators = ["open", "high", "low", "close", "volume", "amount", "avgPrice"]
payload = self._request(
"high_frequency",
{
"codes": self._codes(code),
"indicators": ",".join(indicators),
"starttime": start_time,
"endtime": end_time,
"functionpara": {
"CPS": "forward1",
"Fill": "Previous",
"Timeformat": "LocalTime",
"Interval": "1",
"Limitstart": "09:30:00",
"Limitend": "15:00:00",
},
},
cache_key=f"hf:{code}:{start_time}:{end_time}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def snapshots(
self,
codes: str | list[str],
indicators: list[str],
start_time: str,
end_time: str,
cache_ttl: int = 8,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"snap_shot",
{
"codes": code_text,
"indicators": ",".join(indicators),
"starttime": start_time,
"endtime": end_time,
},
cache_key=f"ss:{code_text}:{start_time}:{end_time}:{','.join(indicators)}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def wencai(self, query: str, search_type: str = "stock", cache_ttl: int = 300) -> list[dict[str, Any]]:
normalized = " ".join(str(query or "").split())
if not normalized:
raise IfindError("问财查询不能为空。")
payload = self._request(
"smart_stock_picking",
{"searchstring": normalized, "searchtype": search_type},
cache_key=f"wc:{search_type}:{normalized}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def report_query(
self,
codes: str | list[str],
begin_date: str,
end_date: str,
cache_ttl: int = 300,
) -> list[dict[str, Any]]:
code_text = self._codes(codes)
payload = self._request(
"report_query",
{
"codes": code_text,
"beginrDate": self._display_date(begin_date),
"endrDate": self._display_date(end_date),
"outputpara": (
"reportDate:Y,thscode:Y,secName:Y,ctime:Y,"
"reportTitle:Y,pdfURL:Y,seq:Y"
),
},
cache_key=f"report:{code_text}:{begin_date}:{end_date}",
cache_ttl=cache_ttl,
)
return self._table_rows(payload)
def _request(
self,
endpoint: str,
body: dict[str, Any],
cache_key: str = "",
cache_ttl: int = 0,
) -> dict[str, Any]:
if not self.configured:
raise IfindError("iFinD 尚未配置。")
if cache_key and cache_ttl > 0:
cached = self._cached(cache_key, cache_ttl)
if cached is not None:
return cached
payload = self._post(endpoint, body, self._ensure_access_token())
if self._is_auth_error(payload) and self._refresh_token:
self._invalidate_access_token()
payload = self._post(endpoint, body, self._ensure_access_token(force=True))
self._validate_payload(payload)
if cache_key and cache_ttl > 0:
with self._cache_lock:
self._cache[cache_key] = {
"created_at": time.time(),
"payload": copy.deepcopy(payload),
}
return payload
def _ensure_access_token(self, force: bool = False) -> str:
with self._token_lock:
now = datetime.now().astimezone().replace(tzinfo=None)
token_valid = bool(self._access_token) and (
self._access_expires_at is None
or self._access_expires_at > now + timedelta(minutes=2)
)
if token_valid and not force:
return self._access_token
if not self._refresh_token:
if self._access_token:
return self._access_token
raise IfindError("iFinD Refresh Token 尚未配置。")
payload = self._post(self.AUTH_ENDPOINT, {}, "", self._refresh_token)
self._validate_payload(payload)
data = payload.get("data") or {}
token = str(data.get("access_token") or "").strip()
if not token:
raise IfindError("iFinD 未返回 Access Token。")
expires_at = self._parse_datetime(data.get("expired_time"))
self._access_token = token
self._access_expires_at = expires_at
return token
def _post(
self,
endpoint: str,
body: dict[str, Any],
access_token: str,
refresh_token: str = "",
) -> dict[str, Any]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "XiaobaiReviewWeb/1.0",
"ifindlang": "cn",
}
if access_token:
headers["access_token"] = access_token
if refresh_token:
headers["refresh_token"] = refresh_token
request = urllib.request.Request(
f"{self.BASE_URL}/{endpoint}",
data=json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = ""
try:
detail_payload = json.loads(exc.read().decode("utf-8", errors="replace"))
detail = str(detail_payload.get("errmsg") or detail_payload.get("message") or "")
except (json.JSONDecodeError, OSError):
pass
raise IfindError(f"iFinD HTTP {exc.code}{f'{detail[:160]}' if detail else ''}") from exc
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
raise IfindError("iFinD 数据请求失败。") from exc
if not isinstance(payload, dict):
raise IfindError("iFinD 返回格式不正确。")
return payload
def _cached(self, key: str, ttl: int) -> dict[str, Any] | None:
with self._cache_lock:
cached = self._cache.get(key)
if not cached:
return None
if time.time() - float(cached.get("created_at") or 0) > ttl:
self._cache.pop(key, None)
return None
return copy.deepcopy(cached["payload"])
def _invalidate_access_token(self) -> None:
with self._token_lock:
self._access_token = ""
self._access_expires_at = None
@classmethod
def _validate_payload(cls, payload: dict[str, Any]) -> None:
try:
error_code = int(payload.get("errorcode") or 0)
except (TypeError, ValueError):
error_code = -1
if error_code != 0:
message = str(payload.get("errmsg") or "未知错误")
raise IfindError(f"iFinD 返回错误:{message[:200]}")
@classmethod
def _is_auth_error(cls, payload: dict[str, Any]) -> bool:
try:
error_code = int(payload.get("errorcode") or 0)
except (TypeError, ValueError):
error_code = 0
message = str(payload.get("errmsg") or "").casefold()
return error_code in cls.AUTH_ERROR_CODES or "token" in message or "鉴权" in message
@staticmethod
def _table_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
tables = payload.get("tables") or []
if isinstance(tables, dict):
tables = [tables]
rows: list[dict[str, Any]] = []
for block in tables if isinstance(tables, list) else []:
if not isinstance(block, dict):
continue
table = block.get("table") or {}
if not isinstance(table, dict):
continue
times = block.get("time") or []
codes = block.get("thscode") or block.get("thscodes") or []
if isinstance(codes, str):
codes = [codes]
lengths = [len(value) for value in table.values() if isinstance(value, list)]
row_count = max(lengths or [len(times) if isinstance(times, list) else 0, 1 if table else 0])
for index in range(row_count):
row: dict[str, Any] = {}
if isinstance(times, list) and index < len(times):
row["time"] = times[index]
if codes:
row["thscode"] = codes[index] if index < len(codes) else codes[0]
for field, values in table.items():
if isinstance(values, list):
row[field] = values[index] if index < len(values) else None
elif index == 0:
row[field] = values
rows.append(row)
return rows
@staticmethod
def _codes(codes: str | list[str]) -> str:
if isinstance(codes, list):
values = [str(code or "").strip().upper() for code in codes]
else:
values = [part.strip().upper() for part in str(codes or "").split(",")]
values = [value for value in values if value]
if not values:
raise IfindError("iFinD 证券代码不能为空。")
if len(values) > 100:
raise IfindError("iFinD 单次证券代码过多。")
return ",".join(values)
@staticmethod
def _display_date(value: str) -> str:
compact = str(value or "").replace("-", "")
if len(compact) != 8 or not compact.isdigit():
raise IfindError("iFinD 日期格式不正确。")
return f"{compact[:4]}-{compact[4:6]}-{compact[6:]}"
@staticmethod
def _parse_datetime(value: Any) -> datetime | None:
text = str(value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text)
except ValueError:
return None
sys.modules[__name__] = _implementation
+4 -423
View File
@@ -1,426 +1,7 @@
from __future__ import annotations
"""Compatibility alias for the canonical display-only realtime observer."""
import copy
import http.client
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime
from threading import Lock
from typing import Any, ClassVar
import sys
from backend.data import realtime as _implementation
class RealtimeAggregateError(RuntimeError):
pass
EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
EASTMONEY_SECTOR_URL = "https://push2.eastmoney.com/api/qt/clist/get"
TENCENT_INDEX_URL = "https://qt.gtimg.cn/q=sh000001,sz399001,sz399006"
THS_LIMIT_URL = "https://data.10jqka.com.cn/dataapi/limit_up/limit_up_pool"
XGB_POOL_URL = "https://flash-api.xuangubao.cn/api/pool/detail"
BROWSER_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/138.0.0.0 Safari/537.36"
)
@dataclass
class WebRealtimeAggregator:
timeout: int = 8
retry_attempts: int = 3
retry_delay_seconds: float = 0.2
response_cache_ttl_seconds: int = 90
_sector_cache: ClassVar[dict[str, Any]] = {}
_sector_cache_lock: ClassVar[Lock] = Lock()
_response_cache: ClassVar[dict[str, dict[str, Any]]] = {}
_response_cache_lock: ClassVar[Lock] = Lock()
def health_snapshot(self, sector: str = "") -> dict[str, Any]:
started = time.perf_counter()
sources: dict[str, dict[str, Any]] = {}
indices: list[dict[str, Any]] = []
sector_payload: dict[str, Any] | None = None
indices, sources["eastmoney_indices"] = self._capture(self.eastmoney_indices)
if sector.strip():
sector_payload, sources["eastmoney_sector"] = self._capture(
lambda: self.eastmoney_sector(sector)
)
ths_observation, sources["ths_limit_pool"] = self._capture(self.ths_limit_pool)
xgb_observation, sources["xgb_limit_pool"] = self._capture(self.xgb_limit_pool)
index_times = [int(item.get("quote_time_epoch") or 0) for item in indices or []]
now = datetime.now().astimezone()
max_skew = 120 if now.hour >= 15 else 15
index_consistent = bool(index_times) and max(index_times) - min(index_times) <= max_skew
ready = (
bool(indices)
and len(indices) == 3
and index_consistent
and (not sector.strip() or bool(sector_payload))
)
return {
"ready": ready,
"isolated": True,
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"elapsed_ms": round((time.perf_counter() - started) * 1000),
"indices": indices or [],
"index_consistent": index_consistent,
"sector": sector_payload,
"sources": sources,
"observations": {
"ths_limit_pool": ths_observation,
"xgb_limit_pool": xgb_observation,
},
"policy": {
"integration": "heaven_realtime_fallback",
"max_index_time_skew_seconds": max_skew,
"notice": "聚合源仅作为盘中观势的实时指数与板块外显,主行情快照仍由Tushare维护。",
},
}
def eastmoney_indices(self) -> list[dict[str, Any]]:
try:
payload = self._get_json(
EASTMONEY_INDEX_URL,
{
"secids": "1.000001,0.399001,0.399006",
"fltt": "2",
"invt": "2",
"fields": "f12,f14,f2,f3,f4,f15,f16,f17,f18,f6,f124",
},
referer="https://quote.eastmoney.com/",
)
except RealtimeAggregateError:
return self.tencent_indices()
cache_meta = payload.get("_aggregate_cache") or {}
rows = list((payload.get("data") or {}).get("diff") or [])
result = []
for row in rows:
code = str(row.get("f12") or "")
if code not in {"000001", "399001", "399006"}:
continue
epoch = int(_number(row.get("f124")))
result.append(
{
"code": code,
"name": row.get("f14") or code,
"price": _number(row.get("f2")),
"change": _number(row.get("f3")),
"change_amount": _number(row.get("f4")),
"open": _number(row.get("f17")),
"high": _number(row.get("f15")),
"low": _number(row.get("f16")),
"previous_close": _number(row.get("f18")),
"amount_billion": round(_number(row.get("f6")) / 100000000, 2),
"quote_time_epoch": epoch,
"quote_time": (
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
if epoch else ""
),
"source": (
"eastmoney_push2_cache" if cache_meta else "eastmoney_push2"
),
"cache_age_seconds": cache_meta.get("age_seconds", 0),
}
)
if len(result) != 3:
raise RealtimeAggregateError(f"Eastmoney returned {len(result)}/3 indices")
return result
def tencent_indices(self) -> list[dict[str, Any]]:
raw, cache_age = self._get_text(
TENCENT_INDEX_URL,
referer="https://gu.qq.com/",
encoding="gb18030",
)
result = []
for line in raw.splitlines():
if '="' not in line:
continue
fields = line.split('="', 1)[1].rsplit('";', 1)[0].split("~")
if len(fields) < 38:
continue
code = fields[2]
if code not in {"000001", "399001", "399006"}:
continue
try:
quote_time = datetime.strptime(fields[30], "%Y%m%d%H%M%S").astimezone()
except ValueError as exc:
raise RealtimeAggregateError(
f"Tencent returned invalid quote time for {code}"
) from exc
result.append(
{
"code": code,
"name": fields[1] or code,
"price": _number(fields[3]),
"change": _number(fields[32]),
"change_amount": _number(fields[31]),
"open": _number(fields[5]),
"high": _number(fields[33]),
"low": _number(fields[34]),
"previous_close": _number(fields[4]),
"amount_billion": round(_number(fields[37]) / 10000, 2),
"quote_time_epoch": int(quote_time.timestamp()),
"quote_time": quote_time.isoformat(timespec="seconds"),
"source": "tencent_qt_cache" if cache_age else "tencent_qt",
"cache_age_seconds": cache_age,
}
)
if len(result) != 3:
raise RealtimeAggregateError(f"Tencent returned {len(result)}/3 indices")
return result
def eastmoney_sector(self, query: str) -> dict[str, Any]:
target = _normalize_sector(query)
candidates = self._eastmoney_sector_catalog()
matched = _match_sector(candidates, target)
if not matched:
raise RealtimeAggregateError(f"Eastmoney sector not found: {query}")
epoch = int(_number(matched.get("f124")))
return {
"code": matched.get("f12") or "",
"name": matched.get("f14") or query,
"price": _number(matched.get("f2")),
"change": _number(matched.get("f3")),
"change_amount": _number(matched.get("f4")),
"turnover_rate": _number(matched.get("f8")),
"up_count": int(_number(matched.get("f104"))),
"down_count": int(_number(matched.get("f105"))),
"leader": matched.get("f128") or "--",
"leader_code": matched.get("f140") or "",
"leading_pct": _number(matched.get("f136")),
"quote_time_epoch": epoch,
"quote_time": (
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
if epoch else ""
),
"source": "eastmoney_push2",
"match_query": query,
}
def _eastmoney_sector_catalog(self) -> list[dict[str, Any]]:
now = time.time()
with self._sector_cache_lock:
cached = self._sector_cache.get("eastmoney")
if cached and now - float(cached.get("created_at") or 0) < 600:
return list(cached.get("rows") or [])
def load_page(page: int) -> list[dict[str, Any]]:
payload = self._get_json(
EASTMONEY_SECTOR_URL,
{
"pn": str(page),
"pz": "100",
"po": "1",
"np": "1",
"fltt": "2",
"invt": "2",
"fid": "f3",
"fs": "m:90+t:2",
"fields": "f12,f14,f2,f3,f4,f8,f104,f105,f128,f136,f140,f124",
},
referer="https://quote.eastmoney.com/center/boardlist.html",
)
return list((payload.get("data") or {}).get("diff") or [])
with ThreadPoolExecutor(max_workers=5) as executor:
pages = list(executor.map(load_page, range(1, 6)))
rows = [row for page in pages for row in page]
if not rows:
raise RealtimeAggregateError("Eastmoney sector catalog is empty")
with self._sector_cache_lock:
self._sector_cache["eastmoney"] = {"created_at": now, "rows": rows}
return rows
def ths_limit_pool(self) -> dict[str, Any]:
payload = self._get_json(
THS_LIMIT_URL,
{"page": "1", "limit": "3", "field": "199112"},
referer="https://data.10jqka.com.cn/limit_up/",
)
data = payload.get("data") or payload
return {
"available": True,
"keys": sorted(str(key) for key in data.keys()) if isinstance(data, dict) else [],
"source": "ths_web_dataapi",
}
def xgb_limit_pool(self) -> dict[str, Any]:
payload = self._get_json(
XGB_POOL_URL,
{"pool_name": "limit_up"},
referer="https://xuangubao.cn/",
)
data = payload.get("data") or {}
rows = data if isinstance(data, list) else data.get("pool") or data.get("list") or []
return {
"available": True,
"count": len(rows) if isinstance(rows, list) else 0,
"source": "xuangubao_web_api",
}
def _capture(self, operation):
started = time.perf_counter()
try:
value = operation()
return value, {
"ok": True,
"elapsed_ms": round((time.perf_counter() - started) * 1000),
"error": "",
}
except Exception as exc:
return None, {
"ok": False,
"elapsed_ms": round((time.perf_counter() - started) * 1000),
"error": str(exc)[:500],
}
def _get_json(
self,
url: str,
params: dict[str, str],
referer: str,
) -> dict[str, Any]:
request_url = f"{url}?{urllib.parse.urlencode(params)}"
last_error: Exception | None = None
attempts = max(1, int(self.retry_attempts))
for attempt in range(attempts):
request = urllib.request.Request(
request_url,
headers={
"Accept": "application/json,text/plain,*/*",
"Connection": "close",
"Referer": referer,
"User-Agent": BROWSER_USER_AGENT,
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
content_type = response.headers.get("Content-Type", "")
raw = response.read().decode("utf-8", errors="replace")
if "json" not in content_type.lower() and not raw.lstrip().startswith(("{", "[")):
raise RealtimeAggregateError(
f"non-JSON response: {raw[:120].strip()}"
)
payload = json.loads(raw)
if not isinstance(payload, dict):
raise RealtimeAggregateError("unexpected response shape")
if payload.get("rc") not in (None, 0):
raise RealtimeAggregateError(f"provider rc={payload.get('rc')}")
with self._response_cache_lock:
self._response_cache[request_url] = {
"created_at": time.time(),
"payload": copy.deepcopy(payload),
}
return payload
except (
urllib.error.URLError,
TimeoutError,
ConnectionError,
OSError,
http.client.HTTPException,
json.JSONDecodeError,
RealtimeAggregateError,
) as exc:
last_error = exc
if attempt + 1 < attempts and self.retry_delay_seconds > 0:
time.sleep(self.retry_delay_seconds * (attempt + 1))
now = time.time()
with self._response_cache_lock:
cached = self._response_cache.get(request_url)
cache_age = now - float((cached or {}).get("created_at") or 0)
if cached and cache_age <= self.response_cache_ttl_seconds:
payload = copy.deepcopy(cached.get("payload") or {})
payload["_aggregate_cache"] = {"age_seconds": round(cache_age, 1)}
return payload
raise RealtimeAggregateError(f"request failed after {attempts} attempts: {last_error}") from last_error
def _get_text(
self,
request_url: str,
referer: str,
encoding: str = "utf-8",
) -> tuple[str, float]:
cache_key = f"text:{request_url}"
last_error: Exception | None = None
attempts = max(1, int(self.retry_attempts))
for attempt in range(attempts):
request = urllib.request.Request(
request_url,
headers={
"Accept": "text/plain,*/*",
"Connection": "close",
"Referer": referer,
"User-Agent": BROWSER_USER_AGENT,
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
raw = response.read().decode(encoding, errors="replace")
if not raw.strip():
raise RealtimeAggregateError("empty text response")
with self._response_cache_lock:
self._response_cache[cache_key] = {
"created_at": time.time(),
"payload": raw,
}
return raw, 0
except (
urllib.error.URLError,
TimeoutError,
ConnectionError,
OSError,
http.client.HTTPException,
RealtimeAggregateError,
) as exc:
last_error = exc
if attempt + 1 < attempts and self.retry_delay_seconds > 0:
time.sleep(self.retry_delay_seconds * (attempt + 1))
now = time.time()
with self._response_cache_lock:
cached = self._response_cache.get(cache_key)
cache_age = now - float((cached or {}).get("created_at") or 0)
if cached and cache_age <= self.response_cache_ttl_seconds:
return str(cached.get("payload") or ""), round(cache_age, 1)
raise RealtimeAggregateError(
f"text request failed after {attempts} attempts: {last_error}"
) from last_error
def _normalize_sector(value: Any) -> str:
text = str(value or "").strip().replace(" ", "")
for suffix in ("板块", "概念", "行业", "", "", "(A股)", "A股)"):
text = text.replace(suffix, "")
aliases = {"元器件": "元件", "电子元器件": "元件"}
return aliases.get(text, text)
def _match_sector(rows: list[dict[str, Any]], target: str) -> dict[str, Any] | None:
exact = [row for row in rows if _normalize_sector(row.get("f14")) == target]
if exact:
return min(exact, key=lambda row: len(str(row.get("f14") or "")))
fuzzy = [
row for row in rows
if target and (
target in _normalize_sector(row.get("f14"))
or _normalize_sector(row.get("f14")) in target
)
]
return min(fuzzy, key=lambda row: len(_normalize_sector(row.get("f14")))) if fuzzy else None
def _number(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
sys.modules[__name__] = _implementation
+5 -1
View File
@@ -48,7 +48,11 @@ class DataGatewayTests(unittest.TestCase):
from pathlib import Path
source = (
Path(__file__).resolve().parents[1] / "backend" / "application.py"
Path(__file__).resolve().parents[1]
/ "backend"
/ "features"
/ "market"
/ "service.py"
).read_text(encoding="utf-8")
self.assertEqual(source.count("TushareClient(self.token)"), 1)
self.assertIn("return gateway.tushare()", source)
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
import ast
import hashlib
import unittest
from pathlib import Path
import chart_data_provider
import ifind_client
import realtime_aggregator
import tushare_client
from backend.data import realtime
from backend.data.providers import ifind_client as canonical_ifind
from backend.data.providers import tushare_client as canonical_tushare
from backend.features.market import charts
APP_ROOT = Path(__file__).resolve().parents[1]
ORIGINAL_ROOT = APP_ROOT.parent
MARKET_METHODS = {
"_tushare_client",
"get_dashboard",
"_dashboard_sentiment_ready",
"_display_compact_date",
"_carry_dashboard",
"_realtime_snapshot_due",
"sync_dashboard",
"realtime_aggregate_health",
"_search_market_directory",
"_search_match_score",
"search_entities",
"get_search_detail",
"get_intraday_chart",
"_ths_search_detail",
"_index_search_detail",
"get_stock_detail",
"_stock_detail_bar_date",
"_stock_detail_cache_needs_refresh",
"_prepare_stock_detail",
"_sanitize_stock_detail_prices",
"_valid_realtime_stock_quote",
"_ifind_realtime_stock_quote",
"_merge_realtime_stock_detail",
"get_stock_preview",
"backfill",
"_stock_identity",
"_enrich_stock_detail",
"_with_storage",
"_record_count",
}
MARKET_REPOSITORY_METHODS = {
"get_snapshot",
"get_latest_real_snapshot",
"save_snapshot",
"get_data_snapshot",
"get_latest_data_snapshot",
"save_data_snapshot",
"search_stock_master",
"list_snapshot_payloads",
"start_sync",
"finish_sync",
"status",
}
def class_methods(path: Path, class_name: str) -> dict[str, str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
owner = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == class_name
)
return {
node.name: ast.dump(node, include_attributes=False)
for node in owner.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def top_level_definitions(path: Path) -> dict[str, str]:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
return {
node.name: ast.dump(node, include_attributes=False)
for node in tree.body
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef))
}
class MarketSliceSourceEquivalenceTests(unittest.TestCase):
def test_market_service_methods_are_exact_original_ast(self) -> None:
original = class_methods(ORIGINAL_ROOT / "server.py", "DashboardService")
migrated = class_methods(
APP_ROOT / "backend" / "features" / "market" / "service.py",
"MarketServiceMixin",
)
self.assertEqual(set(migrated), MARKET_METHODS)
for name in sorted(MARKET_METHODS):
self.assertEqual(migrated[name], original[name], name)
def test_market_repository_methods_are_exact_original_ast(self) -> None:
original = class_methods(ORIGINAL_ROOT / "database.py", "ReviewDatabase")
migrated = class_methods(
APP_ROOT / "backend" / "features" / "market" / "repository.py",
"MarketRepositoryMixin",
)
self.assertEqual(set(migrated), MARKET_REPOSITORY_METHODS)
for name in sorted(MARKET_REPOSITORY_METHODS):
self.assertEqual(migrated[name], original[name], name)
def test_original_classes_no_longer_duplicate_moved_methods(self) -> None:
remaining_service = class_methods(APP_ROOT / "backend" / "application.py", "DashboardService")
remaining_database = class_methods(APP_ROOT / "database.py", "ReviewDatabase")
self.assertTrue(MARKET_METHODS.isdisjoint(remaining_service))
self.assertTrue(MARKET_REPOSITORY_METHODS.isdisjoint(remaining_database))
def test_provider_compatibility_modules_are_canonical_aliases(self) -> None:
self.assertIs(tushare_client.TushareClient, canonical_tushare.TushareClient)
self.assertIs(ifind_client.IfindHttpClient, canonical_ifind.IfindHttpClient)
self.assertIs(realtime_aggregator.WebRealtimeAggregator, realtime.WebRealtimeAggregator)
self.assertIs(chart_data_provider.MarketChartClient, charts.MarketChartClient)
def test_provider_logic_is_the_original_implementation(self) -> None:
exact_moves = (
("tushare_client.py", "backend/data/providers/tushare_client.py"),
("ifind_client.py", "backend/data/providers/ifind_client.py"),
("realtime_aggregator.py", "backend/data/realtime.py"),
)
for original, migrated in exact_moves:
self.assertEqual(sha256(ORIGINAL_ROOT / original), sha256(APP_ROOT / migrated))
self.assertEqual(
top_level_definitions(ORIGINAL_ROOT / "chart_data_provider.py"),
top_level_definitions(APP_ROOT / "backend/features/market/charts.py"),
)
def test_unchanged_frontend_assets_match_the_original(self) -> None:
for relative in (
"index.html",
"app.js",
"styles.css",
"renovation.css",
"redesign-v2.css",
"theme.css",
"wentian-v2.css",
):
self.assertEqual(
sha256(APP_ROOT / "static" / relative),
sha256(ORIGINAL_ROOT / "static" / relative),
relative,
)
if __name__ == "__main__":
unittest.main()
+6 -6
View File
@@ -90,8 +90,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
"moneyflow": {},
}
with patch("backend.application.datetime", FixedMarketDatetime), patch(
"backend.application.TushareClient", RealtimeClientStub
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
"backend.features.market.service.TushareClient", RealtimeClientStub
):
result = self.service._prepare_stock_detail(cached, "002141", today)
@@ -112,8 +112,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
"stock": {"code": "002141", "price": 10, "change": 1.2},
"prices": [{"trade_date": historical, "close": 10, "change": 1.2}],
}
with patch("backend.application.datetime", FixedMarketDatetime), patch(
"backend.application.TushareClient", RealtimeClientStub
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
"backend.features.market.service.TushareClient", RealtimeClientStub
):
result = self.service._prepare_stock_detail(payload, "002141", historical)
@@ -151,8 +151,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
},
],
}
with patch("backend.application.datetime", FixedPreopenDatetime), patch(
"backend.application.TushareClient", RealtimeClientStub
with patch("backend.features.market.service.datetime", FixedPreopenDatetime), patch(
"backend.features.market.service.TushareClient", RealtimeClientStub
):
result = self.service._prepare_stock_detail(payload, "002141", today)
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import argparse
import ast
from pathlib import Path
MARKER = " # PRESERVATION_METHODS\n"
def method_span(node: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[int, int]:
start = min((decorator.lineno for decorator in node.decorator_list), default=node.lineno)
if node.end_lineno is None:
raise ValueError(f"Missing end position for {node.name}")
return start - 1, node.end_lineno
def move_methods(
source_path: Path,
class_name: str,
target_path: Path,
method_names: list[str],
) -> None:
source = source_path.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(source_path))
owner = next(
(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == class_name
),
None,
)
if owner is None:
raise ValueError(f"Class not found: {class_name}")
methods = {
node.name: node
for node in owner.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
missing = [name for name in method_names if name not in methods]
if missing:
raise ValueError(f"Methods not found in {class_name}: {', '.join(missing)}")
lines = source.splitlines(keepends=True)
ordered = sorted((methods[name] for name in method_names), key=lambda node: node.lineno)
blocks = ["".join(lines[start:end]).rstrip() for start, end in map(method_span, ordered)]
for start, end in sorted(map(method_span, ordered), reverse=True):
del lines[start:end]
while start < len(lines) - 1 and lines[start] == "\n" and lines[start + 1] == "\n":
del lines[start]
target = target_path.read_text(encoding="utf-8")
if target.count(MARKER) != 1:
raise ValueError(f"Target must contain exactly one method marker: {target_path}")
target = target.replace(MARKER, "\n\n".join(blocks) + "\n")
source_path.write_text("".join(lines), encoding="utf-8")
target_path.write_text(target, encoding="utf-8")
def main() -> None:
parser = argparse.ArgumentParser(description="Mechanically move class methods between modules")
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--class-name", required=True)
parser.add_argument("--target", type=Path, required=True)
parser.add_argument("methods", nargs="+")
args = parser.parse_args()
move_methods(args.source, args.class_name, args.target, args.methods)
if __name__ == "__main__":
main()
+4 -2172
View File
File diff suppressed because it is too large Load Diff