diff --git a/next/backend/bootstrap/container.py b/next/backend/bootstrap/container.py index 0a7b1b1..4dccfec 100644 --- a/next/backend/bootstrap/container.py +++ b/next/backend/bootstrap/container.py @@ -17,6 +17,7 @@ from backend.features.accounts.service import ( MembershipService, ) from backend.features.market import MarketService +from backend.features.market.sync import MarketSnapshotService from backend.security import PasswordHasher, load_or_create_cipher @@ -39,11 +40,13 @@ def build_container(settings: Settings) -> ApplicationContainer: model_pool_repository = ModelPoolRepository() cipher = load_or_create_cipher(settings) credentials = SystemCredentialService(database, credential_repository, cipher) + tushare = TushareProvider(lambda: credentials.get("tushare_token")) + market_repository = MarketRepository() gateway = DataGateway( database, - MarketRepository(), + market_repository, ( - TushareProvider(lambda: credentials.get("tushare_token")), + tushare, IfindProvider( lambda: credentials.get("ifind_refresh_token"), lambda: credentials.get("ifind_access_token"), @@ -60,5 +63,7 @@ def build_container(settings: Settings) -> ApplicationContainer: memberships=MembershipService(database, account_repository), system_credentials=credentials, model_pool=ModelPoolService(database, model_pool_repository, cipher), - market=MarketService(gateway), + market=MarketService( + gateway, MarketSnapshotService(database, market_repository, tushare) + ), ) diff --git a/next/backend/data/providers/base.py b/next/backend/data/providers/base.py index d68934a..d6b5655 100644 --- a/next/backend/data/providers/base.py +++ b/next/backend/data/providers/base.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Protocol +from typing import Any, Protocol from backend.data.contracts import DataSource, ProviderResult @@ -22,3 +22,7 @@ class MarketDataProvider(Protocol): def daily(self, entity_type: str, identifier: str, end_date: str) -> ProviderResult: ... def minute(self, entity_type: str, identifier: str, trade_date: str) -> ProviderResult: ... + + def snapshot_inputs( + self, trade_date: str, previous_trade_date: str + ) -> dict[str, ProviderResult | dict[str, Any]]: ... diff --git a/next/backend/data/providers/eastmoney.py b/next/backend/data/providers/eastmoney.py index b012e88..b2527dc 100644 --- a/next/backend/data/providers/eastmoney.py +++ b/next/backend/data/providers/eastmoney.py @@ -94,6 +94,11 @@ class EastmoneyProvider: ) return ProviderResult(tuple(rows), metadata) + def snapshot_inputs( + self, trade_date: str, previous_trade_date: str + ) -> dict[str, ProviderResult | dict[str, object]]: + raise ProviderError("The display provider cannot build market snapshots") + @staticmethod def _secid(entity_type: str, identifier: str) -> str: if entity_type == "index" and identifier in INDEX_CODES: diff --git a/next/backend/data/providers/ifind.py b/next/backend/data/providers/ifind.py index 5ba8b56..e57fa23 100644 --- a/next/backend/data/providers/ifind.py +++ b/next/backend/data/providers/ifind.py @@ -84,6 +84,11 @@ class IfindProvider: ) return _result(payload, "yuan/share", "forward", SnapshotState.REALTIME) + def snapshot_inputs( + self, trade_date: str, previous_trade_date: str + ) -> dict[str, ProviderResult | dict[str, Any]]: + raise ProviderError("iFinD is not the post-close snapshot authority") + def _request(self, endpoint: str, body: dict[str, Any]) -> dict[str, Any]: if not self.configured: raise ProviderError("实时行情服务尚未配置") diff --git a/next/backend/data/providers/tushare.py b/next/backend/data/providers/tushare.py index e737582..69c8c00 100644 --- a/next/backend/data/providers/tushare.py +++ b/next/backend/data/providers/tushare.py @@ -92,6 +92,44 @@ class TushareProvider: unit="yuan/share", ) + def snapshot_inputs( + self, trade_date: str, previous_trade_date: str + ) -> dict[str, ProviderResult | dict[str, Any]]: + current = _compact(trade_date) + previous = _compact(previous_trade_date) + daily = self._query( + "daily", + {"trade_date": current}, + "ts_code,trade_date,open,high,low,close,pre_close,pct_chg,vol,amount", + unit="mixed", + ) + event_fields = ( + "trade_date,ts_code,industry,name,close,pct_chg,amount,limit_amount," + "float_mv,total_mv,turnover_ratio,fd_amount,first_time,last_time," + "open_times,up_stat,limit_times" + ) + datasets: dict[str, ProviderResult | dict[str, Any]] = {"daily": daily} + datasets["price_limits"] = self._query( + "stk_limit", + {"trade_date": current}, + "ts_code,trade_date,up_limit,down_limit", + unit="yuan/share", + ) + for key, limit_type, date in ( + ("limit_up", "U", current), + ("limit_down", "D", current), + ("broken", "Z", current), + ("previous_limit_up", "U", previous), + ): + datasets[key] = self._query( + "limit_list_d", + {"trade_date": date, "limit_type": limit_type}, + event_fields, + unit="mixed", + empty_is_complete=True, + ) + return datasets + def _query( self, api_name: str, @@ -100,6 +138,7 @@ class TushareProvider: *, unit: str, adjustment: str = "not_applicable", + empty_is_complete: bool = False, ) -> ProviderResult: if not self.configured: raise ProviderError("行情服务尚未配置") @@ -126,7 +165,8 @@ class TushareProvider: data = payload.get("data") or {} columns = data.get("fields") or [] rows = tuple(dict(zip(columns, item, strict=False)) for item in data.get("items") or []) - return ProviderResult(rows, _metadata(self.source, unit, 1 if rows else 0, adjustment)) + coverage = 1 if rows or empty_is_complete else 0 + return ProviderResult(rows, _metadata(self.source, unit, coverage, adjustment)) def _token(self) -> str: return str(self._token_provider() or "").strip() diff --git a/next/backend/data/repository.py b/next/backend/data/repository.py index d249628..715ce4f 100644 --- a/next/backend/data/repository.py +++ b/next/backend/data/repository.py @@ -126,6 +126,62 @@ class MarketRepository: ) ) + def active_stock_count(self, connection: sqlite3.Connection) -> int: + row = connection.execute( + """ + SELECT COUNT(*) AS count FROM market_entities + WHERE entity_type = 'stock' AND active = 1 + """ + ).fetchone() + return int(row["count"] if row else 0) + + def save_summary( + self, + connection: sqlite3.Connection, + *, + trade_date: str, + observed_at: str, + state: str, + source: str, + coverage: float, + payload: dict[str, Any], + ) -> None: + connection.execute( + """ + INSERT INTO market_summaries + (trade_date, observed_at, state, source, coverage, payload_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(trade_date) DO UPDATE SET + observed_at = excluded.observed_at, + state = excluded.state, + source = excluded.source, + coverage = excluded.coverage, + payload_json = excluded.payload_json, + created_at = excluded.created_at + """, + ( + trade_date, + observed_at, + state, + source, + coverage, + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + datetime.now().astimezone().isoformat(timespec="seconds"), + ), + ) + + def summaries( + self, connection: sqlite3.Connection, through: str, limit: int = 260 + ) -> tuple[sqlite3.Row, ...]: + rows = connection.execute( + """ + SELECT * FROM market_summaries WHERE trade_date <= ? + ORDER BY trade_date DESC LIMIT ? + """, + (through, limit), + ).fetchall() + return tuple(reversed(rows)) + def latest_summary(self, connection: sqlite3.Connection, through: str) -> sqlite3.Row | None: return connection.execute( "SELECT * FROM market_summaries WHERE trade_date <= ? ORDER BY trade_date DESC LIMIT 1", diff --git a/next/backend/features/market/routes.py b/next/backend/features/market/routes.py index fbc25cf..b9469a0 100644 --- a/next/backend/features/market/routes.py +++ b/next/backend/features/market/routes.py @@ -8,8 +8,10 @@ from backend.features.accounts.auth import AdminWritePrincipal, AuthenticatedPri from backend.features.market.schemas import ( ChartResponse, MarketSummaryResponse, + MarketWorkspaceResponse, ReferenceSyncResponse, SearchResponse, + SnapshotSyncResponse, TradeContextResponse, ) @@ -57,3 +59,25 @@ def chart( @router.post("/reference-sync", response_model=ReferenceSyncResponse) def refresh_reference(request: Request, _principal: AdminWritePrincipal) -> dict[str, int | str]: return request.app.state.container.market.refresh_reference() + + +@router.post("/snapshot-sync", response_model=SnapshotSyncResponse) +def sync_snapshot( + request: Request, + _principal: AdminWritePrincipal, + requested_date: Annotated[str | None, Query(alias="date")] = None, +) -> dict: + return request.app.state.container.market.sync_snapshot(requested_date) + + +@router.get("/workspaces/{key}", response_model=MarketWorkspaceResponse) +def workspace( + request: Request, + _principal: AuthenticatedPrincipal, + key: Annotated[ + Literal["emotion", "pool", "broken", "limit-down", "yesterday", "performance"], + Path(), + ], + requested_date: Annotated[str | None, Query(alias="date")] = None, +) -> dict: + return request.app.state.container.market.workspace(key, requested_date) diff --git a/next/backend/features/market/schemas.py b/next/backend/features/market/schemas.py index 6019d6f..fb49145 100644 --- a/next/backend/features/market/schemas.py +++ b/next/backend/features/market/schemas.py @@ -69,3 +69,25 @@ class ReferenceSyncResponse(BaseModel): calendar_days: int = Field(ge=1) entities: int = Field(ge=1) observed_at: datetime + + +class SnapshotSyncResponse(BaseModel): + trade_date: str + observed_at: datetime + coverage: float + stocks: int + limit_up: int + limit_down: int + broken: int + temperature: int + + +class MarketWorkspaceResponse(BaseModel): + trade_date: str | None + observed_at: datetime | None = None + carried_forward: bool = False + message: str = "" + overview: dict[str, Any] = Field(default_factory=dict) + sentiment: dict[str, Any] | None = None + history: list[dict[str, Any]] | None = None + items: list[dict[str, Any]] | None = None diff --git a/next/backend/features/market/sentiment.py b/next/backend/features/market/sentiment.py new file mode 100644 index 0000000..76faf4c --- /dev/null +++ b/next/backend/features/market/sentiment.py @@ -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 diff --git a/next/backend/features/market/service.py b/next/backend/features/market/service.py index b980d86..7042bb3 100644 --- a/next/backend/features/market/service.py +++ b/next/backend/features/market/service.py @@ -5,12 +5,14 @@ from typing import Any from backend.data.gateway import DataGateway, MarketDataUnavailable from backend.data.providers.base import ProviderError from backend.data.quality import DataQualityError +from backend.features.market.sync import MarketSnapshotService, SnapshotSyncError from backend.http.errors import AppError class MarketService: - def __init__(self, gateway: DataGateway) -> None: + def __init__(self, gateway: DataGateway, snapshots: MarketSnapshotService) -> None: self._gateway = gateway + self._snapshots = snapshots def context(self, requested_date: str | None = None) -> dict[str, Any]: context = self._call(self._gateway.trade_context, requested_date) @@ -76,11 +78,17 @@ class MarketService: def refresh_reference(self) -> dict[str, int | str]: return self._call(self._gateway.refresh_reference) + def sync_snapshot(self, requested_date: str | None = None) -> dict[str, Any]: + return self._call(self._snapshots.sync, requested_date) + + def workspace(self, key: str, requested_date: str | None = None) -> dict[str, Any]: + return self._call(self._snapshots.workspace, key, requested_date) + @staticmethod def _call(function, *args): try: return function(*args) - except (MarketDataUnavailable, ProviderError, DataQualityError) as exc: + except (MarketDataUnavailable, ProviderError, DataQualityError, SnapshotSyncError) as exc: raise AppError("market_data_unavailable", str(exc), 503) from exc diff --git a/next/backend/features/market/snapshot.py b/next/backend/features/market/snapshot.py new file mode 100644 index 0000000..0c05047 --- /dev/null +++ b/next/backend/features/market/snapshot.py @@ -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]}" diff --git a/next/backend/features/market/sync.py b/next/backend/features/market/sync.py new file mode 100644 index 0000000..7156504 --- /dev/null +++ b/next/backend/features/market/sync.py @@ -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 diff --git a/next/docs/evidence/stage-6/README.md b/next/docs/evidence/stage-6/README.md new file mode 100644 index 0000000..d771969 --- /dev/null +++ b/next/docs/evidence/stage-6/README.md @@ -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;它们复用本阶段的日度市场快照,不建立第二份涨停结构数据。 diff --git a/next/docs/evidence/stage-6/emotion-light-1920x1080.jpg b/next/docs/evidence/stage-6/emotion-light-1920x1080.jpg new file mode 100644 index 0000000..3404bf4 Binary files /dev/null and b/next/docs/evidence/stage-6/emotion-light-1920x1080.jpg differ diff --git a/next/docs/evidence/stage-6/performance-dark-390x844.jpg b/next/docs/evidence/stage-6/performance-dark-390x844.jpg new file mode 100644 index 0000000..bfc9919 Binary files /dev/null and b/next/docs/evidence/stage-6/performance-dark-390x844.jpg differ diff --git a/next/docs/evidence/stage-6/pool-dark-1920x1080.jpg b/next/docs/evidence/stage-6/pool-dark-1920x1080.jpg new file mode 100644 index 0000000..ca873d5 Binary files /dev/null and b/next/docs/evidence/stage-6/pool-dark-1920x1080.jpg differ diff --git a/next/docs/evidence/stage-6/pool-dark-3840x2160.jpg b/next/docs/evidence/stage-6/pool-dark-3840x2160.jpg new file mode 100644 index 0000000..eb6faed Binary files /dev/null and b/next/docs/evidence/stage-6/pool-dark-3840x2160.jpg differ diff --git a/next/frontend/src/app/shell/TopBar.vue b/next/frontend/src/app/shell/TopBar.vue index a9410e5..c16bd56 100644 --- a/next/frontend/src/app/shell/TopBar.vue +++ b/next/frontend/src/app/shell/TopBar.vue @@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import { useRoute, useRouter } from "vue-router"; +import { marketApi } from "../../shared/api/market"; import { useMarketStore } from "../../shared/stores/market"; import { useSessionStore } from "../../shared/stores/session"; import { useUiStore } from "../../shared/stores/ui"; @@ -14,6 +15,7 @@ const session = useSessionStore(); const ui = useUiStore(); const menuOpen = ref(false); const menuRoot = ref(null); +const refreshing = ref(false); const title = computed(() => { if (route.name === "system") return "系统管理"; @@ -56,6 +58,19 @@ async function endSession(action: "logout" | "switch-account"): Promise { } } +async function refreshMarket(): Promise { + if (refreshing.value) return; + refreshing.value = true; + try { + const result = await marketApi.syncSnapshot(market.selectedDate); + ui.showToast(`${result.trade_date} 行情快照已更新,刷新页面后读取新数据`); + } catch (error) { + ui.showToast(error instanceof Error ? error.message : "行情刷新失败,请稍后重试"); + } finally { + refreshing.value = false; + } +} + onMounted(() => document.addEventListener("mousedown", closeOnOutside)); onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside)); @@ -74,7 +89,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside)) - +
diff --git a/next/frontend/src/app/views/WorkspaceView.vue b/next/frontend/src/app/views/WorkspaceView.vue index d4ebb95..46d4b43 100644 --- a/next/frontend/src/app/views/WorkspaceView.vue +++ b/next/frontend/src/app/views/WorkspaceView.vue @@ -3,6 +3,7 @@ import { computed } from "vue"; import { useRoute } from "vue-router"; import EmptyState from "../../shared/components/EmptyState.vue"; +import MarketWorkspaceView from "../../pages/market/MarketWorkspaceView.vue"; import { useMarketStore } from "../../shared/stores/market"; import { useSessionStore } from "../../shared/stores/session"; import { useUiStore } from "../../shared/stores/ui"; @@ -16,10 +17,16 @@ const workspace = computed(() => findWorkspace(String(route.params.workspace)) ? const locked = computed( () => ["screener", "mentor", "heaven"].includes(workspace.value.key) && !session.account?.smart_access, ); +const implementedMarket = computed(() => + ["emotion", "pool", "broken", "limit-down", "yesterday", "performance"].includes( + workspace.value.key, + ), +);