223 lines
9.1 KiB
Python
223 lines
9.1 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
from datetime import datetime, timedelta
|
|
from typing import Any
|
|
|
|
from backend.data.numbers import non_nan_number as _number
|
|
from backend.data.providers.tushare_client import TushareError
|
|
from backend.features.market.insights_context import _display_date
|
|
|
|
|
|
class MarketThemeInsightsMixin:
|
|
def _theme_directory(self) -> list[dict[str, Any]]:
|
|
cached = self.database.get_data_snapshot("theme_directory_v1", "ths") or {}
|
|
if cached.get("items"):
|
|
return list(cached["items"])
|
|
rows = self.client.query(
|
|
"ths_index", {}, "ts_code,name,count,exchange,list_date,type"
|
|
)
|
|
items = [
|
|
{
|
|
"code": str(row.get("ts_code") or ""),
|
|
"name": str(row.get("name") or ""),
|
|
"member_count": int(_number(row.get("count"))),
|
|
"list_date": str(row.get("list_date") or ""),
|
|
}
|
|
for row in rows
|
|
if str(row.get("type") or "").upper() == "N"
|
|
and str(row.get("exchange") or "").upper() == "A"
|
|
and row.get("ts_code")
|
|
and row.get("name")
|
|
]
|
|
self.database.save_data_snapshot(
|
|
"theme_directory_v1", "ths", "market", {"items": items}
|
|
)
|
|
return items
|
|
|
|
def theme_library(self, requested_date: str, force: bool = False) -> dict[str, Any]:
|
|
trade_date, previous_date = self._trade_context(requested_date)
|
|
if not force:
|
|
cached = self.database.get_data_snapshot("theme_library_v1", trade_date)
|
|
if cached:
|
|
result = copy.deepcopy(cached)
|
|
result["meta"] = {**result.get("meta", {}), "cached": True}
|
|
return result
|
|
|
|
try:
|
|
daily = self.client.query(
|
|
"ths_daily",
|
|
{"trade_date": trade_date},
|
|
"ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate",
|
|
)
|
|
except TushareError:
|
|
fallback = self._latest_feature_snapshot("theme_library_v1", trade_date)
|
|
if fallback:
|
|
result = copy.deepcopy(fallback)
|
|
result["meta"] = {
|
|
**result.get("meta", {}),
|
|
"requested_date": _display_date(requested_date),
|
|
"carried_forward": True,
|
|
"cached": True,
|
|
"notice": "当前题材行情暂不可用,展示最近有效快照",
|
|
}
|
|
return result
|
|
daily = []
|
|
actual_date = trade_date
|
|
carried_forward = False
|
|
if not daily and previous_date:
|
|
try:
|
|
daily = self.client.query(
|
|
"ths_daily",
|
|
{"trade_date": previous_date},
|
|
"ts_code,trade_date,open,high,low,close,pre_close,pct_change,vol,turnover_rate",
|
|
)
|
|
except TushareError:
|
|
daily = []
|
|
actual_date = previous_date
|
|
carried_forward = bool(daily)
|
|
daily_map = {str(row.get("ts_code") or ""): row for row in daily}
|
|
try:
|
|
hot_rows = self.client.query("ths_hot", {"trade_date": actual_date})
|
|
except TushareError:
|
|
hot_rows = []
|
|
hot_map = {
|
|
str(row.get("ts_code") or ""): int(_number(row.get("rank")))
|
|
for row in hot_rows
|
|
if str(row.get("data_type") or "") == "概念板块"
|
|
}
|
|
items = []
|
|
for item in self._theme_directory():
|
|
quote = daily_map.get(item["code"], {})
|
|
items.append(
|
|
{
|
|
**item,
|
|
"change": round(_number(quote.get("pct_change")), 2),
|
|
"close": round(_number(quote.get("close")), 3),
|
|
"turnover_rate": round(_number(quote.get("turnover_rate")), 2),
|
|
"volume": round(_number(quote.get("vol")), 2),
|
|
"hot_rank": hot_map.get(item["code"]),
|
|
"has_quote": bool(quote),
|
|
}
|
|
)
|
|
items.sort(
|
|
key=lambda item: (
|
|
item["has_quote"],
|
|
item["hot_rank"] is not None,
|
|
-(item["hot_rank"] or 9999),
|
|
item["change"],
|
|
),
|
|
reverse=True,
|
|
)
|
|
quoted = [item for item in items if item["has_quote"]]
|
|
result = {
|
|
"meta": {
|
|
"requested_date": _display_date(requested_date),
|
|
"trade_date": _display_date(actual_date),
|
|
"carried_forward": carried_forward,
|
|
"cached": False,
|
|
"notice": "" if quoted else "该交易日暂无题材行情,已保留题材目录",
|
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
},
|
|
"summary": {
|
|
"theme_count": len(items),
|
|
"quoted_count": len(quoted),
|
|
"up_count": sum(item["change"] > 0 for item in quoted),
|
|
"down_count": sum(item["change"] < 0 for item in quoted),
|
|
"hot_count": len(hot_map),
|
|
},
|
|
"items": items,
|
|
}
|
|
self.database.save_data_snapshot("theme_library_v1", trade_date, "market", result)
|
|
return result
|
|
|
|
def theme_detail(self, code: str, requested_date: str) -> dict[str, Any]:
|
|
code = str(code or "").strip().upper()
|
|
library = self.theme_library(requested_date)
|
|
theme = next((item for item in library["items"] if item["code"] == code), None)
|
|
if not theme:
|
|
raise ValueError("未找到对应题材。")
|
|
actual_date = str(library["meta"]["trade_date"]).replace("-", "")
|
|
detail_key = f"{actual_date}:{code}"
|
|
cached_detail = self.database.get_data_snapshot("theme_detail_v1", detail_key)
|
|
if cached_detail:
|
|
return cached_detail
|
|
try:
|
|
members = self.client.query(
|
|
"ths_member", {"ts_code": code, "is_new": "Y"}, "ts_code,con_code,con_name"
|
|
)
|
|
except TushareError:
|
|
members = []
|
|
bars = self.database.daily_bars_for_date(actual_date)
|
|
if not bars:
|
|
bars = self.client.query(
|
|
"daily",
|
|
{"trade_date": actual_date},
|
|
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
|
|
)
|
|
self.database.upsert_daily_bars(bars)
|
|
bar_map = {str(row.get("ts_code") or ""): row for row in bars}
|
|
normalized_members = []
|
|
for member in members:
|
|
ts_code = str(member.get("con_code") or "")
|
|
quote = bar_map.get(ts_code, {})
|
|
normalized_members.append(
|
|
{
|
|
"code": ts_code.split(".")[0],
|
|
"ts_code": ts_code,
|
|
"name": str(member.get("con_name") or "--"),
|
|
"price": round(_number(quote.get("close")), 2),
|
|
"change": round(_number(quote.get("pct_chg")), 2),
|
|
"amount_billion": round(_number(quote.get("amount")) / 100_000, 2),
|
|
"has_quote": bool(quote),
|
|
}
|
|
)
|
|
normalized_members.sort(
|
|
key=lambda item: (item["has_quote"], item["change"], item["amount_billion"]),
|
|
reverse=True,
|
|
)
|
|
end = datetime.strptime(actual_date, "%Y%m%d")
|
|
try:
|
|
history = self.client.query(
|
|
"ths_daily",
|
|
{
|
|
"ts_code": code,
|
|
"start_date": (end - timedelta(days=190)).strftime("%Y%m%d"),
|
|
"end_date": actual_date,
|
|
},
|
|
"ts_code,trade_date,open,high,low,close,pct_change,vol,turnover_rate",
|
|
)
|
|
except TushareError:
|
|
history = []
|
|
history.sort(key=lambda row: str(row.get("trade_date") or ""))
|
|
series = [
|
|
{
|
|
"trade_date": _display_date(str(row.get("trade_date") or "")),
|
|
"open": _number(row.get("open")),
|
|
"high": _number(row.get("high")),
|
|
"low": _number(row.get("low")),
|
|
"close": _number(row.get("close")),
|
|
"change": _number(row.get("pct_change")),
|
|
"volume": _number(row.get("vol")),
|
|
}
|
|
for row in history[-90:]
|
|
]
|
|
result = {
|
|
"meta": {
|
|
"trade_date": _display_date(actual_date),
|
|
"notice": "" if members or history else "题材成分与走势暂不可用",
|
|
},
|
|
"theme": theme,
|
|
"series": series,
|
|
"members": normalized_members,
|
|
"summary": {
|
|
"member_count": len(normalized_members),
|
|
"up_count": sum(item["change"] > 0 for item in normalized_members if item["has_quote"]),
|
|
"down_count": sum(item["change"] < 0 for item in normalized_members if item["has_quote"]),
|
|
"quoted_count": sum(item["has_quote"] for item in normalized_members),
|
|
},
|
|
}
|
|
if members or history:
|
|
self.database.save_data_snapshot("theme_detail_v1", detail_key, "market", result)
|
|
return result
|