feat: integrate iFinD data and refine intelligent workspaces

This commit is contained in:
leefer
2026-07-28 16:38:56 +08:00
parent 6adeb54458
commit f4b2d7152a
22 changed files with 6481 additions and 389 deletions
+622 -55
View File
@@ -19,7 +19,7 @@ from urllib.parse import parse_qs, unquote, urlparse
from alert_service import AlertService
from assistant_agent import ReviewAssistantError, stream_review_assistant
from api_access import required_role
from chart_data_provider import ChartDataError, EastmoneyChartClient
from chart_data_provider import ChartDataError, EastmoneyChartClient, MarketChartClient
from app_config import (
DATA_DIR,
MENTOR_SKILLS_DIR,
@@ -50,6 +50,7 @@ from heaven_engine import (
build_personal_field,
hexagram_from_lines,
)
from ifind_client import IfindError, IfindHttpClient
from llm_strategy import LLMCompilerError, compile_strategy_with_llm, test_llm_connection
from mentor_agent import MentorAgentError, MentorSkillRegistry, stream_with_mentor
from market_insights import MarketInsightsService
@@ -76,6 +77,8 @@ from tushare_client import TushareClient, TushareError, _sector_coverage_issue
LEGACY_SECRET_KEYS = {
"TUSHARE_TOKEN",
"IFIND_REFRESH_TOKEN",
"IFIND_ACCESS_TOKEN",
"LLM_API_KEY",
"LLM_BASE_URL",
"LLM_MODEL",
@@ -104,12 +107,51 @@ THS_SEARCH_TYPES = {
"N": ("theme", "概念题材"),
}
MENTOR_DATA_PROFILES = {
"emotion": {
"kobe92-perspective", "niepanchongsheng-perspective",
"chaojiyangjia-perspective", "tuixuechaogu-perspective",
"chenxiaoqun-perspective", "zhiyechaoshou-perspective",
},
"first_board": {
"beijingchaojia-perspective", "chuangshiji-perspective",
"xuxiang-perspective", "foshanwuyingjiao-perspective",
},
"leader": {
"zhaolaoge-perspective", "fangxinxia-perspective",
"xiaoe-perspective", "sunge-perspective", "liuyizhonglu-perspective",
},
"trend": {
"zhangdetao-perspective", "zhangmengzhu-perspective",
"zuoshouxinyi-perspective",
},
"low_absorption": {
"qiaobangzhu-perspective", "asking-perspective",
"longfeihu-perspective", "ruihexian-perspective",
},
"macro": {"shuipi-perspective"},
}
MENTOR_INDEX_UNIVERSE = (
("000001.SH", "上证指数"), ("399001.SZ", "深证成指"),
("399006.SZ", "创业板指"), ("000016.SH", "上证50"),
("000300.SH", "沪深300"), ("000905.SH", "中证500"),
("000852.SH", "中证1000"), ("932000.CSI", "中证2000"),
)
MENTOR_ETF_UNIVERSE = (
("510050.SH", "上证50ETF"), ("510300.SH", "沪深300ETF"),
("510500.SH", "中证500ETF"), ("512100.SH", "中证1000ETF"),
)
class DashboardService:
def __init__(self) -> None:
load_local_env()
environment_credentials = {
"tushare_token": os.environ.get("TUSHARE_TOKEN", "").strip(),
"ifind_refresh_token": os.environ.get("IFIND_REFRESH_TOKEN", "").strip(),
"ifind_access_token": os.environ.get("IFIND_ACCESS_TOKEN", "").strip(),
"platform_llm_primary_api_key": os.environ.get(
"LLM_PRIMARY_API_KEY", os.environ.get("LLM_API_KEY", "")
).strip(),
@@ -133,15 +175,20 @@ class DashboardService:
self.sync_lock = threading.Lock()
self.auth_lock = threading.Lock()
self.system_lock = threading.Lock()
self._ifind_event_lock = threading.Lock()
self._request_context = threading.local()
self._system_credentials = self._load_system_credentials(environment_credentials)
self.ifind = IfindHttpClient(
str(self._system_credentials.get("ifind_refresh_token") or ""),
str(self._system_credentials.get("ifind_access_token") or ""),
)
self.screener = ScreenerEngine(self.database)
self.strategy_tracking = StrategyTrackingService(self.database)
self.alert_service = AlertService(self.database)
self.trade_journal = TradeJournalService(self.database)
self.mentor_skills = MentorSkillRegistry(MENTOR_SKILLS_DIR, PRIVATE_MENTOR_SKILLS_DIR)
self.realtime_aggregator = WebRealtimeAggregator()
self.chart_data = EastmoneyChartClient()
self.chart_data = MarketChartClient(self.ifind, EastmoneyChartClient())
self.screener.ensure_builtin_strategies()
self._background_stop = threading.Event()
self._background_thread = threading.Thread(
@@ -162,6 +209,8 @@ class DashboardService:
first_personal = self.vault.decrypt_json(first_encrypted) if first_encrypted else {}
defaults = {
"tushare_token": environment.get("tushare_token") or first_personal.get("tushare_token") or "",
"ifind_refresh_token": environment.get("ifind_refresh_token") or "",
"ifind_access_token": environment.get("ifind_access_token") or "",
"platform_llm_primary_api_key": environment.get("platform_llm_primary_api_key") or first_personal.get("llm_primary_api_key") or "",
"platform_llm_primary_base_url": environment.get("platform_llm_primary_base_url") or first_personal.get("llm_primary_base_url") or "https://api.openai.com/v1",
"platform_llm_primary_model": environment.get("platform_llm_primary_model") or first_personal.get("llm_primary_model") or "",
@@ -208,6 +257,11 @@ class DashboardService:
with self.system_lock:
self.database.save_system_setting("credentials", self.vault.encrypt_json(credentials))
self._system_credentials = dict(credentials)
if hasattr(self, "ifind"):
self.ifind.set_credentials(
str(credentials.get("ifind_refresh_token") or ""),
str(credentials.get("ifind_access_token") or ""),
)
@property
def configured(self) -> bool:
@@ -514,6 +568,7 @@ class DashboardService:
return {
"data": {
"configured": self.configured,
"ifind": self.ifind.status(),
"background_refresh_enabled": bool(
self._system_credentials.get("background_refresh_enabled", True)
),
@@ -538,6 +593,16 @@ class DashboardService:
token = str(payload.get("tushare_token") or current.get("tushare_token") or "").strip()
if token and not TOKEN_PATTERN.fullmatch(token):
raise ValueError("Tushare Token 格式不正确。")
ifind_refresh_token = str(
payload.get("ifind_refresh_token")
or current.get("ifind_refresh_token")
or ""
).strip()
if ifind_refresh_token and (
len(ifind_refresh_token) > 2048
or any(character.isspace() for character in ifind_refresh_token)
):
raise ValueError("iFinD Refresh Token 格式不正确。")
existing_models = {
str(item.get("id") or ""): item
for item in current.get("llm_models") or []
@@ -600,6 +665,7 @@ class DashboardService:
current.update(
{
"tushare_token": token,
"ifind_refresh_token": ifind_refresh_token,
"llm_models": models,
"primary_model_id": primary_model_id,
"fallback_model_id": fallback_model_id,
@@ -868,11 +934,51 @@ class DashboardService:
snapshot.setdefault("meta", {}).update(
{"realtime": False, "market_status": "closed"}
)
snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date)
if not self._dashboard_sentiment_ready(snapshot):
snapshot = self._enrich_dashboard_sentiment(snapshot, normalized_date)
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 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]}"
@@ -945,6 +1051,17 @@ class DashboardService:
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",
@@ -1073,7 +1190,11 @@ class DashboardService:
def _market_insights(self) -> MarketInsightsService:
if not self.configured:
raise ValueError("行情数据尚未配置。")
return MarketInsightsService(self.database, TushareClient(self.token))
return MarketInsightsService(
self.database,
TushareClient(self.token),
ifind=self.ifind,
)
def auction_center(self, trade_date: str, force: bool = False) -> dict[str, Any]:
return self._market_insights().auction_center(
@@ -1089,6 +1210,30 @@ class DashboardService:
def popularity(self, trade_date: str, force: bool = False) -> dict[str, Any]:
return self._market_insights().popularity(normalize_date(trade_date), force)
@staticmethod
def _ifind_field(row: dict[str, Any], tokens: tuple[str, ...]) -> Any:
for key, value in row.items():
label = str(key or "")
if any(token.casefold() == label.casefold() for token in tokens):
return value
for key, value in row.items():
label = str(key or "")
if any(token in label for token in tokens):
return value
return None
@classmethod
def _ifind_row_code(cls, row: dict[str, Any]) -> str:
value = cls._ifind_field(row, ("股票代码", "证券代码", "代码", "thscode"))
match = re.search(r"(?<!\d)(\d{6})(?!\d)", str(value or ""))
if match:
return match.group(1)
for value in row.values():
match = re.search(r"(?<!\d)(\d{6})\.(?:SH|SZ|BJ)(?![A-Z])", str(value or ""), re.I)
if match:
return match.group(1)
return ""
def screener_setup(self, trade_date: str) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
regime = self.screener.detect_regime(normalized_date)
@@ -1150,6 +1295,9 @@ class DashboardService:
"latest_results": self.database.latest_screener_runs(
self.current_user_id, normalized_date
),
"recent_results": self.database.latest_screener_context_runs(
self.current_user_id, normalized_date
),
# Kept during the client transition for compatibility with older frontends.
"latest_result": self.database.latest_screener_run(
self.current_user_id, normalized_date, "smart"
@@ -1578,7 +1726,7 @@ class DashboardService:
skill = self.mentor_skills.get_skill(
mentor_id, include_private=self.membership()["is_admin"]
)
context = self._build_mentor_context(trade_date, question)
context = self._build_mentor_context(trade_date, question, skill)
source = self.ensure_llm_access("mentor")
profiles = []
@@ -2990,7 +3138,9 @@ class DashboardService:
history.append({"role": item["role"], "content": content})
return history
def _build_mentor_context(self, trade_date: str, question: str) -> dict[str, Any]:
def _build_mentor_context(
self, trade_date: str, question: str, skill: Any | None = None
) -> dict[str, Any]:
dashboard = self.get_dashboard(trade_date)
data_trade_date = normalize_date(
str(dashboard.get("meta", {}).get("trade_date") or trade_date)
@@ -3009,6 +3159,10 @@ class DashboardService:
if code in codes or (len(name) >= 2 and name in question):
if not any(item.get("code") == code for item in matched_rows):
matched_rows.append(row)
for row in matched_rows:
code = str(row.get("code") or "")
if code and code not in codes:
codes.append(code)
stock_details = []
for code in codes[:2]:
try:
@@ -3023,8 +3177,17 @@ class DashboardService:
except Exception as exc:
stock_details.append({"code": code, "error": str(exc)})
skill_id = str(getattr(skill, "skill_id", "") or "")
profile = next(
(
profile_name
for profile_name, skill_ids in MENTOR_DATA_PROFILES.items()
if skill_id in skill_ids
),
"balanced",
)
dragon_tiger = None
if codes or any(keyword in question for keyword in ("龙虎榜", "席位", "机构", "游资")):
if any(keyword in question for keyword in ("龙虎榜", "席位", "机构", "游资")):
try:
dragon_payload = self.get_dragon_tiger(data_trade_date)
rows = list(dragon_payload.get("rows") or [])
@@ -3042,45 +3205,189 @@ class DashboardService:
except Exception as exc:
dragon_tiger = {"error": str(exc)}
return {
context: dict[str, Any] = {
"data_trade_date": data_trade_date,
"source": dashboard.get("meta", {}).get("source"),
"notice": dashboard.get("meta", {}).get("notice") or "",
"data_profile": profile,
"overview": dashboard.get("overview") or {},
"market_regime": regime,
"recent_market_history": self.database.snapshot_summaries(data_trade_date, 10),
"limit_ladder": dashboard.get("ladders") or [],
"limit_performance": dashboard.get("limit_performance") or [],
"hot_sectors": (dashboard.get("sectors") or [])[:20],
"sector_rotation": (dashboard.get("sector_rotation") or [])[:20],
"limit_up_stocks": sorted(
limits,
key=lambda row: (
float(row.get("streak") or 0),
float(row.get("amount_billion") or 0),
),
reverse=True,
)[:30],
"broken_stocks": sorted(
broken,
key=lambda row: float(row.get("amount_billion") or 0),
reverse=True,
)[:20],
"limit_down_stocks": sorted(
down_limits,
key=lambda row: float(row.get("amount_billion") or 0),
reverse=True,
)[:25],
"yesterday_limit_performance": sorted(
yesterday_limits,
key=lambda row: float(row.get("change") or 0),
reverse=True,
)[:25],
"question_matched_stocks": matched_rows[:10],
"stock_details": stock_details,
"dragon_tiger": dragon_tiger,
}
ordered_limits = sorted(
limits,
key=lambda row: (
float(row.get("streak") or 0),
float(row.get("amount_billion") or 0),
),
reverse=True,
)
if profile in {"emotion", "balanced"}:
context.update(
{
"limit_ladder": dashboard.get("ladders") or [],
"limit_performance": dashboard.get("limit_performance") or [],
"hot_sectors": (dashboard.get("sectors") or [])[:15],
"sector_rotation": (dashboard.get("sector_rotation") or [])[:15],
"limit_up_stocks": ordered_limits[:30],
"broken_stocks": sorted(
broken,
key=lambda row: float(row.get("amount_billion") or 0),
reverse=True,
)[:20],
"limit_down_stocks": down_limits[:20],
"yesterday_limit_performance": sorted(
yesterday_limits,
key=lambda row: float(row.get("change") or 0),
reverse=True,
)[:20],
}
)
elif profile == "first_board":
context.update(
{
"first_board_environment": {
"seal_rate": (dashboard.get("overview") or {}).get("seal_rate"),
"broken_count": len(broken),
"first_boards": [row for row in ordered_limits if int(row.get("streak") or 1) == 1][:35],
"broken_stocks": sorted(
broken,
key=lambda row: float(row.get("amount_billion") or 0),
reverse=True,
)[:30],
},
"hot_sectors": (dashboard.get("sectors") or [])[:12],
}
)
elif profile == "leader":
context.update(
{
"limit_ladder": dashboard.get("ladders") or [],
"multi_board_leaders": [
row for row in ordered_limits if int(row.get("streak") or 0) >= 2
][:25],
"hot_sectors": (dashboard.get("sectors") or [])[:12],
"sector_rotation": (dashboard.get("sector_rotation") or [])[:12],
}
)
try:
popularity = self.popularity(data_trade_date)
context["popularity_core"] = {
"consensus": [
row for row in (popularity.get("combined") or [])
if row.get("dual_source")
][:10],
"ths": (popularity.get("ths") or [])[:10],
"eastmoney": (popularity.get("dc") or [])[:10],
}
except Exception:
context["popularity_core"] = {"unavailable": True}
elif profile == "trend":
context.update(
{
"index_momentum": self._mentor_market_matrix(
data_trade_date, MENTOR_INDEX_UNIVERSE
),
"sector_rotation": (dashboard.get("sector_rotation") or [])[:20],
"hot_sectors": (dashboard.get("sectors") or [])[:20],
"market_breadth": {
key: (dashboard.get("overview") or {}).get(key)
for key in ("up_count", "down_count", "flat_count", "amount_billion")
},
}
)
elif profile == "low_absorption":
context.update(
{
"yesterday_limit_performance": sorted(
yesterday_limits,
key=lambda row: float(row.get("change") or 0),
reverse=True,
)[:35],
"broken_stocks": broken[:20],
"hot_sectors": (dashboard.get("sectors") or [])[:12],
}
)
elif profile == "macro":
context.update(
{
"broad_indexes": self._mentor_market_matrix(
data_trade_date, MENTOR_INDEX_UNIVERSE
),
"core_etfs": self._mentor_market_matrix(
data_trade_date, MENTOR_ETF_UNIVERSE
),
"market_style": {
"amount_billion": (dashboard.get("overview") or {}).get("amount_billion"),
"breadth": {
"up": (dashboard.get("overview") or {}).get("up_count"),
"down": (dashboard.get("overview") or {}).get("down_count"),
},
"top_sectors": (dashboard.get("sectors") or [])[:15],
},
"unavailable_data": [
"政策原文与隔夜资讯尚未接入",
"汇率、利率和商品宏观序列当前不可用",
],
}
)
if dragon_tiger is not None:
context["dragon_tiger"] = dragon_tiger
return context
def _mentor_market_matrix(
self, trade_date: str, universe: tuple[tuple[str, str], ...]
) -> list[dict[str, Any]]:
ifind = getattr(self, "ifind", None)
if not ifind or not ifind.configured:
return []
end = datetime.strptime(trade_date, "%Y%m%d")
start = (end - timedelta(days=45)).strftime("%Y%m%d")
names = {code: name for code, name in universe}
try:
rows = ifind.history(
list(names), ["close", "volume", "amount"], start, trade_date, cache_ttl=600
)
except IfindError:
return []
grouped: dict[str, list[dict[str, Any]]] = {}
for row in rows:
code = str(row.get("thscode") or "").upper()
if code in names:
grouped.setdefault(code, []).append(row)
result = []
for code, name in universe:
series = sorted(grouped.get(code, []), key=lambda row: str(row.get("time") or ""))
closes = []
for row in series:
try:
close = float(row.get("close") or 0)
except (TypeError, ValueError):
continue
if close > 0:
closes.append(close)
if not closes:
continue
def period_return(days: int) -> float | None:
if len(closes) <= days or closes[-days - 1] <= 0:
return None
return round((closes[-1] / closes[-days - 1] - 1) * 100, 2)
previous = closes[-2] if len(closes) > 1 else 0
result.append(
{
"code": code,
"name": name,
"close": round(closes[-1], 3),
"change": round((closes[-1] / previous - 1) * 100, 2) if previous else None,
"return_5d": period_return(5),
"return_10d": period_return(10),
"return_20d": period_return(20),
"latest_amount": series[-1].get("amount") if series else None,
}
)
return result
def run_screener(self, payload: dict[str, Any]) -> dict[str, Any]:
trade_date = normalize_date(str(payload.get("trade_date") or date.today().isoformat()))
regime = str(payload.get("regime") or "")
@@ -3118,6 +3425,65 @@ class DashboardService:
)
return result
def get_hot_money_profiles(self, force: bool = False) -> dict[str, Any]:
cache_kind = "hot_money_profiles_v1"
cache_key = "directory"
cached = self.database.get_data_snapshot(cache_kind, cache_key)
if cached and not force:
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return cached
if self.configured:
try:
payload = TushareClient(self.token).hot_money_profiles()
except TushareError:
if cached:
cached["meta"] = {
**cached.get("meta", {}),
"cached": True,
"stale": True,
"notice": "名录暂未完成更新,当前展示最近一次收录结果。",
}
return cached
return {
"meta": {
"source": "unavailable",
"status": "unavailable",
"schema_version": 1,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "游资名录暂不可用,请稍后重试。",
},
"summary": {
"profile_count": 0,
"described_count": 0,
"organization_count": 0,
},
"profiles": [],
}
payload["meta"]["cached"] = False
if payload.get("meta", {}).get("status") == "success":
self.database.save_data_snapshot(cache_kind, cache_key, "tushare", payload)
return payload
if cached:
cached["meta"] = {**cached.get("meta", {}), "cached": True}
return cached
return {
"meta": {
"source": "unavailable",
"status": "unavailable",
"schema_version": 1,
"cached": False,
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"notice": "游资名录暂不可用,请联系管理员检查行情配置。",
},
"summary": {
"profile_count": 0,
"described_count": 0,
"organization_count": 0,
},
"profiles": [],
}
def get_dragon_tiger(self, trade_date: str, force: bool = False) -> dict[str, Any]:
normalized_date = normalize_date(trade_date)
cache_kind = "hot_money_detail_v3"
@@ -3400,6 +3766,12 @@ class DashboardService:
}
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(
@@ -3407,6 +3779,8 @@ class DashboardService:
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
@@ -3492,6 +3866,21 @@ class DashboardService:
}
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)),
@@ -3500,14 +3889,14 @@ class DashboardService:
"entity": {
**basic,
"type_label": SEARCH_TYPE_LABELS["index"],
"value": float(current.get("close") or 0),
"change": float(current.get("pct_chg") or 0),
"value": latest_close,
"change": latest_change,
},
"series": series,
"metrics": [
{"label": "涨跌幅", "value": round(float(current.get("pct_chg") or 0), 2), "unit": "%", "tone": "change"},
{"label": "近5日", "value": round(float(current.get("return_5d") or 0), 2), "unit": "%", "tone": "change"},
{"label": "近20日", "value": round(float(current.get("return_20d") or 0), 2), "unit": "%", "tone": "change"},
{"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": "亿"},
],
}
@@ -3584,22 +3973,30 @@ class DashboardService:
self, payload: dict[str, Any], code: str, requested_date: str
) -> dict[str, Any]:
result = copy.deepcopy(payload)
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
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:]}",
}
if self.configured:
client = TushareClient(self.token)
now = datetime.now().astimezone()
today = now.strftime("%Y%m%d")
should_merge = (
requested_date == today
and actual_date < today
and now.time().replace(tzinfo=None) >= dt_time(9, 15)
)
if should_merge:
now = datetime.now().astimezone()
today = now.strftime("%Y%m%d")
should_merge = (
requested_date == today
and actual_date <= today
and now.time().replace(tzinfo=None) >= dt_time(9, 15)
)
if should_merge:
quote = self._ifind_realtime_stock_quote(code)
if quote:
self._merge_realtime_stock_detail(result, quote, requested_date)
elif self.configured and actual_date < today:
client = TushareClient(self.token)
try:
resolved_date, _ = client.resolve_trade_context(requested_date)
if resolved_date == today:
@@ -3609,6 +4006,42 @@ class DashboardService:
pass
return self._enrich_stock_detail(result)
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),
}
@staticmethod
def _merge_realtime_stock_detail(
payload: dict[str, Any], quote: dict[str, Any], trade_date: str
@@ -3621,7 +4054,7 @@ class DashboardService:
"low": quote["low"],
"close": quote["price"],
"change": quote["change"],
"volume": quote["volume"] / 100,
"volume": quote["volume"] if quote.get("volume_unit") == "lots" else quote["volume"] / 100,
"amount_billion": quote["amount_billion"],
"realtime": True,
}
@@ -3654,7 +4087,9 @@ class DashboardService:
self, code: str, trade_date: str, force: bool = False
) -> dict[str, Any]:
code = validate_stock_code(code)
detail = self.get_stock_detail(code, trade_date, force)
# 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]] = []
@@ -3758,6 +4193,11 @@ class DashboardService:
def _apply_reason_overrides(self, dashboard: dict[str, Any]) -> dict[str, Any]:
trade_date = str(dashboard.get("meta", {}).get("trade_date", "")).replace("-", "")
enrichment = self.database.get_data_snapshot("ifind_event_enrichment_v1", trade_date)
if enrichment:
self._merge_ifind_event_enrichment(dashboard, enrichment)
else:
self._schedule_ifind_event_enrichment(trade_date)
overrides = self.database.reason_overrides(trade_date)
if not overrides:
return dashboard
@@ -3768,6 +4208,122 @@ class DashboardService:
row["reason_source"] = "manual"
return dashboard
def _schedule_ifind_event_enrichment(self, trade_date: str) -> None:
ifind = getattr(self, "ifind", None)
if not ifind or not ifind.configured or not re.fullmatch(r"\d{8}", trade_date):
return
now = datetime.now().astimezone()
if trade_date == now.strftime("%Y%m%d") and now.time().replace(tzinfo=None) < dt_time(15, 0):
return
thread = threading.Thread(
target=self._refresh_ifind_event_enrichment,
args=(trade_date,),
name=f"ifind-event-{trade_date}",
daemon=True,
)
thread.start()
def _refresh_ifind_event_enrichment(self, trade_date: str) -> None:
if not self._ifind_event_lock.acquire(blocking=False):
return
try:
if self.database.get_data_snapshot("ifind_event_enrichment_v1", trade_date):
return
ifind = getattr(self, "ifind", None)
if not ifind or not ifind.configured:
return
current = datetime.strptime(trade_date, "%Y%m%d")
display_date = f"{current.year}{current.month}{current.day}"
requests = {
"limits": (
f"{display_date}涨停股票,股票代码、股票简称、涨停原因、"
"首次涨停时间、最终涨停时间、开板次数"
),
"broken": (
f"{display_date}曾涨停但收盘未涨停的股票,股票代码、股票简称、"
"涨停原因、首次涨停时间、开板次数"
),
"down_limits": (
f"{display_date}跌停股票,股票代码、股票简称、跌停原因"
),
}
result: dict[str, Any] = {
"trade_date": trade_date,
"generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"limits": {}, "broken": {}, "down_limits": {}, "partial": False,
}
for kind, query in requests.items():
try:
rows = ifind.wencai(query, "stock", cache_ttl=900)
except IfindError:
result["partial"] = True
continue
for raw in rows:
code = self._ifind_row_code(raw)
if not code:
continue
reason_tokens = (
("跌停原因", "风险线索", "原因")
if kind == "down_limits"
else ("涨停原因类别", "涨停原因", "触板逻辑", "原因")
)
reason = str(self._ifind_field(raw, reason_tokens) or "").strip()
first_time = self._normalize_ifind_event_time(
self._ifind_field(raw, ("首次涨停时间", "首次触板时间", "首次封板时间"))
)
last_time = self._normalize_ifind_event_time(
self._ifind_field(raw, ("最终涨停时间", "最后涨停时间", "最后封板时间"))
)
open_times = self._ifind_field(raw, ("开板次数", "打开涨停次数"))
try:
open_count = max(0, int(float(open_times))) if open_times not in (None, "") else None
except (TypeError, ValueError):
open_count = None
result[kind][code] = {
"reason": reason,
"first_time": first_time,
"last_time": last_time,
"open_times": open_count,
}
if any(result[kind] for kind in ("limits", "broken", "down_limits")):
self.database.save_data_snapshot(
"ifind_event_enrichment_v1", trade_date, "ifind", result
)
finally:
self._ifind_event_lock.release()
@staticmethod
def _normalize_ifind_event_time(value: Any) -> str:
text = str(value or "").strip()
match = re.search(r"(?:^|\s)(\d{1,2}:\d{2}(?::\d{2})?)(?:$|\s)", text)
if not match:
match = re.search(r"(?<!\d)(\d{6})(?!\d)", text)
if match:
compact = match.group(1)
return f"{compact[:2]}:{compact[2:4]}:{compact[4:]}"
return ""
parts = match.group(1).split(":")
return ":".join(part.zfill(2) for part in parts)
@staticmethod
def _merge_ifind_event_enrichment(
dashboard: dict[str, Any], enrichment: dict[str, Any]
) -> None:
for kind in ("limits", "broken", "down_limits"):
records = enrichment.get(kind) or {}
for row in dashboard.get(kind) or []:
event = records.get(str(row.get("code") or "")) or {}
reason = str(event.get("reason") or "").strip()
if reason:
row["reason"] = reason
row["reason_source"] = "market_event"
if event.get("first_time"):
row["first_time"] = event["first_time"]
if event.get("last_time"):
row["last_time"] = event["last_time"]
if event.get("open_times") is not None:
row["open_times"] = event["open_times"]
def _apply_seat_aliases(self, payload: dict[str, Any]) -> dict[str, Any]:
aliases = self.database.list_seat_aliases()
result = dict(payload)
@@ -4094,6 +4650,17 @@ class RequestHandler(BaseHTTPRequestHandler):
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/dragon-tiger/profiles":
query = parse_qs(parsed.query)
try:
self.send_json(
SERVICE.get_hot_money_profiles(
query.get("force", ["0"])[0] == "1"
)
)
except ValueError as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
return
if parsed.path == "/api/search":
query = parse_qs(parsed.query)
search_query = query.get("q", [""])[0]