rebuild(stage-8): deliver market insight workspaces
This commit is contained in:
@@ -17,6 +17,7 @@ from backend.features.accounts.service import (
|
|||||||
MembershipService,
|
MembershipService,
|
||||||
)
|
)
|
||||||
from backend.features.market import MarketService
|
from backend.features.market import MarketService
|
||||||
|
from backend.features.market.insights import MarketInsightService
|
||||||
from backend.features.market.sync import MarketSnapshotService
|
from backend.features.market.sync import MarketSnapshotService
|
||||||
from backend.security import PasswordHasher, load_or_create_cipher
|
from backend.security import PasswordHasher, load_or_create_cipher
|
||||||
|
|
||||||
@@ -64,6 +65,8 @@ def build_container(settings: Settings) -> ApplicationContainer:
|
|||||||
system_credentials=credentials,
|
system_credentials=credentials,
|
||||||
model_pool=ModelPoolService(database, model_pool_repository, cipher),
|
model_pool=ModelPoolService(database, model_pool_repository, cipher),
|
||||||
market=MarketService(
|
market=MarketService(
|
||||||
gateway, MarketSnapshotService(database, market_repository, gateway)
|
gateway,
|
||||||
|
MarketSnapshotService(database, market_repository, gateway),
|
||||||
|
MarketInsightService(database, market_repository, gateway),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from backend.data.contracts import (
|
|||||||
DataUsage,
|
DataUsage,
|
||||||
MarketEntity,
|
MarketEntity,
|
||||||
ObservationMetadata,
|
ObservationMetadata,
|
||||||
|
ProviderResult,
|
||||||
SnapshotState,
|
SnapshotState,
|
||||||
TradeContext,
|
TradeContext,
|
||||||
)
|
)
|
||||||
@@ -114,6 +115,39 @@ class DataGateway:
|
|||||||
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
self._policy.assert_allowed(provider.source, DataUsage.CALCULATION)
|
||||||
return provider.snapshot_inputs(trade_date, previous_trade_date)
|
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(
|
def sector_members(
|
||||||
self, trade_date: str, sector_name: str, representative: str
|
self, trade_date: str, sector_name: str, representative: str
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -30,5 +30,7 @@ class DataSourcePolicy:
|
|||||||
("daily_chart", DataUsage.DISPLAY): (DataSource.IFIND, DataSource.TUSHARE),
|
("daily_chart", DataUsage.DISPLAY): (DataSource.IFIND, DataSource.TUSHARE),
|
||||||
("minute_chart", DataUsage.DISPLAY): (DataSource.IFIND, DataSource.EASTMONEY),
|
("minute_chart", DataUsage.DISPLAY): (DataSource.IFIND, DataSource.EASTMONEY),
|
||||||
("realtime_quote", DataUsage.CALCULATION): (DataSource.IFIND, DataSource.TUSHARE),
|
("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), ())
|
return routes.get((dataset, usage), ())
|
||||||
|
|||||||
@@ -28,3 +28,15 @@ class MarketDataProvider(Protocol):
|
|||||||
) -> dict[str, ProviderResult | dict[str, Any]]: ...
|
) -> dict[str, ProviderResult | dict[str, Any]]: ...
|
||||||
|
|
||||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult: ...
|
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: ...
|
||||||
|
|||||||
@@ -102,6 +102,20 @@ class EastmoneyProvider:
|
|||||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||||
raise ProviderError("The display provider is not the constituent authority")
|
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
|
@staticmethod
|
||||||
def _secid(entity_type: str, identifier: str) -> str:
|
def _secid(entity_type: str, identifier: str) -> str:
|
||||||
if entity_type == "index" and identifier in INDEX_CODES:
|
if entity_type == "index" and identifier in INDEX_CODES:
|
||||||
|
|||||||
@@ -92,6 +92,51 @@ class IfindProvider:
|
|||||||
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
def sector_members(self, representative: str, trade_date: str) -> ProviderResult:
|
||||||
raise ProviderError("iFinD is not the Shenwan constituent authority")
|
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]:
|
def _request(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
if not self.configured:
|
if not self.configured:
|
||||||
raise ProviderError("实时行情服务尚未配置")
|
raise ProviderError("实时行情服务尚未配置")
|
||||||
|
|||||||
@@ -189,6 +189,109 @@ class TushareProvider:
|
|||||||
coverage = sum(bool(row["quoted"]) for row in rows) / len(rows)
|
coverage = sum(bool(row["quoted"]) for row in rows) / len(rows)
|
||||||
return ProviderResult(tuple(rows), _metadata(self.source, "mixed", coverage))
|
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]]:
|
def _membership_rows(self, params: dict[str, str]) -> list[dict[str, Any]]:
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
fields = (
|
fields = (
|
||||||
|
|||||||
@@ -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(
|
def search(
|
||||||
self, connection: sqlite3.Connection, query: str, limit: int = 32
|
self, connection: sqlite3.Connection, query: str, limit: int = 32
|
||||||
) -> tuple[MarketEntity, ...]:
|
) -> tuple[MarketEntity, ...]:
|
||||||
@@ -135,6 +172,16 @@ class MarketRepository:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
return int(row["count"] if row else 0)
|
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(
|
def save_summary(
|
||||||
self,
|
self,
|
||||||
connection: sqlite3.Connection,
|
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(
|
def save_chart(
|
||||||
self,
|
self,
|
||||||
connection: sqlite3.Connection,
|
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,
|
||||||
|
)
|
||||||
@@ -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.m0002_model_pool import MIGRATION as MODEL_POOL
|
||||||
from backend.database.migrations.m0003_market_foundation import MIGRATION as MARKET_FOUNDATION
|
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.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
|
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
|
||||||
@@ -7,12 +7,16 @@ from fastapi import APIRouter, Path, Query, Request
|
|||||||
from backend.features.accounts.auth import AdminWritePrincipal, AuthenticatedPrincipal
|
from backend.features.accounts.auth import AdminWritePrincipal, AuthenticatedPrincipal
|
||||||
from backend.features.market.schemas import (
|
from backend.features.market.schemas import (
|
||||||
ChartResponse,
|
ChartResponse,
|
||||||
|
MarketInsightResponse,
|
||||||
MarketSummaryResponse,
|
MarketSummaryResponse,
|
||||||
MarketWorkspaceResponse,
|
MarketWorkspaceResponse,
|
||||||
ReferenceSyncResponse,
|
ReferenceSyncResponse,
|
||||||
RotationMembersResponse,
|
RotationMembersResponse,
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
|
SeatAliasRequest,
|
||||||
|
SeatAliasResponse,
|
||||||
SnapshotSyncResponse,
|
SnapshotSyncResponse,
|
||||||
|
ThemeDetailResponse,
|
||||||
TradeContextResponse,
|
TradeContextResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,3 +99,52 @@ def rotation_members(
|
|||||||
requested_date: Annotated[str | None, Query(alias="date")] = None,
|
requested_date: Annotated[str | None, Query(alias="date")] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return request.app.state.container.market.rotation_members(sector_name, requested_date)
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class TradeContextResponse(BaseModel):
|
class TradeContextResponse(BaseModel):
|
||||||
@@ -101,3 +101,37 @@ class RotationMembersResponse(BaseModel):
|
|||||||
quoted_count: int = Field(ge=0)
|
quoted_count: int = Field(ge=0)
|
||||||
coverage: float = Field(ge=0, le=1)
|
coverage: float = Field(ge=0, le=1)
|
||||||
items: list[dict[str, Any]] = Field(default_factory=list)
|
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
|
||||||
|
|||||||
@@ -5,14 +5,22 @@ from typing import Any
|
|||||||
from backend.data.gateway import DataGateway, MarketDataUnavailable
|
from backend.data.gateway import DataGateway, MarketDataUnavailable
|
||||||
from backend.data.providers.base import ProviderError
|
from backend.data.providers.base import ProviderError
|
||||||
from backend.data.quality import DataQualityError
|
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.features.market.sync import MarketSnapshotService, SnapshotSyncError
|
||||||
from backend.http.errors import AppError
|
from backend.http.errors import AppError
|
||||||
|
|
||||||
|
|
||||||
class MarketService:
|
class MarketService:
|
||||||
def __init__(self, gateway: DataGateway, snapshots: MarketSnapshotService) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
gateway: DataGateway,
|
||||||
|
snapshots: MarketSnapshotService,
|
||||||
|
insights: MarketInsightService,
|
||||||
|
) -> None:
|
||||||
self._gateway = gateway
|
self._gateway = gateway
|
||||||
self._snapshots = snapshots
|
self._snapshots = snapshots
|
||||||
|
self._insights = insights
|
||||||
|
|
||||||
def context(self, requested_date: str | None = None) -> dict[str, Any]:
|
def context(self, requested_date: str | None = None) -> dict[str, Any]:
|
||||||
context = self._call(self._gateway.trade_context, requested_date)
|
context = self._call(self._gateway.trade_context, requested_date)
|
||||||
@@ -94,11 +102,37 @@ class MarketService:
|
|||||||
self._gateway.sector_members, trade_date, sector_name, representative
|
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
|
@staticmethod
|
||||||
def _call(function, *args):
|
def _call(function, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
return function(*args)
|
return function(*args, **kwargs)
|
||||||
except (MarketDataUnavailable, ProviderError, DataQualityError, SnapshotSyncError) as exc:
|
except (
|
||||||
|
MarketDataUnavailable,
|
||||||
|
ProviderError,
|
||||||
|
DataQualityError,
|
||||||
|
SnapshotSyncError,
|
||||||
|
MarketInsightError,
|
||||||
|
) as exc:
|
||||||
raise AppError("market_data_unavailable", str(exc), 503) from exc
|
raise AppError("market_data_unavailable", str(exc), 503) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 生产构建:通过。
|
||||||
|
- Playwright:6 项通过,覆盖阶段 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,
|
() => ["screener", "mentor", "heaven"].includes(workspace.value.key) && !session.account?.smart_access,
|
||||||
);
|
);
|
||||||
const implementedMarket = computed(() =>
|
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,
|
workspace.value.key,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import "./shared/styles/auth.css";
|
|||||||
import "./shared/styles/account.css";
|
import "./shared/styles/account.css";
|
||||||
import "./shared/styles/market.css";
|
import "./shared/styles/market.css";
|
||||||
import "./shared/styles/market-workspace.css";
|
import "./shared/styles/market-workspace.css";
|
||||||
|
import "./shared/styles/market-insights.css";
|
||||||
import "./shared/styles/system.css";
|
import "./shared/styles/system.css";
|
||||||
import "./shared/styles/mobile.css";
|
import "./shared/styles/mobile.css";
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<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 EmptyState from "../../shared/components/EmptyState.vue";
|
||||||
import { useMarketStore } from "../../shared/stores/market";
|
import { useMarketStore } from "../../shared/stores/market";
|
||||||
import EmotionPage from "./emotion/EmotionPage.vue";
|
import EmotionPage from "./emotion/EmotionPage.vue";
|
||||||
import LadderPage from "./structure/LadderPage.vue";
|
import LadderPage from "./structure/LadderPage.vue";
|
||||||
import RotationPage from "./structure/RotationPage.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 PerformancePage from "./pools/PerformancePage.vue";
|
||||||
import PoolPage from "./pools/PoolPage.vue";
|
import PoolPage from "./pools/PoolPage.vue";
|
||||||
|
|
||||||
const props = defineProps<{ workspaceKey: string }>();
|
const props = defineProps<{ workspaceKey: string }>();
|
||||||
const market = useMarketStore();
|
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 loading = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
let sequence = 0;
|
let sequence = 0;
|
||||||
@@ -22,7 +27,9 @@ async function load(): Promise<void> {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
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;
|
if (current === sequence) data.value = result;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
if (current === sequence) error.value = reason instanceof Error ? reason.message : "页面数据读取失败";
|
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" />
|
<EmotionPage v-else-if="workspaceKey === 'emotion'" :data="data" />
|
||||||
<LadderPage v-else-if="workspaceKey === 'ladder'" :data="data" />
|
<LadderPage v-else-if="workspaceKey === 'ladder'" :data="data" />
|
||||||
<RotationPage v-else-if="workspaceKey === 'rotation'" :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" />
|
<PerformancePage v-else-if="workspaceKey === 'performance'" :data="data" />
|
||||||
<PoolPage v-else :kind="workspaceKey" :data="data" />
|
<PoolPage v-else :kind="workspaceKey" :data="data" />
|
||||||
</main>
|
</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>
|
||||||
@@ -77,6 +77,41 @@ export type RotationMembersData = {
|
|||||||
items: Record<string, unknown>[];
|
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 = {
|
export const marketApi = {
|
||||||
summary(date?: string): Promise<MarketSummary> {
|
summary(date?: string): Promise<MarketSummary> {
|
||||||
const query = date ? `?date=${encodeURIComponent(date)}` : "";
|
const query = date ? `?date=${encodeURIComponent(date)}` : "";
|
||||||
@@ -103,4 +138,22 @@ export const marketApi = {
|
|||||||
`/market/rotation-members?sector=${encodeURIComponent(sector)}&date=${encodeURIComponent(date)}`,
|
`/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 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
@@ -110,7 +110,7 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
|||||||
database = Database(tmp_path / "app.db")
|
database = Database(tmp_path / "app.db")
|
||||||
runner = MigrationRunner(database)
|
runner = MigrationRunner(database)
|
||||||
|
|
||||||
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4)
|
assert runner.upgrade(MIGRATIONS) == (1, 2, 3, 4, 5, 6)
|
||||||
assert {
|
assert {
|
||||||
"users",
|
"users",
|
||||||
"memberships",
|
"memberships",
|
||||||
@@ -125,8 +125,11 @@ def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
|
|||||||
"market_summaries",
|
"market_summaries",
|
||||||
"chart_series",
|
"chart_series",
|
||||||
"sector_member_snapshots",
|
"sector_member_snapshots",
|
||||||
|
"market_insight_snapshots",
|
||||||
|
"seat_aliases",
|
||||||
|
"watchlist_entries",
|
||||||
} <= table_names(database)
|
} <= 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 "users" not in table_names(database)
|
||||||
assert "llm_models" not in table_names(database)
|
assert "llm_models" not in table_names(database)
|
||||||
|
|||||||
Reference in New Issue
Block a user