rebuild(stage-6): deliver emotion and market pools

This commit is contained in:
leefer
2026-07-30 03:03:53 +08:00
parent 889963862a
commit 59f6011ae8
30 changed files with 1751 additions and 9 deletions
+8 -3
View File
@@ -17,6 +17,7 @@ from backend.features.accounts.service import (
MembershipService,
)
from backend.features.market import MarketService
from backend.features.market.sync import MarketSnapshotService
from backend.security import PasswordHasher, load_or_create_cipher
@@ -39,11 +40,13 @@ def build_container(settings: Settings) -> ApplicationContainer:
model_pool_repository = ModelPoolRepository()
cipher = load_or_create_cipher(settings)
credentials = SystemCredentialService(database, credential_repository, cipher)
tushare = TushareProvider(lambda: credentials.get("tushare_token"))
market_repository = MarketRepository()
gateway = DataGateway(
database,
MarketRepository(),
market_repository,
(
TushareProvider(lambda: credentials.get("tushare_token")),
tushare,
IfindProvider(
lambda: credentials.get("ifind_refresh_token"),
lambda: credentials.get("ifind_access_token"),
@@ -60,5 +63,7 @@ def build_container(settings: Settings) -> ApplicationContainer:
memberships=MembershipService(database, account_repository),
system_credentials=credentials,
model_pool=ModelPoolService(database, model_pool_repository, cipher),
market=MarketService(gateway),
market=MarketService(
gateway, MarketSnapshotService(database, market_repository, tushare)
),
)
+5 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Protocol
from typing import Any, Protocol
from backend.data.contracts import DataSource, ProviderResult
@@ -22,3 +22,7 @@ class MarketDataProvider(Protocol):
def daily(self, entity_type: str, identifier: str, end_date: str) -> ProviderResult: ...
def minute(self, entity_type: str, identifier: str, trade_date: str) -> ProviderResult: ...
def snapshot_inputs(
self, trade_date: str, previous_trade_date: str
) -> dict[str, ProviderResult | dict[str, Any]]: ...
+5
View File
@@ -94,6 +94,11 @@ class EastmoneyProvider:
)
return ProviderResult(tuple(rows), metadata)
def snapshot_inputs(
self, trade_date: str, previous_trade_date: str
) -> dict[str, ProviderResult | dict[str, object]]:
raise ProviderError("The display provider cannot build market snapshots")
@staticmethod
def _secid(entity_type: str, identifier: str) -> str:
if entity_type == "index" and identifier in INDEX_CODES:
+5
View File
@@ -84,6 +84,11 @@ class IfindProvider:
)
return _result(payload, "yuan/share", "forward", SnapshotState.REALTIME)
def snapshot_inputs(
self, trade_date: str, previous_trade_date: str
) -> dict[str, ProviderResult | dict[str, Any]]:
raise ProviderError("iFinD is not the post-close snapshot authority")
def _request(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]:
if not self.configured:
raise ProviderError("实时行情服务尚未配置")
+41 -1
View File
@@ -92,6 +92,44 @@ class TushareProvider:
unit="yuan/share",
)
def snapshot_inputs(
self, trade_date: str, previous_trade_date: str
) -> dict[str, ProviderResult | dict[str, Any]]:
current = _compact(trade_date)
previous = _compact(previous_trade_date)
daily = self._query(
"daily",
{"trade_date": current},
"ts_code,trade_date,open,high,low,close,pre_close,pct_chg,vol,amount",
unit="mixed",
)
event_fields = (
"trade_date,ts_code,industry,name,close,pct_chg,amount,limit_amount,"
"float_mv,total_mv,turnover_ratio,fd_amount,first_time,last_time,"
"open_times,up_stat,limit_times"
)
datasets: dict[str, ProviderResult | dict[str, Any]] = {"daily": daily}
datasets["price_limits"] = self._query(
"stk_limit",
{"trade_date": current},
"ts_code,trade_date,up_limit,down_limit",
unit="yuan/share",
)
for key, limit_type, date in (
("limit_up", "U", current),
("limit_down", "D", current),
("broken", "Z", current),
("previous_limit_up", "U", previous),
):
datasets[key] = self._query(
"limit_list_d",
{"trade_date": date, "limit_type": limit_type},
event_fields,
unit="mixed",
empty_is_complete=True,
)
return datasets
def _query(
self,
api_name: str,
@@ -100,6 +138,7 @@ class TushareProvider:
*,
unit: str,
adjustment: str = "not_applicable",
empty_is_complete: bool = False,
) -> ProviderResult:
if not self.configured:
raise ProviderError("行情服务尚未配置")
@@ -126,7 +165,8 @@ class TushareProvider:
data = payload.get("data") or {}
columns = data.get("fields") or []
rows = tuple(dict(zip(columns, item, strict=False)) for item in data.get("items") or [])
return ProviderResult(rows, _metadata(self.source, unit, 1 if rows else 0, adjustment))
coverage = 1 if rows or empty_is_complete else 0
return ProviderResult(rows, _metadata(self.source, unit, coverage, adjustment))
def _token(self) -> str:
return str(self._token_provider() or "").strip()
+56
View File
@@ -126,6 +126,62 @@ class MarketRepository:
)
)
def active_stock_count(self, connection: sqlite3.Connection) -> int:
row = connection.execute(
"""
SELECT COUNT(*) AS count FROM market_entities
WHERE entity_type = 'stock' AND active = 1
"""
).fetchone()
return int(row["count"] if row else 0)
def save_summary(
self,
connection: sqlite3.Connection,
*,
trade_date: str,
observed_at: str,
state: str,
source: str,
coverage: float,
payload: dict[str, Any],
) -> None:
connection.execute(
"""
INSERT INTO market_summaries
(trade_date, observed_at, state, source, coverage, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(trade_date) DO UPDATE SET
observed_at = excluded.observed_at,
state = excluded.state,
source = excluded.source,
coverage = excluded.coverage,
payload_json = excluded.payload_json,
created_at = excluded.created_at
""",
(
trade_date,
observed_at,
state,
source,
coverage,
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
datetime.now().astimezone().isoformat(timespec="seconds"),
),
)
def summaries(
self, connection: sqlite3.Connection, through: str, limit: int = 260
) -> tuple[sqlite3.Row, ...]:
rows = connection.execute(
"""
SELECT * FROM market_summaries WHERE trade_date <= ?
ORDER BY trade_date DESC LIMIT ?
""",
(through, limit),
).fetchall()
return tuple(reversed(rows))
def latest_summary(self, connection: sqlite3.Connection, through: str) -> sqlite3.Row | None:
return connection.execute(
"SELECT * FROM market_summaries WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT 1",
+24
View File
@@ -8,8 +8,10 @@ from backend.features.accounts.auth import AdminWritePrincipal, AuthenticatedPri
from backend.features.market.schemas import (
ChartResponse,
MarketSummaryResponse,
MarketWorkspaceResponse,
ReferenceSyncResponse,
SearchResponse,
SnapshotSyncResponse,
TradeContextResponse,
)
@@ -57,3 +59,25 @@ def chart(
@router.post("/reference-sync", response_model=ReferenceSyncResponse)
def refresh_reference(request: Request, _principal: AdminWritePrincipal) -> dict[str, int | str]:
return request.app.state.container.market.refresh_reference()
@router.post("/snapshot-sync", response_model=SnapshotSyncResponse)
def sync_snapshot(
request: Request,
_principal: AdminWritePrincipal,
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.sync_snapshot(requested_date)
@router.get("/workspaces/{key}", response_model=MarketWorkspaceResponse)
def workspace(
request: Request,
_principal: AuthenticatedPrincipal,
key: Annotated[
Literal["emotion", "pool", "broken", "limit-down", "yesterday", "performance"],
Path(),
],
requested_date: Annotated[str | None, Query(alias="date")] = None,
) -> dict:
return request.app.state.container.market.workspace(key, requested_date)
+22
View File
@@ -69,3 +69,25 @@ class ReferenceSyncResponse(BaseModel):
calendar_days: int = Field(ge=1)
entities: int = Field(ge=1)
observed_at: datetime
class SnapshotSyncResponse(BaseModel):
trade_date: str
observed_at: datetime
coverage: float
stocks: int
limit_up: int
limit_down: int
broken: int
temperature: int
class MarketWorkspaceResponse(BaseModel):
trade_date: str | None
observed_at: datetime | None = None
carried_forward: bool = False
message: str = ""
overview: dict[str, Any] = Field(default_factory=dict)
sentiment: dict[str, Any] | None = None
history: list[dict[str, Any]] | None = None
items: list[dict[str, Any]] | None = None
+270
View File
@@ -0,0 +1,270 @@
from __future__ import annotations
from statistics import mean, median
from typing import Any
WEIGHTS = {
"breadth": 20,
"limit_ecology": 25,
"profit_effect": 30,
"ladder_structure": 15,
"liquidity": 10,
}
def calculate_sentiment(snapshot: dict[str, Any], history: list[dict[str, Any]]) -> dict[str, Any]:
stats = _stats(snapshot)
historical = [_stats(item) for item in history[-250:]]
breadth = _clamp(stats["breadth_ratio"])
limit_strength = _adaptive(
stats["limit_up"], _linear(stats["limit_up"], 10, 100), _series(historical, "limit_up")
)
down_pressure = _adaptive(
stats["limit_down"], _linear(stats["limit_down"], 0, 50), _series(historical, "limit_down")
)
down_relief = 100 - down_pressure
seal_quality = _linear(stats["seal_rate"], 35, 90)
ecology = limit_strength * 0.35 + seal_quality * 0.35 + down_relief * 0.30
systemic_health = breadth * 0.60 + down_relief * 0.40
gate = 1 if systemic_health >= 35 else 0.35 + systemic_health / 35 * 0.65
profit = _profit(stats)
max_height = _adaptive(
stats["max_height"],
_linear(stats["max_height"], 1, 7),
_series(historical, "max_height"),
)
continuation = (stats["second_board"] + stats["three_plus"]) / max(stats["limit_up"], 1) * 100
three_density = stats["three_plus"] / max(stats["limit_up"], 1) * 100
three_score = _adaptive(
stats["three_plus"], _clamp(three_density * 5), _series(historical, "three_plus")
)
ladder = (
max_height * 0.30
+ _clamp(continuation * 3) * 0.25
+ three_score * 0.25
+ stats["ladder_completeness"] * 0.20
)
prior_amounts = [item["amount"] for item in historical[-20:] if item["amount"] > 0]
baseline = mean(prior_amounts) if prior_amounts else stats["amount"] or 1
amount_ratio = stats["amount"] / max(baseline, 1)
amount_score = _clamp(50 + (amount_ratio - 1) * 100)
limit_share = stats["limit_amount"] / max(stats["amount"], 1) * 100
liquidity = amount_score * 0.70 + _clamp(limit_share * 20) * 0.30
components = {
"breadth": breadth,
"limit_ecology": ecology,
"profit_effect": profit,
"ladder_structure": ladder,
"liquidity": liquidity,
}
score = round(sum(components[key] * weight / 100 for key, weight in WEIGHTS.items()) * gate)
extreme = stats["breadth_ratio"] <= 15 and stats["limit_down"] >= 100
if extreme:
score = min(score, 15)
elif stats["breadth_ratio"] <= 25 and stats["limit_down"] >= 50:
score = min(score, 24)
prior_sentiments = [item.get("sentiment") or {} for item in history[-3:]]
prior_scores = [
float(item["score"]) for item in prior_sentiments if item.get("score") is not None
]
momentum = score - mean(prior_scores) if prior_scores else 0
direction = "升温" if momentum > 3 else "降温" if momentum < -3 else "持平"
previous = prior_sentiments[-1] if prior_sentiments else None
day_change = (
score - float(previous["score"]) if previous and previous.get("score") is not None else 0
)
signal = _phase_signal(score, momentum, profit)
phase, reason = _phase(
previous, score, day_change, systemic_health, profit, ecology, signal, extreme
)
fermentation_ready = signal == "发酵" and score >= 45 and profit >= 45 and systemic_health >= 35
previous_count = int(previous.get("fermentation_signal_count") or 0) if previous else 0
fermentation_count = previous_count + 1 if fermentation_ready else 0
history_days = len(history)
confidence = min(95, round(55 + min(history_days, 20) * 1.25 + (15 if phase == signal else 7)))
labels = {
"breadth": "市场宽度",
"limit_ecology": "涨停生态",
"profit_effect": "赚钱效应",
"ladder_structure": "连板结构",
"liquidity": "成交活跃度",
}
return {
"score": score,
"label": _label(score),
"direction": direction,
"momentum": round(momentum, 1),
"day_change": round(day_change, 1),
"phase": phase,
"phase_signal": signal,
"transition_reason": reason,
"fermentation_signal_count": fermentation_count,
"confidence": confidence,
"history_days": history_days,
"systemic_health": round(systemic_health, 1),
"components": [
{
"key": key,
"label": labels[key],
"score": round(value, 1),
"weight": WEIGHTS[key],
}
for key, value in components.items()
],
"stats": stats,
}
def _stats(snapshot: dict[str, Any]) -> dict[str, float]:
overview = snapshot.get("overview") or {}
limits = snapshot.get("limits") or []
yesterday = snapshot.get("yesterday_limits") or []
streaks = [max(1, int(_number(row.get("streak"), 1))) for row in limits]
levels = set(streaks)
max_height = max(streaks, default=0)
active = _number(overview.get("up_count")) + _number(overview.get("down_count"))
changes = [_number(row.get("current_change")) for row in yesterday]
previous_count = len(yesterday)
return {
"breadth_ratio": _number(overview.get("up_count")) / max(active, 1) * 100,
"limit_up": _number(overview.get("limit_up")),
"limit_down": _number(overview.get("limit_down")),
"broken": _number(overview.get("broken")),
"seal_rate": _number(overview.get("seal_rate")),
"amount": _number(overview.get("amount")),
"limit_amount": sum(_number(row.get("amount")) for row in limits),
"second_board": sum(streak == 2 for streak in streaks),
"three_plus": sum(streak >= 3 for streak in streaks),
"max_height": max_height,
"ladder_completeness": (
sum(level in levels for level in range(1, max_height + 1)) / max_height * 100
if max_height
else 0
),
"previous_count": previous_count,
"positive_rate": sum(change > 0 for change in changes) / max(previous_count, 1) * 100,
"advance_rate": sum(row.get("outcome") == "晋级" for row in yesterday)
/ max(previous_count, 1)
* 100,
"average_change": mean(changes) if changes else 0,
"median_change": median(changes) if changes else 0,
"severe_loss_rate": sum(change <= -5 for change in changes) / max(previous_count, 1) * 100,
"previous_down_rate": sum(row.get("outcome") == "跌停" for row in yesterday)
/ max(previous_count, 1)
* 100,
}
def _profit(stats: dict[str, float]) -> float:
if not stats["previous_count"]:
return 50
median_score = _clamp(50 + stats["median_change"] * 7)
average_score = _clamp(50 + stats["average_change"] * 6)
advance_score = _clamp(stats["advance_rate"] * 2.5)
loss_safety = _clamp(100 - stats["severe_loss_rate"] * 3)
down_safety = _clamp(100 - stats["previous_down_rate"] * 7)
tail = loss_safety * 0.70 + down_safety * 0.30
return (
stats["positive_rate"] * 0.30
+ median_score * 0.25
+ average_score * 0.10
+ advance_score * 0.20
+ tail * 0.15
)
def _phase_signal(score: float, momentum: float, profit: float) -> str:
if score < 25:
return "修复" if momentum > 3 else "冰点"
if score < 45:
return "修复" if momentum > 3 else "退潮"
if score >= 80:
return "高潮" if momentum >= -2 and profit >= 60 else "分化"
if score >= 65:
return "分化" if momentum < -3 or profit < 50 else "发酵"
if momentum < -5:
return "退潮"
return "发酵" if momentum >= 0 and profit >= 45 else "分化"
def _phase(previous, score, change, health, profit, ecology, signal, extreme):
if not previous:
return signal, "首个连续交易日,采用原始阶段信号"
prior = str(previous.get("phase") or signal)
if extreme:
return "冰点", "市场宽度与跌停数量触发极端冰点"
recovery = change >= 6 and score >= 25 and health >= 24
climax = score >= 80 and profit >= 60 and health >= 60 and ecology >= 70
if prior in {"冰点", "退潮"}:
if score < 25:
return "冰点", "市场仍处于冰点区间"
return ("修复", "出现有效回升") if recovery else (prior, "尚未形成有效修复")
if prior == "修复":
if score < 25:
return "冰点", "修复失败并跌入冰点"
if change <= -6 and score < 45:
return "退潮", "修复失败且显著降温"
prior_signal = int(previous.get("fermentation_signal_count") or 0)
if signal == "发酵" and prior_signal >= 1:
return "发酵", "发酵条件连续两个交易日成立"
return "修复", "修复延续,等待发酵确认"
if prior == "发酵":
if score < 45 and (change < 0 or health < 35):
return "退潮", "温度与系统健康度转弱"
if climax:
return "高潮", "温度、赚钱效应与涨停生态达到高潮条件"
return ("分化", "发酵阶段出现降温") if signal in {"分化", "退潮"} else ("发酵", "发酵延续")
if prior == "高潮":
if climax:
return "高潮", "高潮条件继续成立"
return ("退潮", "风险快速释放") if score < 45 or health < 30 else ("分化", "高潮条件消退")
if score < 25:
return "冰点", "分化继续恶化至冰点"
if score < 45 or health < 30:
return "退潮", "分化后继续转弱"
return "分化", "分化延续,等待方向确认"
def _label(score: float) -> str:
if score >= 80:
return "情绪高涨"
if score >= 60:
return "情绪偏强"
if score >= 40:
return "情绪中性"
if score >= 20:
return "情绪偏弱"
return "情绪冰点"
def _series(rows: list[dict[str, float]], key: str) -> list[float]:
return [row[key] for row in rows]
def _adaptive(value: float, fixed: float, history: list[float]) -> float:
if len(history) < 20:
return fixed
below = sum(item < value for item in history[-250:])
equal = sum(item == value for item in history[-250:])
percentile = (below + equal * 0.5) / len(history[-250:]) * 100
return fixed * 0.25 + percentile * 0.75
def _linear(value: float, low: float, high: float) -> float:
return _clamp((value - low) / (high - low) * 100) if high > low else 50
def _clamp(value: float) -> float:
return min(100, max(0, value))
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
+10 -2
View File
@@ -5,12 +5,14 @@ from typing import Any
from backend.data.gateway import DataGateway, MarketDataUnavailable
from backend.data.providers.base import ProviderError
from backend.data.quality import DataQualityError
from backend.features.market.sync import MarketSnapshotService, SnapshotSyncError
from backend.http.errors import AppError
class MarketService:
def __init__(self, gateway: DataGateway) -> None:
def __init__(self, gateway: DataGateway, snapshots: MarketSnapshotService) -> None:
self._gateway = gateway
self._snapshots = snapshots
def context(self, requested_date: str | None = None) -> dict[str, Any]:
context = self._call(self._gateway.trade_context, requested_date)
@@ -76,11 +78,17 @@ class MarketService:
def refresh_reference(self) -> dict[str, int | str]:
return self._call(self._gateway.refresh_reference)
def sync_snapshot(self, requested_date: str | None = None) -> dict[str, Any]:
return self._call(self._snapshots.sync, requested_date)
def workspace(self, key: str, requested_date: str | None = None) -> dict[str, Any]:
return self._call(self._snapshots.workspace, key, requested_date)
@staticmethod
def _call(function, *args):
try:
return function(*args)
except (MarketDataUnavailable, ProviderError, DataQualityError) as exc:
except (MarketDataUnavailable, ProviderError, DataQualityError, SnapshotSyncError) as exc:
raise AppError("market_data_unavailable", str(exc), 503) from exc
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
from statistics import mean
from typing import Any
from backend.data.contracts import ProviderResult
def build_snapshot(
trade_date: str,
previous_trade_date: str,
inputs: dict[str, ProviderResult | dict[str, Any]],
) -> dict[str, Any]:
daily_rows = _rows(inputs, "daily")
daily = {str(row.get("ts_code") or ""): row for row in daily_rows}
limits = _pool(_rows(inputs, "limit_up"), "涨停")
broken = _pool(_rows(inputs, "broken"), "炸板")
down_limits = _pool(_rows(inputs, "limit_down"), "跌停")
price_limits = {str(row.get("ts_code") or ""): row for row in _rows(inputs, "price_limits")}
for row in broken:
up_limit = _number(price_limits.get(row["identifier"], {}).get("up_limit"))
row["distance_to_limit"] = (
round((up_limit - row["price"]) / up_limit * 100, 2) if up_limit else None
)
yesterday = _yesterday(
_pool(_rows(inputs, "previous_limit_up"), "涨停"),
daily,
limits,
broken,
down_limits,
)
up_count = sum(_number(row.get("pct_chg")) > 0 for row in daily_rows)
down_count = sum(_number(row.get("pct_chg")) < 0 for row in daily_rows)
flat_count = len(daily_rows) - up_count - down_count
amount = sum(_number(row.get("amount")) * 1000 for row in daily_rows)
seal_rate = len(limits) / max(len(limits) + len(broken), 1) * 100
overview = {
"up_count": up_count,
"down_count": down_count,
"flat_count": flat_count,
"limit_up": len(limits),
"limit_down": len(down_limits),
"broken": len(broken),
"seal_rate": round(seal_rate, 1),
"amount": round(amount, 2),
}
return {
"trade_date": trade_date,
"previous_trade_date": previous_trade_date,
"overview": overview,
"limits": limits,
"broken": broken,
"down_limits": down_limits,
"yesterday_limits": yesterday,
"limit_performance": _performance(yesterday),
}
def _rows(
inputs: dict[str, ProviderResult | dict[str, Any]], key: str
) -> tuple[dict[str, Any], ...]:
value = inputs.get(key)
return value.rows if isinstance(value, ProviderResult) else ()
def _pool(rows: tuple[dict[str, Any], ...], status: str) -> list[dict[str, Any]]:
result = []
for row in rows:
identifier = str(row.get("ts_code") or "")
amount = _number(row.get("amount")) * 1000
result.append(
{
"identifier": identifier,
"code": identifier.split(".")[0],
"name": str(row.get("name") or "").strip(),
"price": _number(row.get("close")),
"change": _number(row.get("pct_chg")),
"sector": str(row.get("industry") or "").strip(),
"reason": "",
"first_time": _time(row.get("first_time")),
"last_time": _time(row.get("last_time")),
"open_times": int(_number(row.get("open_times"))),
"streak": max(1, int(_number(row.get("limit_times"), 1))),
"turnover_rate": _number(row.get("turnover_ratio")),
"amount": amount,
"seal_amount": _number(row.get("fd_amount")),
"float_market_value": _number(row.get("float_mv")),
"status": status,
}
)
return result
def _yesterday(
previous: list[dict[str, Any]],
daily: dict[str, dict[str, Any]],
current: list[dict[str, Any]],
broken: list[dict[str, Any]],
down: list[dict[str, Any]],
) -> list[dict[str, Any]]:
current_map = {row["identifier"]: row for row in current}
broken_codes = {row["identifier"] for row in broken}
down_codes = {row["identifier"] for row in down}
result = []
for prior in previous:
identifier = prior["identifier"]
quote = daily.get(identifier, {})
change = _number(quote.get("pct_chg"))
if identifier in current_map:
outcome = "晋级"
elif identifier in broken_codes:
outcome = "炸板"
elif identifier in down_codes:
outcome = "跌停"
elif change > 0:
outcome = "红盘"
else:
outcome = "断板"
result.append(
{
"identifier": identifier,
"code": prior["code"],
"name": prior["name"],
"prior_streak": prior["streak"],
"current_streak": current_map.get(identifier, {}).get("streak", 0),
"current_change": change,
"current_price": _number(quote.get("close")),
"sector": prior["sector"],
"reason": prior["reason"],
"outcome": outcome,
}
)
return result
def _performance(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
result = []
for level in sorted({int(row["prior_streak"]) for row in rows}, reverse=True):
group = [row for row in rows if int(row["prior_streak"]) == level]
advanced = sum(row["outcome"] == "晋级" for row in group)
positive = sum(_number(row["current_change"]) > 0 for row in group)
outcomes = {
outcome: sum(row["outcome"] == outcome for row in group)
for outcome in ("晋级", "红盘", "断板", "炸板", "跌停")
}
result.append(
{
"level": level,
"count": len(group),
"advanced": advanced,
"red": outcomes["红盘"],
"broken": outcomes["断板"],
"opened": outcomes["炸板"],
"limit_down": outcomes["跌停"],
"advance_rate": round(advanced / len(group) * 100, 1),
"positive_rate": round(positive / len(group) * 100, 1),
"average_change": round(mean(_number(row["current_change"]) for row in group), 2),
}
)
return result
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
def _time(value: Any) -> str:
text = str(value or "").strip().replace(":", "")
if len(text) < 4 or not text[:4].isdigit():
return ""
return f"{text[:2]}:{text[2:4]}"
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
import json
from datetime import date, datetime, time, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from backend.data.contracts import ProviderResult, SnapshotState
from backend.data.providers.base import ProviderError
from backend.data.providers.tushare import TushareProvider
from backend.data.repository import MarketRepository
from backend.database.connection import Database
from backend.features.market.sentiment import calculate_sentiment
from backend.features.market.snapshot import build_snapshot
SHANGHAI = ZoneInfo("Asia/Shanghai")
class SnapshotSyncError(RuntimeError):
pass
class MarketSnapshotService:
def __init__(
self, database: Database, repository: MarketRepository, provider: TushareProvider
) -> None:
self._database = database
self._repository = repository
self._provider = provider
def sync(
self, requested_date: str | None = None, now: datetime | None = None
) -> dict[str, Any]:
clock = now or datetime.now(SHANGHAI)
requested = _date(requested_date or clock.date().isoformat())
through = requested
if requested == clock.date().isoformat() and clock.time() < time(9, 15):
through = (clock.date() - timedelta(days=1)).isoformat()
elif requested == clock.date().isoformat() and clock.time() < time(15, 10):
raise SnapshotSyncError("盘中快照任务尚未开放,请保留最近真实收盘数据")
with self._database.read() as connection:
dates = self._repository.open_dates(connection, through, 2)
active_count = self._repository.active_stock_count(connection)
if len(dates) < 2:
raise SnapshotSyncError("请先同步完整交易日历")
if active_count <= 0:
raise SnapshotSyncError("请先同步股票目录")
trade_date, previous_date = dates[0], dates[1]
try:
inputs = self._provider.snapshot_inputs(trade_date, previous_date)
except ProviderError as exc:
raise SnapshotSyncError("收盘行情读取失败,已保留原有快照") from exc
daily = inputs.get("daily")
if not isinstance(daily, ProviderResult):
raise SnapshotSyncError("收盘日线缺失,已保留原有快照")
coverage = len(daily.rows) / active_count
if coverage < 0.98:
raise SnapshotSyncError(f"收盘日线覆盖率仅{coverage * 100:.1f}%,未写入不完整快照")
for key in ("limit_up", "limit_down", "broken", "previous_limit_up", "price_limits"):
value = inputs.get(key)
if not isinstance(value, ProviderResult) or value.metadata.coverage < 1:
raise SnapshotSyncError("涨跌停事件数据不完整,已保留原有快照")
snapshot = build_snapshot(trade_date, previous_date, inputs)
with self._database.read() as connection:
rows = self._repository.summaries(connection, previous_date, 250)
history = [json.loads(str(row["payload_json"])) for row in rows]
sentiment = calculate_sentiment(snapshot, history)
snapshot["sentiment"] = sentiment
snapshot.update(snapshot["overview"])
snapshot["temperature"] = sentiment["score"]
observed_at = datetime.combine(
date.fromisoformat(trade_date), time(15), tzinfo=SHANGHAI
).isoformat(timespec="seconds")
state = (
SnapshotState.FINAL if trade_date == clock.date().isoformat() else SnapshotState.ARCHIVE
)
with self._database.transaction() as connection:
self._repository.save_summary(
connection,
trade_date=trade_date,
observed_at=observed_at,
state=state.value,
source="tushare",
coverage=min(coverage, 1),
payload=snapshot,
)
return {
"trade_date": trade_date,
"observed_at": observed_at,
"coverage": round(min(coverage, 1), 4),
"stocks": len(daily.rows),
"limit_up": len(snapshot["limits"]),
"limit_down": len(snapshot["down_limits"]),
"broken": len(snapshot["broken"]),
"temperature": sentiment["score"],
}
def workspace(self, key: str, requested_date: str | None = None) -> dict[str, Any]:
requested = _date(requested_date or datetime.now(SHANGHAI).date().isoformat())
with self._database.read() as connection:
row = self._repository.latest_summary(connection, requested)
history_rows = self._repository.summaries(connection, requested, 60)
if row is None:
return {"trade_date": None, "message": "等待管理员首次同步真实收盘行情"}
payload = json.loads(str(row["payload_json"]))
response: dict[str, Any] = {
"trade_date": str(row["trade_date"]),
"observed_at": str(row["observed_at"]),
"carried_forward": str(row["trade_date"]) != requested,
"message": "沿用最近真实收盘快照" if str(row["trade_date"]) != requested else "",
"overview": payload.get("overview") or {},
}
if key == "emotion":
response["sentiment"] = payload.get("sentiment") or {}
response["history"] = [
_history_item(json.loads(str(item["payload_json"]))) for item in history_rows
]
elif key == "pool":
response["items"] = payload.get("limits") or []
elif key == "broken":
response["items"] = payload.get("broken") or []
elif key == "limit-down":
response["items"] = payload.get("down_limits") or []
elif key == "yesterday":
response["items"] = payload.get("yesterday_limits") or []
elif key == "performance":
response["items"] = payload.get("limit_performance") or []
else:
raise SnapshotSyncError("不支持的市场工作区")
return response
def _history_item(payload: dict[str, Any]) -> dict[str, Any]:
sentiment = payload.get("sentiment") or {}
stats = sentiment.get("stats") or {}
overview = payload.get("overview") or {}
return {
"trade_date": payload.get("trade_date"),
"temperature": sentiment.get("score"),
"phase": sentiment.get("phase"),
"direction": sentiment.get("direction"),
"positive_rate": stats.get("positive_rate"),
"seal_rate": overview.get("seal_rate"),
"limit_up": overview.get("limit_up"),
"broken": overview.get("broken"),
"limit_down": overview.get("limit_down"),
"max_height": stats.get("max_height"),
"amount": overview.get("amount"),
}
def _date(value: str) -> str:
try:
return date.fromisoformat(value).isoformat()
except ValueError as exc:
raise SnapshotSyncError("日期格式无效") from exc
+44
View File
@@ -0,0 +1,44 @@
# 阶段6验收记录
## 交付范围
- 情绪周期:五维温度、阶段状态、置信度、昨日涨停反馈、10/20/60日明细与全页滚动。
- 涨停池、炸板池、跌停池、昨日涨停:共用一个表格与筛选实现,支持搜索、排序、CSV导出和空态。
- 涨停表现:按真实昨日高度动态扩展,展示晋级、红盘、断板、炸板、跌停、兑现率与市场宽度。
- 管理员后台刷新接入唯一收盘快照任务;完成后只提示,不强制重绘当前页面。
## 数据与失败边界
- 六个页面读取同一份`market_summaries.payload_json`日度快照,没有逐页重复落表或前端重复计算。
- 公开展示源不进入情绪计算;权威涨跌停事件不完整时拒绝覆盖已有快照。
- 收盘日线覆盖率低于98%时拒绝写入;盘中收盘快照任务尚未开放时明确失败并保留最近真实快照。
- 涨停原因、炸板原因和风险线索没有权威字段时保持空值,不以行业名称冒充。
## 自动门禁
- Ruff:通过。
- pytest49项通过。
- Vue TypeScript:通过。
- Vitest2个文件、5项通过。
- Vite生产构建:通过。
- Playwright:4项通过,覆盖既有阶段4/5回归及阶段6页面。
- `git diff --check`:通过。
## 视觉证据
- `emotion-light-1920x1080.jpg`:情绪周期、评分构成与交易日明细全页滚动。
- `pool-dark-1920x1080.jpg`:夜间涨停池筛选、统一表格和右侧结构信息。
- `pool-dark-3840x2160.jpg`4K最大可读宽度与信息密度。
- `performance-dark-390x844.jpg`:7板动态结构在手机端单列重排,无页面横向溢出。
## 减法证据
- 情绪和五个市场结构页面只有一个快照生产入口。
- 四类股票明细共用`PoolPage``DataTable`,未复制四份表格、排序或CSV逻辑。
- 涨停表现直接消费后端确定性聚合,不在页面复刻结果分类公式。
- 新增页面最长207行,新页面CSS 118行,均低于迁移章程建议门禁。
## 剩余边界
- 盘中情绪快照未在本阶段开放,不能把收盘接口伪装成实时数据。
- 市场天梯和板块轮动进入阶段7;它们复用本阶段的日度市场快照,不建立第二份涨停结构数据。
Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

+16 -1
View File
@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { marketApi } from "../../shared/api/market";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
@@ -14,6 +15,7 @@ const session = useSessionStore();
const ui = useUiStore();
const menuOpen = ref(false);
const menuRoot = ref<HTMLElement | null>(null);
const refreshing = ref(false);
const title = computed(() => {
if (route.name === "system") return "系统管理";
@@ -56,6 +58,19 @@ async function endSession(action: "logout" | "switch-account"): Promise<void> {
}
}
async function refreshMarket(): Promise<void> {
if (refreshing.value) return;
refreshing.value = true;
try {
const result = await marketApi.syncSnapshot(market.selectedDate);
ui.showToast(`${result.trade_date} 行情快照已更新,刷新页面后读取新数据`);
} catch (error) {
ui.showToast(error instanceof Error ? error.message : "行情刷新失败,请稍后重试");
} finally {
refreshing.value = false;
}
}
onMounted(() => document.addEventListener("mousedown", closeOnOutside));
onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside));
</script>
@@ -74,7 +89,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside))
<button class="btn btn-small" type="button" :title="ui.theme === 'light' ? '切换夜间模式' : '切换日间模式'" @click="ui.toggleTheme">
{{ ui.theme === "light" ? "夜间" : "日间" }}
</button>
<button v-if="session.isAdmin" class="btn btn-small desktop-only" type="button" @click="ui.showToast('行情刷新任务暂不可用')">后台刷新</button>
<button v-if="session.isAdmin" class="btn btn-small desktop-only" type="button" :disabled="refreshing" @click="refreshMarket">{{ refreshing ? "刷新中" : "后台刷新" }}</button>
<button v-if="session.isAdmin" class="btn btn-small desktop-only" type="button" @click="router.push('/system')">系统管理</button>
<div class="identity-cluster">
<button v-if="session.isMember" class="tag tag-vip" type="button" title="查看会员状态" @click="ui.openDialog('membership')">V 会员</button>
@@ -3,6 +3,7 @@ import { computed } from "vue";
import { useRoute } from "vue-router";
import EmptyState from "../../shared/components/EmptyState.vue";
import MarketWorkspaceView from "../../pages/market/MarketWorkspaceView.vue";
import { useMarketStore } from "../../shared/stores/market";
import { useSessionStore } from "../../shared/stores/session";
import { useUiStore } from "../../shared/stores/ui";
@@ -16,10 +17,16 @@ const workspace = computed(() => findWorkspace(String(route.params.workspace)) ?
const locked = computed(
() => ["screener", "mentor", "heaven"].includes(workspace.value.key) && !session.account?.smart_access,
);
const implementedMarket = computed(() =>
["emotion", "pool", "broken", "limit-down", "yesterday", "performance"].includes(
workspace.value.key,
),
);
</script>
<template>
<main class="page-frame">
<MarketWorkspaceView v-if="implementedMarket" :workspace-key="workspace.key" />
<main v-else class="page-frame">
<header class="page-header">
<h1>{{ workspace.title }}</h1>
<p class="page-subtitle">{{ workspace.description }} · 数据日期 {{ market.selectedDate }}</p>
+1
View File
@@ -11,6 +11,7 @@ import "./shared/styles/shell.css";
import "./shared/styles/auth.css";
import "./shared/styles/account.css";
import "./shared/styles/market.css";
import "./shared/styles/market-workspace.css";
import "./shared/styles/system.css";
import "./shared/styles/mobile.css";
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { marketApi, type MarketWorkspaceData } from "../../shared/api/market";
import EmptyState from "../../shared/components/EmptyState.vue";
import { useMarketStore } from "../../shared/stores/market";
import EmotionPage from "./emotion/EmotionPage.vue";
import PerformancePage from "./pools/PerformancePage.vue";
import PoolPage from "./pools/PoolPage.vue";
const props = defineProps<{ workspaceKey: string }>();
const market = useMarketStore();
const data = ref<MarketWorkspaceData | null>(null);
const loading = ref(false);
const error = ref("");
let sequence = 0;
async function load(): Promise<void> {
const current = ++sequence;
loading.value = true;
error.value = "";
try {
const result = await marketApi.workspace(props.workspaceKey, market.selectedDate);
if (current === sequence) data.value = result;
} catch (reason) {
if (current === sequence) error.value = reason instanceof Error ? reason.message : "页面数据读取失败";
} finally {
if (current === sequence) loading.value = false;
}
}
watch([() => props.workspaceKey, () => market.selectedDate], () => void load(), { immediate: true });
</script>
<template>
<main class="page-frame market-workspace-page">
<div v-if="loading" class="card workspace-state">正在读取本地复盘数据</div>
<EmptyState v-else-if="error" title="页面数据暂不可用" :description="error" class="card" />
<EmptyState v-else-if="!data?.trade_date" title="暂无真实行情快照" :description="data?.message || '等待管理员首次同步真实收盘行情'" class="card" />
<EmotionPage v-else-if="workspaceKey === 'emotion'" :data="data" />
<PerformancePage v-else-if="workspaceKey === 'performance'" :data="data" />
<PoolPage v-else :kind="workspaceKey" :data="data" />
</main>
</template>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import type { MarketWorkspaceData } from "../../../shared/api/market";
const props = defineProps<{ data: MarketWorkspaceData }>();
const days = ref<10 | 20 | 60>(20);
const sentiment = computed(() => props.data.sentiment ?? {});
const stats = computed<Record<string, number>>(
() => (sentiment.value.stats as Record<string, number> | undefined) ?? {},
);
const components = computed<Record<string, unknown>[]>(
() => (sentiment.value.components as Record<string, unknown>[] | undefined) ?? [],
);
const history = computed(() => (props.data.history ?? []).slice(-days.value));
const graph = computed(() => (props.data.history ?? []).slice(-20));
const width = 720;
const height = 190;
const inset = 18;
function pointX(index: number): number {
return inset + (index / Math.max(graph.value.length - 1, 1)) * (width - inset * 2);
}
function pointY(value: unknown): number {
return height - inset - (Number(value ?? 0) / 100) * (height - inset * 2);
}
const temperaturePath = computed(() =>
graph.value.map((item, index) => `${index ? "L" : "M"}${pointX(index)},${pointY(item.temperature)}`).join(" "),
);
const averagePath = computed(() =>
graph.value.map((_, index) => {
const window = graph.value.slice(Math.max(0, index - 4), index + 1);
const average = window.reduce((sum, item) => sum + Number(item.temperature ?? 0), 0) / window.length;
return `${index ? "L" : "M"}${pointX(index)},${pointY(average)}`;
}).join(" "),
);
const warning = computed(() => {
if (sentiment.value.direction === "降温") return "情绪指标继续走弱";
if (sentiment.value.direction === "升温") return "情绪指标正在回暖";
return "情绪指标保持平稳";
});
function number(value: unknown, digits = 0): string {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed.toFixed(digits) : "";
}
function amount(value: unknown): string {
const parsed = Number(value);
return Number.isFinite(parsed) ? `${(parsed / 100_000_000).toFixed(2)} 亿` : "";
}
function selectDays(value: number): void {
if (value === 10 || value === 20 || value === 60) days.value = 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="emotion-overview">
<article class="card temperature-card">
<header class="card-header"><h2>温度走势</h2><span class="faint">情绪温度 · 五日均线</span></header>
<div class="temperature-chart-wrap">
<svg class="temperature-chart" :viewBox="`0 0 ${width} ${height}`" role="img" aria-label="情绪温度走势">
<line v-for="line in [20, 40, 60, 80]" :key="line" class="temperature-grid" :x1="inset" :x2="width - inset" :y1="pointY(line)" :y2="pointY(line)" />
<path class="temperature-average" :d="averagePath" />
<path class="temperature-line" :d="temperaturePath" />
<circle v-for="(item, index) in graph" :key="String(item.trade_date)" class="temperature-point" :cx="pointX(index)" :cy="pointY(item.temperature)" :r="3" />
</svg>
</div>
</article>
<article class="card phase-card">
<header class="card-header"><h2>当前阶段</h2><span class="tag">{{ sentiment.phase }}</span></header>
<div class="phase-body">
<div class="phase-name">{{ sentiment.phase }}</div>
<div class="phase-confidence">置信度 {{ sentiment.confidence }}%</div>
<p>{{ sentiment.transition_reason }}</p>
<div class="phase-metrics">
<span>温度 <strong>{{ sentiment.score }}</strong></span>
<span>较前日 <strong>{{ Number(sentiment.day_change ?? 0) > 0 ? "+" : "" }}{{ sentiment.day_change }}</strong></span>
<span>{{ sentiment.direction }}</span>
<span>封板率 <strong>{{ number(data.overview.seal_rate, 1) }}%</strong></span>
<span>涨停 <strong>{{ data.overview.limit_up }}</strong> / 炸板 <strong>{{ data.overview.broken }}</strong></span>
<span>昨涨停红盘 <strong>{{ number(stats.positive_rate, 1) }}%</strong></span>
<span>样本 <strong>{{ stats.yesterday_count }}</strong></span>
</div>
<div class="notice notice-warning phase-warning">{{ warning }}</div>
</div>
</article>
</section>
<section class="card component-card">
<header class="card-header"><h2>评分构成</h2><span class="faint">五维加权后执行系统健康门控</span></header>
<div class="component-grid">
<div v-for="component in components" :key="String(component.key)" class="component-item">
<div><span>{{ component.label }}</span><strong>{{ number(component.score, 1) }}</strong></div>
<div class="score-track"><span :style="{ width: `${Number(component.score ?? 0)}%` }"></span></div>
<small>权重 {{ component.weight }}%</small>
</div>
</div>
</section>
<section class="card history-card">
<header class="card-header history-header">
<h2>交易日明细</h2>
<div class="seg-control" aria-label="明细天数">
<button v-for="value in [10, 20, 60]" :key="value" type="button" :class="{ active: days === value }" @click="selectDays(value)">{{ value }}</button>
</div>
</header>
<div class="data-table-wrap emotion-history-wrap">
<table class="data-table emotion-history-table">
<thead><tr><th>日期</th><th class="numeric">温度</th><th>阶段</th><th>方向</th><th class="numeric">红盘率%</th><th class="numeric">封板率%</th><th class="numeric">涨停</th><th class="numeric">炸板</th><th class="numeric">跌停</th><th class="numeric">最高板</th><th class="numeric">成交额亿</th></tr></thead>
<tbody><tr v-for="item in history" :key="String(item.trade_date)"><td>{{ item.trade_date }}</td><td class="numeric">{{ item.temperature }}</td><td>{{ item.phase }}</td><td>{{ item.direction }}</td><td class="numeric">{{ number(item.positive_rate, 1) }}</td><td class="numeric">{{ number(item.seal_rate, 1) }}</td><td class="numeric">{{ item.limit_up }}</td><td class="numeric">{{ item.broken }}</td><td class="numeric">{{ item.limit_down }}</td><td class="numeric">{{ item.max_height }}</td><td class="numeric">{{ amount(item.amount) }}</td></tr></tbody>
</table>
</div>
</section>
</template>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed } from "vue";
import type { MarketWorkspaceData } from "../../../shared/api/market";
import EmptyState from "../../../shared/components/EmptyState.vue";
const props = defineProps<{ data: MarketWorkspaceData }>();
const levels = computed(() => props.data.items ?? []);
const overview = computed(() => props.data.overview ?? {});
const breadthTotal = computed(() => Number(overview.value.up_count ?? 0) + Number(overview.value.down_count ?? 0) + Number(overview.value.flat_count ?? 0));
const breadthRate = (value: unknown) => breadthTotal.value ? Number(value ?? 0) / breadthTotal.value * 100 : 0;
const conclusion = computed(() => {
const samples = levels.value.reduce((sum, row) => sum + Number(row.count ?? 0), 0);
const advanced = levels.value.reduce((sum, row) => sum + Number(row.advanced ?? 0), 0);
const rate = advanced / Math.max(samples, 1) * 100;
if (rate >= 40) return "连板晋级表现较强,短线接力保持活跃。";
if (rate >= 20) return "连板晋级表现中性,结构分化较为明显。";
return "连板晋级表现偏弱,市场兑现压力较高。";
});
</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 v-if="levels.length" class="performance-layout">
<div class="performance-levels">
<article v-for="level in levels" :key="String(level.level)" class="card performance-level-card">
<header><strong>{{ level.level }}</strong><span>{{ level.count }}</span></header>
<div class="performance-outcomes">
<span><b class="up">{{ level.advanced }}</b>晋级</span><span><b class="up">{{ level.red }}</b>红盘</span><span><b>{{ level.broken }}</b>断板</span><span><b class="warning">{{ level.opened }}</b>炸板</span><span><b class="down">{{ level.limit_down }}</b>跌停</span>
</div>
<footer><span>晋级率 {{ level.advance_rate }}%</span><span>兑现率 {{ level.positive_rate }}%</span><span>平均涨幅 {{ Number(level.average_change) > 0 ? "+" : "" }}{{ level.average_change }}%</span></footer>
</article>
</div>
<aside class="card performance-conclusion"><header class="card-header"><h2>今日结论</h2></header><div class="card-body"><p>{{ conclusion }}</p><dl><div><dt>封板率</dt><dd>{{ overview.seal_rate }}%</dd></div><div><dt>涨停</dt><dd>{{ overview.limit_up }}</dd></div><div><dt>炸板</dt><dd>{{ overview.broken }}</dd></div><div><dt>跌停</dt><dd>{{ overview.limit_down }}</dd></div></dl></div></aside>
</section>
<EmptyState v-else class="card" title="暂无昨日涨停样本" description="最近真实快照中没有可统计的昨日涨停记录。" />
<section class="card breadth-card">
<header class="card-header"><h2>市场宽度</h2><span class="faint">全市场上涨平盘与下跌分布</span></header>
<div class="breadth-body">
<div class="breadth-legend"><span>上涨 <strong class="up">{{ overview.up_count }}</strong></span><span>平盘 <strong>{{ overview.flat_count }}</strong></span><span>下跌 <strong class="down">{{ overview.down_count }}</strong></span><span>红盘率 <strong>{{ breadthRate(overview.up_count).toFixed(1) }}%</strong></span></div>
<div class="breadth-track" aria-label="市场宽度比例"><span class="breadth-up" :style="{ width: `${breadthRate(overview.up_count)}%` }"></span><span class="breadth-flat" :style="{ width: `${breadthRate(overview.flat_count)}%` }"></span><span class="breadth-down" :style="{ width: `${breadthRate(overview.down_count)}%` }"></span></div>
</div>
</section>
</template>
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { MarketWorkspaceData } from "../../../shared/api/market";
import DataTable from "../../../shared/components/DataTable.vue";
import EmptyState from "../../../shared/components/EmptyState.vue";
type PoolKind = "pool" | "broken" | "limit-down" | "yesterday";
type FilterOption = readonly [value: string, label: string];
type Column = {
key: string;
label: string;
numeric?: boolean;
wide?: boolean;
code?: boolean;
sortable?: boolean;
format?: (value: unknown, row: Record<string, unknown>) => string;
};
const props = defineProps<{ kind: string; data: MarketWorkspaceData }>();
const search = ref("");
const filter = ref("all");
const sortKey = ref("");
const sortDirection = ref<"asc" | "desc">("desc");
const definitions: Record<PoolKind, { title: string; description: string; columns: Column[] }> = {
pool: {
title: "涨停池",
description: "当日封板股票与连板结构",
columns: [
{ key: "code", label: "代码", code: true, sortable: true },
{ key: "name", label: "股票", sortable: true },
{ key: "streak", label: "连板", numeric: true, sortable: true },
{ key: "change", label: "涨幅(%", numeric: true, sortable: true, format: percent },
{ key: "price", label: "价格(元)", numeric: true, sortable: true, format: decimal },
{ key: "sector", label: "板块", sortable: true },
{ key: "first_time", label: "首封", sortable: true },
{ key: "last_time", label: "最后封板", sortable: true },
{ key: "open_times", label: "开板次数", numeric: true, sortable: true },
{ key: "turnover_rate", label: "换手率(%", numeric: true, sortable: true, format: percent },
{ key: "amount", label: "成交额(亿)", numeric: true, sortable: true, format: amount },
{ key: "seal_amount", label: "封单额(亿)", numeric: true, sortable: true, format: amount },
{ key: "reason", label: "涨停原因", wide: true },
],
},
broken: {
title: "炸板池",
description: "触板未封股票与开板情况",
columns: [
{ key: "code", label: "代码", code: true, sortable: true },
{ key: "name", label: "股票", sortable: true },
{ key: "change", label: "现价涨幅(%", numeric: true, sortable: true, format: percent },
{ key: "distance_to_limit", label: "距涨停(%", numeric: true, sortable: true, format: percent },
{ key: "price", label: "价格(元)", numeric: true, sortable: true, format: decimal },
{ key: "sector", label: "板块", sortable: true },
{ key: "first_time", label: "首次触板", sortable: true },
{ key: "open_times", label: "开板次数", numeric: true, sortable: true },
{ key: "turnover_rate", label: "换手率(%", numeric: true, sortable: true, format: percent },
{ key: "amount", label: "成交额(亿)", numeric: true, sortable: true, format: amount },
{ key: "reason", label: "炸板原因", wide: true },
],
},
"limit-down": {
title: "跌停池",
description: "跌停风险与行业聚集",
columns: [
{ key: "code", label: "代码", code: true, sortable: true },
{ key: "name", label: "股票", sortable: true },
{ key: "change", label: "跌幅(%", numeric: true, sortable: true, format: percent },
{ key: "price", label: "价格(元)", numeric: true, sortable: true, format: decimal },
{ key: "sector", label: "板块", sortable: true },
{ key: "turnover_rate", label: "换手率(%", numeric: true, sortable: true, format: percent },
{ key: "amount", label: "成交额(亿)", numeric: true, sortable: true, format: amount },
{ key: "streak", label: "连续跌停", numeric: true, sortable: true },
{ key: "reason", label: "风险线索", wide: true },
],
},
yesterday: {
title: "昨日涨停",
description: "昨日涨停股票的次日反馈",
columns: [
{ key: "code", label: "代码", code: true, sortable: true },
{ key: "name", label: "股票", sortable: true },
{ key: "prior_streak", label: "昨日高度", numeric: true, sortable: true },
{ key: "current_change", label: "今日涨幅(%", numeric: true, sortable: true, format: percent },
{ key: "outcome", label: "今日结果", sortable: true },
{ key: "current_streak", label: "当前高度", numeric: true, sortable: true },
{ key: "sector", label: "板块", sortable: true },
{ key: "reason", label: "涨停逻辑", wide: true },
],
},
};
const definition = computed(() => definitions[props.kind as PoolKind] ?? definitions.pool);
const sourceRows = computed(() => props.data.items ?? []);
const filters = computed<FilterOption[]>(() => props.kind === "pool"
? [["all", "全部"], ["first", "首板"], ["second", "2板"], ["three", "3板+"]]
: props.kind === "yesterday"
? [["all", "全部"], ["advanced", "晋级"], ["red", "红盘"], ["broken", "断板"], ["failed", "炸板+跌停"]]
: []);
const rows = computed(() => {
const keyword = search.value.trim().toLocaleLowerCase();
const filtered = sourceRows.value.filter((row) => {
const matchesSearch = !keyword || [row.code, row.name, row.sector]
.some((value) => String(value ?? "").toLocaleLowerCase().includes(keyword));
if (!matchesSearch) return false;
if (props.kind === "pool") {
const streak = Number(row.streak ?? 0);
return filter.value === "all" || (filter.value === "first" && streak === 1)
|| (filter.value === "second" && streak === 2) || (filter.value === "three" && streak >= 3);
}
if (props.kind === "yesterday") {
const outcome = String(row.outcome ?? "");
return filter.value === "all" || (filter.value === "advanced" && outcome === "晋级")
|| (filter.value === "red" && outcome === "红盘") || (filter.value === "broken" && outcome === "断板")
|| (filter.value === "failed" && ["炸板", "跌停"].includes(outcome));
}
return true;
});
if (!sortKey.value) return filtered;
const direction = sortDirection.value === "asc" ? 1 : -1;
return [...filtered].sort((left, right) => compare(left[sortKey.value], right[sortKey.value]) * direction);
});
const sectorSummary = computed(() => {
const counts = new Map<string, number>();
for (const row of sourceRows.value) {
const sector = String(row.sector ?? "").trim();
if (sector) counts.set(sector, (counts.get(sector) ?? 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
});
const maxHeight = computed(() => Math.max(0, ...sourceRows.value.map((row) => Number(row.streak ?? 0))));
const positiveRate = computed(() => sourceRows.value.length
? sourceRows.value.filter((row) => Number(row.current_change ?? 0) > 0).length / sourceRows.value.length * 100
: 0);
watch(() => props.kind, () => { filter.value = "all"; search.value = ""; sortKey.value = ""; });
function sort(key: string): void {
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
else { sortKey.value = key; sortDirection.value = "desc"; }
}
function exportCsv(): void {
const columns = definition.value.columns;
const values = [columns.map((column) => column.label), ...rows.value.map((row) => columns.map((column) => {
const value = column.format ? column.format(row[column.key], row) : String(row[column.key] ?? "");
return `"${value.replaceAll('"', '""')}"`;
}))];
const blob = new Blob(["\ufeff", values.map((line) => line.join(",")).join("\r\n")], { type: "text/csv;charset=utf-8" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = `${definition.value.title}-${props.data.trade_date}.csv`;
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");
}
function numeric(value: unknown, digits: number): string {
if (value === null || value === undefined || value === "") return "";
const number = Number(value);
return Number.isFinite(number) ? number.toFixed(digits) : "";
}
function percent(value: unknown): string { return numeric(value, 2); }
function decimal(value: unknown): string { return numeric(value, 2); }
function amount(value: unknown): string {
if (value === null || value === undefined || value === "") return "";
const number = Number(value);
return Number.isFinite(number) ? (number / 100_000_000).toFixed(2) : "";
}
</script>
<template>
<header class="page-header market-page-header">
<div><h1>{{ definition.title }}</h1><p class="page-subtitle">{{ definition.description }} · 数据日期 {{ data.trade_date }}</p></div>
<span v-if="data.message" class="tag warning">{{ data.message }}</span>
</header>
<section v-if="kind === 'yesterday'" class="pool-summary-grid">
<article class="card summary-metric"><span>昨日涨停</span><strong>{{ sourceRows.length }}</strong></article>
<article class="card summary-metric"><span>晋级</span><strong>{{ sourceRows.filter((row) => row.outcome === '晋级').length }}</strong></article>
<article class="card summary-metric"><span>红盘兑现率</span><strong>{{ positiveRate.toFixed(1) }}%</strong></article>
</section>
<section class="pool-layout" :class="{ 'pool-layout-single': !['pool', 'limit-down'].includes(kind) }">
<article class="card pool-table-card">
<header class="pool-toolbar">
<div v-if="filters.length" class="seg-control pool-filters">
<button v-for="item in filters" :key="item[0]" type="button" :class="{ active: filter === item[0] }" @click="filter = item[0]">{{ item[1] }}</button>
</div>
<input v-model="search" class="input pool-search" type="search" placeholder="搜索代码、股票或板块" aria-label="搜索股池" />
<button class="btn btn-small" type="button" @click="exportCsv">导出 CSV</button>
</header>
<DataTable v-if="rows.length" :columns="definition.columns" :rows="rows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
<EmptyState v-else title="暂无符合条件个股" description="当前真实快照中没有满足筛选条件的记录。" />
</article>
<aside v-if="kind === 'pool'" class="pool-aside">
<article class="card"><header class="card-header"><h2>连板高度</h2></header><div class="pool-stat-body"><strong>{{ maxHeight }}</strong><span>当前市场最高连板</span></div></article>
<article class="card"><header class="card-header"><h2>热点板块</h2></header><ol class="sector-list"><li v-for="item in sectorSummary" :key="item[0]"><span>{{ item[0] }}</span><strong>{{ item[1] }}</strong></li></ol></article>
</aside>
<aside v-else-if="kind === 'limit-down'" class="pool-aside">
<article class="card"><header class="card-header"><h2>风险行业聚集</h2></header><ol class="sector-list"><li v-for="item in sectorSummary" :key="item[0]"><span>{{ item[0] }}</span><strong>{{ item[1] }}家</strong></li></ol><p class="risk-note">仅表示当日跌停数量聚集不构成个股确定性结论</p></article>
</aside>
</section>
</template>
+19
View File
@@ -56,6 +56,17 @@ export type ChartSeries = {
points: ChartPoint[];
};
export type MarketWorkspaceData = {
trade_date: string | null;
observed_at?: string;
carried_forward: boolean;
message: string;
overview: Record<string, number>;
sentiment?: Record<string, unknown>;
history?: Record<string, unknown>[];
items?: Record<string, unknown>[];
};
export const marketApi = {
summary(date?: string): Promise<MarketSummary> {
const query = date ? `?date=${encodeURIComponent(date)}` : "";
@@ -69,4 +80,12 @@ export const marketApi = {
const identifier = encodeURIComponent(entity.identifier);
return api.get<ChartSeries>(`/market/entities/${type}/${identifier}/charts/${interval}`);
},
workspace(key: string, date: string): Promise<MarketWorkspaceData> {
return api.get<MarketWorkspaceData>(
`/market/workspaces/${encodeURIComponent(key)}?date=${encodeURIComponent(date)}`,
);
},
syncSnapshot(date: string): Promise<{ trade_date: string; temperature: number }> {
return api.post(`/market/snapshot-sync?date=${encodeURIComponent(date)}`);
},
};
@@ -0,0 +1,45 @@
<script setup lang="ts">
type TableColumn = {
key: string;
label: string;
numeric?: boolean;
wide?: boolean;
code?: boolean;
sortable?: boolean;
format?: (value: unknown, row: Record<string, unknown>) => string;
};
defineProps<{
columns: TableColumn[];
rows: Record<string, unknown>[];
sortKey: string;
sortDirection: "asc" | "desc";
}>();
const emit = defineEmits<{ sort: [key: string] }>();
</script>
<template>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th class="index-column">序号</th>
<th v-for="column in columns" :key="column.key" :class="{ numeric: column.numeric, 'wide-column': column.wide, 'code-column': column.code }">
<button v-if="column.sortable" type="button" @click="emit('sort', column.key)">
{{ column.label }}<span v-if="sortKey === column.key">{{ sortDirection === "asc" ? "↑" : "↓" }}</span>
</button>
<span v-else>{{ column.label }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in rows" :key="String(row.identifier ?? index)">
<td class="index-column numeric">{{ index + 1 }}</td>
<td v-for="column in columns" :key="column.key" :class="{ numeric: column.numeric, 'wide-column': column.wide, 'code-column': column.code, up: column.key.includes('change') && Number(row[column.key]) > 0, down: column.key.includes('change') && Number(row[column.key]) < 0 }">
{{ column.format ? column.format(row[column.key], row) : (row[column.key] ?? "") }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
@@ -0,0 +1,118 @@
.market-workspace-page {
display: grid;
align-content: start;
gap: var(--layout-gap);
}
.market-workspace-page > .page-header {
margin-bottom: 0;
}
.market-page-header {
justify-content: space-between;
}
.workspace-state {
min-height: var(--s-200);
display: grid;
place-items: center;
color: var(--color-text-secondary);
}
.emotion-overview,
.performance-layout,
.pool-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--s-320);
gap: var(--layout-gap);
align-items: start;
}
.temperature-chart-wrap {
min-height: var(--s-200);
padding: var(--s-10) var(--s-14);
}
.temperature-chart {
width: 100%;
height: var(--s-200);
overflow: visible;
}
.temperature-grid { stroke: var(--color-divider); stroke-width: var(--s-1); }
.temperature-line { fill: none; stroke: var(--color-primary); stroke-width: var(--s-2); }
.temperature-average { fill: none; stroke: var(--color-warning); stroke-width: var(--s-2); stroke-dasharray: var(--s-6) var(--s-4); }
.temperature-point { fill: var(--color-surface); stroke: var(--color-primary); stroke-width: var(--s-2); }
.phase-body { display: grid; gap: var(--s-8); padding: var(--s-14); }
.phase-name { color: var(--color-primary); font-size: var(--font-18); font-weight: var(--weight-800); }
.phase-confidence { color: var(--color-text-secondary); font-size: var(--font-12); }
.phase-body > p { color: var(--color-text-secondary); line-height: var(--s-20); }
.phase-metrics { display: flex; flex-wrap: wrap; gap: var(--s-8) var(--s-14); color: var(--color-text-secondary); font-size: var(--font-12); }
.phase-metrics strong { color: var(--color-text); }
.phase-warning { margin-top: var(--s-4); }
.component-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--s-14);
padding: var(--s-14);
}
.component-item { display: grid; gap: var(--s-6); }
.component-item > div:first-child { display: flex; justify-content: space-between; gap: var(--s-8); }
.component-item small { color: var(--color-text-faint); font-size: var(--font-11); }
.score-track { height: var(--s-6); overflow: hidden; border-radius: var(--radius-round); background: var(--color-surface-muted); }
.score-track span { display: block; height: 100%; border-radius: inherit; background: var(--color-primary); }
.history-header { justify-content: space-between; }
.history-header .seg-control { margin-left: 0; }
.emotion-history-wrap { max-height: none; }
.pool-layout { grid-template-columns: minmax(0, 1fr) var(--s-320); }
.pool-layout-single { grid-template-columns: minmax(0, 1fr); }
.pool-table-card { min-width: 0; overflow: hidden; }
.pool-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); }
.pool-filters { margin-left: 0; }
.pool-search { width: var(--s-260); min-height: var(--s-32); margin-left: auto; }
.pool-aside { display: grid; gap: var(--layout-gap); }
.pool-stat-body { display: grid; gap: var(--s-6); padding: var(--s-16); }
.pool-stat-body strong { color: var(--color-primary); font-size: var(--font-18); }
.pool-stat-body span, .risk-note { color: var(--color-text-secondary); font-size: var(--font-12); }
.sector-list { display: grid; gap: var(--s-1); margin: 0; padding: var(--s-8) var(--s-14); list-style: none; }
.sector-list li { display: flex; justify-content: space-between; gap: var(--s-8); padding: var(--s-8) 0; border-bottom: var(--s-1) solid var(--color-divider); }
.sector-list strong { font-variant-numeric: tabular-nums; }
.risk-note { padding: 0 var(--s-14) var(--s-14); line-height: var(--s-18); }
.pool-summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--layout-gap); }
.summary-metric { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s-12); padding: var(--s-12) var(--s-14); }
.summary-metric span { color: var(--color-text-secondary); }
.summary-metric strong { font-size: var(--font-17); }
.data-table-wrap { width: 100%; overflow-x: auto; }
.data-table th.numeric, .data-table td.numeric { text-align: right; font-variant-numeric: tabular-nums; }
.data-table th.index-column, .data-table td.index-column { width: var(--s-44); }
.data-table th.code-column, .data-table td.code-column { min-width: var(--s-64); }
.data-table th.wide-column, .data-table td.wide-column { width: var(--s-200); min-width: var(--s-200); white-space: normal; }
.data-table th button { display: inline-flex; align-items: center; gap: var(--s-4); padding: 0; color: inherit; background: var(--c-transparent); font-weight: inherit; }
.performance-layout { grid-template-columns: minmax(0, 1fr) var(--s-320); }
.performance-levels { min-width: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(var(--s-320), 1fr)); gap: var(--layout-gap); }
.performance-level-card { min-width: 0; padding: var(--s-12) var(--s-14); }
.performance-level-card > header, .performance-level-card > footer { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--s-8) var(--s-14); }
.performance-level-card > header span, .performance-level-card > footer { color: var(--color-text-secondary); font-size: var(--font-11); }
.performance-outcomes { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--s-6); margin: var(--s-12) 0; }
.performance-outcomes span { display: grid; gap: var(--s-4); padding: var(--s-8); border-radius: var(--control-radius); background: var(--color-surface-muted); color: var(--color-text-secondary); font-size: var(--font-11); text-align: center; }
.performance-outcomes b { color: var(--color-text); font-size: var(--font-15); }
.performance-conclusion { position: sticky; top: calc(var(--shell-topbar-height) + var(--shell-summary-height) + var(--s-12)); }
.performance-conclusion p { color: var(--color-text-secondary); line-height: var(--s-20); }
.performance-conclusion dl { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-8); margin: var(--s-14) 0 0; }
.performance-conclusion dl div { padding: var(--s-8); border-radius: var(--control-radius); background: var(--color-surface-muted); }
.performance-conclusion dt { color: var(--color-text-secondary); font-size: var(--font-11); }
.performance-conclusion dd { margin: var(--s-4) 0 0; font-weight: var(--weight-700); font-variant-numeric: tabular-nums; }
.breadth-body { display: grid; gap: var(--s-10); padding: var(--s-14); }
.breadth-legend { display: flex; flex-wrap: wrap; gap: var(--s-8) var(--s-20); color: var(--color-text-secondary); }
.breadth-track { height: var(--s-10); display: flex; overflow: hidden; border-radius: var(--radius-round); background: var(--color-surface-muted); }
.breadth-track span { height: 100%; }
.breadth-up { background: var(--color-up); }
.breadth-flat { background: var(--color-text-faint); }
.breadth-down { background: var(--color-down); }
@@ -163,4 +163,38 @@
.member-editor {
position: static;
}
.emotion-overview,
.pool-layout,
.performance-layout {
grid-template-columns: minmax(0, 1fr);
}
.component-grid {
grid-template-columns: 1fr 1fr;
}
.pool-toolbar {
align-items: stretch;
flex-wrap: wrap;
}
.pool-filters {
width: 100%;
overflow-x: auto;
}
.pool-search {
width: 100%;
margin-left: 0;
}
.pool-summary-grid,
.performance-levels {
grid-template-columns: minmax(0, 1fr);
}
.performance-conclusion {
position: static;
}
}
+114
View File
@@ -0,0 +1,114 @@
const fs = require("node:fs");
const path = require("node:path");
const { expect, test } = require("@playwright/test");
const evidence = path.resolve(__dirname, "../../docs/evidence/stage-6");
test.beforeAll(() => fs.mkdirSync(evidence, { recursive: true }));
async function authenticate(page) {
await page.goto("/");
await page.getByLabel("账号名").fill("stage6admin");
await page.getByLabel("密码").fill("Stage6-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();
}
}
function summary() {
return {
context: {
requested_date: "2026-07-30", actual_date: "2026-07-29", previous_date: "2026-07-28",
observed_at: "2026-07-29T15:00:00+08:00", state: "final", carried_forward: true,
message: "沿用最近真实收盘快照",
},
values: { temperature: 42, limit_up: 68, limit_down: 4, broken: 24, seal_rate: 73.9, amount: 1628000000000 },
};
}
const overview = { up_count: 2960, down_count: 1980, flat_count: 112, limit_up: 68, limit_down: 4, broken: 24, seal_rate: 73.9, amount: 1628000000000 };
function history() {
return Array.from({ length: 60 }, (_, index) => ({
trade_date: `2026-07-${String(index + 1).padStart(2, "0")}`,
temperature: 26 + index % 35, phase: index % 3 === 0 ? "退潮" : "修复",
direction: index % 2 ? "升温" : "降温", positive_rate: 45 + index % 20,
seal_rate: 60 + index % 20, limit_up: 30 + index, broken: 9 + index % 15,
limit_down: 2 + index % 8, max_height: 3 + index % 5, amount: 1300000000000 + index * 1000000000,
}));
}
function poolItems() {
return Array.from({ length: 18 }, (_, index) => ({
identifier: `${String(index + 1).padStart(6, "0")}.SZ`, code: String(index + 1).padStart(6, "0"),
name: `样本股票${index + 1}`, streak: index % 5 + 1, change: 9.8 + index / 100,
price: 10 + index, sector: index % 2 ? "半导体" : "机器人", first_time: "09:35", last_time: "14:20",
open_times: index % 3, turnover_rate: 8 + index, amount: 300000000 + index * 10000000,
seal_amount: 50000000 + index * 1000000, reason: index % 2 ? "国产替代" : "产业链催化",
}));
}
function yesterdayItems() {
const outcomes = ["晋级", "红盘", "断板", "炸板", "跌停"];
return Array.from({ length: 15 }, (_, index) => ({
identifier: `${String(index + 101).padStart(6, "0")}.SZ`, code: String(index + 101).padStart(6, "0"),
name: `昨日样本${index + 1}`, prior_streak: index % 7 + 1, current_change: 10 - index,
outcome: outcomes[index % outcomes.length], current_streak: index % outcomes.length === 0 ? index % 7 + 2 : 0,
sector: "电子", reason: "事件催化",
}));
}
function performanceItems() {
return Array.from({ length: 7 }, (_, index) => ({
level: 7 - index, count: 3 + index, advanced: index % 3, red: 1, broken: 1,
opened: index % 2, limit_down: index === 6 ? 1 : 0, advance_rate: 28.6,
positive_rate: 57.1, average_change: 2.35,
}));
}
async function mockMarket(page) {
await page.route("**/api/market/summary", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify(summary()) }));
await page.route("**/api/market/workspaces/*", (route) => {
const key = new URL(route.request().url()).pathname.split("/").pop();
const base = { trade_date: "2026-07-29", observed_at: "2026-07-29T15:00:00+08:00", carried_forward: false, message: "", overview };
if (key === "emotion") return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...base, sentiment: { score: 42, phase: "退潮", direction: "降温", day_change: -8, confidence: 92, transition_reason: "赚钱效应与连板结构同步转弱", stats: { positive_rate: 53.2, yesterday_count: 47 }, components: [{ key: "breadth", label: "市场宽度", score: 58, weight: 20 }, { key: "limit_ecology", label: "涨停生态", score: 62, weight: 25 }, { key: "profit_effect", label: "赚钱效应", score: 34, weight: 30 }, { key: "ladder_structure", label: "连板结构", score: 38, weight: 15 }, { key: "liquidity", label: "成交活跃度", score: 52, weight: 10 }] }, history: history() }) });
if (key === "yesterday") return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...base, items: yesterdayItems() }) });
if (key === "performance") return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...base, items: performanceItems() }) });
return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...base, items: poolItems() }) });
});
}
test("emotion and pool workspaces remain usable across desktop and mobile", async ({ page }) => {
const consoleErrors = [];
page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text()); });
await mockMarket(page);
await authenticate(page);
await expect(page.getByRole("heading", { name: "情绪周期" })).toBeVisible();
await expect(page.getByText("情绪指标继续走弱")).toBeVisible();
await page.getByRole("button", { name: "60日" }).click();
await expect(page.locator(".emotion-history-table tbody tr")).toHaveCount(60);
expect(await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight)).toBe(true);
await page.screenshot({ path: path.join(evidence, "emotion-light-1920x1080.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("link", { name: /涨停池/ }).click();
await page.getByRole("button", { name: "3板+" }).click();
await expect(page.locator(".data-table tbody tr")).toHaveCount(10);
await expect(page.getByText("5板")).toBeVisible();
await page.getByRole("button", { name: "夜间" }).click();
await page.screenshot({ path: path.join(evidence, "pool-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
await page.setViewportSize({ width: 3840, height: 2160 });
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
await page.screenshot({ path: path.join(evidence, "pool-dark-3840x2160.jpg"), type: "jpeg", quality: 82 });
await page.getByRole("link", { name: /涨停表现/ }).click();
await expect(page.getByText("7板")).toBeVisible();
await expect(page.getByRole("heading", { name: "今日结论" })).toBeVisible();
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, "performance-dark-390x844.jpg"), type: "jpeg", quality: 82 });
expect(consoleErrors).toEqual([]);
});
+150
View File
@@ -19,9 +19,13 @@ from backend.data.contracts import (
from backend.data.gateway import DataGateway
from backend.data.policy import DataPolicyError, DataSourcePolicy
from backend.data.providers.ifind import IfindProvider
from backend.data.providers.tushare import TushareProvider
from backend.data.repository import MarketRepository
from backend.database.connection import Database
from backend.database.migrations import MIGRATIONS, MigrationRunner
from backend.features.market.sentiment import calculate_sentiment
from backend.features.market.snapshot import build_snapshot
from backend.features.market.sync import MarketSnapshotService, SnapshotSyncError
from tests.support import run_scenario
SHANGHAI = ZoneInfo("Asia/Shanghai")
@@ -256,3 +260,149 @@ def test_search_groups_are_fixed_and_require_authentication(tmp_path) -> None:
assert groups[-1]["items"][0]["name"] == "上证指数"
run_scenario(application, scenario)
def calculation_result(rows) -> ProviderResult:
return ProviderResult(
tuple(rows),
ObservationMetadata(
source=DataSource.TUSHARE,
observed_at=datetime(2026, 7, 29, 15, tzinfo=SHANGHAI),
unit="mixed",
adjustment="not_applicable",
freshness_seconds=0,
coverage=1,
state=SnapshotState.FINAL,
usage=DataUsage.CALCULATION,
),
)
def test_market_snapshot_units_and_yesterday_outcomes_are_deterministic() -> None:
daily = [
{
"ts_code": f"00000{index}.SZ",
"close": 10 + index,
"pct_chg": change,
"amount": 100,
}
for index, change in enumerate((10, 4, -10, 2, -2), start=1)
]
def event(index, streak=1):
return {
"ts_code": f"00000{index}.SZ",
"name": f"样本{index}",
"industry": "测试行业",
"close": 10 + index,
"pct_chg": daily[index - 1]["pct_chg"],
"amount": 100,
"limit_times": streak,
}
snapshot = build_snapshot(
"2026-07-29",
"2026-07-28",
{
"daily": calculation_result(daily),
"limit_up": calculation_result([event(1, 2)]),
"broken": calculation_result([event(2)]),
"limit_down": calculation_result([event(3)]),
"previous_limit_up": calculation_result([event(index) for index in range(1, 6)]),
"price_limits": calculation_result(
[{"ts_code": "000002.SZ", "up_limit": 15, "down_limit": 9}]
),
},
)
assert snapshot["overview"]["amount"] == 500_000
assert snapshot["broken"][0]["distance_to_limit"] == 20
assert [row["outcome"] for row in snapshot["yesterday_limits"]] == [
"晋级",
"炸板",
"跌停",
"红盘",
"断板",
]
performance = snapshot["limit_performance"][0]
assert performance == {
"level": 1,
"count": 5,
"advanced": 1,
"red": 1,
"broken": 1,
"opened": 1,
"limit_down": 1,
"advance_rate": 20.0,
"positive_rate": 60.0,
"average_change": 0.8,
}
def test_sentiment_has_all_weighted_components_and_extreme_risk_cap() -> None:
snapshot = {
"overview": {
"up_count": 10,
"down_count": 90,
"limit_up": 10,
"limit_down": 100,
"broken": 20,
"seal_rate": 33.3,
"amount": 100_000_000_000,
},
"limits": [{"streak": 1, "amount": 100_000_000} for _ in range(10)],
"yesterday_limits": [],
}
sentiment = calculate_sentiment(snapshot, [])
assert sentiment["score"] <= 15
assert sentiment["phase"] == "冰点"
assert {item["key"]: item["weight"] for item in sentiment["components"]} == {
"breadth": 20,
"limit_ecology": 25,
"profit_effect": 30,
"ladder_structure": 15,
"liquidity": 10,
}
def test_incomplete_daily_snapshot_is_rejected_without_overwriting(tmp_path) -> None:
database = Database(tmp_path / "sync.db")
MigrationRunner(database).upgrade(MIGRATIONS)
repository = MarketRepository()
with database.transaction() as connection:
repository.replace_calendar(
connection,
(
{"cal_date": "20260728", "is_open": 1, "pretrade_date": "20260727"},
{"cal_date": "20260729", "is_open": 1, "pretrade_date": "20260728"},
),
"tushare",
"2026-07-29T15:00:00+08:00",
)
repository.replace_stocks(
connection,
tuple(
{
"ts_code": f"{index:06d}.SZ",
"symbol": f"{index:06d}",
"name": f"样本{index}",
"industry": "测试",
"list_status": "L",
}
for index in range(1, 101)
),
"tushare",
"2026-07-29T15:00:00+08:00",
)
provider = TushareProvider("test-token")
provider.snapshot_inputs = lambda *_: {
"daily": calculation_result([{"ts_code": "000001.SZ"}]),
"limit_up": calculation_result([]),
"limit_down": calculation_result([]),
"broken": calculation_result([]),
"previous_limit_up": calculation_result([]),
"price_limits": calculation_result([]),
}
service = MarketSnapshotService(database, repository, provider)
with pytest.raises(SnapshotSyncError, match="覆盖率"):
service.sync("2026-07-29", datetime(2026, 7, 30, 16, tzinfo=SHANGHAI))
with database.read() as connection:
assert repository.latest_summary(connection, "2026-07-29") is None