262 lines
9.8 KiB
Python
262 lines
9.8 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from typing import Any
|
|
|
|
from backend.data.gateway import DataGateway
|
|
from backend.data.repository import MarketRepository
|
|
from backend.database.connection import Database
|
|
from backend.features.mentor.skills import MentorSkill
|
|
from backend.features.screener.repository import ScreenerRepository
|
|
|
|
INDEX_UNIVERSE = (
|
|
("index", "000001.SH", "上证指数"),
|
|
("index", "399001.SZ", "深证成指"),
|
|
("index", "399006.SZ", "创业板指"),
|
|
("index", "000016.SH", "上证50"),
|
|
("index", "000300.SH", "沪深300"),
|
|
("index", "000905.SH", "中证500"),
|
|
("index", "000852.SH", "中证1000"),
|
|
("index", "932000.CSI", "中证2000"),
|
|
)
|
|
ETF_UNIVERSE = (
|
|
("stock", "510050.SH", "上证50ETF"),
|
|
("stock", "510300.SH", "沪深300ETF"),
|
|
("stock", "510500.SH", "中证500ETF"),
|
|
("stock", "512100.SH", "中证1000ETF"),
|
|
)
|
|
|
|
|
|
class MentorContextBuilder:
|
|
def __init__(
|
|
self,
|
|
database: Database,
|
|
market_repository: MarketRepository,
|
|
screener_repository: ScreenerRepository,
|
|
gateway: DataGateway,
|
|
) -> None:
|
|
self._database = database
|
|
self._market = market_repository
|
|
self._screener = screener_repository
|
|
self._gateway = gateway
|
|
|
|
def build(self, requested_date: str, question: str, skill: MentorSkill) -> dict[str, Any]:
|
|
context = self._gateway.trade_context(requested_date)
|
|
trade_date = context.actual_date or requested_date
|
|
with self._database.read() as connection:
|
|
summary_row = self._market.latest_summary(connection, trade_date)
|
|
summary = _payload(summary_row)
|
|
history = [
|
|
_summary_item(row)
|
|
for row in self._market.summaries(connection, trade_date, 10)
|
|
]
|
|
popularity = _payload(
|
|
self._market.latest_insight_snapshot(connection, "popularity", trade_date)
|
|
)
|
|
dragon = _payload(
|
|
self._market.latest_insight_snapshot(connection, "dragon-list", trade_date)
|
|
)
|
|
matched = self._matched_stocks(connection, trade_date, question)
|
|
indexes = self._market_matrix(connection, INDEX_UNIVERSE)
|
|
etfs = self._market_matrix(connection, ETF_UNIVERSE)
|
|
result: dict[str, Any] = {
|
|
"data_trade_date": str(summary_row["trade_date"]) if summary_row else trade_date,
|
|
"data_profile": skill.profile,
|
|
"overview": summary.get("overview") or {},
|
|
"sentiment": summary.get("sentiment") or {},
|
|
"recent_market_history": history,
|
|
"question_matched_stocks": matched,
|
|
}
|
|
self._apply_profile(result, skill.profile, summary, popularity, indexes, etfs)
|
|
if _requires_dragon_context(question):
|
|
result["dragon_list"] = _dragon_context(dragon, matched)
|
|
return result
|
|
|
|
def _matched_stocks(
|
|
self, connection, trade_date: str, question: str
|
|
) -> list[dict[str, Any]]:
|
|
directory = self._gateway.stock_directory()
|
|
identifiers = _matched_identifiers(directory, question)
|
|
snapshot = self._screener.latest_factor_snapshot(connection, trade_date)
|
|
factor_rows = (
|
|
{
|
|
row["identifier"]: row
|
|
for row in self._screener.factor_rows(connection, int(snapshot["id"]))
|
|
}
|
|
if snapshot
|
|
else {}
|
|
)
|
|
result = []
|
|
for identifier in identifiers[:2]:
|
|
row = dict(factor_rows.get(identifier) or directory.get(identifier) or {})
|
|
chart = self._market.chart(connection, "stock", identifier, "day")
|
|
row["recent_prices"] = (_payload(chart).get("points") or [])[-20:]
|
|
result.append(row)
|
|
return result
|
|
|
|
def _market_matrix(
|
|
self, connection, universe: tuple[tuple[str, str, str], ...]
|
|
) -> list[dict[str, Any]]:
|
|
result = []
|
|
for entity_type, identifier, name in universe:
|
|
row = self._market.chart(connection, entity_type, identifier, "day")
|
|
points = _payload(row).get("points") or []
|
|
closes = [float(item["close"]) for item in points if item.get("close")]
|
|
result.append(
|
|
{
|
|
"code": identifier,
|
|
"name": name,
|
|
"available": bool(closes),
|
|
"close": closes[-1] if closes else None,
|
|
"change": _return(closes, 1),
|
|
"return_5d": _return(closes, 5),
|
|
"return_10d": _return(closes, 10),
|
|
"return_20d": _return(closes, 20),
|
|
"amount": points[-1].get("amount") if points else None,
|
|
}
|
|
)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _apply_profile(
|
|
result: dict[str, Any],
|
|
profile: str,
|
|
summary: dict[str, Any],
|
|
popularity: dict[str, Any],
|
|
indexes: list[dict[str, Any]],
|
|
etfs: list[dict[str, Any]],
|
|
) -> None:
|
|
limits = list(summary.get("limits") or [])
|
|
broken = list(summary.get("broken") or [])
|
|
yesterday = list(summary.get("yesterday_limits") or [])
|
|
rotation = list(summary.get("sector_rotation") or [])
|
|
ordered = sorted(
|
|
limits,
|
|
key=lambda row: (int(row.get("streak") or 0), float(row.get("amount") or 0)),
|
|
reverse=True,
|
|
)
|
|
if profile == "first_board":
|
|
result["first_board_environment"] = {
|
|
"seal_rate": (summary.get("overview") or {}).get("seal_rate"),
|
|
"first_boards": [row for row in ordered if int(row.get("streak") or 1) == 1][:35],
|
|
"broken_stocks": broken[:30],
|
|
"hot_sectors": rotation[:12],
|
|
}
|
|
elif profile == "leader":
|
|
result.update(
|
|
{
|
|
"limit_ladder": summary.get("ladders") or [],
|
|
"multi_board_leaders": [
|
|
row for row in ordered if int(row.get("streak") or 0) >= 2
|
|
][:25],
|
|
"sector_rotation": rotation[:12],
|
|
"popularity_core": (popularity.get("combined") or [])[:10],
|
|
}
|
|
)
|
|
elif profile == "trend":
|
|
result.update(
|
|
{
|
|
"index_momentum": indexes,
|
|
"sector_rotation": rotation[:20],
|
|
"market_breadth": summary.get("overview") or {},
|
|
}
|
|
)
|
|
elif profile == "low_absorption":
|
|
result.update(
|
|
{
|
|
"yesterday_limit_performance": yesterday[:35],
|
|
"broken_stocks": broken[:20],
|
|
"hot_sectors": rotation[:12],
|
|
}
|
|
)
|
|
elif profile == "macro":
|
|
result.update(
|
|
{
|
|
"broad_indexes": indexes,
|
|
"core_etfs": etfs,
|
|
"market_style": {
|
|
"overview": summary.get("overview") or {},
|
|
"top_sectors": rotation[:15],
|
|
},
|
|
"unavailable_data": [
|
|
"政策原文与隔夜资讯尚未接入",
|
|
"汇率、利率和商品宏观序列当前不可用",
|
|
],
|
|
}
|
|
)
|
|
else:
|
|
result.update(
|
|
{
|
|
"limit_ladder": summary.get("ladders") or [],
|
|
"limit_performance": summary.get("limit_performance") or [],
|
|
"sector_rotation": rotation[:15],
|
|
"limit_up_stocks": ordered[:30],
|
|
"broken_stocks": broken[:20],
|
|
"limit_down_stocks": (summary.get("down_limits") or [])[:20],
|
|
"yesterday_limit_performance": yesterday[:20],
|
|
}
|
|
)
|
|
|
|
|
|
def _payload(row) -> dict[str, Any]:
|
|
if row is None:
|
|
return {}
|
|
try:
|
|
value = json.loads(str(row["payload_json"]))
|
|
except (KeyError, TypeError, json.JSONDecodeError):
|
|
return {}
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def _summary_item(row) -> dict[str, Any]:
|
|
payload = _payload(row)
|
|
return {
|
|
"trade_date": str(row["trade_date"]),
|
|
"overview": payload.get("overview") or {},
|
|
"sentiment": payload.get("sentiment") or {},
|
|
}
|
|
|
|
|
|
def _return(closes: list[float], 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)
|
|
|
|
|
|
def _dragon_context(payload: dict[str, Any], matched: list[dict[str, Any]]) -> dict[str, Any]:
|
|
codes = {
|
|
str(item.get("code") or item.get("identifier") or "").split(".")[0]
|
|
for item in matched
|
|
}
|
|
operations = payload.get("stocks") or payload.get("operations") or []
|
|
return {
|
|
"matched": [
|
|
row
|
|
for row in operations
|
|
if str(row.get("ts_code") or row.get("code") or "").split(".")[0] in codes
|
|
][:20],
|
|
"largest_net_flows": sorted(
|
|
operations,
|
|
key=lambda row: abs(float(row.get("net_buy") or row.get("net_million") or 0)),
|
|
reverse=True,
|
|
)[:12],
|
|
}
|
|
|
|
|
|
def _requires_dragon_context(question: str) -> bool:
|
|
return any(word in question for word in ("龙虎榜", "席位", "机构", "游资"))
|
|
|
|
|
|
def _matched_identifiers(directory: dict[str, dict[str, Any]], question: str) -> list[str]:
|
|
codes = set(re.findall(r"(?<!\d)\d{6}(?!\d)", question))
|
|
result = []
|
|
for identifier, row in directory.items():
|
|
code = str(row.get("symbol") or identifier.split(".")[0])
|
|
name = str(row.get("name") or "")
|
|
if code in codes or (len(name) >= 2 and name in question):
|
|
result.append(identifier)
|
|
if len(result) == 2:
|
|
break
|
|
return result
|