rebuild(stage-8): deliver market insight workspaces
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from backend.features.market.insights.service import MarketInsightService
|
||||
|
||||
__all__ = ["MarketInsightService"]
|
||||
@@ -0,0 +1,519 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from statistics import median
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_auction(
|
||||
*,
|
||||
trade_date: str,
|
||||
raw_rows: tuple[dict[str, Any], ...],
|
||||
price_limits: tuple[dict[str, Any], ...],
|
||||
directory: dict[str, dict[str, Any]],
|
||||
prior_snapshot: dict[str, Any],
|
||||
ths_hot: tuple[dict[str, Any], ...],
|
||||
dc_hot: tuple[dict[str, Any], ...],
|
||||
history: list[dict[str, Any]],
|
||||
dynamic: bool,
|
||||
) -> dict[str, Any]:
|
||||
rows = _normalize_rows(raw_rows, price_limits, directory, dynamic)
|
||||
candidates, focus_rows = _score_candidates(rows, prior_snapshot, ths_hot, dc_hot)
|
||||
scored = {str(item["code"]): item for item in candidates}
|
||||
candidate_codes = {str(item["code"]) for item in candidates}
|
||||
one_price_rows = [
|
||||
{
|
||||
**item,
|
||||
**scored.get(str(item["code"]), {}),
|
||||
"attention_score": None,
|
||||
"expectation": "",
|
||||
"expected_change": None,
|
||||
"expectation_reason": "竞价价格封于当日涨停价,已从普通异动评分中隔离",
|
||||
}
|
||||
for item in rows
|
||||
if item["is_one_price"]
|
||||
]
|
||||
one_price_codes = {str(item["code"]) for item in one_price_rows}
|
||||
candidates = [item for item in candidates if item["code"] not in one_price_codes]
|
||||
focus_rows = [item for item in focus_rows if item["code"] 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,
|
||||
)
|
||||
changes = [float(item["change"]) for item in rows]
|
||||
amount_billion = round(sum(float(item["amount_million"]) for item in rows) / 100, 2)
|
||||
amount_history = [item for item in history if item.get("trade_date") != trade_date][-9:]
|
||||
amount_history.append(
|
||||
{"trade_date": trade_date, "amount_billion": amount_billion, "stock_count": len(rows)}
|
||||
)
|
||||
prior_amounts = [float(item["amount_billion"]) for item in amount_history[:-1]]
|
||||
previous_amount = prior_amounts[-1] if prior_amounts else 0
|
||||
five_day = prior_amounts[-5:]
|
||||
five_day_average = sum(five_day) / len(five_day) if five_day else 0
|
||||
eligible = sum(
|
||||
bool(item.get("identifier"))
|
||||
and not str(item.get("name") or "").upper().startswith(("N", "C"))
|
||||
for item in directory.values()
|
||||
)
|
||||
coverage = min(len(rows) / max(eligible, 1), 1)
|
||||
expectations = {
|
||||
label: sum(item.get("expectation") == label for item in candidates)
|
||||
for label in ("超预期", "符合预期", "低于预期")
|
||||
}
|
||||
return {
|
||||
"trade_date": trade_date,
|
||||
"dynamic": dynamic,
|
||||
"coverage": round(coverage, 4),
|
||||
"summary": {
|
||||
"stock_count": len(rows),
|
||||
"candidate_count": len(candidates),
|
||||
"focus_count": len(focus_rows),
|
||||
"one_price_count": len(one_price_rows),
|
||||
"amount_billion": amount_billion,
|
||||
"amount_change_previous": (
|
||||
round((amount_billion / previous_amount - 1) * 100, 1)
|
||||
if previous_amount
|
||||
else None
|
||||
),
|
||||
"amount_change_5d": (
|
||||
round((amount_billion / five_day_average - 1) * 100, 1)
|
||||
if five_day_average
|
||||
else None
|
||||
),
|
||||
"median_change": round(median(changes), 2) if changes else None,
|
||||
},
|
||||
"expectations": expectations,
|
||||
"themes": _theme_evidence(prior_snapshot, candidates + one_price_rows),
|
||||
"amount_history": amount_history,
|
||||
"focus_rows": focus_rows,
|
||||
"one_price_rows": one_price_rows,
|
||||
"_market_rows": rows,
|
||||
"rows": candidates,
|
||||
"all_market_count": len(rows),
|
||||
"candidate_market_count": len(candidate_codes),
|
||||
}
|
||||
|
||||
|
||||
def build_watchlist_rows(
|
||||
market_rows: list[dict[str, Any]],
|
||||
candidates: list[dict[str, Any]],
|
||||
one_price_rows: list[dict[str, Any]],
|
||||
watchlist: tuple[dict[str, Any], ...],
|
||||
) -> list[dict[str, Any]]:
|
||||
market = {str(item["identifier"]): item for item in market_rows}
|
||||
enriched = {
|
||||
str(item["identifier"]): item for item in candidates + one_price_rows
|
||||
}
|
||||
result = []
|
||||
for saved in watchlist:
|
||||
identifier = str(saved.get("identifier") or "")
|
||||
row = enriched.get(identifier)
|
||||
if row:
|
||||
result.append({**row, "is_watchlist": True, "available": True})
|
||||
continue
|
||||
raw = market.get(identifier)
|
||||
if raw:
|
||||
actual = _number(raw.get("change")) + _confirmation(raw)
|
||||
item = {
|
||||
**raw,
|
||||
"candidate_sources": ["我的自选"],
|
||||
"source_label": "我的自选",
|
||||
"prior_streak": 0,
|
||||
"concepts": [],
|
||||
"expected_change": 0.0,
|
||||
"actual_strength": round(actual, 2),
|
||||
"expectation": _expectation(actual, 0),
|
||||
"core_tags": [],
|
||||
"is_market_core": False,
|
||||
"is_watchlist": True,
|
||||
"available": True,
|
||||
}
|
||||
item["attention_score"] = _attention(item, 0, False, False)
|
||||
item["expectation_reason"] = "自选观察,按当日竞价强度与成交确认评估"
|
||||
result.append(item)
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"code": identifier.split(".")[0],
|
||||
"name": str(saved.get("name") or ""),
|
||||
"sector": str(saved.get("sector") or ""),
|
||||
"is_watchlist": True,
|
||||
"available": False,
|
||||
}
|
||||
)
|
||||
return sorted(
|
||||
result,
|
||||
key=lambda item: (
|
||||
bool(item.get("available")),
|
||||
_number(item.get("attention_score")),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_rows(
|
||||
raw_rows: tuple[dict[str, Any], ...],
|
||||
price_limits: tuple[dict[str, Any], ...],
|
||||
directory: dict[str, dict[str, Any]],
|
||||
dynamic: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
limits = {str(row.get("ts_code") or ""): row for row in price_limits}
|
||||
latest: dict[str, dict[str, Any]] = {}
|
||||
for raw in raw_rows:
|
||||
identifier = str(raw.get("thscode") or raw.get("ts_code") or "").upper()
|
||||
if not identifier or identifier not in directory:
|
||||
continue
|
||||
previous = latest.get(identifier)
|
||||
if previous is None or str(raw.get("time") or "") >= str(previous.get("time") or ""):
|
||||
latest[identifier] = raw
|
||||
rows = []
|
||||
for identifier, raw in latest.items():
|
||||
stock = directory[identifier]
|
||||
price = _number(raw.get("latest" if dynamic else "price"))
|
||||
pre_close = _number(raw.get("preClose" if dynamic else "pre_close"))
|
||||
volume = _number(raw.get("volume" if dynamic else "vol"))
|
||||
amount = _number(raw.get("amount"))
|
||||
if amount <= 0 and price > 0 and volume > 0:
|
||||
amount = price * volume
|
||||
if price <= 0 or pre_close <= 0:
|
||||
continue
|
||||
change = (price / pre_close - 1) * 100
|
||||
up_limit = _number((limits.get(identifier) or {}).get("up_limit"))
|
||||
rows.append(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"code": str(stock.get("code") or identifier.split(".")[0]),
|
||||
"name": str(stock.get("name") or ""),
|
||||
"sector": str(stock.get("sector") or "其他"),
|
||||
"price": round(price, 2),
|
||||
"change": round(change, 2),
|
||||
"amount_million": round(amount / 1_000_000, 2),
|
||||
"turnover_rate": round(
|
||||
_number(raw.get("turnoverRatio" if dynamic else "turnover_rate")), 4
|
||||
),
|
||||
"volume_ratio": round(
|
||||
_number(raw.get("volumeRatio" if dynamic else "volume_ratio")), 2
|
||||
),
|
||||
"up_limit": round(up_limit, 2) if up_limit else None,
|
||||
"is_one_price": bool(
|
||||
up_limit > 0 and abs(price - up_limit) <= max(0.001, up_limit * 0.00005)
|
||||
),
|
||||
"snapshot_time": str(raw.get("time") or ""),
|
||||
}
|
||||
)
|
||||
rows.sort(
|
||||
key=lambda item: (float(item["amount_million"]), float(item["volume_ratio"])),
|
||||
reverse=True,
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _score_candidates(
|
||||
rows: list[dict[str, Any]],
|
||||
prior_snapshot: dict[str, Any],
|
||||
ths_hot: tuple[dict[str, Any], ...],
|
||||
dc_hot: tuple[dict[str, Any], ...],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
prior_limits = list(prior_snapshot.get("limits") or [])
|
||||
prior_broken = list(prior_snapshot.get("broken") or [])
|
||||
prior_sectors = list(prior_snapshot.get("sectors") or [])
|
||||
strong_sectors = {str(item.get("name") or "") for item in prior_sectors[:5]}
|
||||
identities: dict[str, dict[str, Any]] = {}
|
||||
core_tags: dict[str, set[str]] = {}
|
||||
|
||||
def ensure(item: dict[str, Any]) -> tuple[str, dict[str, Any]] | None:
|
||||
code = str(item.get("code") or str(item.get("ts_code") or "").split(".")[0])
|
||||
if not code:
|
||||
return None
|
||||
return code, identities.setdefault(
|
||||
code,
|
||||
{
|
||||
"sources": [],
|
||||
"streak": 0,
|
||||
"sector": str(item.get("sector") or "其他"),
|
||||
"concepts": [],
|
||||
"ths_rank": None,
|
||||
"dc_rank": None,
|
||||
},
|
||||
)
|
||||
|
||||
highest = max((int(_number(item.get("streak"), 1)) for item in prior_limits), default=0)
|
||||
for item in prior_limits:
|
||||
entry = ensure(item)
|
||||
if not entry:
|
||||
continue
|
||||
code, identity = entry
|
||||
streak = max(1, int(_number(item.get("streak"), 1)))
|
||||
identity["streak"] = streak
|
||||
identity["sources"].append("昨日涨停")
|
||||
if streak >= 3:
|
||||
core_tags.setdefault(code, set()).add("三板以上")
|
||||
if highest and streak == highest:
|
||||
core_tags.setdefault(code, set()).add("市场最高板")
|
||||
for item in prior_broken:
|
||||
entry = ensure(item)
|
||||
if entry and "昨日炸板" not in entry[1]["sources"]:
|
||||
entry[1]["sources"].append("昨日炸板")
|
||||
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 members:
|
||||
leader = max(
|
||||
members,
|
||||
key=lambda item: (
|
||||
int(_number(item.get("streak"), 1)),
|
||||
_number(item.get("amount")),
|
||||
),
|
||||
)
|
||||
core_tags.setdefault(str(leader.get("code") or ""), set()).add("题材核心")
|
||||
if prior_limits:
|
||||
leader = max(
|
||||
prior_limits,
|
||||
key=lambda item: (
|
||||
int(_number(item.get("streak"), 1)),
|
||||
str(item.get("sector") or "") in strong_sectors,
|
||||
_number(item.get("amount")),
|
||||
),
|
||||
)
|
||||
core_tags.setdefault(str(leader.get("code") or ""), set()).add("市场领涨")
|
||||
|
||||
hot_records: dict[str, dict[str, Any]] = {}
|
||||
for rows_source, source, expected_type, rank_key in (
|
||||
(ths_hot, "同花顺热榜", "热股", "ths_rank"),
|
||||
(dc_hot, "东方财富热榜", "A股市场", "dc_rank"),
|
||||
):
|
||||
for item in rows_source:
|
||||
if str(item.get("data_type") or "") != expected_type:
|
||||
continue
|
||||
code = str(item.get("ts_code") or "").split(".")[0]
|
||||
rank = max(1, int(_number(item.get("rank"), 9999)))
|
||||
if not code or rank > 20:
|
||||
continue
|
||||
hot = hot_records.setdefault(
|
||||
code, {"ths_rank": None, "dc_rank": None, "concepts": []}
|
||||
)
|
||||
hot[rank_key] = rank
|
||||
if rank_key == "ths_rank":
|
||||
hot["concepts"] = _concepts(item.get("concept"))
|
||||
identity = identities.setdefault(
|
||||
code,
|
||||
{
|
||||
"sources": [],
|
||||
"streak": 0,
|
||||
"sector": "其他",
|
||||
"concepts": [],
|
||||
"ths_rank": None,
|
||||
"dc_rank": None,
|
||||
},
|
||||
)
|
||||
identity[rank_key] = rank
|
||||
identity["concepts"] = hot["concepts"] or identity["concepts"]
|
||||
if source not in identity["sources"]:
|
||||
identity["sources"].append(source)
|
||||
hot_ranked = sorted(
|
||||
hot_records,
|
||||
key=lambda code: (
|
||||
(21 - (hot_records[code]["ths_rank"] or 21)) * 0.5
|
||||
+ (21 - (hot_records[code]["dc_rank"] or 21)) * 0.25
|
||||
+ (10 if hot_records[code]["ths_rank"] and hot_records[code]["dc_rank"] else 0)
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
for code in hot_ranked[:5]:
|
||||
core_tags.setdefault(code, set()).add("人气前5")
|
||||
|
||||
normalized = []
|
||||
for row in rows:
|
||||
identity = identities.get(str(row["code"]))
|
||||
if not identity:
|
||||
continue
|
||||
ranks = [
|
||||
rank
|
||||
for rank in (identity.get("ths_rank"), identity.get("dc_rank"))
|
||||
if isinstance(rank, int)
|
||||
]
|
||||
if ranks and min(ranks) > 10 and len(ranks) == 1 and row["code"] not in core_tags:
|
||||
if not any(source in {"昨日涨停", "昨日炸板"} for source in identity["sources"]):
|
||||
continue
|
||||
streak = int(identity["streak"])
|
||||
expected = {0: 0.5, 1: 1.5, 2: 3.0, 3: 4.0}.get(streak, 5.0)
|
||||
expected += 0.8 if len(ranks) == 2 else 0.7 if ranks and min(ranks) <= 10 else 0
|
||||
expected = min(expected, 6.5)
|
||||
confirmation = _confirmation(row)
|
||||
actual_strength = float(row["change"]) + confirmation
|
||||
expectation = _expectation(actual_strength, expected)
|
||||
tags = sorted(core_tags.get(str(row["code"]), set()))
|
||||
scored = {
|
||||
**row,
|
||||
"sector": identity["sector"] if identity["sector"] != "其他" else row["sector"],
|
||||
"candidate_sources": identity["sources"],
|
||||
"source_label": " · ".join(identity["sources"]),
|
||||
"prior_streak": streak,
|
||||
"concepts": identity["concepts"],
|
||||
"expected_change": round(expected, 2),
|
||||
"actual_strength": round(actual_strength, 2),
|
||||
"expectation": expectation,
|
||||
"core_tags": tags,
|
||||
"is_market_core": bool(tags),
|
||||
}
|
||||
scored["attention_score"] = _attention(
|
||||
scored,
|
||||
expected,
|
||||
bool(tags),
|
||||
str(scored["sector"]) in strong_sectors,
|
||||
)
|
||||
scored["expectation_reason"] = _reason(scored)
|
||||
normalized.append(scored)
|
||||
normalized.sort(
|
||||
key=lambda item: (float(item["attention_score"]), float(item["amount_million"])),
|
||||
reverse=True,
|
||||
)
|
||||
matched = {
|
||||
str(item["code"])
|
||||
for item in [row for row in normalized if row["expectation"] == "符合预期"][:20]
|
||||
}
|
||||
mandatory = [item for item in normalized if item["is_market_core"]]
|
||||
optional = [
|
||||
item
|
||||
for item in normalized
|
||||
if not item["is_market_core"]
|
||||
and (
|
||||
(item["attention_score"] >= 55 and item["expectation"] != "符合预期")
|
||||
or item["code"] in matched
|
||||
)
|
||||
]
|
||||
focus = mandatory + optional[: max(0, 30 - len(mandatory))]
|
||||
focus.sort(key=lambda item: float(item["attention_score"]), reverse=True)
|
||||
return normalized, focus
|
||||
|
||||
|
||||
def _confirmation(row: dict[str, Any]) -> float:
|
||||
volume_ratio = _number(row.get("volume_ratio"))
|
||||
turnover = _number(row.get("turnover_rate"))
|
||||
amount = _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 >= 0.15 else -0.25 if turnover < 0.03 else 0)
|
||||
+ (0.3 if amount >= 20 else 0.15 if amount >= 5 else -0.3 if amount < 1 else 0)
|
||||
)
|
||||
|
||||
|
||||
def _attention(
|
||||
row: dict[str, Any], expected: float, core: bool, strong_sector: bool
|
||||
) -> float:
|
||||
sources = list(row.get("candidate_sources") or [])
|
||||
streak = int(row.get("prior_streak") or 0)
|
||||
identity = 35 if core else 27 if streak >= 2 else 21 if sources else 14
|
||||
deviation = min(30, abs(_number(row.get("change")) - expected) * 5)
|
||||
volume = min(10, max(0, _number(row.get("volume_ratio"))) / 2 * 10)
|
||||
amount = min(6, max(0, _number(row.get("amount_million"))) / 10 * 6)
|
||||
turnover = min(4, max(0, _number(row.get("turnover_rate"))) / 0.2 * 4)
|
||||
theme = 15 if strong_sector else 7 if row.get("concepts") else 0
|
||||
return round(min(100, identity + deviation + volume + amount + turnover + theme), 1)
|
||||
|
||||
|
||||
def _expectation(actual: float, expected: float) -> str:
|
||||
difference = actual - expected
|
||||
return "超预期" if difference >= 1.5 else "低于预期" if difference <= -1.5 else "符合预期"
|
||||
|
||||
|
||||
def _reason(row: dict[str, Any]) -> str:
|
||||
streak = int(row.get("prior_streak") or 0)
|
||||
identity = f"昨日{streak}板" if streak > 1 else "昨日首板" if streak else "热榜标的"
|
||||
difference = _number(row.get("change")) - _number(row.get("expected_change"))
|
||||
direction = "高于" if difference > 0 else "低于" if difference < 0 else "贴合"
|
||||
return f"{identity},竞价涨幅{direction}预期{abs(difference):.1f}个百分点"
|
||||
|
||||
|
||||
def _theme_evidence(
|
||||
prior_snapshot: dict[str, Any], 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 "其他")
|
||||
members = [row for row in rows if str(row.get("sector") or "其他") == name]
|
||||
changes = [_number(row.get("change")) for row in members]
|
||||
middle = median(changes) if changes else None
|
||||
positive = sum(value > 0.2 for value in changes) / len(changes) * 100 if changes else 0
|
||||
status = (
|
||||
"强承接" if middle is not None and middle >= 2 and positive >= 60
|
||||
else "有承接" if middle is not None and middle >= 0 and positive >= 50
|
||||
else "分歧" if middle is not None and middle > -2
|
||||
else "承接弱"
|
||||
)
|
||||
carry.append(
|
||||
{
|
||||
"name": name,
|
||||
"status": status,
|
||||
"prior_limit_count": int(_number(sector.get("count"))),
|
||||
"matched_count": len(members),
|
||||
"median_change": round(middle, 2) if middle is not None else None,
|
||||
"positive_rate": round(positive, 1),
|
||||
}
|
||||
)
|
||||
prior_names = {str(item.get("name") or "") for item in prior_sectors}
|
||||
groups: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
for row in rows:
|
||||
for concept in row.get("concepts") or []:
|
||||
if concept and concept not in prior_names:
|
||||
groups.setdefault(str(concept), {})[str(row["code"])] = row
|
||||
new_themes = []
|
||||
for name, mapped in groups.items():
|
||||
members = list(mapped.values())
|
||||
changes = [_number(item.get("change")) for item in members]
|
||||
positive_rate = sum(value > 0.2 for value in changes) / len(changes)
|
||||
if len(members) >= 2 and median(changes) >= 2 and positive_rate >= 0.67:
|
||||
new_themes.append(
|
||||
{
|
||||
"name": name,
|
||||
"stock_count": len(members),
|
||||
"median_change": round(median(changes), 2),
|
||||
"leaders": [
|
||||
str(item.get("name") or "")
|
||||
for item in sorted(
|
||||
members,
|
||||
key=lambda item: _number(item.get("change")),
|
||||
reverse=True,
|
||||
)[:3]
|
||||
],
|
||||
}
|
||||
)
|
||||
new_themes.sort(key=lambda item: (item["stock_count"], item["median_change"]), reverse=True)
|
||||
return {"carry": carry, "new_themes": new_themes[:8]}
|
||||
|
||||
|
||||
def _concepts(value: Any) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() 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).strip() for item in parsed if str(item).strip()]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return [part.strip() for part in text.replace(",", ",").split(",") if part.strip()]
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,302 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def profiles(rows: tuple[dict[str, Any], ...]) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
seen = set()
|
||||
for row in rows:
|
||||
name = _text(row.get("name"))
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
organizations = _organizations(row.get("orgs"))
|
||||
result.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": _text(row.get("desc")),
|
||||
"organizations": organizations,
|
||||
"organization_count": len(organizations),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def build_dragon_list(
|
||||
*,
|
||||
trade_date: str,
|
||||
official_rows: tuple[dict[str, Any], ...] | None,
|
||||
profile_rows: tuple[dict[str, Any], ...] | None,
|
||||
stock_rows: tuple[dict[str, Any], ...] | None,
|
||||
seat_rows: tuple[dict[str, Any], ...] | None,
|
||||
aliases: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
profile_items = profiles(profile_rows or ())
|
||||
profile_map = {str(item["name"]): item for item in profile_items}
|
||||
organization_map = {
|
||||
organization: str(item["name"])
|
||||
for item in profile_items
|
||||
for organization in item["organizations"]
|
||||
}
|
||||
stocks = _stock_context(stock_rows or ())
|
||||
operations = _official_operations(official_rows or (), profile_map, stocks)
|
||||
official_keys = {
|
||||
(str(item["identifier"]), str(item["seat_name"]), round(float(item["net_million"]), 2))
|
||||
for item in operations
|
||||
}
|
||||
for row in seat_rows or ():
|
||||
operation = _seat_operation(row, stocks, aliases, organization_map)
|
||||
key = (
|
||||
str(operation["identifier"]),
|
||||
str(operation["seat_name"]),
|
||||
round(float(operation["net_million"]), 2),
|
||||
)
|
||||
if key not in official_keys:
|
||||
operations.append(operation)
|
||||
|
||||
traders = _aggregate_traders(operations, profile_map)
|
||||
unclassified = _aggregate_unclassified(operations)
|
||||
official_stock_count = len(stocks)
|
||||
detail_available = official_rows is not None or seat_rows is not None
|
||||
detail_count = len(official_rows or ()) + len(seat_rows or ())
|
||||
recognized_count = sum(bool(item["recognized"]) for item in operations)
|
||||
if official_rows is None and stock_rows is None and seat_rows is None:
|
||||
status = "unavailable"
|
||||
message = "龙虎榜数据请求失败,请稍后重新检查"
|
||||
elif official_stock_count == 0 and detail_count == 0:
|
||||
status = "empty"
|
||||
message = "该交易日没有股票上榜"
|
||||
elif official_stock_count > 0 and (not detail_available or detail_count == 0):
|
||||
status = "detail_missing"
|
||||
message = f"当日有 {official_stock_count} 只股票上榜,但席位明细尚未返回"
|
||||
elif detail_count > 0 and recognized_count == 0:
|
||||
status = "unclassified"
|
||||
message = f"当日有 {official_stock_count} 只股票上榜,席位均待归类"
|
||||
else:
|
||||
status = "success" if not unclassified else "partial"
|
||||
message = "部分营业部尚未归类" if unclassified else ""
|
||||
return {
|
||||
"trade_date": trade_date,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"summary": {
|
||||
"official_stock_count": official_stock_count,
|
||||
"trader_count": len(traders),
|
||||
"operation_count": len(operations),
|
||||
"unclassified_count": len(unclassified),
|
||||
"net_million": round(
|
||||
sum(float(item["net_million"]) for item in operations), 2
|
||||
),
|
||||
"profile_count": len(profile_items),
|
||||
},
|
||||
"traders": traders,
|
||||
"operations": sorted(
|
||||
operations, key=lambda item: abs(float(item["net_million"])), reverse=True
|
||||
),
|
||||
"unclassified_seats": unclassified,
|
||||
"profiles": profile_items,
|
||||
}
|
||||
|
||||
|
||||
def _stock_context(rows: tuple[dict[str, Any], ...]) -> dict[str, dict[str, Any]]:
|
||||
result = {}
|
||||
for row in rows:
|
||||
identifier = str(row.get("ts_code") or "")
|
||||
if identifier and identifier not in result:
|
||||
result[identifier] = {
|
||||
"name": _text(row.get("name")),
|
||||
"change": _optional_number(row.get("pct_change")),
|
||||
"reason": _text(row.get("reason")),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _official_operations(
|
||||
rows: tuple[dict[str, Any], ...],
|
||||
profile_map: dict[str, dict[str, Any]],
|
||||
stocks: dict[str, dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
result = []
|
||||
for row in rows:
|
||||
identifier = str(row.get("ts_code") or "")
|
||||
trader = _text(row.get("hm_name")) or "未命名游资"
|
||||
profile = profile_map.get(trader) or {}
|
||||
seat = _text(row.get("hm_orgs")) or ""
|
||||
stock = stocks.get(identifier) or {}
|
||||
result.append(
|
||||
_operation(
|
||||
identifier=identifier,
|
||||
name=_text(row.get("ts_name")) or str(stock.get("name") or ""),
|
||||
change=stock.get("change"),
|
||||
reason=str(stock.get("reason") or ""),
|
||||
seat_name=seat or "未提供营业部",
|
||||
trader_name=trader,
|
||||
description=str(profile.get("description") or ""),
|
||||
buy=_number(row.get("buy_amount")) / 1_000_000,
|
||||
sell=_number(row.get("sell_amount")) / 1_000_000,
|
||||
net=_number(row.get("net_amount")) / 1_000_000,
|
||||
recognized=True,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _seat_operation(
|
||||
row: dict[str, Any],
|
||||
stocks: dict[str, dict[str, Any]],
|
||||
aliases: dict[str, str],
|
||||
organization_map: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
identifier = str(row.get("ts_code") or "")
|
||||
seat = _text(row.get("exalter")) or "未命名营业部"
|
||||
trader = aliases.get(seat) or organization_map.get(seat) or ""
|
||||
stock = stocks.get(identifier) or {}
|
||||
buy = _number(row.get("buy")) / 1_000_000
|
||||
sell = _number(row.get("sell")) / 1_000_000
|
||||
net = _number(row.get("net_buy")) / 1_000_000
|
||||
if net == 0 and (buy or sell):
|
||||
net = buy - sell
|
||||
return _operation(
|
||||
identifier=identifier,
|
||||
name=str(stock.get("name") or ""),
|
||||
change=stock.get("change"),
|
||||
reason=_text(row.get("reason")) or str(stock.get("reason") or ""),
|
||||
seat_name=seat,
|
||||
trader_name=trader,
|
||||
description="",
|
||||
buy=buy,
|
||||
sell=sell,
|
||||
net=net,
|
||||
recognized=bool(trader),
|
||||
)
|
||||
|
||||
|
||||
def _operation(
|
||||
*,
|
||||
identifier: str,
|
||||
name: str,
|
||||
change: float | None,
|
||||
reason: str,
|
||||
seat_name: str,
|
||||
trader_name: str,
|
||||
description: str,
|
||||
buy: float,
|
||||
sell: float,
|
||||
net: float,
|
||||
recognized: bool,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"identifier": identifier,
|
||||
"code": identifier.split(".")[0],
|
||||
"name": name,
|
||||
"change": change,
|
||||
"direction": "买入" if net > 0 else "卖出" if net < 0 else "持平",
|
||||
"buy_million": round(buy, 2),
|
||||
"sell_million": round(sell, 2),
|
||||
"net_million": round(net, 2),
|
||||
"seat_name": seat_name,
|
||||
"trader_name": trader_name,
|
||||
"description": description,
|
||||
"reason": reason,
|
||||
"recognized": recognized,
|
||||
}
|
||||
|
||||
|
||||
def _aggregate_traders(
|
||||
operations: list[dict[str, Any]], profile_map: dict[str, dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
groups: dict[str, dict[str, Any]] = {}
|
||||
for operation in operations:
|
||||
name = str(operation.get("trader_name") or "")
|
||||
if not operation.get("recognized") or not name:
|
||||
continue
|
||||
group = groups.setdefault(
|
||||
name,
|
||||
{
|
||||
"name": name,
|
||||
"description": str((profile_map.get(name) or {}).get("description") or ""),
|
||||
"buy_million": 0.0,
|
||||
"sell_million": 0.0,
|
||||
"net_million": 0.0,
|
||||
"seats": set(),
|
||||
"stocks": set(),
|
||||
"operations": [],
|
||||
},
|
||||
)
|
||||
group["buy_million"] += float(operation["buy_million"])
|
||||
group["sell_million"] += float(operation["sell_million"])
|
||||
group["net_million"] += float(operation["net_million"])
|
||||
group["seats"].add(str(operation["seat_name"]))
|
||||
group["stocks"].add(str(operation["code"]))
|
||||
group["operations"].append(operation)
|
||||
result = []
|
||||
for group in groups.values():
|
||||
result.append(
|
||||
{
|
||||
"name": group["name"],
|
||||
"description": group["description"],
|
||||
"buy_million": round(group["buy_million"], 2),
|
||||
"sell_million": round(group["sell_million"], 2),
|
||||
"net_million": round(group["net_million"], 2),
|
||||
"seat_count": len(group["seats"]),
|
||||
"stock_count": len(group["stocks"]),
|
||||
"operation_count": len(group["operations"]),
|
||||
"operations": sorted(
|
||||
group["operations"],
|
||||
key=lambda item: abs(float(item["net_million"])),
|
||||
reverse=True,
|
||||
),
|
||||
}
|
||||
)
|
||||
return sorted(result, key=lambda item: abs(float(item["net_million"])), reverse=True)
|
||||
|
||||
|
||||
def _aggregate_unclassified(operations: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
groups: dict[str, dict[str, Any]] = {}
|
||||
for operation in operations:
|
||||
if operation.get("recognized"):
|
||||
continue
|
||||
seat = str(operation["seat_name"])
|
||||
group = groups.setdefault(
|
||||
seat, {"seat_name": seat, "net_million": 0.0, "operation_count": 0}
|
||||
)
|
||||
group["net_million"] += float(operation["net_million"])
|
||||
group["operation_count"] += 1
|
||||
result = [
|
||||
{**group, "net_million": round(float(group["net_million"]), 2)}
|
||||
for group in groups.values()
|
||||
]
|
||||
return sorted(result, key=lambda item: abs(float(item["net_million"])), reverse=True)
|
||||
|
||||
|
||||
def _organizations(value: Any) -> list[str]:
|
||||
text = _text(value)
|
||||
parsed: Any = None
|
||||
if text.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
values = parsed if isinstance(parsed, list) else re.split(r"[,,;;\n]+", text)
|
||||
return list(dict.fromkeys(_text(item) for item in values if _text(item)))
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _number(value: Any) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
return number if number == number else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _optional_number(value: Any) -> float | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return _number(value)
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_popularity(
|
||||
trade_date: str,
|
||||
ths_rows: tuple[dict[str, Any], ...],
|
||||
dc_rows: tuple[dict[str, Any], ...],
|
||||
previous_ths: tuple[dict[str, Any], ...],
|
||||
previous_dc: tuple[dict[str, Any], ...],
|
||||
) -> dict[str, Any]:
|
||||
ths = _normalize(ths_rows, "热股", previous_ths)
|
||||
dc = _normalize(dc_rows, "A股市场", previous_dc)
|
||||
ths_map = {str(item["identifier"]): item for item in ths}
|
||||
dc_map = {str(item["identifier"]): item for item in dc}
|
||||
combined = []
|
||||
for identifier in set(ths_map) | set(dc_map):
|
||||
ths_item = ths_map.get(identifier)
|
||||
dc_item = dc_map.get(identifier)
|
||||
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": list((ths_item or {}).get("concepts") or []),
|
||||
}
|
||||
)
|
||||
combined.sort(
|
||||
key=lambda item: (bool(item["dual_source"]), float(item["score"])), reverse=True
|
||||
)
|
||||
for rank, item in enumerate(combined, start=1):
|
||||
item["rank"] = rank
|
||||
return {
|
||||
"trade_date": trade_date,
|
||||
"summary": {
|
||||
"ths_count": len(ths),
|
||||
"dc_count": len(dc),
|
||||
"dual_count": sum(bool(item["dual_source"]) for item in combined),
|
||||
},
|
||||
"combined": combined[:200],
|
||||
"ths": ths,
|
||||
"dc": dc,
|
||||
}
|
||||
|
||||
|
||||
def _normalize(
|
||||
rows: tuple[dict[str, Any], ...],
|
||||
data_type: str,
|
||||
previous_rows: tuple[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
|
||||
identifier = str(row.get("ts_code") or "")
|
||||
rank = int(_number(row.get("rank")))
|
||||
if not identifier or rank <= 0:
|
||||
continue
|
||||
prior = previous.get(identifier)
|
||||
items.append(
|
||||
{
|
||||
"rank": rank,
|
||||
"identifier": identifier,
|
||||
"code": identifier.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 if prior else None,
|
||||
"concepts": _concepts(row.get("concept")),
|
||||
"reason": str(row.get("rank_reason") or ""),
|
||||
"rank_time": str(row.get("rank_time") or ""),
|
||||
}
|
||||
)
|
||||
return sorted(items, key=lambda item: int(item["rank"]))
|
||||
|
||||
|
||||
def _concepts(value: Any) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() 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).strip() for item in parsed if str(item).strip()]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return [part.strip() for part in text.replace(",", ",").split(",") if part.strip()]
|
||||
|
||||
|
||||
def _number(value: Any) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
return number if number == number else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
@@ -0,0 +1,490 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, time
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from backend.data.contracts import SnapshotState
|
||||
from backend.data.gateway import DataGateway, MarketDataUnavailable
|
||||
from backend.data.providers.base import ProviderError
|
||||
from backend.data.repository import MarketRepository
|
||||
from backend.database.connection import Database
|
||||
from backend.features.market.insights.auction import build_auction, build_watchlist_rows
|
||||
from backend.features.market.insights.dragon import build_dragon_list
|
||||
from backend.features.market.insights.popularity import build_popularity
|
||||
from backend.features.market.insights.support import (
|
||||
auction_phase as _auction_phase,
|
||||
)
|
||||
from backend.features.market.insights.support import (
|
||||
clock as _clock,
|
||||
)
|
||||
from backend.features.market.insights.support import (
|
||||
decorate as _decorate,
|
||||
)
|
||||
from backend.features.market.insights.support import (
|
||||
empty_auction as _empty_auction,
|
||||
)
|
||||
from backend.features.market.insights.support import (
|
||||
empty_standard as _empty_standard,
|
||||
)
|
||||
from backend.features.market.insights.support import (
|
||||
number as _number,
|
||||
)
|
||||
from backend.features.market.insights.support import (
|
||||
result as _result,
|
||||
)
|
||||
from backend.features.market.insights.support import (
|
||||
rows as _rows,
|
||||
)
|
||||
from backend.features.market.insights.support import (
|
||||
serialized_rows as _serialized_rows,
|
||||
)
|
||||
from backend.features.market.insights.support import (
|
||||
standard as _standard,
|
||||
)
|
||||
from backend.features.market.insights.support import (
|
||||
tuple_or_none as _tuple_or_none,
|
||||
)
|
||||
from backend.features.market.insights.support import (
|
||||
valid_date as _date,
|
||||
)
|
||||
from backend.features.market.insights.themes import build_theme_detail, build_theme_library
|
||||
|
||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
class MarketInsightError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class MarketInsightService:
|
||||
def __init__(
|
||||
self, database: Database, repository: MarketRepository, gateway: DataGateway
|
||||
) -> None:
|
||||
self._database = database
|
||||
self._repository = repository
|
||||
self._gateway = gateway
|
||||
|
||||
def workspace(
|
||||
self,
|
||||
key: str,
|
||||
requested_date: str | None = None,
|
||||
*,
|
||||
user_id: int,
|
||||
force: bool = False,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if key == "auction":
|
||||
return self.auction(requested_date, user_id=user_id, force=force, now=now)
|
||||
if key == "themes":
|
||||
return self.themes(requested_date, force=force)
|
||||
if key == "popularity":
|
||||
return self.popularity(requested_date, force=force)
|
||||
if key == "dragon-list":
|
||||
return self.dragon_list(requested_date, force=force)
|
||||
raise MarketInsightError("不支持的市场洞察工作区")
|
||||
|
||||
def auction(
|
||||
self,
|
||||
requested_date: str | None,
|
||||
*,
|
||||
user_id: int,
|
||||
force: bool = False,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
clock = _clock(now)
|
||||
requested, trade_date, previous = self._trade_dates(requested_date, clock)
|
||||
phase = _auction_phase(requested, trade_date, clock)
|
||||
target = previous if phase == "pending" else trade_date
|
||||
baseline = self._previous_date(target)
|
||||
cached = self._snapshot("auction", target)
|
||||
if cached and not force and phase not in {"observing", "selection"}:
|
||||
return _decorate(
|
||||
self._personalize_auction(cached, user_id),
|
||||
requested,
|
||||
phase,
|
||||
carried_forward=target != requested,
|
||||
message=(
|
||||
"今日竞价尚未开始,显示前一交易日归档"
|
||||
if phase == "pending"
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
inputs = self._gateway.insight_inputs("auction", target, baseline)
|
||||
raw = _rows(inputs.get("auction"))
|
||||
dynamic = False
|
||||
observed_at = clock
|
||||
if phase in {"observing", "selection"}:
|
||||
identifiers = self._auction_universe(baseline, inputs)
|
||||
end = time(9, 25) if phase == "selection" else clock.time().replace(tzinfo=None)
|
||||
try:
|
||||
live = self._gateway.dynamic_auction(
|
||||
identifiers,
|
||||
f"{target} 09:15:00",
|
||||
f"{target} {end.strftime('%H:%M:%S')}",
|
||||
)
|
||||
raw = live.rows
|
||||
observed_at = live.metadata.observed_at
|
||||
dynamic = True
|
||||
except (MarketDataUnavailable, ProviderError):
|
||||
if phase == "observing":
|
||||
prior = self._snapshot("auction", previous)
|
||||
if prior:
|
||||
return _decorate(
|
||||
self._personalize_auction(prior, user_id),
|
||||
requested,
|
||||
phase,
|
||||
carried_forward=True,
|
||||
message="今日动态竞价暂不可用,当前显示前一交易日归档",
|
||||
current_available=False,
|
||||
)
|
||||
return _empty_auction(
|
||||
requested,
|
||||
previous,
|
||||
phase,
|
||||
"今日动态竞价暂不可用,且没有历史归档",
|
||||
)
|
||||
if not raw:
|
||||
if cached:
|
||||
return _decorate(
|
||||
self._personalize_auction(cached, user_id),
|
||||
requested,
|
||||
phase,
|
||||
False,
|
||||
"当前读取失败,保留真实归档",
|
||||
)
|
||||
return _empty_auction(requested, target, phase, "该交易日暂无可用竞价快照")
|
||||
|
||||
payload = build_auction(
|
||||
trade_date=target,
|
||||
raw_rows=raw,
|
||||
price_limits=_rows(inputs.get("price_limits")),
|
||||
directory=self._gateway.stock_directory(),
|
||||
prior_snapshot=self._market_snapshot(baseline),
|
||||
ths_hot=_rows(inputs.get("ths_hot")),
|
||||
dc_hot=_rows(inputs.get("dc_hot")),
|
||||
history=self._auction_history(target),
|
||||
dynamic=dynamic,
|
||||
)
|
||||
minimum = 0.8 if phase == "observing" else 0.9
|
||||
if float(payload["coverage"]) < minimum:
|
||||
if cached:
|
||||
return _decorate(
|
||||
self._personalize_auction(cached, user_id),
|
||||
requested,
|
||||
phase,
|
||||
False,
|
||||
f"竞价覆盖率不足{minimum * 100:.0f}%,保留原有真实归档",
|
||||
)
|
||||
return _empty_auction(
|
||||
requested,
|
||||
target,
|
||||
phase,
|
||||
f"竞价覆盖率不足{minimum * 100:.0f}%,未形成正式结果",
|
||||
)
|
||||
state = (
|
||||
SnapshotState.REALTIME
|
||||
if phase == "observing"
|
||||
else SnapshotState.FINAL
|
||||
if target == clock.date().isoformat()
|
||||
else SnapshotState.ARCHIVE
|
||||
)
|
||||
payload["observed_at"] = observed_at.isoformat(timespec="seconds")
|
||||
payload["state"] = state.value
|
||||
if phase != "observing":
|
||||
source = "ifind" if dynamic else "tushare"
|
||||
self._save("auction", target, "", payload, state, source, payload["coverage"])
|
||||
return _decorate(
|
||||
self._personalize_auction(payload, user_id),
|
||||
requested,
|
||||
phase,
|
||||
target != requested,
|
||||
"",
|
||||
)
|
||||
|
||||
def _personalize_auction(
|
||||
self, payload: dict[str, Any], user_id: int
|
||||
) -> dict[str, Any]:
|
||||
result = {**payload}
|
||||
market_rows = list(result.pop("_market_rows", ()))
|
||||
with self._database.read() as connection:
|
||||
watchlist = tuple(
|
||||
dict(row) for row in self._repository.watchlist(connection, user_id)
|
||||
)
|
||||
result["watchlist_rows"] = build_watchlist_rows(
|
||||
market_rows,
|
||||
list(result.get("rows") or ()),
|
||||
list(result.get("one_price_rows") or ()),
|
||||
watchlist,
|
||||
)
|
||||
result["watchlist_ready"] = bool(market_rows) or not watchlist
|
||||
return result
|
||||
|
||||
def themes(self, requested_date: str | None, *, force: bool = False) -> dict[str, Any]:
|
||||
requested, trade_date, _ = self._trade_dates(requested_date)
|
||||
cached = self._snapshot("themes", trade_date)
|
||||
if cached and not force:
|
||||
return _standard(cached, requested)
|
||||
inputs = self._gateway.insight_inputs("themes", trade_date)
|
||||
directory = _result(inputs.get("directory"))
|
||||
daily = _result(inputs.get("daily"))
|
||||
hot = _result(inputs.get("hot"))
|
||||
if directory is None:
|
||||
fallback = self._latest_snapshot("themes", trade_date)
|
||||
if fallback:
|
||||
return _standard(fallback, requested, "当前题材目录暂不可用,显示最近有效榜单")
|
||||
raise MarketInsightError("题材目录暂不可用")
|
||||
payload = build_theme_library(
|
||||
trade_date,
|
||||
directory.rows,
|
||||
daily.rows if daily else (),
|
||||
hot.rows if hot else (),
|
||||
)
|
||||
payload["observed_at"] = directory.metadata.observed_at.isoformat(timespec="seconds")
|
||||
payload["state"] = SnapshotState.ARCHIVE.value
|
||||
payload["message"] = "" if daily and daily.rows else "该交易日暂无题材行情"
|
||||
with self._database.transaction() as connection:
|
||||
self._repository.replace_themes(
|
||||
connection,
|
||||
list(payload["items"]),
|
||||
directory.metadata.source.value,
|
||||
payload["observed_at"],
|
||||
)
|
||||
self._save("themes", trade_date, "", payload, SnapshotState.ARCHIVE, "tushare", 1)
|
||||
return _standard(payload, requested)
|
||||
|
||||
def theme_detail(self, identifier: str, requested_date: str | None) -> dict[str, Any]:
|
||||
library = self.themes(requested_date)
|
||||
code = identifier.strip().upper()
|
||||
theme = next((item for item in library["items"] if item["code"] == code), None)
|
||||
if theme is None:
|
||||
raise MarketInsightError("未找到该题材")
|
||||
trade_date = str(library["trade_date"])
|
||||
cached = self._snapshot("themes", trade_date, code)
|
||||
if cached:
|
||||
return cached
|
||||
inputs = self._gateway.insight_inputs("theme-detail", trade_date, identifier=code)
|
||||
members = _result(inputs.get("members"))
|
||||
daily = _result(inputs.get("daily"))
|
||||
payload = build_theme_detail(
|
||||
trade_date,
|
||||
theme,
|
||||
members.rows if members else (),
|
||||
daily.rows if daily else (),
|
||||
)
|
||||
payload["message"] = "" if members and members.rows else "该题材暂无可核验成分股"
|
||||
payload["observed_at"] = (
|
||||
members.metadata.observed_at if members else datetime.now(SHANGHAI)
|
||||
).isoformat(timespec="seconds")
|
||||
payload["state"] = SnapshotState.ARCHIVE.value
|
||||
if members is not None:
|
||||
self._save(
|
||||
"themes", trade_date, code, payload, SnapshotState.ARCHIVE, "tushare", 1
|
||||
)
|
||||
return payload
|
||||
|
||||
def popularity(
|
||||
self, requested_date: str | None, *, force: bool = False
|
||||
) -> dict[str, Any]:
|
||||
requested, trade_date, previous = self._trade_dates(requested_date)
|
||||
cached = self._snapshot("popularity", trade_date)
|
||||
if cached and not force:
|
||||
return _standard(cached, requested)
|
||||
inputs = self._gateway.insight_inputs("popularity", trade_date, previous)
|
||||
ths = _result(inputs.get("ths"))
|
||||
dc = _result(inputs.get("dc"))
|
||||
if not ((ths and ths.rows) or (dc and dc.rows)):
|
||||
fallback = self._latest_snapshot("popularity", previous)
|
||||
if fallback:
|
||||
return _standard(fallback, requested, "当日榜单尚未生成,显示最近有效榜单")
|
||||
return _empty_standard(requested, trade_date, "该交易日暂无可用人气榜")
|
||||
payload = build_popularity(
|
||||
trade_date,
|
||||
ths.rows if ths else (),
|
||||
dc.rows if dc else (),
|
||||
_rows(inputs.get("previous_ths")),
|
||||
_rows(inputs.get("previous_dc")),
|
||||
)
|
||||
payload["observed_at"] = datetime.now(SHANGHAI).isoformat(timespec="seconds")
|
||||
payload["state"] = SnapshotState.ARCHIVE.value
|
||||
missing = []
|
||||
if ths is None:
|
||||
missing.append("同花顺榜单暂不可用")
|
||||
if dc is None:
|
||||
missing.append("东方财富榜单暂不可用")
|
||||
payload["message"] = ";".join(missing)
|
||||
coverage = (int(ths is not None) + int(dc is not None)) / 2
|
||||
self._save(
|
||||
"popularity",
|
||||
trade_date,
|
||||
"",
|
||||
payload,
|
||||
SnapshotState.ARCHIVE,
|
||||
"tushare",
|
||||
coverage,
|
||||
)
|
||||
return _standard(payload, requested)
|
||||
|
||||
def dragon_list(
|
||||
self, requested_date: str | None, *, force: bool = False
|
||||
) -> dict[str, Any]:
|
||||
requested, trade_date, previous = self._trade_dates(requested_date)
|
||||
raw = self._snapshot("dragon-list", trade_date)
|
||||
if raw is None or force:
|
||||
inputs = self._gateway.insight_inputs("dragon-list", trade_date)
|
||||
raw = {
|
||||
"trade_date": trade_date,
|
||||
"observed_at": datetime.now(SHANGHAI).isoformat(timespec="seconds"),
|
||||
"state": SnapshotState.ARCHIVE.value,
|
||||
"official": _serialized_rows(inputs.get("official")),
|
||||
"profiles": _serialized_rows(inputs.get("profiles")),
|
||||
"stocks": _serialized_rows(inputs.get("stocks")),
|
||||
"seats": _serialized_rows(inputs.get("seats")),
|
||||
}
|
||||
coverage = sum(value is not None for value in raw.values() if isinstance(value, list))
|
||||
self._save(
|
||||
"dragon-list",
|
||||
trade_date,
|
||||
"",
|
||||
raw,
|
||||
SnapshotState.ARCHIVE,
|
||||
"tushare",
|
||||
min(coverage / 4, 1),
|
||||
)
|
||||
with self._database.read() as connection:
|
||||
aliases = self._repository.seat_aliases(connection)
|
||||
result = build_dragon_list(
|
||||
trade_date=trade_date,
|
||||
official_rows=_tuple_or_none(raw.get("official")),
|
||||
profile_rows=_tuple_or_none(raw.get("profiles")),
|
||||
stock_rows=_tuple_or_none(raw.get("stocks")),
|
||||
seat_rows=_tuple_or_none(raw.get("seats")),
|
||||
aliases=aliases,
|
||||
)
|
||||
result.update(
|
||||
{
|
||||
"requested_date": requested,
|
||||
"previous_date": previous,
|
||||
"observed_at": raw.get("observed_at"),
|
||||
"state": SnapshotState.ARCHIVE.value,
|
||||
"carried_forward": False,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def save_seat_alias(self, seat_name: str, alias_name: str, user_id: int) -> dict[str, str]:
|
||||
seat = " ".join(seat_name.split())
|
||||
alias = " ".join(alias_name.split())
|
||||
if not seat or not alias:
|
||||
raise MarketInsightError("营业部和游资名称不能为空")
|
||||
with self._database.transaction() as connection:
|
||||
self._repository.save_seat_alias(
|
||||
connection,
|
||||
seat,
|
||||
alias,
|
||||
datetime.now(SHANGHAI).isoformat(timespec="seconds"),
|
||||
user_id,
|
||||
)
|
||||
return {"seat_name": seat, "alias_name": alias}
|
||||
|
||||
def _trade_dates(
|
||||
self, requested_date: str | None, clock: datetime | None = None
|
||||
) -> tuple[str, str, str]:
|
||||
try:
|
||||
requested = _date(
|
||||
requested_date or (clock or datetime.now(SHANGHAI)).date().isoformat()
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise MarketInsightError("日期格式无效") from exc
|
||||
dates = self._gateway.trading_dates(requested, 2)
|
||||
if len(dates) < 2:
|
||||
raise MarketInsightError("请先同步完整交易日历")
|
||||
return requested, dates[0], dates[1]
|
||||
|
||||
def _previous_date(self, trade_date: str) -> str:
|
||||
dates = self._gateway.trading_dates(trade_date, 2)
|
||||
if len(dates) < 2:
|
||||
raise MarketInsightError("缺少前一交易日")
|
||||
return dates[1]
|
||||
|
||||
def _auction_universe(
|
||||
self, baseline: str, inputs: dict[str, Any]
|
||||
) -> tuple[str, ...]:
|
||||
snapshot = self._market_snapshot(baseline)
|
||||
codes = {
|
||||
str(item.get("identifier") or "")
|
||||
for key in ("limits", "broken")
|
||||
for item in snapshot.get(key) or []
|
||||
}
|
||||
for key, data_type in (("ths_hot", "热股"), ("dc_hot", "A股市场")):
|
||||
for row in _rows(inputs.get(key)):
|
||||
valid_type = str(row.get("data_type") or "") == data_type
|
||||
top_twenty = int(_number(row.get("rank"), 9999)) <= 20
|
||||
if valid_type and top_twenty:
|
||||
codes.add(str(row.get("ts_code") or ""))
|
||||
return tuple(sorted(code for code in codes if code))
|
||||
|
||||
def _market_snapshot(self, trade_date: str) -> dict[str, Any]:
|
||||
with self._database.read() as connection:
|
||||
row = self._repository.latest_summary(connection, trade_date)
|
||||
if row is None or str(row["trade_date"]) != trade_date:
|
||||
return {}
|
||||
return json.loads(str(row["payload_json"]))
|
||||
|
||||
def _auction_history(self, trade_date: str) -> list[dict[str, Any]]:
|
||||
with self._database.read() as connection:
|
||||
rows = self._repository.insight_snapshots(connection, "auction", trade_date, 10)
|
||||
result = []
|
||||
for row in rows:
|
||||
payload = json.loads(str(row["payload_json"]))
|
||||
summary = payload.get("summary") or {}
|
||||
result.append(
|
||||
{
|
||||
"trade_date": str(row["trade_date"]),
|
||||
"amount_billion": _number(summary.get("amount_billion")),
|
||||
"stock_count": int(summary.get("stock_count") or 0),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def _snapshot(
|
||||
self, kind: str, trade_date: str, entity_key: str = ""
|
||||
) -> dict[str, Any] | None:
|
||||
with self._database.read() as connection:
|
||||
row = self._repository.insight_snapshot(connection, kind, trade_date, entity_key)
|
||||
return json.loads(str(row["payload_json"])) if row else None
|
||||
|
||||
def _latest_snapshot(
|
||||
self, kind: str, through: str, entity_key: str = ""
|
||||
) -> dict[str, Any] | None:
|
||||
with self._database.read() as connection:
|
||||
row = self._repository.latest_insight_snapshot(connection, kind, through, entity_key)
|
||||
return json.loads(str(row["payload_json"])) if row else None
|
||||
|
||||
def _save(
|
||||
self,
|
||||
kind: str,
|
||||
trade_date: str,
|
||||
entity_key: str,
|
||||
payload: dict[str, Any],
|
||||
state: SnapshotState,
|
||||
source: str,
|
||||
coverage: float,
|
||||
) -> None:
|
||||
with self._database.transaction() as connection:
|
||||
self._repository.save_insight_snapshot(
|
||||
connection,
|
||||
kind=kind,
|
||||
trade_date=trade_date,
|
||||
entity_key=entity_key,
|
||||
observed_at=str(
|
||||
payload.get("observed_at")
|
||||
or datetime.now(SHANGHAI).isoformat(timespec="seconds")
|
||||
),
|
||||
state=state.value,
|
||||
source=source,
|
||||
coverage=max(0, min(coverage, 1)),
|
||||
payload=payload,
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from backend.data.contracts import ProviderResult
|
||||
|
||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def auction_phase(requested: str, trade_date: str, clock: datetime) -> str:
|
||||
if requested != clock.date().isoformat() or trade_date != clock.date().isoformat():
|
||||
return "archive"
|
||||
local = clock.time().replace(tzinfo=None)
|
||||
if local < time(9, 15):
|
||||
return "pending"
|
||||
if local < time(9, 25):
|
||||
return "observing"
|
||||
if local < time(9, 30):
|
||||
return "selection"
|
||||
return "finalized"
|
||||
|
||||
|
||||
def decorate(
|
||||
payload: dict[str, Any],
|
||||
requested: str,
|
||||
phase: str,
|
||||
carried_forward: bool,
|
||||
message: str,
|
||||
current_available: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
**payload,
|
||||
"requested_date": requested,
|
||||
"phase": phase,
|
||||
"carried_forward": carried_forward,
|
||||
"message": message or str(payload.get("message") or ""),
|
||||
"current_available": current_available,
|
||||
}
|
||||
|
||||
|
||||
def standard(payload: dict[str, Any], requested: str, message: str = "") -> dict[str, Any]:
|
||||
trade_date = str(payload.get("trade_date") or "")
|
||||
return {
|
||||
**payload,
|
||||
"requested_date": requested,
|
||||
"carried_forward": trade_date != requested,
|
||||
"message": message or str(payload.get("message") or ""),
|
||||
}
|
||||
|
||||
|
||||
def empty_standard(requested: str, trade_date: str, message: str) -> dict[str, Any]:
|
||||
return {
|
||||
"requested_date": requested,
|
||||
"trade_date": trade_date,
|
||||
"observed_at": None,
|
||||
"state": None,
|
||||
"carried_forward": trade_date != requested,
|
||||
"message": message,
|
||||
"summary": {},
|
||||
"items": [],
|
||||
"combined": [],
|
||||
"ths": [],
|
||||
"dc": [],
|
||||
}
|
||||
|
||||
|
||||
def empty_auction(
|
||||
requested: str, trade_date: str, phase: str, message: str
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
**empty_standard(requested, trade_date, message),
|
||||
"phase": phase,
|
||||
"current_available": False,
|
||||
"expectations": {"超预期": 0, "符合预期": 0, "低于预期": 0},
|
||||
"themes": {"carry": [], "new_themes": []},
|
||||
"amount_history": [],
|
||||
"focus_rows": [],
|
||||
"one_price_rows": [],
|
||||
"rows": [],
|
||||
"watchlist_rows": [],
|
||||
"watchlist_ready": False,
|
||||
}
|
||||
|
||||
|
||||
def result(value: Any) -> ProviderResult | None:
|
||||
return value if isinstance(value, ProviderResult) else None
|
||||
|
||||
|
||||
def rows(value: Any) -> tuple[dict[str, Any], ...]:
|
||||
provider_result = result(value)
|
||||
return provider_result.rows if provider_result else ()
|
||||
|
||||
|
||||
def serialized_rows(value: Any) -> list[dict[str, Any]] | None:
|
||||
provider_result = result(value)
|
||||
return [dict(row) for row in provider_result.rows] if provider_result else None
|
||||
|
||||
|
||||
def tuple_or_none(value: Any) -> tuple[dict[str, Any], ...] | None:
|
||||
if value is None:
|
||||
return None
|
||||
return tuple(dict(row) for row in value)
|
||||
|
||||
|
||||
def valid_date(value: str) -> str:
|
||||
try:
|
||||
return date.fromisoformat(value).isoformat()
|
||||
except ValueError as exc:
|
||||
raise ValueError("日期格式无效") from exc
|
||||
|
||||
|
||||
def clock(value: datetime | None) -> datetime:
|
||||
current = value or datetime.now(SHANGHAI)
|
||||
return (
|
||||
current.replace(tzinfo=SHANGHAI)
|
||||
if current.tzinfo is None
|
||||
else current.astimezone(SHANGHAI)
|
||||
)
|
||||
|
||||
|
||||
def number(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
parsed = float(value)
|
||||
return parsed if parsed == parsed else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_theme_library(
|
||||
trade_date: str,
|
||||
directory_rows: tuple[dict[str, Any], ...],
|
||||
daily_rows: tuple[dict[str, Any], ...],
|
||||
hot_rows: tuple[dict[str, Any], ...],
|
||||
) -> dict[str, Any]:
|
||||
daily = {str(row.get("ts_code") or ""): row for row in daily_rows}
|
||||
hot = {
|
||||
str(row.get("ts_code") or ""): int(_number(row.get("rank"), 9999))
|
||||
for row in hot_rows
|
||||
if str(row.get("data_type") or "") == "概念板块"
|
||||
}
|
||||
items = []
|
||||
for row in directory_rows:
|
||||
if str(row.get("type") or "").upper() != "N":
|
||||
continue
|
||||
if str(row.get("exchange") or "").upper() != "A":
|
||||
continue
|
||||
code = str(row.get("ts_code") or "")
|
||||
name = str(row.get("name") or "").strip()
|
||||
if not code or not name:
|
||||
continue
|
||||
quote = daily.get(code)
|
||||
items.append(
|
||||
{
|
||||
"code": code,
|
||||
"name": name,
|
||||
"member_count": int(_number(row.get("count"))),
|
||||
"change": round(_number((quote or {}).get("pct_change")), 2)
|
||||
if quote
|
||||
else None,
|
||||
"close": round(_number((quote or {}).get("close")), 3) if quote else None,
|
||||
"turnover_rate": round(_number((quote or {}).get("turnover_rate")), 2)
|
||||
if quote
|
||||
else None,
|
||||
"hot_rank": hot.get(code),
|
||||
"has_quote": bool(quote),
|
||||
}
|
||||
)
|
||||
items.sort(
|
||||
key=lambda item: (
|
||||
bool(item["has_quote"]),
|
||||
item["hot_rank"] is not None,
|
||||
-(item["hot_rank"] or 9999),
|
||||
_number(item["change"], -999),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
quoted = [item for item in items if item["has_quote"]]
|
||||
return {
|
||||
"trade_date": trade_date,
|
||||
"summary": {
|
||||
"theme_count": len(items),
|
||||
"quoted_count": len(quoted),
|
||||
"up_count": sum(_number(item["change"]) > 0 for item in quoted),
|
||||
"down_count": sum(_number(item["change"]) < 0 for item in quoted),
|
||||
"hot_count": len(hot),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
def build_theme_detail(
|
||||
trade_date: str,
|
||||
theme: dict[str, Any],
|
||||
member_rows: tuple[dict[str, Any], ...],
|
||||
daily_rows: tuple[dict[str, Any], ...],
|
||||
) -> dict[str, Any]:
|
||||
daily = {str(row.get("ts_code") or ""): row for row in daily_rows}
|
||||
members = []
|
||||
seen = set()
|
||||
for row in member_rows:
|
||||
identifier = str(row.get("con_code") or "")
|
||||
if not identifier or identifier in seen:
|
||||
continue
|
||||
seen.add(identifier)
|
||||
quote = daily.get(identifier)
|
||||
members.append(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"code": identifier.split(".")[0],
|
||||
"name": str(row.get("con_name") or ""),
|
||||
"change": round(_number((quote or {}).get("pct_chg")), 2)
|
||||
if quote
|
||||
else None,
|
||||
"close": round(_number((quote or {}).get("close")), 2) if quote else None,
|
||||
"amount": _number((quote or {}).get("amount")) * 1000 if quote else None,
|
||||
"quoted": bool(quote),
|
||||
}
|
||||
)
|
||||
members.sort(
|
||||
key=lambda item: (
|
||||
bool(item["quoted"]),
|
||||
_number(item["change"], -999),
|
||||
_number(item["amount"]),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
quoted = [item for item in members if item["quoted"]]
|
||||
return {
|
||||
"trade_date": trade_date,
|
||||
"theme": theme,
|
||||
"summary": {
|
||||
"member_count": len(members),
|
||||
"quoted_count": len(quoted),
|
||||
"up_count": sum(_number(item["change"]) > 0 for item in quoted),
|
||||
"down_count": sum(_number(item["change"]) < 0 for item in quoted),
|
||||
"turnover_rate": theme.get("turnover_rate"),
|
||||
},
|
||||
"members": members,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user