rebuild(stage-8): deliver market insight workspaces

This commit is contained in:
leefer
2026-07-30 04:12:04 +08:00
parent a18e8e9d27
commit 976a5cac03
39 changed files with 3671 additions and 14 deletions
+4 -1
View File
@@ -17,6 +17,7 @@ from backend.features.accounts.service import (
MembershipService,
)
from backend.features.market import MarketService
from backend.features.market.insights import MarketInsightService
from backend.features.market.sync import MarketSnapshotService
from backend.security import PasswordHasher, load_or_create_cipher
@@ -64,6 +65,8 @@ def build_container(settings: Settings) -> ApplicationContainer:
system_credentials=credentials,
model_pool=ModelPoolService(database, model_pool_repository, cipher),
market=MarketService(
gateway, MarketSnapshotService(database, market_repository, gateway)
gateway,
MarketSnapshotService(database, market_repository, gateway),
MarketInsightService(database, market_repository, gateway),
),
)
+34
View File
@@ -12,6 +12,7 @@ from backend.data.contracts import (
DataUsage,
MarketEntity,
ObservationMetadata,
ProviderResult,
SnapshotState,
TradeContext,
)
@@ -114,6 +115,39 @@ class DataGateway:
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
return provider.snapshot_inputs(trade_date, previous_trade_date)
def trading_dates(self, through: str, limit: int = 2) -> tuple[str, ...]:
requested = _date(through)
with self._database.read() as connection:
return self._repository.open_dates(connection, requested, limit)
def stock_directory(self) -> dict[str, dict[str, Any]]:
with self._database.read() as connection:
rows = self._repository.stock_directory(connection)
return {str(row["identifier"]): dict(row) for row in rows}
def insight_inputs(
self,
kind: str,
trade_date: str,
previous_trade_date: str = "",
identifier: str = "",
) -> dict[str, Any]:
provider = self._provider(DataSource.TUSHARE)
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
return provider.market_insight(kind, trade_date, previous_trade_date, identifier)
def dynamic_auction(
self, identifiers: tuple[str, ...], start_time: str, end_time: str
) -> ProviderResult:
if not identifiers:
raise MarketDataUnavailable("动态竞价候选范围为空")
provider = self._provider(DataSource.IFIND)
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
result = provider.realtime_snapshots(identifiers, start_time, end_time)
if not result.rows:
raise MarketDataUnavailable("当前动态竞价快照暂不可用")
return result
def sector_members(
self, trade_date: str, sector_name: str, representative: str
) -> dict[str, Any]:
+2
View File
@@ -30,5 +30,7 @@ class DataSourcePolicy:
("daily_chart", DataUsage.DISPLAY): (DataSource.IFIND, DataSource.TUSHARE),
("minute_chart", DataUsage.DISPLAY): (DataSource.IFIND, DataSource.EASTMONEY),
("realtime_quote", DataUsage.CALCULATION): (DataSource.IFIND, DataSource.TUSHARE),
("market_insight", DataUsage.CALCULATION): (DataSource.TUSHARE,),
("dynamic_auction", DataUsage.CALCULATION): (DataSource.IFIND,),
}
return routes.get((dataset, usage), ())
+12
View File
@@ -28,3 +28,15 @@ class MarketDataProvider(Protocol):
) -> dict[str, ProviderResult | dict[str, Any]]: ...
def sector_members(self, representative: str, trade_date: str) -> ProviderResult: ...
def market_insight(
self,
kind: str,
trade_date: str,
previous_trade_date: str = "",
identifier: str = "",
) -> dict[str, ProviderResult | None]: ...
def realtime_snapshots(
self, identifiers: tuple[str, ...], start_time: str, end_time: str
) -> ProviderResult: ...
+14
View File
@@ -102,6 +102,20 @@ class EastmoneyProvider:
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
raise ProviderError("The display provider is not the constituent authority")
def market_insight(
self,
kind: str,
trade_date: str,
previous_trade_date: str = "",
identifier: str = "",
) -> dict[str, ProviderResult | None]:
raise ProviderError("The display provider cannot supply market insight archives")
def realtime_snapshots(
self, identifiers: tuple[str, ...], start_time: str, end_time: str
) -> ProviderResult:
raise ProviderError("The display provider cannot supply calculation snapshots")
@staticmethod
def _secid(entity_type: str, identifier: str) -> str:
if entity_type == "index" and identifier in INDEX_CODES:
+45
View File
@@ -92,6 +92,51 @@ class IfindProvider:
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
raise ProviderError("iFinD is not the Shenwan constituent authority")
def market_insight(
self,
kind: str,
trade_date: str,
previous_trade_date: str = "",
identifier: str = "",
) -> dict[str, ProviderResult | None]:
raise ProviderError("iFinD只承担许可范围内的动态竞价快照")
def realtime_snapshots(
self, identifiers: tuple[str, ...], start_time: str, end_time: str
) -> ProviderResult:
rows: list[dict[str, Any]] = []
for offset in range(0, len(identifiers), 80):
batch = identifiers[offset : offset + 80]
if not batch:
continue
payload = self._request(
"snap_shot",
{
"codes": ",".join(batch),
"indicators": "latest,volume,amount,preClose,turnoverRatio,volumeRatio,"
"bid1,bidSize1,ask1,askSize1",
"starttime": start_time,
"endtime": end_time,
},
)
rows.extend(_result(payload, "mixed", "not_applicable", SnapshotState.REALTIME).rows)
covered = {
str(row.get("thscode") or "") for row in rows if row.get("thscode")
}
return ProviderResult(
tuple(rows),
ObservationMetadata(
source=self.source,
observed_at=datetime.now(SHANGHAI),
unit="mixed",
adjustment="not_applicable",
freshness_seconds=0,
coverage=min(len(covered) / max(len(identifiers), 1), 1),
state=SnapshotState.REALTIME,
usage=DataUsage.CALCULATION,
),
)
def _request(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]:
if not self.configured:
raise ProviderError("实时行情服务尚未配置")
+103
View File
@@ -189,6 +189,109 @@ class TushareProvider:
coverage = sum(bool(row["quoted"]) for row in rows) / len(rows)
return ProviderResult(tuple(rows), _metadata(self.source, "mixed", coverage))
def market_insight(
self,
kind: str,
trade_date: str,
previous_trade_date: str = "",
identifier: str = "",
) -> dict[str, ProviderResult | None]:
current = _compact(trade_date)
previous = _compact(previous_trade_date) if previous_trade_date else current
if kind == "auction":
return {
"auction": self._optional_query(
"stk_auction",
{"trade_date": current},
"ts_code,trade_date,vol,price,amount,pre_close,turnover_rate,"
"volume_ratio,float_share",
),
"price_limits": self._optional_query(
"stk_limit",
{"trade_date": current},
"trade_date,ts_code,up_limit,down_limit",
),
"ths_hot": self._optional_query("ths_hot", {"trade_date": previous}, ""),
"dc_hot": self._optional_query("dc_hot", {"trade_date": previous}, ""),
}
if kind == "themes":
return {
"directory": self._optional_query(
"ths_index", {}, "ts_code,name,count,exchange,list_date,type"
),
"daily": self._optional_query(
"ths_daily",
{"trade_date": current},
"ts_code,trade_date,open,high,low,close,pre_close,pct_change,"
"vol,turnover_rate",
),
"hot": self._optional_query("ths_hot", {"trade_date": current}, ""),
}
if kind == "theme-detail":
return {
"members": self._optional_query(
"ths_member",
{"ts_code": identifier, "is_new": "Y"},
"ts_code,con_code,con_name",
),
"daily": self._optional_query(
"daily",
{"trade_date": current},
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
),
}
if kind == "popularity":
return {
"ths": self._optional_query("ths_hot", {"trade_date": current}, ""),
"dc": self._optional_query("dc_hot", {"trade_date": current}, ""),
"previous_ths": self._optional_query(
"ths_hot", {"trade_date": previous}, ""
),
"previous_dc": self._optional_query(
"dc_hot", {"trade_date": previous}, ""
),
}
if kind == "dragon-list":
return {
"official": self._optional_query(
"hm_detail",
{"trade_date": current},
"trade_date,ts_code,ts_name,buy_amount,sell_amount,net_amount,"
"hm_name,hm_orgs,tag",
),
"profiles": self._optional_query("hm_list", {}, "name,desc,orgs"),
"stocks": self._optional_query(
"top_list",
{"trade_date": current},
"trade_date,ts_code,name,pct_change,reason",
),
"seats": self._optional_query(
"top_inst",
{"trade_date": current},
"trade_date,ts_code,exalter,buy,sell,net_buy,side,reason",
),
}
raise ProviderError("不支持的市场洞察数据集")
def realtime_snapshots(
self, identifiers: tuple[str, ...], start_time: str, end_time: str
) -> ProviderResult:
raise ProviderError("Tushare不提供动态竞价快照")
def _optional_query(
self, api_name: str, params: dict[str, Any], fields: str
) -> ProviderResult | None:
try:
return self._query(
api_name,
params,
fields,
unit="mixed",
empty_is_complete=True,
)
except ProviderError:
return None
def _membership_rows(self, params: dict[str, str]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
fields = (
+169
View File
@@ -79,6 +79,43 @@ class MarketRepository:
],
)
def replace_themes(
self,
connection: sqlite3.Connection,
rows: list[dict[str, Any]],
source: str,
observed_at: str,
) -> None:
connection.executemany(
"""
INSERT INTO market_entities (
entity_type, identifier, code, name, search_key,
sector, active, source, observed_at
) VALUES ('theme', ?, ?, ?, ?, NULL, 1, ?, ?)
ON CONFLICT(entity_type, identifier) DO UPDATE SET
code = excluded.code,
name = excluded.name,
search_key = excluded.search_key,
active = 1,
source = excluded.source,
observed_at = excluded.observed_at
""",
[
(
str(row.get("code") or "").upper(),
str(row.get("code") or "").split(".")[0],
str(row.get("name") or "").strip(),
_normalize(
f"{row.get('code') or ''} {row.get('name') or ''}"
),
source,
observed_at,
)
for row in rows
if row.get("code") and row.get("name")
],
)
def search(
self, connection: sqlite3.Connection, query: str, limit: int = 32
) -> tuple[MarketEntity, ...]:
@@ -135,6 +172,16 @@ class MarketRepository:
).fetchone()
return int(row["count"] if row else 0)
def stock_directory(self, connection: sqlite3.Connection) -> tuple[sqlite3.Row, ...]:
return tuple(
connection.execute(
"""
SELECT identifier, code, name, sector FROM market_entities
WHERE entity_type = 'stock' AND active = 1
"""
).fetchall()
)
def save_summary(
self,
connection: sqlite3.Connection,
@@ -234,6 +281,128 @@ class MarketRepository:
),
)
def insight_snapshot(
self,
connection: sqlite3.Connection,
kind: str,
trade_date: str,
entity_key: str = "",
) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT * FROM market_insight_snapshots
WHERE kind = ? AND trade_date = ? AND entity_key = ?
""",
(kind, trade_date, entity_key),
).fetchone()
def latest_insight_snapshot(
self,
connection: sqlite3.Connection,
kind: str,
through: str,
entity_key: str = "",
) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT * FROM market_insight_snapshots
WHERE kind = ? AND trade_date <= ? AND entity_key = ?
ORDER BY trade_date DESC LIMIT 1
""",
(kind, through, entity_key),
).fetchone()
def insight_snapshots(
self, connection: sqlite3.Connection, kind: str, through: str, limit: int
) -> tuple[sqlite3.Row, ...]:
rows = connection.execute(
"""
SELECT * FROM market_insight_snapshots
WHERE kind = ? AND trade_date <= ? AND entity_key = ''
ORDER BY trade_date DESC LIMIT ?
""",
(kind, through, limit),
).fetchall()
return tuple(reversed(rows))
def save_insight_snapshot(
self,
connection: sqlite3.Connection,
*,
kind: str,
trade_date: str,
entity_key: str,
observed_at: str,
state: str,
source: str,
coverage: float,
payload: dict[str, Any],
) -> None:
connection.execute(
"""
INSERT INTO market_insight_snapshots (
kind, trade_date, entity_key, observed_at, state, source, coverage, payload_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(kind, trade_date, entity_key) DO UPDATE SET
observed_at = excluded.observed_at,
state = excluded.state,
source = excluded.source,
coverage = excluded.coverage,
payload_json = excluded.payload_json
""",
(
kind,
trade_date,
entity_key,
observed_at,
state,
source,
coverage,
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
),
)
def seat_aliases(self, connection: sqlite3.Connection) -> dict[str, str]:
return {
str(row["seat_name"]): str(row["alias_name"])
for row in connection.execute(
"SELECT seat_name, alias_name FROM seat_aliases ORDER BY seat_name"
).fetchall()
}
def save_seat_alias(
self,
connection: sqlite3.Connection,
seat_name: str,
alias_name: str,
updated_at: str,
updated_by: int,
) -> None:
connection.execute(
"""
INSERT INTO seat_aliases (seat_name, alias_name, updated_at, updated_by)
VALUES (?, ?, ?, ?)
ON CONFLICT(seat_name) DO UPDATE SET
alias_name = excluded.alias_name,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by
""",
(seat_name, alias_name, updated_at, updated_by),
)
def watchlist(
self, connection: sqlite3.Connection, user_id: int
) -> tuple[sqlite3.Row, ...]:
return tuple(
connection.execute(
"""
SELECT identifier, name, sector FROM watchlist_entries
WHERE user_id = ? ORDER BY created_at, identifier
""",
(user_id,),
).fetchall()
)
def save_chart(
self,
connection: sqlite3.Connection,
@@ -0,0 +1,49 @@
from __future__ import annotations
import sqlite3
from backend.database.migrations.runner import Migration
def upgrade(connection: sqlite3.Connection) -> None:
connection.execute(
"""
CREATE TABLE market_insight_snapshots (
kind TEXT NOT NULL CHECK (
kind IN ('auction', 'themes', 'popularity', 'dragon-list')
),
trade_date TEXT NOT NULL,
entity_key TEXT NOT NULL DEFAULT '',
observed_at TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('realtime', 'final', 'archive')),
source TEXT NOT NULL,
coverage REAL NOT NULL CHECK (coverage >= 0 AND coverage <= 1),
payload_json TEXT NOT NULL,
PRIMARY KEY (kind, trade_date, entity_key)
)
"""
)
connection.execute(
"""
CREATE TABLE seat_aliases (
seat_name TEXT PRIMARY KEY,
alias_name TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL
)
"""
)
def downgrade(connection: sqlite3.Connection) -> None:
connection.execute("DROP TABLE seat_aliases")
connection.execute("DROP TABLE market_insight_snapshots")
MIGRATION = Migration(
version=5,
name="create_market_insight_archives",
signature="market:v3:auction-themes-popularity-dragon-list",
upgrade=upgrade,
downgrade=downgrade,
)
@@ -0,0 +1,33 @@
from __future__ import annotations
import sqlite3
from backend.database.migrations.runner import Migration
def upgrade(connection: sqlite3.Connection) -> None:
connection.execute(
"""
CREATE TABLE watchlist_entries (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
identifier TEXT NOT NULL,
name TEXT NOT NULL,
sector TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, identifier)
)
"""
)
def downgrade(connection: sqlite3.Connection) -> None:
connection.execute("DROP TABLE watchlist_entries")
MIGRATION = Migration(
version=6,
name="create_account_watchlists",
signature="accounts:v2:isolated-watchlist-entries",
upgrade=upgrade,
downgrade=downgrade,
)
+10 -1
View File
@@ -2,6 +2,15 @@ from backend.database.migrations.m0001_accounts import MIGRATION as ACCOUNTS
from backend.database.migrations.m0002_model_pool import MIGRATION as MODEL_POOL
from backend.database.migrations.m0003_market_foundation import MIGRATION as MARKET_FOUNDATION
from backend.database.migrations.m0004_sector_members import MIGRATION as SECTOR_MEMBERS
from backend.database.migrations.m0005_market_insights import MIGRATION as MARKET_INSIGHTS
from backend.database.migrations.m0006_watchlists import MIGRATION as WATCHLISTS
from backend.database.migrations.runner import Migration
MIGRATIONS: tuple[Migration, ...] = (ACCOUNTS, MODEL_POOL, MARKET_FOUNDATION, SECTOR_MEMBERS)
MIGRATIONS: tuple[Migration, ...] = (
ACCOUNTS,
MODEL_POOL,
MARKET_FOUNDATION,
SECTOR_MEMBERS,
MARKET_INSIGHTS,
WATCHLISTS,
)
@@ -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
+53
View File
@@ -7,12 +7,16 @@ from fastapi import APIRouter, Path, Query, Request
from backend.features.accounts.auth import AdminWritePrincipal, AuthenticatedPrincipal
from backend.features.market.schemas import (
ChartResponse,
MarketInsightResponse,
MarketSummaryResponse,
MarketWorkspaceResponse,
ReferenceSyncResponse,
RotationMembersResponse,
SearchResponse,
SeatAliasRequest,
SeatAliasResponse,
SnapshotSyncResponse,
ThemeDetailResponse,
TradeContextResponse,
)
@@ -95,3 +99,52 @@ def rotation_members(
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.rotation_members(sector_name, requested_date)
@router.get("/insights/{key}", response_model=MarketInsightResponse)
def insight(
request: Request,
principal: AuthenticatedPrincipal,
key: Annotated[
Literal["auction", "themes", "popularity", "dragon-list"], Path()
],
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.insight(
key, requested_date, principal.user.id
)
@router.post("/insights/{key}/sync", response_model=MarketInsightResponse)
def sync_insight(
request: Request,
principal: AdminWritePrincipal,
key: Annotated[
Literal["auction", "themes", "popularity", "dragon-list"], Path()
],
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.sync_insight(
key, requested_date, principal.user.id
)
@router.get("/themes/{identifier}", response_model=ThemeDetailResponse)
def theme_detail(
request: Request,
_principal: AuthenticatedPrincipal,
identifier: Annotated[str, Path(min_length=1, max_length=40)],
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.theme_detail(identifier, requested_date)
@router.put("/seat-aliases", response_model=SeatAliasResponse)
def save_seat_alias(
payload: SeatAliasRequest,
request: Request,
principal: AdminWritePrincipal,
) -> dict[str, str]:
return request.app.state.container.market.save_seat_alias(
payload.seat_name, payload.alias_name, principal.user.id
)
+35 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
class TradeContextResponse(BaseModel):
@@ -101,3 +101,37 @@ class RotationMembersResponse(BaseModel):
quoted_count: int = Field(ge=0)
coverage: float = Field(ge=0, le=1)
items: list[dict[str, Any]] = Field(default_factory=list)
class MarketInsightResponse(BaseModel):
model_config = ConfigDict(extra="allow")
requested_date: str
trade_date: str
observed_at: datetime | None = None
state: str | None = None
carried_forward: bool = False
message: str = ""
summary: dict[str, Any] = Field(default_factory=dict)
class ThemeDetailResponse(BaseModel):
model_config = ConfigDict(extra="allow")
trade_date: str
observed_at: datetime | None = None
state: str | None = None
message: str = ""
theme: dict[str, Any]
summary: dict[str, Any]
members: list[dict[str, Any]]
class SeatAliasRequest(BaseModel):
seat_name: str = Field(min_length=1, max_length=200)
alias_name: str = Field(min_length=1, max_length=80)
class SeatAliasResponse(BaseModel):
seat_name: str
alias_name: str
+38 -4
View File
@@ -5,14 +5,22 @@ from typing import Any
from backend.data.gateway import DataGateway, MarketDataUnavailable
from backend.data.providers.base import ProviderError
from backend.data.quality import DataQualityError
from backend.features.market.insights import MarketInsightService
from backend.features.market.insights.service import MarketInsightError
from backend.features.market.sync import MarketSnapshotService, SnapshotSyncError
from backend.http.errors import AppError
class MarketService:
def __init__(self, gateway: DataGateway, snapshots: MarketSnapshotService) -> None:
def __init__(
self,
gateway: DataGateway,
snapshots: MarketSnapshotService,
insights: MarketInsightService,
) -> None:
self._gateway = gateway
self._snapshots = snapshots
self._insights = insights
def context(self, requested_date: str | None = None) -> dict[str, Any]:
context = self._call(self._gateway.trade_context, requested_date)
@@ -94,11 +102,37 @@ class MarketService:
self._gateway.sector_members, trade_date, sector_name, representative
)
def insight(
self, key: str, requested_date: str | None, user_id: int
) -> dict[str, Any]:
return self._call(self._insights.workspace, key, requested_date, user_id=user_id)
def sync_insight(
self, key: str, requested_date: str | None, user_id: int
) -> dict[str, Any]:
return self._call(
self._insights.workspace, key, requested_date, user_id=user_id, force=True
)
def theme_detail(self, identifier: str, requested_date: str | None = None) -> dict[str, Any]:
return self._call(self._insights.theme_detail, identifier, requested_date)
def save_seat_alias(
self, seat_name: str, alias_name: str, user_id: int
) -> dict[str, str]:
return self._call(self._insights.save_seat_alias, seat_name, alias_name, user_id)
@staticmethod
def _call(function, *args):
def _call(function, *args, **kwargs):
try:
return function(*args)
except (MarketDataUnavailable, ProviderError, DataQualityError, SnapshotSyncError) as exc:
return function(*args, **kwargs)
except (
MarketDataUnavailable,
ProviderError,
DataQualityError,
SnapshotSyncError,
MarketInsightError,
) as exc:
raise AppError("market_data_unavailable", str(exc), 503) from exc
+54
View File
@@ -0,0 +1,54 @@
# 阶段 8 验收记录
## 交付范围
- 集合竞价:盘前归档、9:15 至 9:25 动态观察、9:25 选定和盘后归档状态;重点异动、我的自选、全部候选、竞价一字四组数据及成交额多日对比。
- 题材库:题材排名、行情状态、成分股明细和题材悬浮日 K/分时预览;行情缺失与成分股缺失分别表达。
- 人气热榜:同花顺、东方财富和双榜共识独立呈现;单一来源不会伪装成双榜共识。
- 龙虎榜:官方上榜数、游资操作明细、未识别席位和游资档案;档案采用左侧列表与右侧详情,不打开长弹窗。
## 数据与生命周期
- 外部行情仍只通过 `DataGateway` 进入业务层。正式计算使用 iFinD 动态竞价和 Tushare 权威归档数据,公开网页源不进入确定性计算。
- 动态竞价不可用时不会把前一交易日归档伪装成当日实时结果;携带旧归档时明确标记日期、状态和说明。
- 竞价一字板使用供应商给出的当日真实涨停价判定,因此同时覆盖新规 ST 10% 涨跌幅,不在业务代码硬编码 5%。
- 竞价成交额摘要与多日图形使用同一个标准化股票集合,避免同日数字口径不一致。
- 龙虎榜区分无上榜、明细缺失、席位全部未识别、请求不可用和部分/完整识别状态,不用统一空态掩盖数据链路问题。
- 席位别名持久化后直接重算已归档操作,不重新请求外部数据。
## 账号边界
- 竞价市场快照不保存任何用户私有字段。
- “我的自选”在响应时依据当前已认证账号单独读取并合成,两个账号无法读取彼此的自选记录。
- 自选股表由独立的版本 6 Migration 建立,避免修改已经执行的历史迁移。
## 自动验收
- Ruff:通过。
- Pytest:58 项通过,其中包含竞价生命周期、口径、四类龙虎榜状态、Migration 前进/回退和自选股跨账号隔离。
- Vue TypeScript 检查:通过。
- Vitest:2 个文件、5 项通过。
- Vite 生产构建:通过。
- Playwright6 项通过,覆盖阶段 4 至 8 的完整回归、日间/夜间、1920x1080 和 390x844 视口。
- 浏览器控制台无未处理错误,390px 页面无横向溢出。
- 敏感值扫描:已提供账号密码和令牌未进入 `next/`
- `git diff --check`:通过。
## 视觉证据
- `auction-light-1920x1080.jpg`:集合竞价日间桌面视图。
- `popularity-dark-1920x1080.jpg`:人气热榜夜间桌面视图。
- `dragon-profiles-dark-390x844.jpg`:游资档案夜间移动视图。
## 减法证据
- 四个页面共用唯一市场洞察服务、统一表格列定义和现有行情预览组件,没有复制页面专属 API 客户端或图表实现。
- 竞价共享归档与用户自选在服务端边界合成,不建立每用户一份重复市场快照。
- 新增页面最长 108 行,页面样式 168 行;后端最长纯计算模块 492 行,均在章程门禁内。
- 没有复制旧系统巨型文件、供应商客户端、通用 CRUD、通用缓存或兼容层。
## 剩余边界
- 阶段 9 才迁移智能选股、36 套策略、自定义选股和持续跟踪,本阶段没有预建空策略框架。
- 外部数据是否可用仍取决于管理员凭据和供应商权限;缺少权威数据时保持失败关闭,不生成模拟结果。
- NAS 生产容器保持不变,最终切换仍需人工明确确认。
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

@@ -18,7 +18,10 @@ const locked = computed(
() => ["screener", "mentor", "heaven"].includes(workspace.value.key) && !session.account?.smart_access,
);
const implementedMarket = computed(() =>
["emotion", "pool", "broken", "limit-down", "yesterday", "performance", "ladder", "rotation"].includes(
[
"emotion", "pool", "broken", "limit-down", "yesterday", "performance", "ladder",
"rotation", "auction", "themes", "popularity", "dragon-list",
].includes(
workspace.value.key,
),
);
+1
View File
@@ -12,6 +12,7 @@ import "./shared/styles/auth.css";
import "./shared/styles/account.css";
import "./shared/styles/market.css";
import "./shared/styles/market-workspace.css";
import "./shared/styles/market-insights.css";
import "./shared/styles/system.css";
import "./shared/styles/mobile.css";
@@ -1,18 +1,23 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { marketApi, type MarketWorkspaceData } from "../../shared/api/market";
import { marketApi, type MarketInsightData, type MarketWorkspaceData } from "../../shared/api/market";
import EmptyState from "../../shared/components/EmptyState.vue";
import { useMarketStore } from "../../shared/stores/market";
import EmotionPage from "./emotion/EmotionPage.vue";
import LadderPage from "./structure/LadderPage.vue";
import RotationPage from "./structure/RotationPage.vue";
import AuctionPage from "./insights/AuctionPage.vue";
import DragonPage from "./insights/DragonPage.vue";
import PopularityPage from "./insights/PopularityPage.vue";
import ThemesPage from "./insights/ThemesPage.vue";
import PerformancePage from "./pools/PerformancePage.vue";
import PoolPage from "./pools/PoolPage.vue";
const props = defineProps<{ workspaceKey: string }>();
const market = useMarketStore();
const data = ref<MarketWorkspaceData | null>(null);
const data = ref<MarketWorkspaceData | MarketInsightData | null>(null);
const insightData = computed(() => data.value as MarketInsightData);
const loading = ref(false);
const error = ref("");
let sequence = 0;
@@ -22,7 +27,9 @@ async function load(): Promise<void> {
loading.value = true;
error.value = "";
try {
const result = await marketApi.workspace(props.workspaceKey, market.selectedDate);
const result = ["auction", "themes", "popularity", "dragon-list"].includes(props.workspaceKey)
? await marketApi.insight(props.workspaceKey, market.selectedDate)
: await marketApi.workspace(props.workspaceKey, market.selectedDate);
if (current === sequence) data.value = result;
} catch (reason) {
if (current === sequence) error.value = reason instanceof Error ? reason.message : "页面数据读取失败";
@@ -42,6 +49,10 @@ watch([() => props.workspaceKey, () => market.selectedDate], () => void load(),
<EmotionPage v-else-if="workspaceKey === 'emotion'" :data="data" />
<LadderPage v-else-if="workspaceKey === 'ladder'" :data="data" />
<RotationPage v-else-if="workspaceKey === 'rotation'" :data="data" />
<AuctionPage v-else-if="workspaceKey === 'auction'" :data="insightData" />
<ThemesPage v-else-if="workspaceKey === 'themes'" :data="insightData" />
<PopularityPage v-else-if="workspaceKey === 'popularity'" :data="insightData" />
<DragonPage v-else-if="workspaceKey === 'dragon-list'" :data="insightData" @refresh="load" />
<PerformancePage v-else-if="workspaceKey === 'performance'" :data="data" />
<PoolPage v-else :kind="workspaceKey" :data="data" />
</main>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { MarketInsightData } from "../../../shared/api/market";
import DataTable from "../../../shared/components/DataTable.vue";
import EmptyState from "../../../shared/components/EmptyState.vue";
import { exportCsv, formatNumber, sortRows, type SortDirection } from "../../../shared/market/table";
const props = defineProps<{ data: MarketInsightData }>();
const dataset = ref<"focus" | "watchlist" | "all" | "one-price">("focus");
const expectation = ref("all");
const query = ref("");
const sortKey = ref("attention_score");
const sortDirection = ref<SortDirection>("desc");
const summary = computed(() => props.data.summary ?? {});
const phaseLabels = {
pending: "待开始",
observing: "观察中",
selection: "筛选确认",
finalized: "已归档",
archive: "历史归档",
};
const sources = computed(() => ({
focus: props.data.focus_rows ?? [],
watchlist: props.data.watchlist_rows ?? [],
all: props.data.rows ?? [],
"one-price": props.data.one_price_rows ?? [],
}));
const rows = computed(() => {
const normalized = query.value.trim().toLocaleLowerCase();
const selected = sources.value[dataset.value].filter((row) => {
if (expectation.value !== "all" && row.expectation !== expectation.value) return false;
if (!normalized) return true;
return [row.code, row.name, row.sector, row.source_label]
.some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized));
});
return sortRows(selected, sortKey.value, sortDirection.value);
});
const columns = [
{ key: "code", label: "代码", code: true, sortable: true },
{ key: "name", label: "股票", sortable: true },
{ key: "source_label", label: "候选身份", wide: true, sortable: true },
{ key: "sector", label: "板块", sortable: true },
{ key: "expectation", label: "预期", sortable: true },
{ key: "change", label: "竞价涨幅(%", numeric: true, sortable: true, format: number },
{ key: "expected_change", label: "预期中枢(%", numeric: true, sortable: true, format: number },
{ key: "attention_score", label: "关注分", numeric: true, sortable: true, format: oneDecimal },
{ key: "volume_ratio", label: "量比", numeric: true, sortable: true, format: number },
{ key: "amount_million", label: "竞价额(百万)", numeric: true, sortable: true, format: number },
];
const maxAmount = computed(() => Math.max(1, ...(props.data.amount_history ?? []).map((row) => Number(row.amount_billion ?? 0))));
const fiveDayAverage = computed(() => {
const values = (props.data.amount_history ?? []).slice(-5).map((row) => Number(row.amount_billion ?? 0));
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
});
function sort(key: string): void {
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
else { sortKey.value = key; sortDirection.value = "desc"; }
}
function number(value: unknown): string { return formatNumber(value, 2); }
function oneDecimal(value: unknown): string { return formatNumber(value, 1); }
function download(): void {
const lines = [["代码", "股票", "身份", "板块", "预期", "竞价涨幅(%", "预期中枢(%", "关注分", "量比", "竞价额(百万)"]];
for (const row of rows.value) lines.push([
String(row.code ?? ""), String(row.name ?? ""), String(row.source_label ?? ""),
String(row.sector ?? ""), String(row.expectation ?? ""), String(row.change ?? ""),
String(row.expected_change ?? ""), String(row.attention_score ?? ""),
String(row.volume_ratio ?? ""), String(row.amount_million ?? ""),
]);
exportCsv(`集合竞价-${props.data.trade_date}.csv`, lines);
}
</script>
<template>
<header class="page-header market-page-header">
<div><h1>集合竞价中心</h1><p class="page-subtitle">{{ phaseLabels[data.phase ?? "archive"] }} · 数据日期 {{ data.trade_date }}</p></div>
<span class="tag" :class="{ warning: !data.current_available }">{{ data.message || `竞价覆盖 ${Number(data.coverage ?? 0) * 100}%` }}</span>
</header>
<section class="auction-layout">
<article class="card auction-main">
<div class="auction-dataset-bar">
<div class="seg-control auction-datasets">
<button type="button" :class="{ active: dataset === 'focus' }" @click="dataset = 'focus'">重点异动 <b>{{ data.focus_rows?.length ?? 0 }}</b></button>
<button type="button" :class="{ active: dataset === 'watchlist' }" @click="dataset = 'watchlist'">我的自选 <b>{{ data.watchlist_rows?.length ?? 0 }}</b></button>
<button type="button" :class="{ active: dataset === 'all' }" @click="dataset = 'all'">全部候选 <b>{{ data.rows?.length ?? 0 }}</b></button>
<button type="button" :class="{ active: dataset === 'one-price' }" @click="dataset = 'one-price'">竞价一字 <b>{{ data.one_price_rows?.length ?? 0 }}</b></button>
</div>
<dl class="auction-inline-summary">
<div><dt>竞价覆盖</dt><dd>{{ summary.stock_count ?? 0 }}</dd></div>
<div><dt>重点异动</dt><dd>{{ summary.focus_count ?? 0 }}</dd></div>
<div><dt>竞价一字</dt><dd>{{ summary.one_price_count ?? 0 }}</dd></div>
<div><dt>竞价成交额</dt><dd>{{ number(summary.amount_billion) }} 亿</dd></div>
</dl>
</div>
<div class="auction-toolbar">
<div class="seg-control">
<button v-for="item in [['all','全部'],['超预期','超预期'],['符合预期','符合预期'],['低于预期','低于预期']]" :key="item[0]" type="button" :class="{ active: expectation === item[0] }" @click="expectation = item[0]">{{ item[1] }}</button>
</div>
<label class="search-control"><span>搜索</span><input v-model="query" type="search" placeholder="代码、名称或板块"></label>
<button class="btn btn-small" type="button" @click="download">导出 CSV</button>
</div>
<DataTable v-if="rows.length" :columns="columns" :rows="rows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
<EmptyState v-else title="没有符合条件的竞价候选" :description="dataset === 'watchlist' ? '当前账号还没有可用的自选竞价结果。' : '调整预期筛选或搜索条件后再查看。'" />
</article>
<aside class="auction-side">
<article class="card"><header class="card-header"><h2>题材承接</h2><span class="faint">昨日强势方向</span></header><div class="auction-evidence-list"><div v-for="item in data.themes?.carry ?? []" :key="String(item.name)"><strong>{{ item.name }}</strong><span>{{ item.status }}</span><small>{{ item.matched_count }}只 · 中位 {{ item.median_change ?? '' }}%</small></div><p v-if="!data.themes?.carry?.length" class="faint">暂无可核验承接结果</p></div></article>
<article class="card"><header class="card-header"><h2>今日新线索</h2></header><div class="auction-evidence-list"><div v-for="item in data.themes?.new_themes ?? []" :key="String(item.name)"><strong>{{ item.name }}</strong><span>{{ item.stock_count }}只</span><small>{{ (item.leaders as string[])?.join('、') }}</small></div><p v-if="!data.themes?.new_themes?.length" class="faint">尚未形成新的聚集方向</p></div></article>
<article class="card auction-amount-card"><header class="card-header"><h2>竞价成交额对比</h2><span class="faint">5日均值 {{ fiveDayAverage.toFixed(2) }} 亿</span></header><div class="auction-bars"><div v-for="item in data.amount_history ?? []" :key="String(item.trade_date)"><span>{{ String(item.trade_date).slice(5) }}</span><i><b :style="{ width: `${Number(item.amount_billion ?? 0) / maxAmount * 100}%` }"></b></i><strong>{{ number(item.amount_billion) }}</strong></div></div></article>
</aside>
</section>
</template>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { marketApi, type MarketInsightData } from "../../../shared/api/market";
import DataTable from "../../../shared/components/DataTable.vue";
import EmptyState from "../../../shared/components/EmptyState.vue";
import { formatNumber, sortRows, type SortDirection } from "../../../shared/market/table";
import { useSessionStore } from "../../../shared/stores/session";
import { useUiStore } from "../../../shared/stores/ui";
const props = defineProps<{ data: MarketInsightData }>();
const emit = defineEmits<{ refresh: [] }>();
const session = useSessionStore();
const ui = useUiStore();
const view = ref<"daily" | "profiles">("daily");
const filter = ref<"all" | "buy" | "sell" | "pending">("all");
const query = ref("");
const selectedTrader = ref("");
const selectedProfile = ref("");
const sortKey = ref("net_million");
const sortDirection = ref<SortDirection>("desc");
const aliases = reactive<Record<string, string>>({});
const savingSeat = ref("");
const traders = computed(() => props.data.traders ?? []);
const profiles = computed(() => props.data.profiles ?? []);
const profile = computed(() => profiles.value.find((item) => item.name === selectedProfile.value) ?? profiles.value[0]);
const operations = computed(() => {
const normalized = query.value.trim().toLocaleLowerCase();
const rows = (props.data.operations ?? []).filter((row) => {
if (selectedTrader.value && row.trader_name !== selectedTrader.value) return false;
if (filter.value === "buy" && Number(row.net_million ?? 0) <= 0) return false;
if (filter.value === "sell" && Number(row.net_million ?? 0) >= 0) return false;
if (filter.value === "pending" && row.recognized) return false;
if (!normalized) return true;
return [row.code, row.name, row.seat_name, row.trader_name, row.reason]
.some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized));
});
return sortRows(rows, sortKey.value, sortDirection.value);
});
const columns = [
{ key: "code", label: "代码", code: true, sortable: true },
{ key: "name", label: "股票", sortable: true },
{ key: "direction", label: "方向", sortable: true },
{ key: "buy_million", label: "买入(百万)", numeric: true, sortable: true, format: number },
{ key: "sell_million", label: "卖出(百万)", numeric: true, sortable: true, format: number },
{ key: "net_million", label: "净额(百万)", numeric: true, sortable: true, format: number },
{ key: "trader_name", label: "游资", sortable: true },
{ key: "seat_name", label: "营业部", wide: true, sortable: true },
{ key: "reason", label: "上榜原因", wide: true },
];
watch(profiles, (items) => {
if (!selectedProfile.value && items[0]) selectedProfile.value = String(items[0].name ?? "");
}, { immediate: true });
function sort(key: string): void {
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
else { sortKey.value = key; sortDirection.value = "desc"; }
}
function number(value: unknown): string { return formatNumber(value, 2); }
async function saveAlias(seat: string): Promise<void> {
const alias = aliases[seat]?.trim();
if (!alias) return;
savingSeat.value = seat;
try {
await marketApi.saveSeatAlias(seat, alias);
ui.showToast("席位归类已保存");
emit("refresh");
} catch (reason) {
ui.showToast(reason instanceof Error ? reason.message : "席位归类保存失败");
} finally {
savingSeat.value = "";
}
}
</script>
<template>
<header class="page-header market-page-header">
<div><h1>龙虎榜</h1><p class="page-subtitle">上榜明细与活跃席位 · 数据日期 {{ data.trade_date }}</p></div>
<div class="page-actions"><div class="seg-control"><button type="button" :class="{ active: view === 'daily' }" @click="view = 'daily'">每日明细</button><button type="button" :class="{ active: view === 'profiles' }" @click="view = 'profiles'">游资档案</button></div></div>
</header>
<template v-if="view === 'daily'">
<section class="dragon-summary-grid">
<article class="card"><span>上榜股票</span><strong>{{ data.summary.official_stock_count ?? 0 }}</strong></article>
<article class="card"><span>活跃游资</span><strong>{{ data.summary.trader_count ?? 0 }}</strong></article>
<article class="card"><span>操作明细</span><strong>{{ data.summary.operation_count ?? 0 }}</strong></article>
<article class="card"><span>待归类席位</span><strong>{{ data.summary.unclassified_count ?? 0 }}</strong></article>
<article class="card"><span>席位净额</span><strong>{{ number(data.summary.net_million) }} 百万</strong></article>
</section>
<div v-if="data.message" class="notice" :class="data.status === 'unavailable' ? 'notice-warning' : ''">{{ data.message }}<span v-if="data.previous_date"> · 可切换至 {{ data.previous_date }} 查看</span></div>
<section v-if="data.status !== 'empty' && data.status !== 'detail_missing' && data.status !== 'unavailable'" class="dragon-daily-layout">
<aside class="card dragon-traders">
<header class="card-header"><h2>活跃游资</h2><button v-if="selectedTrader" class="btn btn-small" type="button" @click="selectedTrader = ''">显示全部</button></header>
<div class="dragon-trader-list"><button v-for="trader in traders" :key="String(trader.name)" type="button" :class="{ active: selectedTrader === trader.name }" @click="selectedTrader = String(trader.name)"><span><strong>{{ trader.name }}</strong><small>{{ trader.description || '暂无简介' }}</small></span><b :class="Number(trader.net_million) >= 0 ? 'up' : 'down'">{{ number(trader.net_million) }}</b></button><p v-if="!traders.length" class="faint">当前没有已识别游资</p></div>
<section v-if="data.unclassified_seats?.length" class="dragon-pending"><header><strong>待归类营业部</strong><span>{{ data.unclassified_seats.length }}个</span></header><div v-for="seat in data.unclassified_seats" :key="String(seat.seat_name)"><p><span>{{ seat.seat_name }}</span><b>{{ number(seat.net_million) }}</b></p><form v-if="session.isAdmin" @submit.prevent="saveAlias(String(seat.seat_name))"><input v-model="aliases[String(seat.seat_name)]" type="text" placeholder="归类为游资名称"><button class="btn btn-small" type="submit" :disabled="savingSeat === seat.seat_name">保存</button></form></div></section>
</aside>
<article class="card dragon-operations">
<header class="card-header"><h2>当日操作明细</h2><span class="faint">{{ operations.length }}</span></header>
<div class="dragon-toolbar"><div class="seg-control"><button type="button" :class="{ active: filter === 'all' }" @click="filter = 'all'">全部</button><button type="button" :class="{ active: filter === 'buy' }" @click="filter = 'buy'">净买入</button><button type="button" :class="{ active: filter === 'sell' }" @click="filter = 'sell'">净卖出</button><button type="button" :class="{ active: filter === 'pending' }" @click="filter = 'pending'">待归类</button></div><label class="search-control"><span>搜索</span><input v-model="query" type="search" placeholder="代码、股票、游资或营业部"></label></div>
<div class="dragon-table-scroll"><DataTable v-if="operations.length" :columns="columns" :rows="operations" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" /><EmptyState v-else title="暂无符合条件的操作明细" description="调整筛选条件或选择其他活跃游资。" /></div>
</article>
</section>
<EmptyState v-else class="card" :title="data.status === 'empty' ? '当日没有股票上榜' : data.status === 'detail_missing' ? '席位明细尚未返回' : '龙虎榜数据暂不可用'" :description="data.message" />
</template>
<section v-else class="card dragon-profiles">
<aside><header class="card-header"><h2>游资名录</h2><span class="faint">{{ profiles.length }}</span></header><div><button v-for="item in profiles" :key="String(item.name)" type="button" :class="{ active: profile?.name === item.name }" @click="selectedProfile = String(item.name)"><strong>{{ item.name }}</strong><small>{{ item.organization_count }}个关联营业部</small></button></div></aside>
<article v-if="profile"><header><div><h2>{{ profile.name }}</h2><p>{{ profile.description || '暂无公开简介' }}</p></div><span class="tag">{{ profile.organization_count }}个营业部</span></header><section><h3>关联营业部</h3><ul><li v-for="organization in profile.organizations as string[]" :key="organization">{{ organization }}</li></ul></section><p class="notice">档案统计以当前可用名录和已归类席位为准覆盖范围会随归档累积</p></article>
<EmptyState v-else title="游资名录暂不可用" description="当前没有可核验的游资档案。" />
</section>
</template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { MarketInsightData } from "../../../shared/api/market";
import DataTable from "../../../shared/components/DataTable.vue";
import EmptyState from "../../../shared/components/EmptyState.vue";
import { formatNumber, sortRows, type SortDirection } from "../../../shared/market/table";
const props = defineProps<{ data: MarketInsightData }>();
const source = ref<"combined" | "ths" | "dc">("combined");
const query = ref("");
const sortKey = ref("rank");
const sortDirection = ref<SortDirection>("asc");
const sourceRows = computed(() => props.data[source.value] ?? []);
const rows = computed(() => {
const normalized = query.value.trim().toLocaleLowerCase();
const filtered = sourceRows.value.filter((row) => !normalized || [row.code, row.name, ...(row.concepts as unknown[] ?? [])]
.some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized)));
return sortRows(filtered, sortKey.value, sortDirection.value);
});
const columns = [
{ key: "rank", label: "排名", numeric: true, sortable: true },
{ key: "code", label: "代码", code: true, sortable: true },
{ key: "name", label: "股票", sortable: true },
{ key: "change", label: "涨跌幅(%", numeric: true, sortable: true, format: number },
{ key: "ths_rank", label: "同花顺排名", numeric: true, sortable: true },
{ key: "dc_rank", label: "东方财富排名", numeric: true, sortable: true },
{ key: "rank_change", label: "排名变化", numeric: true, sortable: true, format: signed },
{ key: "concepts", label: "相关题材", wide: true, format: concepts },
{ key: "reason", label: "上榜线索", wide: true },
];
const topThs = computed(() => (props.data.ths ?? []).slice(0, 3));
const topDc = computed(() => (props.data.dc ?? []).slice(0, 3));
const consensus = computed(() => (props.data.combined ?? []).filter((row) => row.dual_source).slice(0, 3));
function sort(key: string): void {
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
else { sortKey.value = key; sortDirection.value = key === "rank" ? "asc" : "desc"; }
}
function number(value: unknown): string { return formatNumber(value, 2); }
function signed(value: unknown): string {
const text = formatNumber(value, 0);
return text && Number(value) > 0 ? `+${text}` : text;
}
function concepts(value: unknown): string { return Array.isArray(value) ? value.join(" · ") : ""; }
</script>
<template>
<header class="page-header market-page-header">
<div><h1>人气热榜</h1><p class="page-subtitle">双平台人气与共识 · 榜单日期 {{ data.trade_date }}</p></div>
<span v-if="data.message" class="tag warning">{{ data.message }}</span>
</header>
<section class="popularity-summary-strip">
<article><header><span>同花顺热度 Top3</span><strong>{{ data.summary.ths_count ?? 0 }}</strong></header><p>{{ topThs.map((item) => item.name).join(' · ') || '暂无榜单' }}</p></article>
<article><header><span>东方财富热度 Top3</span><strong>{{ data.summary.dc_count ?? 0 }}</strong></header><p>{{ topDc.map((item) => item.name).join(' · ') || '暂无榜单' }}</p></article>
<article><header><span>双榜共识</span><strong>{{ data.summary.dual_count ?? 0 }}</strong></header><p>{{ consensus.map((item) => item.name).join(' · ') || '暂无共识' }}</p></article>
</section>
<section class="card popularity-table-card">
<div class="popularity-toolbar">
<div class="seg-control"><button type="button" :class="{ active: source === 'combined' }" @click="source = 'combined'">双榜综合</button><button type="button" :class="{ active: source === 'ths' }" @click="source = 'ths'">同花顺</button><button type="button" :class="{ active: source === 'dc' }" @click="source = 'dc'">东方财富</button></div>
<label class="search-control"><span>搜索</span><input v-model="query" type="search" placeholder="代码、名称或题材"></label>
</div>
<DataTable v-if="rows.length" :columns="columns" :rows="rows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
<EmptyState v-else title="暂无人气榜结果" description="当日榜单尚未生成时,系统会显示最近有效榜单并标注真实日期。" />
</section>
</template>
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { marketApi, type MarketEntity, type MarketInsightData, type ThemeDetailData } from "../../../shared/api/market";
import DataTable from "../../../shared/components/DataTable.vue";
import EmptyState from "../../../shared/components/EmptyState.vue";
import MarketPreviewPanel from "../../../shared/market/MarketPreviewPanel.vue";
import { formatAmount, formatNumber, sortRows, type SortDirection } from "../../../shared/market/table";
const props = defineProps<{ data: MarketInsightData }>();
const query = ref("");
const selectedCode = ref("");
const detail = ref<ThemeDetailData | null>(null);
const loading = ref(false);
const error = ref("");
const preview = ref<MarketEntity | null>(null);
const sortKey = ref("change");
const sortDirection = ref<SortDirection>("desc");
let requestSequence = 0;
const themes = computed(() => {
const normalized = query.value.trim().toLocaleLowerCase();
return (props.data.items ?? []).filter((item) => !normalized || [item.code, item.name]
.some((value) => String(value ?? "").toLocaleLowerCase().includes(normalized)));
});
const members = computed(() => sortRows(detail.value?.members ?? [], sortKey.value, sortDirection.value));
const columns = [
{ key: "code", label: "代码", code: true, sortable: true },
{ key: "name", label: "股票", sortable: true },
{ key: "change", label: "涨跌幅(%", numeric: true, sortable: true, format: number },
{ key: "close", label: "收盘价(元)", numeric: true, sortable: true, format: number },
{ key: "amount", label: "成交额", numeric: true, sortable: true, format: amount },
{ key: "quoted", label: "行情状态", sortable: true, format: quoteState },
];
watch(() => props.data.trade_date, () => {
const first = props.data.items?.[0];
if (first) void selectTheme(first);
}, { immediate: true });
async function selectTheme(theme: Record<string, unknown>): Promise<void> {
const code = String(theme.code ?? "");
if (!code) return;
selectedCode.value = code;
const sequence = ++requestSequence;
loading.value = true;
error.value = "";
try {
const result = await marketApi.themeDetail(code, props.data.trade_date ?? props.data.requested_date);
if (sequence === requestSequence) detail.value = result;
} catch (reason) {
if (sequence === requestSequence) {
detail.value = null;
error.value = reason instanceof Error ? reason.message : "题材成分股读取失败";
}
} finally {
if (sequence === requestSequence) loading.value = false;
}
}
function showPreview(theme: Record<string, unknown>): void {
const identifier = String(theme.code ?? "");
preview.value = identifier ? {
entity_type: "theme",
identifier,
code: identifier.split(".")[0] ?? identifier,
name: String(theme.name ?? ""),
sector: null,
} : null;
}
function sort(key: string): void {
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
else { sortKey.value = key; sortDirection.value = "desc"; }
}
function number(value: unknown): string { return formatNumber(value, 2); }
function amount(value: unknown): string { return formatAmount(value); }
function quoteState(value: unknown): string { return value ? "正常交易" : "当日无行情"; }
</script>
<template>
<header class="page-header market-page-header">
<div><h1>题材库</h1><p class="page-subtitle">题材排行与成分行情 · 数据日期 {{ data.trade_date }}</p></div>
<span v-if="data.message" class="tag warning">{{ data.message }}</span>
</header>
<section class="themes-layout">
<article class="card theme-directory">
<header class="card-header"><h2>题材排行</h2><span class="faint">{{ data.summary.theme_count ?? 0 }}个题材</span></header>
<label class="search-control theme-search"><span>搜索</span><input v-model="query" type="search" placeholder="题材名称或代码"></label>
<div class="theme-rank-list">
<button v-for="(theme, index) in themes" :key="String(theme.code)" type="button" :class="{ active: selectedCode === theme.code }" @click="selectTheme(theme)" @mouseenter="showPreview(theme)" @mouseleave="preview = null">
<b>{{ index + 1 }}</b><span><strong>{{ theme.name }}</strong><small>{{ theme.code }}</small></span><em :class="{ up: Number(theme.change) > 0, down: Number(theme.change) < 0 }">{{ theme.change === null ? '' : `${number(theme.change)}%` }}</em>
</button>
</div>
<div v-if="preview" class="theme-preview-popover"><MarketPreviewPanel :entity="preview" /></div>
</article>
<article class="card theme-detail-card">
<header class="card-header"><h2>{{ detail?.theme.name || "题材基础行情" }}</h2><span class="faint">{{ detail?.theme.code || "选择左侧题材" }}</span></header>
<div v-if="detail" class="theme-summary-strip">
<div><span>成分股</span><strong>{{ detail.summary.member_count ?? 0 }}</strong></div>
<div><span>有行情</span><strong>{{ detail.summary.quoted_count ?? 0 }}</strong></div>
<div><span>上涨</span><strong class="up">{{ detail.summary.up_count ?? 0 }}</strong></div>
<div><span>下跌</span><strong class="down">{{ detail.summary.down_count ?? 0 }}</strong></div>
<div><span>换手率</span><strong>{{ number(detail.summary.turnover_rate) }}%</strong></div>
</div>
<div v-if="loading" class="workspace-state">正在核验题材成分行情</div>
<EmptyState v-else-if="error" title="题材成分暂不可用" :description="error" />
<DataTable v-else-if="members.length" :columns="columns" :rows="members" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
<EmptyState v-else title="暂无题材成分数据" :description="detail?.message || '选择左侧题材后显示成分股。'" />
</article>
</section>
</template>
+53
View File
@@ -77,6 +77,41 @@ export type RotationMembersData = {
items: Record<string, unknown>[];
};
export type MarketInsightData = MarketWorkspaceData & {
requested_date: string;
previous_date?: string;
phase?: "pending" | "observing" | "selection" | "finalized" | "archive";
current_available?: boolean;
coverage?: number;
summary: Record<string, unknown>;
expectations?: Record<string, number>;
themes?: { carry: Record<string, unknown>[]; new_themes: Record<string, unknown>[] };
amount_history?: Record<string, unknown>[];
focus_rows?: Record<string, unknown>[];
rows?: Record<string, unknown>[];
one_price_rows?: Record<string, unknown>[];
watchlist_rows?: Record<string, unknown>[];
watchlist_ready?: boolean;
combined?: Record<string, unknown>[];
ths?: Record<string, unknown>[];
dc?: Record<string, unknown>[];
status?: string;
traders?: Record<string, unknown>[];
operations?: Record<string, unknown>[];
unclassified_seats?: Record<string, unknown>[];
profiles?: Record<string, unknown>[];
};
export type ThemeDetailData = {
trade_date: string;
observed_at?: string;
state?: string;
message: string;
theme: Record<string, unknown>;
summary: Record<string, unknown>;
members: Record<string, unknown>[];
};
export const marketApi = {
summary(date?: string): Promise<MarketSummary> {
const query = date ? `?date=${encodeURIComponent(date)}` : "";
@@ -103,4 +138,22 @@ export const marketApi = {
`/market/rotation-members?sector=${encodeURIComponent(sector)}&date=${encodeURIComponent(date)}`,
);
},
insight(key: string, date: string): Promise<MarketInsightData> {
return api.get<MarketInsightData>(
`/market/insights/${encodeURIComponent(key)}?date=${encodeURIComponent(date)}`,
);
},
syncInsight(key: string, date: string): Promise<MarketInsightData> {
return api.post<MarketInsightData>(
`/market/insights/${encodeURIComponent(key)}/sync?date=${encodeURIComponent(date)}`,
);
},
themeDetail(identifier: string, date: string): Promise<ThemeDetailData> {
return api.get<ThemeDetailData>(
`/market/themes/${encodeURIComponent(identifier)}?date=${encodeURIComponent(date)}`,
);
},
saveSeatAlias(seat_name: string, alias_name: string): Promise<{ seat_name: string; alias_name: string }> {
return api.put("/market/seat-aliases", { seat_name, alias_name });
},
};
+43
View File
@@ -0,0 +1,43 @@
export type SortDirection = "asc" | "desc";
export function sortRows(
rows: Record<string, unknown>[],
key: string,
direction: SortDirection,
): Record<string, unknown>[] {
const multiplier = direction === "asc" ? 1 : -1;
return [...rows].sort((left, right) => compare(left[key], right[key]) * multiplier);
}
export function formatNumber(value: unknown, digits = 2): string {
if (value === null || value === undefined || value === "") return "";
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed.toFixed(digits) : "";
}
export function formatAmount(value: unknown): string {
if (value === null || value === undefined || value === "") return "";
const parsed = Number(value);
if (!Number.isFinite(parsed)) return "";
return parsed >= 100_000_000
? `${(parsed / 100_000_000).toFixed(2)} 亿`
: `${(parsed / 10_000).toFixed(2)}`;
}
export function exportCsv(filename: string, rows: string[][]): void {
const content = rows
.map((row) => row.map((value) => `"${value.replaceAll('"', '""')}"`).join(","))
.join("\r\n");
const link = document.createElement("a");
link.href = URL.createObjectURL(new Blob(["\ufeff", content], { type: "text/csv;charset=utf-8" }));
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
function compare(left: unknown, right: unknown): number {
const a = Number(left);
const b = Number(right);
if (Number.isFinite(a) && Number.isFinite(b)) return a - b;
return String(left ?? "").localeCompare(String(right ?? ""), "zh-CN");
}
@@ -0,0 +1,187 @@
.search-control {
min-height: var(--s-32);
display: flex;
align-items: center;
gap: var(--s-8);
padding: 0 var(--s-10);
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
color: var(--color-text-secondary);
background: var(--color-surface);
font-size: var(--font-11);
}
.search-control input {
min-width: 0;
flex: 1;
border: 0;
color: var(--color-text);
background: var(--c-transparent);
}
.search-control input::placeholder { color: var(--color-text-faint); }
.auction-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--s-360);
gap: var(--layout-gap);
align-items: start;
}
.auction-main,
.popularity-table-card,
.theme-detail-card,
.dragon-operations {
min-width: 0;
overflow: hidden;
}
.auction-dataset-bar {
min-height: var(--s-46);
display: flex;
align-items: stretch;
border-bottom: var(--s-1) solid var(--color-divider);
}
.auction-datasets {
align-self: center;
margin-left: var(--s-10);
}
.auction-datasets b { margin-left: var(--s-4); font-variant-numeric: tabular-nums; }
.auction-inline-summary {
min-width: var(--s-360);
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0 0 0 auto;
border-left: var(--s-1) solid var(--color-divider);
}
.auction-inline-summary div {
display: grid;
align-content: center;
gap: var(--s-2);
padding: var(--s-6) var(--s-8);
border-right: var(--s-1) solid var(--color-divider);
text-align: right;
}
.auction-inline-summary div:last-child { border-right: 0; }
.auction-inline-summary dt { color: var(--color-text-faint); font-size: var(--font-10-5); }
.auction-inline-summary dd { margin: 0; font-size: var(--font-12); font-weight: var(--weight-700); font-variant-numeric: tabular-nums; }
.auction-toolbar,
.popularity-toolbar,
.dragon-toolbar {
min-height: var(--s-46);
display: flex;
align-items: center;
gap: var(--s-8);
padding: var(--s-7) var(--s-10);
border-bottom: var(--s-1) solid var(--color-divider);
}
.auction-toolbar .seg-control,
.popularity-toolbar .seg-control,
.dragon-toolbar .seg-control { margin-left: 0; }
.auction-toolbar .search-control,
.popularity-toolbar .search-control,
.dragon-toolbar .search-control { width: var(--s-260); margin-left: auto; }
.auction-side { display: grid; gap: var(--layout-gap); }
.auction-evidence-list { display: grid; gap: var(--s-1); padding: var(--s-6) var(--s-14) var(--s-10); }
.auction-evidence-list > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--s-2) var(--s-8); padding: var(--s-7) 0; border-bottom: var(--s-1) solid var(--color-divider); }
.auction-evidence-list > div:last-of-type { border-bottom: 0; }
.auction-evidence-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auction-evidence-list span { color: var(--color-primary); font-size: var(--font-11); }
.auction-evidence-list small { grid-column: 1 / -1; color: var(--color-text-secondary); font-size: var(--font-10-5); }
.auction-evidence-list > p { padding: var(--s-14) 0; text-align: center; }
.auction-bars { display: grid; gap: var(--s-8); padding: var(--s-12) var(--s-14); }
.auction-bars > div { display: grid; grid-template-columns: var(--s-44) minmax(0, 1fr) var(--s-44); align-items: center; gap: var(--s-8); font-size: var(--font-10-5); }
.auction-bars i { height: var(--s-7); overflow: hidden; border-radius: var(--radius-round); background: var(--color-surface-muted); }
.auction-bars b { display: block; height: 100%; border-radius: inherit; background: var(--color-primary); }
.auction-bars strong { text-align: right; font-variant-numeric: tabular-nums; }
.themes-layout {
display: grid;
grid-template-columns: var(--s-320) minmax(0, 1fr);
gap: var(--layout-gap);
align-items: start;
}
.theme-directory { position: relative; min-width: 0; }
.theme-search { margin: var(--s-10); }
.theme-rank-list { max-height: calc(100vh - var(--s-260)); overflow-y: auto; border-top: var(--s-1) solid var(--color-divider); }
.theme-rank-list > button { width: 100%; display: grid; grid-template-columns: var(--s-28) minmax(0, 1fr) var(--s-56); align-items: center; gap: var(--s-8); padding: var(--s-8) var(--s-10); border-bottom: var(--s-1) solid var(--color-divider); background: var(--color-surface); text-align: left; }
.theme-rank-list > button:hover,
.theme-rank-list > button.active { background: var(--color-primary-soft); }
.theme-rank-list > button > b { color: var(--color-text-faint); text-align: center; font-variant-numeric: tabular-nums; }
.theme-rank-list > button:nth-child(-n+3) > b { color: var(--color-warning); font-size: var(--font-14); }
.theme-rank-list > button > span { min-width: 0; display: grid; gap: var(--s-2); }
.theme-rank-list strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.theme-rank-list small { color: var(--color-text-faint); font-size: var(--font-10-5); }
.theme-rank-list em { font-style: normal; text-align: right; font-size: var(--font-11); font-variant-numeric: tabular-nums; }
.theme-preview-popover { position: absolute; top: var(--s-64); left: calc(100% + var(--layout-gap)); z-index: var(--z-popover); width: var(--s-400); overflow: hidden; border: var(--s-1) solid var(--color-border); border-radius: var(--card-radius); background: var(--color-surface-raised); box-shadow: var(--shadow-float); }
.theme-summary-strip { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border-bottom: var(--s-1) solid var(--color-divider); }
.theme-summary-strip > div { display: grid; gap: var(--s-4); padding: var(--s-10) var(--s-14); border-right: var(--s-1) solid var(--color-divider); }
.theme-summary-strip > div:last-child { border-right: 0; }
.theme-summary-strip span { color: var(--color-text-secondary); font-size: var(--font-11); }
.theme-summary-strip strong { font-size: var(--font-15); font-variant-numeric: tabular-nums; }
.popularity-summary-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
overflow: hidden;
border: var(--s-1) solid var(--color-border);
border-radius: var(--card-radius);
background: var(--color-canvas);
}
.popularity-summary-strip article { min-width: 0; display: grid; gap: var(--s-8); padding: var(--s-12) var(--s-14); border-right: var(--s-1) solid var(--color-border); box-shadow: none; }
.popularity-summary-strip article:nth-child(1) { background: var(--color-primary-soft); }
.popularity-summary-strip article:nth-child(2) { background: var(--color-up-soft); }
.popularity-summary-strip article:nth-child(3) { border-right: 0; background: var(--color-warning-soft); }
.popularity-summary-strip header { display: flex; justify-content: space-between; gap: var(--s-8); }
.popularity-summary-strip header span { color: var(--color-text-secondary); }
.popularity-summary-strip header strong { font-variant-numeric: tabular-nums; }
.popularity-summary-strip p { overflow: hidden; color: var(--color-text); font-size: var(--font-12); text-overflow: ellipsis; white-space: nowrap; }
.dragon-summary-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--layout-gap); }
.dragon-summary-grid article { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s-8); padding: var(--s-12) var(--s-14); }
.dragon-summary-grid span { color: var(--color-text-secondary); font-size: var(--font-11); }
.dragon-summary-grid strong { font-size: var(--font-15); font-variant-numeric: tabular-nums; }
.dragon-daily-layout { display: grid; grid-template-columns: var(--s-320) minmax(0, 1fr); gap: var(--layout-gap); align-items: start; }
.dragon-traders { min-width: 0; overflow: hidden; }
.dragon-traders .card-header .btn { margin-left: auto; }
.dragon-trader-list { display: grid; }
.dragon-trader-list > button { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--s-8); padding: var(--s-9) var(--s-12); border-bottom: var(--s-1) solid var(--color-divider); background: var(--color-surface); text-align: left; }
.dragon-trader-list > button:hover,
.dragon-trader-list > button.active { background: var(--color-primary-soft); }
.dragon-trader-list > button span { min-width: 0; display: grid; gap: var(--s-2); }
.dragon-trader-list small { overflow: hidden; color: var(--color-text-secondary); font-size: var(--font-10-5); text-overflow: ellipsis; white-space: nowrap; }
.dragon-trader-list b { align-self: center; font-variant-numeric: tabular-nums; }
.dragon-trader-list > p { padding: var(--s-20); text-align: center; }
.dragon-pending { border-top: var(--s-1) solid var(--color-divider); }
.dragon-pending > header { display: flex; justify-content: space-between; padding: var(--s-10) var(--s-12); background: var(--color-warning-soft); }
.dragon-pending > div { display: grid; gap: var(--s-6); padding: var(--s-8) var(--s-12); border-top: var(--s-1) solid var(--color-divider); }
.dragon-pending p,
.dragon-pending form { display: flex; align-items: center; gap: var(--s-8); }
.dragon-pending p span { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dragon-pending input { min-width: 0; flex: 1; min-height: var(--s-32); padding: 0 var(--s-8); border: var(--s-1) solid var(--color-border); border-radius: var(--control-radius); background: var(--color-surface); }
.dragon-table-scroll { height: var(--s-400); overflow: auto; }
.dragon-table-scroll .data-table-wrap { overflow: visible; }
.dragon-profiles { min-height: var(--s-400); display: grid; grid-template-columns: var(--s-320) minmax(0, 1fr); overflow: hidden; }
.dragon-profiles > aside { border-right: var(--s-1) solid var(--color-divider); }
.dragon-profiles > aside > div { max-height: var(--s-400); overflow-y: auto; }
.dragon-profiles > aside button { width: 100%; display: grid; gap: var(--s-2); padding: var(--s-9) var(--s-14); border-bottom: var(--s-1) solid var(--color-divider); background: var(--color-surface); text-align: left; }
.dragon-profiles > aside button.active,
.dragon-profiles > aside button:hover { background: var(--color-primary-soft); }
.dragon-profiles > aside small { color: var(--color-text-secondary); font-size: var(--font-10-5); }
.dragon-profiles > article { display: grid; align-content: start; gap: var(--s-20); padding: var(--s-20); }
.dragon-profiles > article > header { display: flex; justify-content: space-between; gap: var(--s-14); }
.dragon-profiles > article > header p { margin-top: var(--s-8); color: var(--color-text-secondary); line-height: var(--s-20); }
.dragon-profiles h3 { margin: 0 0 var(--s-8); font-size: var(--font-13); }
.dragon-profiles ul { display: flex; flex-wrap: wrap; gap: var(--s-8); margin: 0; padding: 0; list-style: none; }
.dragon-profiles li { padding: var(--s-6) var(--s-8); border: var(--s-1) solid var(--color-border); border-radius: var(--control-radius); background: var(--color-surface-muted); font-size: var(--font-11); }
@@ -245,3 +245,92 @@
border-bottom: var(--s-1) solid var(--color-divider);
}
}
@media (max-width: 1399px) {
.auction-layout {
grid-template-columns: minmax(0, 1fr);
}
.auction-side {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.auction-dataset-bar {
align-items: stretch;
flex-direction: column;
}
.auction-datasets {
align-self: stretch;
overflow-x: auto;
margin: var(--s-7) var(--s-10);
}
.auction-inline-summary {
width: 100%;
min-width: 0;
margin-left: 0;
border-top: var(--s-1) solid var(--color-divider);
border-left: 0;
}
}
@media (max-width: 1023px) {
.auction-side,
.themes-layout,
.popularity-summary-strip,
.dragon-summary-grid,
.dragon-daily-layout,
.dragon-profiles {
grid-template-columns: minmax(0, 1fr);
}
.auction-toolbar,
.popularity-toolbar,
.dragon-toolbar {
align-items: stretch;
flex-direction: column;
}
.auction-toolbar .seg-control,
.popularity-toolbar .seg-control,
.dragon-toolbar .seg-control {
width: 100%;
overflow-x: auto;
}
.auction-toolbar .search-control,
.popularity-toolbar .search-control,
.dragon-toolbar .search-control {
width: 100%;
margin-left: 0;
}
.auction-inline-summary,
.theme-summary-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.theme-rank-list {
max-height: var(--s-320);
}
.theme-preview-popover {
display: none;
}
.popularity-summary-strip article,
.dragon-profiles > aside {
border-right: 0;
border-bottom: var(--s-1) solid var(--color-divider);
}
.dragon-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dragon-table-scroll {
height: auto;
overflow: visible;
}
}
+144
View File
@@ -0,0 +1,144 @@
const fs = require("node:fs");
const path = require("node:path");
const { expect, test } = require("@playwright/test");
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-8");
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
async function authenticate(page) {
await page.goto("/");
await page.getByLabel("账号名").fill("stage8admin");
await page.getByLabel("密码").fill("Stage8-pass-123!");
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page.locator(".sidebar, .field-error")).toBeVisible();
if (!(await page.locator(".sidebar").isVisible())) {
await page.getByRole("tab", { name: "注册" }).click();
await page.getByRole("button", { name: "注册并登录" }).click();
}
}
const summary = {
context: {
requested_date: "2026-07-30", actual_date: "2026-07-30", previous_date: "2026-07-29",
observed_at: "2026-07-30T15:00:00+08:00", state: "final", carried_forward: false, message: "",
},
values: { up_count: 2800, down_count: 2100, limit_up: 58, limit_down: 5, broken: 20, seal_rate: 74.4, amount: 1500000000000, temperature: 48 },
};
const candidate = (code, name, expectation, score) => ({
identifier: `${code}.SZ`, code, name, sector: "机器人", source_label: "昨日涨停 · 同花顺热榜",
expectation, change: expectation === "超预期" ? 6.8 : 2.1, expected_change: 4,
attention_score: score, volume_ratio: 1.8, amount_million: 22.5, is_market_core: score > 80,
});
const auction = {
requested_date: "2026-07-30", trade_date: "2026-07-30", observed_at: "2026-07-30T09:25:00+08:00",
state: "final", carried_forward: false, message: "", phase: "finalized", current_available: true, coverage: 0.98,
summary: { stock_count: 5200, focus_count: 2, one_price_count: 1, amount_billion: 42.03, amount_change_previous: -12.4 },
expectations: { 超预期: 1, 符合预期: 1, 低于预期: 0 },
focus_rows: [candidate("000001", "平安银行", "超预期", 88), candidate("000002", "万科A", "符合预期", 72)],
rows: [candidate("000001", "平安银行", "超预期", 88), candidate("000002", "万科A", "符合预期", 72)],
one_price_rows: [{ ...candidate("000003", "国华网安", "", 0), attention_score: null, expectation: "" }],
watchlist_rows: [], watchlist_ready: false,
themes: { carry: [{ name: "机器人", status: "强承接", matched_count: 6, median_change: 4.2 }], new_themes: [{ name: "算力租赁", stock_count: 3, leaders: ["平安银行", "万科A"] }] },
amount_history: [
{ trade_date: "2026-07-28", amount_billion: 36.2, stock_count: 5180 },
{ trade_date: "2026-07-29", amount_billion: 47.8, stock_count: 5190 },
{ trade_date: "2026-07-30", amount_billion: 42.03, stock_count: 5200 },
],
};
const themes = {
requested_date: "2026-07-30", trade_date: "2026-07-30", observed_at: "2026-07-30T15:00:00+08:00",
state: "archive", carried_forward: false, message: "", summary: { theme_count: 3, quoted_count: 3, up_count: 2, down_count: 1 },
items: [
{ code: "885001.TI", name: "机器人", member_count: 120, change: 3.2, turnover_rate: 4.8, hot_rank: 1, has_quote: true },
{ code: "885002.TI", name: "算力租赁", member_count: 82, change: 1.5, turnover_rate: 3.2, hot_rank: 2, has_quote: true },
{ code: "885003.TI", name: "保险", member_count: 18, change: -0.8, turnover_rate: 1.1, hot_rank: 8, has_quote: true },
],
};
const themeDetail = {
trade_date: "2026-07-30", observed_at: "2026-07-30T15:00:00+08:00", state: "archive", message: "",
theme: themes.items[0], summary: { member_count: 3, quoted_count: 2, up_count: 2, down_count: 0, turnover_rate: 4.8 },
members: [
{ identifier: "000001.SZ", code: "000001", name: "平安银行", change: 3.2, close: 12.3, amount: 500000000, quoted: true },
{ identifier: "000002.SZ", code: "000002", name: "万科A", change: 1.1, close: 8.4, amount: 220000000, quoted: true },
{ identifier: "000003.SZ", code: "000003", name: "停牌样本", change: null, close: null, amount: null, quoted: false },
],
};
const hotRow = (rank, code, name, dual = true) => ({ rank, identifier: `${code}.SZ`, code, name, change: 2.4, price: 12.2, ths_rank: rank, dc_rank: dual ? rank + 1 : null, dual_source: dual, rank_change: 2, concepts: ["机器人"], reason: "市场关注度上升" });
const popularity = {
requested_date: "2026-07-30", trade_date: "2026-07-29", observed_at: "2026-07-29T15:00:00+08:00",
state: "archive", carried_forward: true, message: "当日榜单尚未生成,显示最近有效榜单",
summary: { ths_count: 3, dc_count: 3, dual_count: 2 },
combined: [hotRow(1, "000001", "平安银行"), hotRow(2, "000002", "万科A"), hotRow(3, "000003", "国华网安", false)],
ths: [hotRow(1, "000001", "平安银行"), hotRow(2, "000002", "万科A")],
dc: [hotRow(1, "000002", "万科A"), hotRow(2, "000001", "平安银行")],
};
const dragon = {
requested_date: "2026-07-30", trade_date: "2026-07-30", previous_date: "2026-07-29",
observed_at: "2026-07-30T18:00:00+08:00", state: "archive", carried_forward: false,
status: "partial", message: "部分营业部尚未归类",
summary: { official_stock_count: 76, trader_count: 2, operation_count: 3, unclassified_count: 1, net_million: 38.5 },
traders: [
{ name: "章盟主", description: "偏好主线核心与大成交标的", net_million: 28.5, operation_count: 2 },
{ name: "作手新一", description: "重视情绪拐点与辨识度", net_million: 12.2, operation_count: 1 },
],
operations: [
{ identifier: "000001.SZ", code: "000001", name: "平安银行", direction: "买入", buy_million: 30, sell_million: 5, net_million: 25, trader_name: "章盟主", seat_name: "国泰君安上海江苏路", reason: "日涨幅偏离值达7%", recognized: true },
{ identifier: "000002.SZ", code: "000002", name: "万科A", direction: "买入", buy_million: 15, sell_million: 2.8, net_million: 12.2, trader_name: "作手新一", seat_name: "国泰君安南京太平南路", reason: "日振幅值达15%", recognized: true },
{ identifier: "000003.SZ", code: "000003", name: "国华网安", direction: "买入", buy_million: 4.1, sell_million: 2.8, net_million: 1.3, trader_name: "", seat_name: "测试待归类营业部", reason: "连续三个交易日涨幅偏离", recognized: false },
],
unclassified_seats: [{ seat_name: "测试待归类营业部", net_million: 1.3, operation_count: 1 }],
profiles: [
{ name: "章盟主", description: "偏好主线核心与大成交标的", organization_count: 2, organizations: ["国泰君安上海江苏路", "国泰君安上海新闸路"] },
{ name: "作手新一", description: "重视情绪拐点与辨识度", organization_count: 1, organizations: ["国泰君安南京太平南路"] },
],
};
async function mockStage8(page) {
await page.route("**/api/market/summary", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(summary) }));
await page.route("**/api/market/workspaces/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "2026-07-30", observed_at: "2026-07-30T15:00:00+08:00", carried_forward: false, message: "", overview: summary.values, sentiment: {}, history: [] }) }));
await page.route("**/api/market/insights/*", (route) => {
const key = new URL(route.request().url()).pathname.split("/").pop();
const payload = key === "auction" ? auction : key === "themes" ? themes : key === "popularity" ? popularity : dragon;
return route.fulfill({ contentType: "application/json", body: JSON.stringify(payload) });
});
await page.route("**/api/market/themes/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(themeDetail) }));
await page.route("**/api/market/entities/theme/*/charts/*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ entity_type: "theme", identifier: "885001.TI", code: "885001", name: "机器人", interval: "day", trade_date: "2026-07-30", observed_at: "2026-07-30T15:00:00+08:00", previous_close: 1000, range_start: null, range_end: null, points: [{ time: "2026-07-29", open: 1000, high: 1020, low: 990, close: 1010, volume: 100, amount: 1000, average: null }, { time: "2026-07-30", open: 1010, high: 1060, low: 1005, close: 1050, volume: 150, amount: 1600, average: null }] }) }));
}
test("stage 8 market insights preserve lifecycle, hierarchy and empty-state semantics", async ({ page }) => {
const consoleErrors = [];
page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text()); });
await mockStage8(page);
await authenticate(page);
await page.getByRole("link", { name: /集合竞价/ }).click();
await expect(page.getByText("竞价成交额", { exact: true })).toBeVisible();
await expect(page.getByText("42.03 亿", { exact: true })).toBeVisible();
await expect(page.locator(".auction-bars").getByText("42.03", { exact: true })).toBeVisible();
await page.screenshot({ path: path.join(evidence, "auction-light-1920x1080.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("link", { name: /题材库/ }).click();
await expect(page.getByRole("heading", { name: "机器人成分股" })).toHaveCount(0);
await expect(page.getByText("停牌样本")).toBeVisible();
await page.getByRole("button", { name: /机器人/ }).first().hover();
await expect(page.locator(".theme-preview-popover")).toBeVisible();
await page.getByRole("link", { name: /人气热榜/ }).click();
await page.getByRole("button", { name: "夜间" }).click();
await expect(page.locator(".popularity-summary-strip article")).toHaveCount(3);
expect(await page.locator(".popularity-summary-strip article").first().evaluate((element) => getComputedStyle(element).boxShadow)).toBe("none");
await page.screenshot({ path: path.join(evidence, "popularity-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("link", { name: /龙虎榜/ }).click();
await expect(page.getByRole("heading", { name: "当日操作明细" })).toBeVisible();
await expect(page.getByText("测试待归类营业部").first()).toBeVisible();
await page.getByRole("button", { name: "游资档案" }).click();
await expect(page.getByRole("heading", { name: "章盟主" })).toBeVisible();
await expect(page.locator(".dialog-backdrop")).toHaveCount(0);
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
await page.screenshot({ path: path.join(evidence, "dragon-profiles-dark-390x844.jpg"), type: "jpeg", quality: 82 });
expect(consoleErrors).toEqual([]);
});
+401
View File
@@ -0,0 +1,401 @@
from datetime import datetime
from zoneinfo import ZoneInfo
from backend.data.contracts import (
DataSource,
DataUsage,
ObservationMetadata,
ProviderResult,
SnapshotState,
)
from backend.data.gateway import MarketDataUnavailable
from backend.data.repository import MarketRepository
from backend.database.connection import Database
from backend.database.migrations import MIGRATIONS, MigrationRunner
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.service import MarketInsightService
from backend.features.market.insights.themes import build_theme_detail, build_theme_library
SHANGHAI = ZoneInfo("Asia/Shanghai")
def test_auction_keeps_market_cores_and_isolates_real_limit_price() -> None:
directory = {
f"000{index:03d}.SZ": {
"identifier": f"000{index:03d}.SZ",
"code": f"000{index:03d}",
"name": "ST样本" if index == 1 else f"样本{index}",
"sector": "机器人",
}
for index in range(1, 36)
}
prior_limits = [
{
"identifier": identifier,
"code": stock["code"],
"name": stock["name"],
"sector": "机器人",
"streak": 3,
"amount": index * 10_000_000,
}
for index, (identifier, stock) in enumerate(directory.items(), start=1)
]
rows = tuple(
{
"ts_code": identifier,
"price": 11 if index == 1 else 10.5,
"pre_close": 10,
"amount": 5_000_000,
"vol": 500_000,
"turnover_rate": 0.2,
"volume_ratio": 1.5,
}
for index, identifier in enumerate(directory, start=1)
)
result = build_auction(
trade_date="2026-07-30",
raw_rows=rows,
price_limits=({"ts_code": "000001.SZ", "up_limit": 11},),
directory=directory,
prior_snapshot={
"limits": prior_limits,
"broken": [],
"sectors": [{"name": "机器人", "count": 35}],
},
ths_hot=(),
dc_hot=(),
history=[
{"trade_date": "2026-07-29", "amount_billion": 1.2, "stock_count": 35}
],
dynamic=False,
)
assert result["summary"]["one_price_count"] == 1
assert result["one_price_rows"][0]["code"] == "000001"
assert result["one_price_rows"][0]["is_market_core"] is True
assert len(result["focus_rows"]) == 34
assert result["summary"]["amount_billion"] == result["amount_history"][-1]["amount_billion"]
def test_watchlist_rows_are_built_only_from_current_account_entries() -> None:
market = [
{
"identifier": "000001.SZ",
"code": "000001",
"name": "平安银行",
"sector": "银行",
"change": 1.5,
"amount_million": 10,
"volume_ratio": 1.2,
}
]
rows = build_watchlist_rows(
market,
[],
[],
({"identifier": "000001.SZ", "name": "平安银行", "sector": "银行"},),
)
assert [row["identifier"] for row in rows] == ["000001.SZ"]
assert rows[0]["is_watchlist"] is True
assert rows[0]["available"] is True
def test_watchlist_repository_isolates_accounts(tmp_path) -> None:
database = Database(tmp_path / "watchlists.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = MarketRepository()
with database.transaction() as connection:
for user_id, username in ((1, "account-a"), (2, "account-b")):
connection.execute(
"""
INSERT INTO users (
id, username, username_key, password_hash, is_admin,
status, created_at, updated_at
) VALUES (?, ?, ?, 'hash', 0, 'active', '2026-07-30', '2026-07-30')
""",
(user_id, username, username),
)
connection.executemany(
"""
INSERT INTO watchlist_entries (
user_id, identifier, name, sector, created_at
) VALUES (?, ?, ?, ?, '2026-07-30')
""",
(
(1, "000001.SZ", "平安银行", "银行"),
(2, "000002.SZ", "万科A", "房地产"),
),
)
with database.read() as connection:
first = repository.watchlist(connection, 1)
second = repository.watchlist(connection, 2)
assert [row["identifier"] for row in first] == ["000001.SZ"]
assert [row["identifier"] for row in second] == ["000002.SZ"]
def test_popularity_preserves_single_source_without_false_consensus() -> None:
ths = (
{
"data_type": "热股",
"rank": 1,
"ts_code": "000001.SZ",
"ts_name": "平安银行",
"pct_change": 2,
"concept": '["银行"]',
},
)
result = build_popularity("2026-07-30", ths, (), (), ())
assert result["summary"] == {"ths_count": 1, "dc_count": 0, "dual_count": 0}
assert result["combined"][0]["dual_source"] is False
assert result["combined"][0]["concepts"] == ["银行"]
def test_theme_library_and_detail_keep_quote_and_member_empty_states_separate() -> None:
library = build_theme_library(
"2026-07-30",
(
{
"ts_code": "885001.TI",
"name": "机器人",
"count": 2,
"exchange": "A",
"type": "N",
},
),
(),
(),
)
assert library["summary"]["theme_count"] == 1
assert library["summary"]["quoted_count"] == 0
detail = build_theme_detail(
"2026-07-30",
library["items"][0],
({"con_code": "000001.SZ", "con_name": "平安银行"},),
(),
)
assert detail["summary"]["member_count"] == 1
assert detail["summary"]["quoted_count"] == 0
assert detail["members"][0]["quoted"] is False
def test_dragon_list_distinguishes_missing_and_unclassified_seats() -> None:
stocks = (
{
"ts_code": "000001.SZ",
"name": "平安银行",
"pct_change": 3.2,
"reason": "日涨幅偏离值达7%",
},
)
missing = build_dragon_list(
trade_date="2026-07-30",
official_rows=(),
profile_rows=(),
stock_rows=stocks,
seat_rows=(),
aliases={},
)
assert missing["status"] == "detail_missing"
assert "1 只股票上榜" in missing["message"]
seats = (
{
"ts_code": "000001.SZ",
"exalter": "测试营业部",
"buy": 20_000_000,
"sell": 5_000_000,
"net_buy": 15_000_000,
},
)
pending = build_dragon_list(
trade_date="2026-07-30",
official_rows=(),
profile_rows=(),
stock_rows=stocks,
seat_rows=seats,
aliases={},
)
assert pending["status"] == "unclassified"
assert pending["summary"]["unclassified_count"] == 1
classified = build_dragon_list(
trade_date="2026-07-30",
official_rows=(),
profile_rows=(),
stock_rows=stocks,
seat_rows=seats,
aliases={"测试营业部": "测试游资"},
)
assert classified["status"] == "success"
assert classified["traders"][0]["name"] == "测试游资"
class AuctionGateway:
def __init__(self, dynamic_available: bool = True) -> None:
self.dynamic_available = dynamic_available
def trading_dates(self, through: str, limit: int = 2) -> tuple[str, ...]:
dates = ("2026-07-30", "2026-07-29", "2026-07-28")
return tuple(value for value in dates if value <= through)[:limit]
def insight_inputs(
self, kind: str, trade_date: str, previous: str = "", identifier: str = ""
) -> dict:
assert kind == "auction"
return {
"auction": result(()),
"price_limits": result(({"ts_code": "000001.SZ", "up_limit": 11},)),
"ths_hot": result(()),
"dc_hot": result(()),
}
def stock_directory(self) -> dict[str, dict]:
return {
"000001.SZ": {
"identifier": "000001.SZ",
"code": "000001",
"name": "平安银行",
"sector": "银行",
}
}
def dynamic_auction(
self, identifiers: tuple[str, ...], start_time: str, end_time: str
) -> ProviderResult:
if not self.dynamic_available:
raise MarketDataUnavailable("动态竞价暂不可用")
assert identifiers == ("000001.SZ",)
assert start_time.endswith("09:15:00")
return result(
(
{
"thscode": "000001.SZ",
"time": end_time,
"latest": 10.5,
"preClose": 10,
"volume": 1_000_000,
"amount": 10_500_000,
"turnoverRatio": 0.2,
"volumeRatio": 1.5,
},
),
source=DataSource.IFIND,
state=SnapshotState.REALTIME,
)
def result(
rows: tuple[dict, ...],
*,
source: DataSource = DataSource.TUSHARE,
state: SnapshotState = SnapshotState.ARCHIVE,
) -> ProviderResult:
return ProviderResult(
rows,
ObservationMetadata(
source=source,
observed_at=datetime(2026, 7, 30, 9, 20, tzinfo=SHANGHAI),
unit="mixed",
adjustment="not_applicable",
freshness_seconds=0,
coverage=1,
state=state,
usage=DataUsage.CALCULATION,
),
)
def insight_service(tmp_path, dynamic_available: bool = True) -> MarketInsightService:
database = Database(tmp_path / "insights.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = MarketRepository()
prior = {
"trade_date": "2026-07-29",
"limits": [
{
"identifier": "000001.SZ",
"code": "000001",
"name": "平安银行",
"sector": "银行",
"streak": 1,
"amount": 200_000_000,
}
],
"broken": [],
"sectors": [{"name": "银行", "count": 1}],
}
with database.transaction() as connection:
repository.save_summary(
connection,
trade_date="2026-07-29",
observed_at="2026-07-29T15:00:00+08:00",
state="final",
source="tushare",
coverage=1,
payload=prior,
)
return MarketInsightService(
database, repository, AuctionGateway(dynamic_available) # type: ignore[arg-type]
)
def test_auction_lifecycle_uses_live_snapshot_then_archives_925_result(tmp_path) -> None:
service = insight_service(tmp_path)
observing = service.auction(
"2026-07-30", user_id=1, now=datetime(2026, 7, 30, 9, 20, tzinfo=SHANGHAI)
)
assert observing["phase"] == "observing"
assert observing["trade_date"] == "2026-07-30"
assert observing["dynamic"] is True
assert observing["current_available"] is True
selection = service.auction(
"2026-07-30", user_id=1, now=datetime(2026, 7, 30, 9, 26, tzinfo=SHANGHAI)
)
assert selection["phase"] == "selection"
assert selection["state"] == "final"
with service._database.read() as connection:
stored = service._repository.insight_snapshot(
connection, "auction", "2026-07-30"
)
assert stored is not None
def test_observing_without_dynamic_data_never_disguises_previous_archive(tmp_path) -> None:
service = insight_service(tmp_path, dynamic_available=False)
previous = {
"trade_date": "2026-07-29",
"observed_at": "2026-07-29T09:25:00+08:00",
"state": "archive",
"summary": {"stock_count": 1, "amount_billion": 0.1},
"focus_rows": [],
"one_price_rows": [],
"rows": [],
"amount_history": [],
}
with service._database.transaction() as connection:
service._repository.save_insight_snapshot(
connection,
kind="auction",
trade_date="2026-07-29",
entity_key="",
observed_at=previous["observed_at"],
state="archive",
source="tushare",
coverage=1,
payload=previous,
)
current = service.auction(
"2026-07-30", user_id=1, now=datetime(2026, 7, 30, 9, 20, tzinfo=SHANGHAI)
)
assert current["trade_date"] == "2026-07-29"
assert current["carried_forward"] is True
assert current["current_available"] is False
assert "动态竞价暂不可用" in current["message"]
+5 -2
View File
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4)
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6)
assert {
"users",
"memberships",
@@ -125,8 +125,11 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
"market_summaries",
"chart_series",
"sector_member_snapshots",
"market_insight_snapshots",
"seat_aliases",
"watchlist_entries",
} <= table_names(database)
assert runner.downgrade(MIGRATIONS, target_version=0) == (4, 3, 2, 1)
assert runner.downgrade(MIGRATIONS, target_version=0) == (6, 5, 4, 3, 2, 1)
assert "users" not in table_names(database)
assert "llm_models" not in table_names(database)