Files
xiaobaifupan/app/market_insights.py
T

1313 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import copy
import json
from datetime import datetime, time as dt_time, timedelta, timezone
from statistics import median
from typing import Any, Callable
from database import ReviewDatabase
from ifind_client import IfindError, IfindHttpClient
from tushare_client import TushareClient, TushareError
CHINA_TIMEZONE = timezone(timedelta(hours=8))
def _number(value: Any, default: float = 0.0) -> float:
try:
number = float(value)
return number if number == number else default
except (TypeError, ValueError):
return default
def _display_date(value: str) -> str:
text = str(value or "").replace("-", "")
if len(text) != 8:
return str(value or "")
return f"{text[:4]}-{text[4:6]}-{text[6:]}"
class MarketInsightsService:
"""Read-only market features backed by Tushare and shared SQLite caches."""
def __init__(
self,
database: ReviewDatabase,
client: TushareClient,
now_provider: Callable[[], datetime] | None = None,
ifind: IfindHttpClient | None = None,
) -> None:
self.database = database
self.client = client
self._now_provider = now_provider or (lambda: datetime.now(CHINA_TIMEZONE))
self.ifind = ifind
def _trade_context(self, requested_date: str) -> tuple[str, str]:
"""Resolve trading dates without making cached feature pages depend on Tushare uptime."""
requested = str(requested_date or "").replace("-", "")
try:
return self.client.resolve_trade_context(requested)
except TushareError:
latest = self.database.get_latest_real_snapshot(requested) or {}
trade_date = str(
(latest.get("meta") or {}).get("trade_date")
or latest.get("_snapshot_date")
or requested
).replace("-", "")
previous = self.database.get_latest_real_snapshot(trade_date, strictly_before=True) or {}
previous_date = str(
(previous.get("meta") or {}).get("trade_date")
or previous.get("_snapshot_date")
or ""
).replace("-", "")
return trade_date, previous_date
def _latest_feature_snapshot(self, kind: str, trade_date: str) -> dict[str, Any] | None:
return self.database.get_latest_data_snapshot(kind, "", trade_date)
def _auction_session(self, requested_date: str, trade_date: str) -> dict[str, Any]:
now = self._now_provider()
if now.tzinfo is None:
now = now.replace(tzinfo=CHINA_TIMEZONE)
else:
now = now.astimezone(CHINA_TIMEZONE)
requested = str(requested_date or "").replace("-", "")
today = now.strftime("%Y%m%d")
if requested != today or trade_date != today:
return {
"phase": "archive",
"actionable": False,
"next_transition_at": "",
}
local_time = now.time().replace(tzinfo=None)
transitions = (
(dt_time(9, 15), "pending", dt_time(9, 15)),
(dt_time(9, 25), "observing", dt_time(9, 25)),
(dt_time(9, 30), "selection", dt_time(9, 30)),
)
for boundary, phase, next_boundary in transitions:
if local_time < boundary:
transition = now.replace(
hour=next_boundary.hour,
minute=next_boundary.minute,
second=0,
microsecond=0,
)
return {
"phase": phase,
"actionable": phase == "selection",
"next_transition_at": transition.isoformat(timespec="seconds"),
}
return {
"phase": "finalized",
"actionable": False,
"next_transition_at": "",
}
def _stock_master(self) -> dict[str, dict[str, Any]]:
rows = self.database.list_stock_master()
if not rows:
rows = self.client.query(
"stock_basic",
{"list_status": "L"},
"ts_code,name,industry,market,list_date",
)
self.database.upsert_stock_master(rows)
rows = self.database.list_stock_master()
return {str(row.get("ts_code") or ""): row for row in rows}
@staticmethod
def _expectation_label(actual_strength: float, expected_change: float) -> str:
difference = actual_strength - expected_change
if difference >= 1.5:
return "超预期"
if difference <= -1.5:
return "低于预期"
return "符合预期"
@staticmethod
def _auction_confirmation(row: dict[str, Any]) -> float:
volume_ratio = _number(row.get("volume_ratio"))
turnover_rate = _number(row.get("turnover_rate"))
amount_million = _number(row.get("amount_million"))
return (
(0.6 if volume_ratio >= 2 else 0.3 if volume_ratio >= 1.2 else -0.5 if volume_ratio < 0.6 else 0)
+ (0.25 if turnover_rate >= 0.15 else -0.25 if turnover_rate < 0.03 else 0)
+ (0.3 if amount_million >= 20 else 0.15 if amount_million >= 5 else -0.3 if amount_million < 1 else 0)
)
@staticmethod
def _attention_score(
row: dict[str, Any],
expected_change: float,
core_tags: list[str],
sources: list[str],
prior_streak: int,
strong_sector: bool,
) -> float:
if core_tags:
identity_score = 35.0
elif prior_streak >= 2:
identity_score = 27.0
elif any(source in {"昨日涨停", "昨日炸板"} for source in sources):
identity_score = 21.0
else:
identity_score = 14.0
deviation_score = min(30.0, abs(_number(row.get("change")) - expected_change) * 5)
volume_score = min(10.0, max(0.0, _number(row.get("volume_ratio"))) / 2 * 10)
amount_score = min(6.0, max(0.0, _number(row.get("amount_million"))) / 10 * 6)
turnover_score = min(4.0, max(0.0, _number(row.get("turnover_rate"))) / 0.2 * 4)
theme_score = 15.0 if strong_sector else 7.0 if row.get("concepts") else 0.0
return round(min(100.0, identity_score + deviation_score + volume_score + amount_score + turnover_score + theme_score), 1)
def _auction_candidates(
self,
rows: list[dict[str, Any]],
baseline_date: str,
) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]]:
"""Build a narrow, explainable universe from prior limits, breaks and top-20 hot lists."""
snapshot = self.database.get_snapshot(baseline_date) or {}
prior_limits = list(snapshot.get("limits") or [])
prior_broken = list(snapshot.get("broken") or [])
prior_sectors = list(snapshot.get("sectors") or [])
strong_sector_names = {
str(item.get("name") or "") for item in prior_sectors[:5] if item.get("name")
}
ths_rows, dc_rows, errors = self._hot_rows(baseline_date)
candidates: dict[str, dict[str, Any]] = {}
core_tags: dict[str, set[str]] = {}
def ensure_candidate(item: dict[str, Any]) -> dict[str, Any] | None:
code = str(item.get("code") or str(item.get("ts_code") or "").split(".")[0])
if not code:
return None
return candidates.setdefault(
code,
{
"sources": [],
"streak": 0,
"sector": str(item.get("sector") or "其他"),
"name": str(item.get("name") or item.get("ts_name") or "--"),
"concepts": [],
"ths_rank": None,
"dc_rank": None,
},
)
for item in prior_limits:
candidate = ensure_candidate(item)
if candidate is None:
continue
candidate["sources"].append("昨日涨停")
candidate["streak"] = max(1, int(_number(item.get("streak"), 1)))
for item in prior_broken:
candidate = ensure_candidate(item)
if candidate is not None and "昨日炸板" not in candidate["sources"]:
candidate["sources"].append("昨日炸板")
limit_streaks = [max(1, int(_number(item.get("streak"), 1))) for item in prior_limits]
highest_streak = max(limit_streaks, default=0)
for item in prior_limits:
code = str(item.get("code") or "")
streak = max(1, int(_number(item.get("streak"), 1)))
if streak >= 3:
core_tags.setdefault(code, set()).add("三板以上")
if highest_streak and streak == highest_streak:
core_tags.setdefault(code, set()).add("市场最高板")
for sector in prior_sectors[:5]:
name = str(sector.get("name") or "")
members = [item for item in prior_limits if str(item.get("sector") or "其他") == name]
if not members:
continue
leader = max(
members,
key=lambda item: (
int(_number(item.get("streak"), 1)),
_number(item.get("amount_billion")),
-_number(item.get("open_times")),
),
)
core_tags.setdefault(str(leader.get("code") or ""), set()).add("题材核心")
leadership = sorted(
prior_limits,
key=lambda item: (
int(_number(item.get("streak"), 1)),
str(item.get("sector") or "") in strong_sector_names,
_number(item.get("amount_billion")),
),
reverse=True,
)
if leadership:
core_tags.setdefault(str(leadership[0].get("code") or ""), set()).add("市场领涨")
hot_records: dict[str, dict[str, Any]] = {}
for source, hot_rows, data_type in (
("同花顺热榜", ths_rows, "热股"),
("东方财富热榜", dc_rows, "A股市场"),
):
for item in hot_rows:
if str(item.get("data_type") or "") != data_type:
continue
ts_code = str(item.get("ts_code") or "")
code = ts_code.split(".")[0]
rank = max(1, int(_number(item.get("rank"), 9999)))
if not code or rank > 20:
continue
hot = hot_records.setdefault(
code,
{
"name": str(item.get("ts_name") or "--"),
"concepts": [],
"ths_rank": None,
"dc_rank": None,
},
)
hot["ths_rank" if source == "同花顺热榜" else "dc_rank"] = rank
if source == "同花顺热榜":
hot["concepts"] = self._parse_concepts(item.get("concept"))
ranked_hot = sorted(
hot_records.items(),
key=lambda pair: (
((21 - (pair[1].get("ths_rank") or 21)) / 20)
+ ((21 - (pair[1].get("dc_rank") or 21)) / 20)
+ (0.35 if pair[1].get("ths_rank") and pair[1].get("dc_rank") else 0)
),
reverse=True,
)
for code, _ in ranked_hot[:5]:
core_tags.setdefault(code, set()).add("人气前5")
for code, hot in hot_records.items():
ranks = [rank for rank in (hot.get("ths_rank"), hot.get("dc_rank")) if isinstance(rank, int)]
dual = len(ranks) == 2
if not ranks or (min(ranks) > 10 and not dual and code not in candidates and code not in core_tags):
continue
candidate = candidates.setdefault(
code,
{
"sources": [],
"streak": 0,
"sector": "其他",
"name": hot["name"],
"concepts": [],
"ths_rank": None,
"dc_rank": None,
},
)
candidate["ths_rank"] = hot.get("ths_rank")
candidate["dc_rank"] = hot.get("dc_rank")
candidate["concepts"] = hot.get("concepts") or []
if hot.get("ths_rank") and "同花顺热榜" not in candidate["sources"]:
candidate["sources"].append("同花顺热榜")
if hot.get("dc_rank") and "东方财富热榜" not in candidate["sources"]:
candidate["sources"].append("东方财富热榜")
normalized = []
for row in rows:
candidate = candidates.get(str(row.get("code") or ""))
if not candidate:
continue
streak = int(candidate["streak"])
expected_change = {1: 1.5, 2: 3.0, 3: 4.0}.get(streak, 5.0 if streak else 0.5)
ranks = [
rank for rank in (candidate.get("ths_rank"), candidate.get("dc_rank"))
if isinstance(rank, int)
]
if len(ranks) == 2:
expected_change += 0.8
elif ranks:
best_rank = min(ranks)
expected_change += 0.7 if best_rank <= 10 else 0.4 if best_rank <= 30 else 0.2
expected_change = min(expected_change, 6.5)
volume_ratio = _number(row.get("volume_ratio"))
turnover_rate = _number(row.get("turnover_rate"))
amount_million = _number(row.get("amount_million"))
confirmation = self._auction_confirmation(row)
actual_strength = _number(row.get("change")) + confirmation
label = self._expectation_label(actual_strength, expected_change)
is_broken = "昨日炸板" in candidate["sources"] and "昨日涨停" not in candidate["sources"]
identity = f"昨日{streak}板" if streak > 1 else "昨日首板" if streak == 1 else "昨日炸板" if is_broken else "人气榜标的"
popularity = ",双榜共识" if len(ranks) == 2 else ",热榜靠前" if ranks and min(ranks) <= 10 else ""
difference = _number(row.get("change")) - expected_change
direction = "高于" if difference > 0 else "低于" if difference < 0 else "贴合"
reason = (
f"{identity}{popularity};竞价涨幅{direction}预期中枢"
f"{abs(difference):.1f}个百分点,量比{volume_ratio:.2f}"
)
tags = sorted(core_tags.get(str(row.get("code") or ""), set()))
scored_row = {
**row,
"concepts": candidate["concepts"],
}
attention_score = self._attention_score(
scored_row,
expected_change,
tags,
candidate["sources"],
streak,
str(candidate.get("sector") or row.get("sector") or "") in strong_sector_names,
)
normalized.append(
{
**scored_row,
"sector": candidate["sector"] if candidate["sector"] != "其他" else row.get("sector", "其他"),
"candidate_sources": candidate["sources"],
"source_label": " · ".join(candidate["sources"]),
"prior_streak": streak,
"concepts": candidate["concepts"],
"expected_change": round(expected_change, 2),
"actual_strength": round(actual_strength, 2),
"expectation": label,
"attention_score": attention_score,
"core_tags": tags,
"is_market_core": bool(tags),
"expectation_reason": reason,
}
)
normalized.sort(key=lambda item: (_number(item.get("attention_score")), _number(item.get("amount_million"))), reverse=True)
matched_top = {
str(item.get("code") or "")
for item in sorted(
(item for item in normalized if item.get("expectation") == "符合预期"),
key=lambda item: _number(item.get("attention_score")),
reverse=True,
)[:20]
}
focus_candidates = [
item for item in normalized
if item.get("is_market_core")
or (_number(item.get("attention_score")) >= 55 and item.get("expectation") != "符合预期")
or str(item.get("code") or "") in matched_top
]
mandatory = [item for item in focus_candidates if item.get("is_market_core")]
mandatory_codes = {str(item.get("code") or "") for item in mandatory}
optional = [item for item in focus_candidates if str(item.get("code") or "") not in mandatory_codes]
focus_rows = sorted(mandatory, key=lambda item: _number(item.get("attention_score")), reverse=True)
focus_rows.extend(optional[:max(0, 30 - len(focus_rows))])
focus_rows.sort(key=lambda item: _number(item.get("attention_score")), reverse=True)
return normalized, {
"baseline_date": _display_date(baseline_date),
"prior_limit_count": len(prior_limits),
"prior_broken_count": len(prior_broken),
"hot_candidate_count": sum(
any(source in {"同花顺热榜", "东方财富热榜"} for source in item["sources"])
for item in candidates.values()
),
"core_count": sum(bool(item.get("is_market_core")) for item in normalized),
"notice": "".join(errors),
}, focus_rows
@staticmethod
def _auction_theme_evidence(
prior_snapshot: dict[str, Any],
candidate_rows: list[dict[str, Any]],
) -> dict[str, list[dict[str, Any]]]:
prior_sectors = list(prior_snapshot.get("sectors") or [])
carry = []
for sector in prior_sectors[:10]:
name = str(sector.get("name") or "其他")
matched = [row for row in candidate_rows if str(row.get("sector") or "其他") == name]
changes = [_number(row.get("change")) for row in matched]
middle = median(changes) if changes else -10.0
positive_rate = sum(value > 0.2 for value in changes) / len(changes) * 100 if changes else 0.0
if middle >= 2 and positive_rate >= 60:
status = "强承接"
elif middle >= 0 and positive_rate >= 50:
status = "有承接"
elif middle > -2:
status = "分歧"
else:
status = "承接弱"
carry.append(
{
"name": name,
"status": status,
"prior_limit_count": int(_number(sector.get("count"))),
"leader": str(sector.get("leader") or "--"),
"matched_count": len(matched),
"median_change": round(middle, 2) if matched else None,
"positive_rate": round(positive_rate, 1),
"amount_million": round(sum(_number(row.get("amount_million")) for row in matched), 2),
}
)
concept_groups: dict[str, list[dict[str, Any]]] = {}
prior_names = {str(item.get("name") or "") for item in prior_sectors}
for row in candidate_rows:
for concept in row.get("concepts") or []:
if concept and concept not in prior_names:
concept_groups.setdefault(str(concept), []).append(row)
new_themes = []
for name, members in concept_groups.items():
unique = {str(item.get("code") or ""): item for item in members}
values = list(unique.values())
changes = [_number(item.get("change")) for item in values]
if len(values) < 2 or median(changes) < 2 or sum(value > 0.2 for value in changes) / len(values) < 0.67:
continue
new_themes.append(
{
"name": name,
"stock_count": len(values),
"median_change": round(median(changes), 2),
"amount_million": round(sum(_number(item.get("amount_million")) for item in values), 2),
"leaders": [str(item.get("name") or "--") for item in sorted(values, key=lambda value: _number(value.get("change")), reverse=True)[:3]],
}
)
new_themes.sort(key=lambda item: (item["stock_count"], item["median_change"], item["amount_million"]), reverse=True)
return {"carry": carry, "new_themes": new_themes[:8]}
def _auction_amount_history(self, trade_date: str) -> list[dict[str, Any]]:
dates = self.database.auction_factor_dates(trade_date, 10)
stock_list_dates = {
str(item.get("ts_code") or ""): str(item.get("list_date") or "")
for item in self.database.list_stock_master()
if item.get("ts_code")
}
history = []
for current_date in dates:
rows = [
row for row in self.database.auction_factors_for_date(current_date)
if (
str(row.get("ts_code") or "") in stock_list_dates
and (
not stock_list_dates[str(row.get("ts_code") or "")]
or stock_list_dates[str(row.get("ts_code") or "")] < current_date
)
)
]
history.append(
{
"trade_date": _display_date(current_date),
"amount_billion": round(sum(_number(row.get("amount")) for row in rows) / 100_000_000, 2),
"stock_count": len(rows),
}
)
return history
def _ensure_auction_amount_history(self, trade_date: str, target_days: int = 10) -> None:
existing = set(self.database.auction_factor_dates(trade_date, target_days + 5))
if len(existing) >= target_days:
return
end = datetime.strptime(trade_date, "%Y%m%d")
start = (end - timedelta(days=35)).strftime("%Y%m%d")
try:
calendar = self.client.query(
"trade_cal",
{
"exchange": "SSE",
"start_date": start,
"end_date": trade_date,
"is_open": 1,
},
"cal_date,is_open",
)
except TushareError:
return
dates = sorted(
str(item.get("cal_date") or "")
for item in calendar
if int(_number(item.get("is_open"))) == 1 and item.get("cal_date")
)[-target_days:]
for current_date in dates:
if current_date in existing:
continue
try:
rows = self.client.query(
"stk_auction",
{"trade_date": current_date},
"ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,volume_ratio,float_share",
)
except TushareError:
break
if rows:
self.database.upsert_auction_factors(rows)
existing.add(current_date)
def _with_auction_watchlist(
self,
result: dict[str, Any],
trade_date: str,
user_id: int,
) -> dict[str, Any]:
personalized = copy.deepcopy(result)
if not user_id:
personalized["watchlist_rows"] = []
personalized["watchlist_missing_count"] = 0
return personalized
watched = self.database.list_watchlist(user_id)
if not watched:
personalized["watchlist_rows"] = []
personalized["watchlist_missing_count"] = 0
return personalized
public_rows = {
str(item.get("code") or ""): item
for item in (
list(personalized.get("rows") or [])
+ list(personalized.get("one_price_rows") or [])
)
}
factors = {
str(item.get("ts_code") or "").split(".")[0]: item
for item in self.database.auction_factors_for_date(trade_date)
}
master = {
str(item.get("ts_code") or "").split(".")[0]: item
for item in self.database.list_stock_master()
}
rows = []
missing = 0
for item in watched:
code = str(item.get("code") or "")
if code in public_rows:
rows.append({**public_rows[code], "is_watchlist": True})
continue
factor = factors.get(code)
if not factor:
missing += 1
rows.append(
{
"code": code,
"name": str(item.get("name") or "--"),
"sector": str(item.get("sector") or "其他"),
"available": False,
"is_watchlist": True,
}
)
continue
stock = master.get(code, {})
price = _number(factor.get("price"))
pre_close = _number(factor.get("pre_close"))
change = (price / pre_close - 1) * 100 if price > 0 and pre_close > 0 else 0
row = {
"code": code,
"ts_code": str(factor.get("ts_code") or ""),
"name": str(item.get("name") or stock.get("name") or "--"),
"sector": str(item.get("sector") or stock.get("industry") or "其他"),
"price": round(price, 2),
"pre_close": round(pre_close, 2),
"change": round(change, 2),
"amount_million": round(_number(factor.get("amount")) / 1_000_000, 2),
"turnover_rate": round(_number(factor.get("turnover_rate")), 4),
"volume_ratio": round(_number(factor.get("volume_ratio")), 2),
"candidate_sources": ["我的自选"],
"source_label": "我的自选",
"prior_streak": 0,
"concepts": [],
"expected_change": 0.0,
"core_tags": [],
"is_market_core": False,
"is_watchlist": True,
"available": True,
}
actual_strength = change + self._auction_confirmation(row)
row["actual_strength"] = round(actual_strength, 2)
row["expectation"] = self._expectation_label(actual_strength, 0.0)
row["attention_score"] = self._attention_score(row, 0.0, [], ["我的自选"], 0, False)
direction = "高于" if change > 0 else "低于" if change < 0 else "贴合"
row["expectation_reason"] = f"自选观察;竞价涨幅{direction}个人观察基准{abs(change):.1f}个百分点,量比{row['volume_ratio']:.2f}"
rows.append(row)
rows.sort(
key=lambda row: (bool(row.get("available", True)), _number(row.get("attention_score"))),
reverse=True,
)
personalized["watchlist_rows"] = rows
personalized["watchlist_missing_count"] = missing
return personalized
def _dynamic_auction_rows(
self,
trade_date: str,
baseline_date: str,
user_id: int,
) -> list[dict[str, Any]]:
if not self.ifind or not self.ifind.configured:
return []
master = self._stock_master()
placeholders = [
{
"code": str(item.get("code") or ts_code.split(".")[0]),
"ts_code": ts_code,
"name": str(item.get("name") or "--"),
"sector": str(item.get("industry") or "其他"),
}
for ts_code, item in master.items()
]
candidates, _, _ = self._auction_candidates(placeholders, baseline_date)
selected_codes = {
str(item.get("ts_code") or "")
for item in candidates
if item.get("ts_code")
}
if user_id:
watched = {str(item.get("code") or "") for item in self.database.list_watchlist(user_id)}
selected_codes.update(
ts_code for ts_code in master if ts_code.split(".")[0] in watched
)
selected_codes.discard("")
if not selected_codes:
return []
display_date = _display_date(trade_date)
now = self._now_provider()
if now.tzinfo is None:
now = now.replace(tzinfo=CHINA_TIMEZONE)
else:
now = now.astimezone(CHINA_TIMEZONE)
end_time = min(now.time().replace(tzinfo=None), dt_time(9, 25))
end_stamp = f"{display_date} {end_time.strftime('%H:%M:%S')}"
start_stamp = f"{display_date} 09:15:00"
snapshot_rows: list[dict[str, Any]] = []
ordered_codes = sorted(selected_codes)
for index in range(0, len(ordered_codes), 80):
try:
snapshot_rows.extend(
self.ifind.snapshots(
ordered_codes[index:index + 80],
[
"latest", "volume", "amount", "preClose",
"bid1", "bidSize1", "ask1", "askSize1",
],
start_stamp,
end_stamp,
cache_ttl=8,
)
)
except IfindError:
continue
latest: dict[str, dict[str, Any]] = {}
for row in snapshot_rows:
ts_code = str(row.get("thscode") or "")
previous = latest.get(ts_code) or {}
if (
ts_code
and _number(row.get("latest")) > 0
and str(row.get("time") or "") >= str(previous.get("time") or "")
):
latest[ts_code] = row
prior_factors = {
str(item.get("ts_code") or ""): item
for item in self.database.auction_factors_for_date(baseline_date)
}
normalized = []
for ts_code, row in latest.items():
price = _number(row.get("latest"))
pre_close = _number(row.get("preClose"))
volume = _number(row.get("volume"))
bid_size = _number(row.get("bidSize1"))
ask_size = _number(row.get("askSize1"))
if volume <= 0 and bid_size > 0 and ask_size > 0:
volume = min(bid_size, ask_size)
amount = _number(row.get("amount"))
if amount <= 0 and price > 0 and volume > 0:
amount = price * volume
prior_volume = _number((prior_factors.get(ts_code) or {}).get("vol"))
normalized.append(
{
"ts_code": ts_code,
"trade_date": trade_date,
"vol": volume,
"price": price,
"amount": amount,
"pre_close": pre_close,
"turnover_rate": 0,
"volume_ratio": volume / prior_volume if prior_volume > 0 else 0,
"float_share": 0,
"bid_size1": bid_size,
"ask_size1": ask_size,
"snapshot_time": str(row.get("time") or ""),
"dynamic": True,
}
)
return normalized
def auction_center(
self,
requested_date: str,
force: bool = False,
user_id: int = 0,
) -> dict[str, Any]:
trade_date, previous_date = self._trade_context(requested_date)
session = self._auction_session(requested_date, trade_date)
phase = str(session["phase"])
ifind_ready = bool(self.ifind and self.ifind.configured)
live_dynamic = phase == "observing" and ifind_ready
use_ifind_snapshot = phase in {"observing", "selection", "finalized"} and ifind_ready
data_date = previous_date if phase == "pending" or (phase == "observing" and not live_dynamic) else trade_date
carried_forward = data_date != trade_date
cache_key = data_date
if not force and not live_dynamic:
cached = self.database.get_data_snapshot("auction_center_v6", cache_key)
if cached:
result = copy.deepcopy(cached)
result["meta"] = {
**result.get("meta", {}),
**session,
"requested_date": _display_date(requested_date),
"trade_date": _display_date(data_date),
"carried_forward": carried_forward,
"available": bool((result.get("summary") or {}).get("stock_count")),
"cached": True,
}
return self._with_auction_watchlist(result, data_date, user_id)
if use_ifind_snapshot:
rows = self._dynamic_auction_rows(data_date, previous_date, user_id)
else:
rows = []
if not rows and not live_dynamic:
try:
rows = self.client.query("stk_auction", {"trade_date": data_date})
except TushareError:
rows = self.database.auction_factors_for_date(data_date)
if not rows:
return {
"meta": {
**session,
"requested_date": _display_date(requested_date),
"trade_date": _display_date(data_date),
"carried_forward": carried_forward,
"available": False,
"cached": False,
"notice": "该交易日暂无可用竞价快照",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
"summary": {
"stock_count": 0, "up_count": 0, "down_count": 0,
"limit_open_count": 0, "strong_open_count": 0,
"median_change": 0, "amount_billion": 0,
"candidate_count": 0, "focus_count": 0, "one_price_count": 0,
},
"expectations": {"超预期": 0, "符合预期": 0, "低于预期": 0},
"candidate_meta": {"baseline_date": _display_date(previous_date)},
"themes": {"carry": [], "new_themes": []},
"amount_history": self._auction_amount_history(data_date),
"news_feedback": {"available": False, "message": "隔夜消息反馈暂不可用"},
"focus_rows": [], "one_price_rows": [], "rows": [],
"watchlist_rows": [], "watchlist_missing_count": 0,
}
master = self._stock_master()
try:
limit_rows = self.client.query(
"stk_limit",
{"trade_date": data_date},
"trade_date,ts_code,up_limit,down_limit",
)
except TushareError:
limit_rows = []
limit_map = {str(item.get("ts_code") or ""): item for item in limit_rows}
normalized = []
for row in rows:
ts_code = str(row.get("ts_code") or "")
stock = master.get(ts_code)
price = _number(row.get("price"))
pre_close = _number(row.get("pre_close"))
list_date = str((stock or {}).get("list_date") or "")
if (
not stock
or price <= 0
or pre_close <= 0
or (list_date and list_date >= data_date)
):
continue
change = (price / pre_close - 1) * 100
amount_million = _number(row.get("amount")) / 1_000_000
volume_ratio = _number(row.get("volume_ratio"))
turnover_rate = _number(row.get("turnover_rate"))
up_limit = _number((limit_map.get(ts_code) or {}).get("up_limit"))
is_one_price = bool(
up_limit > 0 and abs(price - up_limit) <= max(0.001, up_limit * 0.00005)
)
normalized.append(
{
"code": str(stock.get("code") or ts_code.split(".")[0]),
"ts_code": ts_code,
"name": str(stock.get("name") or "--"),
"sector": str(stock.get("industry") or "其他"),
"price": round(price, 2),
"pre_close": round(pre_close, 2),
"change": round(change, 2),
"volume_ten_thousand": round(_number(row.get("vol")) / 10_000, 2),
"amount_million": round(amount_million, 2),
"turnover_rate": round(turnover_rate, 4),
"volume_ratio": round(volume_ratio, 2),
"up_limit": round(up_limit, 2) if up_limit else None,
"is_one_price": is_one_price,
"signal": (
"竞价涨停" if change >= 9.5 else
"强势高开" if change >= 3 else
"高开" if change > 0.2 else
"深度低开" if change <= -3 else
"低开" if change < -0.2 else "平开"
),
}
)
normalized.sort(key=lambda item: (item["amount_million"], item["volume_ratio"]), reverse=True)
self.database.upsert_auction_factors(rows)
changes = [item["change"] for item in normalized]
total = len(normalized)
_, baseline_date = self._trade_context(data_date)
candidates, candidate_meta, focus_rows = self._auction_candidates(normalized, baseline_date)
candidate_map = {str(item.get("code") or ""): item for item in candidates}
one_price_rows = []
for row in normalized:
if not row.get("is_one_price"):
continue
enriched = candidate_map.get(str(row.get("code") or ""), {})
one_price_rows.append(
{
**row,
**enriched,
"attention_score": None,
"expectation": "",
"expected_change": None,
"expectation_reason": "竞价价格封于当日涨停价,已从普通异动评分中隔离",
}
)
one_price_codes = {str(item.get("code") or "") for item in one_price_rows}
candidates = [item for item in candidates if str(item.get("code") or "") not in one_price_codes]
focus_rows = [item for item in focus_rows if str(item.get("code") or "") not in one_price_codes]
one_price_rows.sort(
key=lambda item: (
bool(item.get("is_market_core")),
_number(item.get("prior_streak")),
_number(item.get("amount_million")),
),
reverse=True,
)
expectations = {
label: sum(item.get("expectation") == label for item in candidates)
for label in ("超预期", "符合预期", "低于预期")
}
prior_snapshot = self.database.get_snapshot(baseline_date) or {}
themes = self._auction_theme_evidence(prior_snapshot, candidates + one_price_rows)
self._ensure_auction_amount_history(data_date)
amount_history = self._auction_amount_history(data_date)
prior_amounts = [item["amount_billion"] for item in amount_history[:-1]]
current_amount = round(sum(item["amount_million"] for item in normalized) / 100, 2)
previous_amount = prior_amounts[-1] if prior_amounts else 0
five_day_amounts = prior_amounts[-5:]
five_day_average = sum(five_day_amounts) / len(five_day_amounts) if five_day_amounts else 0
result = {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(data_date),
"carried_forward": carried_forward,
"available": bool(normalized),
**session,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
"summary": {
"stock_count": total,
"up_count": sum(value > 0.2 for value in changes),
"down_count": sum(value < -0.2 for value in changes),
"limit_open_count": len(one_price_rows),
"strong_open_count": sum(value >= 3 for value in changes),
"median_change": round(median(changes), 2) if changes else 0,
"amount_billion": current_amount,
"amount_change_previous": round((current_amount / previous_amount - 1) * 100, 1) if previous_amount else None,
"amount_change_5d": round((current_amount / five_day_average - 1) * 100, 1) if five_day_average else None,
"candidate_count": len(candidates),
"focus_count": len(focus_rows),
"one_price_count": len(one_price_rows),
},
"expectations": expectations,
"candidate_meta": candidate_meta,
"themes": themes,
"amount_history": amount_history,
"news_feedback": {
"available": False,
"message": "隔夜消息反馈暂不可用",
"detail": "待稳定的新闻与公告数据接入后开放",
},
"focus_rows": focus_rows,
"one_price_rows": one_price_rows,
"rows": candidates,
}
if not live_dynamic:
self.database.save_data_snapshot("auction_center_v6", cache_key, "market", result)
return self._with_auction_watchlist(result, data_date, user_id)
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
@staticmethod
def _parse_concepts(value: Any) -> list[str]:
if isinstance(value, list):
return [str(item) for item in value if str(item).strip()]
text = str(value or "").strip()
if not text:
return []
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return [str(item) for item in parsed if str(item).strip()]
except json.JSONDecodeError:
pass
return [part.strip() for part in text.split(",") if part.strip()]
def popularity(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("popularity_v1", trade_date)
if cached:
result = copy.deepcopy(cached)
result["meta"] = {**result.get("meta", {}), "cached": True}
return result
ths_rows, dc_rows, errors = self._hot_rows(trade_date)
actual_date = trade_date
carried_forward = False
if not ths_rows and not dc_rows and previous_date:
ths_rows, dc_rows, errors = self._hot_rows(previous_date)
actual_date = previous_date
carried_forward = bool(ths_rows or dc_rows)
if not ths_rows and not dc_rows:
fallback = self._latest_feature_snapshot("popularity_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
return {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(trade_date),
"previous_trade_date": _display_date(previous_date),
"carried_forward": False,
"cached": False,
"notice": "该交易日暂无可用人气榜",
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
},
"summary": {"ths_count": 0, "dc_count": 0, "dual_count": 0},
"combined": [], "ths": [], "dc": [],
}
prior_request = (datetime.strptime(actual_date, "%Y%m%d") - timedelta(days=1)).strftime("%Y%m%d")
prior_date, _ = self._trade_context(prior_request)
previous_ths, previous_dc, _ = self._hot_rows(prior_date)
ths = self._normalize_hot(ths_rows, "热股", previous_ths)
dc = self._normalize_hot(dc_rows, "A股市场", previous_dc)
ths_map = {item["ts_code"]: item for item in ths}
dc_map = {item["ts_code"]: item for item in dc}
combined = []
for ts_code in set(ths_map) | set(dc_map):
ths_item = ths_map.get(ts_code)
dc_item = dc_map.get(ts_code)
base = ths_item or dc_item or {}
ths_rank = int(ths_item["rank"]) if ths_item else None
dc_rank = int(dc_item["rank"]) if dc_item else None
score = (
(101 - (ths_rank or 101)) * 0.5
+ (201 - (dc_rank or 201)) * 0.25
)
combined.append(
{
**base,
"ths_rank": ths_rank,
"dc_rank": dc_rank,
"score": round(score, 2),
"dual_source": bool(ths_item and dc_item),
"concepts": (ths_item or {}).get("concepts") or [],
}
)
combined.sort(key=lambda item: (item["dual_source"], item["score"]), reverse=True)
for index, item in enumerate(combined, 1):
item["rank"] = index
result = {
"meta": {
"requested_date": _display_date(requested_date),
"trade_date": _display_date(actual_date),
"previous_trade_date": _display_date(prior_date),
"carried_forward": carried_forward,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "".join(errors),
},
"summary": {
"ths_count": len(ths),
"dc_count": len(dc),
"dual_count": sum(item["dual_source"] for item in combined),
},
"combined": combined[:200],
"ths": ths,
"dc": dc,
}
self.database.save_data_snapshot("popularity_v1", trade_date, "market", result)
return result
def _hot_rows(self, trade_date: str) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]:
errors = []
try:
ths = self.client.query("ths_hot", {"trade_date": trade_date})
except TushareError:
ths = []
errors.append("同花顺榜单暂不可用")
try:
dc = self.client.query("dc_hot", {"trade_date": trade_date})
except TushareError:
dc = []
errors.append("东方财富榜单暂不可用")
return ths, dc, errors
def _normalize_hot(
self,
rows: list[dict[str, Any]],
data_type: str,
previous_rows: list[dict[str, Any]],
) -> list[dict[str, Any]]:
previous = {
str(row.get("ts_code") or ""): int(_number(row.get("rank")))
for row in previous_rows
if str(row.get("data_type") or "") == data_type
}
items = []
for row in rows:
if str(row.get("data_type") or "") != data_type:
continue
rank = int(_number(row.get("rank")))
ts_code = str(row.get("ts_code") or "")
prior_rank = previous.get(ts_code)
items.append(
{
"rank": rank,
"ts_code": ts_code,
"code": ts_code.split(".")[0],
"name": str(row.get("ts_name") or "--"),
"change": round(_number(row.get("pct_change")), 2),
"price": round(_number(row.get("current_price")), 2),
"hot": round(_number(row.get("hot")), 1),
"rank_change": (prior_rank - rank) if prior_rank else None,
"concepts": self._parse_concepts(row.get("concept")),
"reason": str(row.get("rank_reason") or ""),
"rank_time": str(row.get("rank_time") or ""),
}
)
items.sort(key=lambda item: item["rank"])
return items