rebuild(stage-6): deliver emotion and market pools
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.sync import MarketSnapshotService
|
||||||
from backend.security import PasswordHasher, load_or_create_cipher
|
from backend.security import PasswordHasher, load_or_create_cipher
|
||||||
|
|
||||||
|
|
||||||
@@ -39,11 +40,13 @@ def build_container(settings: Settings) -> ApplicationContainer:
|
|||||||
model_pool_repository = ModelPoolRepository()
|
model_pool_repository = ModelPoolRepository()
|
||||||
cipher = load_or_create_cipher(settings)
|
cipher = load_or_create_cipher(settings)
|
||||||
credentials = SystemCredentialService(database, credential_repository, cipher)
|
credentials = SystemCredentialService(database, credential_repository, cipher)
|
||||||
|
tushare = TushareProvider(lambda: credentials.get("tushare_token"))
|
||||||
|
market_repository = MarketRepository()
|
||||||
gateway = DataGateway(
|
gateway = DataGateway(
|
||||||
database,
|
database,
|
||||||
MarketRepository(),
|
market_repository,
|
||||||
(
|
(
|
||||||
TushareProvider(lambda: credentials.get("tushare_token")),
|
tushare,
|
||||||
IfindProvider(
|
IfindProvider(
|
||||||
lambda: credentials.get("ifind_refresh_token"),
|
lambda: credentials.get("ifind_refresh_token"),
|
||||||
lambda: credentials.get("ifind_access_token"),
|
lambda: credentials.get("ifind_access_token"),
|
||||||
@@ -60,5 +63,7 @@ def build_container(settings: Settings) -> ApplicationContainer:
|
|||||||
memberships=MembershipService(database, account_repository),
|
memberships=MembershipService(database, account_repository),
|
||||||
system_credentials=credentials,
|
system_credentials=credentials,
|
||||||
model_pool=ModelPoolService(database, model_pool_repository, cipher),
|
model_pool=ModelPoolService(database, model_pool_repository, cipher),
|
||||||
market=MarketService(gateway),
|
market=MarketService(
|
||||||
|
gateway, MarketSnapshotService(database, market_repository, tushare)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from backend.data.contracts import DataSource, ProviderResult
|
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 daily(self, entity_type: str, identifier: str, end_date: str) -> ProviderResult: ...
|
||||||
|
|
||||||
def minute(self, entity_type: str, identifier: str, trade_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]]: ...
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ class EastmoneyProvider:
|
|||||||
)
|
)
|
||||||
return ProviderResult(tuple(rows), metadata)
|
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
|
@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:
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ class IfindProvider:
|
|||||||
)
|
)
|
||||||
return _result(payload, "yuan/share", "forward", SnapshotState.REALTIME)
|
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]:
|
def _request(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
if not self.configured:
|
if not self.configured:
|
||||||
raise ProviderError("实时行情服务尚未配置")
|
raise ProviderError("实时行情服务尚未配置")
|
||||||
|
|||||||
@@ -92,6 +92,44 @@ class TushareProvider:
|
|||||||
unit="yuan/share",
|
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(
|
def _query(
|
||||||
self,
|
self,
|
||||||
api_name: str,
|
api_name: str,
|
||||||
@@ -100,6 +138,7 @@ class TushareProvider:
|
|||||||
*,
|
*,
|
||||||
unit: str,
|
unit: str,
|
||||||
adjustment: str = "not_applicable",
|
adjustment: str = "not_applicable",
|
||||||
|
empty_is_complete: bool = False,
|
||||||
) -> ProviderResult:
|
) -> ProviderResult:
|
||||||
if not self.configured:
|
if not self.configured:
|
||||||
raise ProviderError("行情服务尚未配置")
|
raise ProviderError("行情服务尚未配置")
|
||||||
@@ -126,7 +165,8 @@ class TushareProvider:
|
|||||||
data = payload.get("data") or {}
|
data = payload.get("data") or {}
|
||||||
columns = data.get("fields") or []
|
columns = data.get("fields") or []
|
||||||
rows = tuple(dict(zip(columns, item, strict=False)) for item in data.get("items") 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:
|
def _token(self) -> str:
|
||||||
return str(self._token_provider() or "").strip()
|
return str(self._token_provider() or "").strip()
|
||||||
|
|||||||
@@ -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:
|
def latest_summary(self, connection: sqlite3.Connection, through: str) -> sqlite3.Row | None:
|
||||||
return connection.execute(
|
return connection.execute(
|
||||||
"SELECT * FROM market_summaries WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT 1",
|
"SELECT * FROM market_summaries WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT 1",
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ from backend.features.accounts.auth import AdminWritePrincipal, AuthenticatedPri
|
|||||||
from backend.features.market.schemas import (
|
from backend.features.market.schemas import (
|
||||||
ChartResponse,
|
ChartResponse,
|
||||||
MarketSummaryResponse,
|
MarketSummaryResponse,
|
||||||
|
MarketWorkspaceResponse,
|
||||||
ReferenceSyncResponse,
|
ReferenceSyncResponse,
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
|
SnapshotSyncResponse,
|
||||||
TradeContextResponse,
|
TradeContextResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,3 +59,25 @@ def chart(
|
|||||||
@router.post("/reference-sync", response_model=ReferenceSyncResponse)
|
@router.post("/reference-sync", response_model=ReferenceSyncResponse)
|
||||||
def refresh_reference(request: Request, _principal: AdminWritePrincipal) -> dict[str, int | str]:
|
def refresh_reference(request: Request, _principal: AdminWritePrincipal) -> dict[str, int | str]:
|
||||||
return request.app.state.container.market.refresh_reference()
|
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)
|
||||||
|
|||||||
@@ -69,3 +69,25 @@ class ReferenceSyncResponse(BaseModel):
|
|||||||
calendar_days: int = Field(ge=1)
|
calendar_days: int = Field(ge=1)
|
||||||
entities: int = Field(ge=1)
|
entities: int = Field(ge=1)
|
||||||
observed_at: datetime
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,12 +5,14 @@ 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.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) -> None:
|
def __init__(self, gateway: DataGateway, snapshots: MarketSnapshotService) -> None:
|
||||||
self._gateway = gateway
|
self._gateway = gateway
|
||||||
|
self._snapshots = snapshots
|
||||||
|
|
||||||
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)
|
||||||
@@ -76,11 +78,17 @@ class MarketService:
|
|||||||
def refresh_reference(self) -> dict[str, int | str]:
|
def refresh_reference(self) -> dict[str, int | str]:
|
||||||
return self._call(self._gateway.refresh_reference)
|
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
|
@staticmethod
|
||||||
def _call(function, *args):
|
def _call(function, *args):
|
||||||
try:
|
try:
|
||||||
return function(*args)
|
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
|
raise AppError("market_data_unavailable", str(exc), 503) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]}"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 阶段6验收记录
|
||||||
|
|
||||||
|
## 交付范围
|
||||||
|
|
||||||
|
- 情绪周期:五维温度、阶段状态、置信度、昨日涨停反馈、10/20/60日明细与全页滚动。
|
||||||
|
- 涨停池、炸板池、跌停池、昨日涨停:共用一个表格与筛选实现,支持搜索、排序、CSV导出和空态。
|
||||||
|
- 涨停表现:按真实昨日高度动态扩展,展示晋级、红盘、断板、炸板、跌停、兑现率与市场宽度。
|
||||||
|
- 管理员后台刷新接入唯一收盘快照任务;完成后只提示,不强制重绘当前页面。
|
||||||
|
|
||||||
|
## 数据与失败边界
|
||||||
|
|
||||||
|
- 六个页面读取同一份`market_summaries.payload_json`日度快照,没有逐页重复落表或前端重复计算。
|
||||||
|
- 公开展示源不进入情绪计算;权威涨跌停事件不完整时拒绝覆盖已有快照。
|
||||||
|
- 收盘日线覆盖率低于98%时拒绝写入;盘中收盘快照任务尚未开放时明确失败并保留最近真实快照。
|
||||||
|
- 涨停原因、炸板原因和风险线索没有权威字段时保持空值,不以行业名称冒充。
|
||||||
|
|
||||||
|
## 自动门禁
|
||||||
|
|
||||||
|
- Ruff:通过。
|
||||||
|
- pytest:49项通过。
|
||||||
|
- Vue TypeScript:通过。
|
||||||
|
- Vitest:2个文件、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 |
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
|
import { marketApi } from "../../shared/api/market";
|
||||||
import { useMarketStore } from "../../shared/stores/market";
|
import { useMarketStore } from "../../shared/stores/market";
|
||||||
import { useSessionStore } from "../../shared/stores/session";
|
import { useSessionStore } from "../../shared/stores/session";
|
||||||
import { useUiStore } from "../../shared/stores/ui";
|
import { useUiStore } from "../../shared/stores/ui";
|
||||||
@@ -14,6 +15,7 @@ const session = useSessionStore();
|
|||||||
const ui = useUiStore();
|
const ui = useUiStore();
|
||||||
const menuOpen = ref(false);
|
const menuOpen = ref(false);
|
||||||
const menuRoot = ref<HTMLElement | null>(null);
|
const menuRoot = ref<HTMLElement | null>(null);
|
||||||
|
const refreshing = ref(false);
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
if (route.name === "system") return "系统管理";
|
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));
|
onMounted(() => document.addEventListener("mousedown", closeOnOutside));
|
||||||
onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside));
|
onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside));
|
||||||
</script>
|
</script>
|
||||||
@@ -74,7 +89,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside))
|
|||||||
<button class="btn btn-small" type="button" :title="ui.theme === 'light' ? '切换夜间模式' : '切换日间模式'" @click="ui.toggleTheme">
|
<button class="btn btn-small" type="button" :title="ui.theme === 'light' ? '切换夜间模式' : '切换日间模式'" @click="ui.toggleTheme">
|
||||||
{{ ui.theme === "light" ? "夜间" : "日间" }}
|
{{ ui.theme === "light" ? "夜间" : "日间" }}
|
||||||
</button>
|
</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>
|
<button v-if="session.isAdmin" class="btn btn-small desktop-only" type="button" @click="router.push('/system')">系统管理</button>
|
||||||
<div class="identity-cluster">
|
<div class="identity-cluster">
|
||||||
<button v-if="session.isMember" class="tag tag-vip" type="button" title="查看会员状态" @click="ui.openDialog('membership')">V 会员</button>
|
<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 { useRoute } from "vue-router";
|
||||||
|
|
||||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||||
|
import MarketWorkspaceView from "../../pages/market/MarketWorkspaceView.vue";
|
||||||
import { useMarketStore } from "../../shared/stores/market";
|
import { useMarketStore } from "../../shared/stores/market";
|
||||||
import { useSessionStore } from "../../shared/stores/session";
|
import { useSessionStore } from "../../shared/stores/session";
|
||||||
import { useUiStore } from "../../shared/stores/ui";
|
import { useUiStore } from "../../shared/stores/ui";
|
||||||
@@ -16,10 +17,16 @@ const workspace = computed(() => findWorkspace(String(route.params.workspace)) ?
|
|||||||
const locked = computed(
|
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(() =>
|
||||||
|
["emotion", "pool", "broken", "limit-down", "yesterday", "performance"].includes(
|
||||||
|
workspace.value.key,
|
||||||
|
),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="page-frame">
|
<MarketWorkspaceView v-if="implementedMarket" :workspace-key="workspace.key" />
|
||||||
|
<main v-else class="page-frame">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>{{ workspace.title }}</h1>
|
<h1>{{ workspace.title }}</h1>
|
||||||
<p class="page-subtitle">{{ workspace.description }} · 数据日期 {{ market.selectedDate }}</p>
|
<p class="page-subtitle">{{ workspace.description }} · 数据日期 {{ market.selectedDate }}</p>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import "./shared/styles/shell.css";
|
|||||||
import "./shared/styles/auth.css";
|
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/system.css";
|
import "./shared/styles/system.css";
|
||||||
import "./shared/styles/mobile.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>
|
||||||
@@ -56,6 +56,17 @@ export type ChartSeries = {
|
|||||||
points: ChartPoint[];
|
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 = {
|
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)}` : "";
|
||||||
@@ -69,4 +80,12 @@ export const marketApi = {
|
|||||||
const identifier = encodeURIComponent(entity.identifier);
|
const identifier = encodeURIComponent(entity.identifier);
|
||||||
return api.get<ChartSeries>(`/market/entities/${type}/${identifier}/charts/${interval}`);
|
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 {
|
.member-editor {
|
||||||
position: static;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -19,9 +19,13 @@ from backend.data.contracts import (
|
|||||||
from backend.data.gateway import DataGateway
|
from backend.data.gateway import DataGateway
|
||||||
from backend.data.policy import DataPolicyError, DataSourcePolicy
|
from backend.data.policy import DataPolicyError, DataSourcePolicy
|
||||||
from backend.data.providers.ifind import IfindProvider
|
from backend.data.providers.ifind import IfindProvider
|
||||||
|
from backend.data.providers.tushare import TushareProvider
|
||||||
from backend.data.repository import MarketRepository
|
from backend.data.repository import MarketRepository
|
||||||
from backend.database.connection import Database
|
from backend.database.connection import Database
|
||||||
from backend.database.migrations import MIGRATIONS, MigrationRunner
|
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
|
from tests.support import run_scenario
|
||||||
|
|
||||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
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"] == "上证指数"
|
assert groups[-1]["items"][0]["name"] == "上证指数"
|
||||||
|
|
||||||
run_scenario(application, scenario)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user