diff --git a/next/backend/data/gateway.py b/next/backend/data/gateway.py index a1226df..354e24b 100644 --- a/next/backend/data/gateway.py +++ b/next/backend/data/gateway.py @@ -250,6 +250,9 @@ class DataGateway: with self._database.read() as connection: return self._repository.search(connection, query) + def resolve_entity(self, entity_type: str, identifier: str) -> MarketEntity: + return self._resolve_entity(entity_type, identifier) + def chart( self, entity_type: str, diff --git a/next/backend/features/market/routes.py b/next/backend/features/market/routes.py index 509ed1f..189922a 100644 --- a/next/backend/features/market/routes.py +++ b/next/backend/features/market/routes.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Path, Query, Request from backend.features.accounts.auth import AdminWritePrincipal, AuthenticatedPrincipal from backend.features.market.schemas import ( ChartResponse, + EntityDetailResponse, MarketInsightResponse, MarketSummaryResponse, MarketWorkspaceResponse, @@ -61,6 +62,19 @@ def chart( return request.app.state.container.market.chart(entity_type, identifier, interval) +@router.get("/entities/{entity_type}/{identifier}/detail", response_model=EntityDetailResponse) +def entity_detail( + request: Request, + _principal: AuthenticatedPrincipal, + entity_type: Annotated[Literal["stock", "sector", "theme", "index"], Path()], + identifier: Annotated[str, Path(min_length=1, max_length=40)], + requested_date: Annotated[str | None, Query(alias="date")] = None, +) -> dict: + return request.app.state.container.market.entity_detail( + entity_type, identifier, requested_date + ) + + @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() diff --git a/next/backend/features/market/schemas.py b/next/backend/features/market/schemas.py index 89e7f48..647b32e 100644 --- a/next/backend/features/market/schemas.py +++ b/next/backend/features/market/schemas.py @@ -65,6 +65,20 @@ class ChartResponse(BaseModel): points: list[ChartPointResponse] +class EntityDetailResponse(BaseModel): + model_config = ConfigDict(extra="allow") + + entity: SearchResultResponse + trade_date: str + observed_at: datetime + price: float + previous_close: float | None + change: float | None + metrics: list[dict[str, Any]] = Field(default_factory=list) + money_flow: dict[str, Any] | None = None + event: dict[str, Any] | None = None + + class ReferenceSyncResponse(BaseModel): calendar_days: int = Field(ge=1) entities: int = Field(ge=1) diff --git a/next/backend/features/market/service.py b/next/backend/features/market/service.py index ae17fdf..2bb79b2 100644 --- a/next/backend/features/market/service.py +++ b/next/backend/features/market/service.py @@ -83,6 +83,13 @@ class MarketService: ], } + def entity_detail( + self, entity_type: str, identifier: str, requested_date: str | None = None + ) -> dict[str, Any]: + return self._call( + self._snapshots.entity_detail, entity_type, identifier, requested_date + ) + def refresh_reference(self) -> dict[str, int | str]: return self._call(self._gateway.refresh_reference) diff --git a/next/backend/features/market/sync.py b/next/backend/features/market/sync.py index 98aceae..4cfe09e 100644 --- a/next/backend/features/market/sync.py +++ b/next/backend/features/market/sync.py @@ -143,6 +143,86 @@ class MarketSnapshotService: raise SnapshotSyncError("不支持的市场工作区") return response + def entity_detail( + self, entity_type: str, identifier: str, requested_date: str | None = None + ) -> dict[str, Any]: + context = self._gateway.trade_context(requested_date) + if context.actual_date is None: + raise SnapshotSyncError("等待管理员首次同步真实收盘行情") + entity = self._gateway.resolve_entity(entity_type, identifier) + series = self._gateway.chart(entity_type, entity.identifier, "day") + eligible = [point for point in series.points if point.time <= context.actual_date] + if not eligible: + raise SnapshotSyncError("所选日期之前没有可核验的真实行情") + current = eligible[-1] + previous = eligible[-2].close if len(eligible) > 1 else series.previous_close + change = (current.close / previous - 1) * 100 if previous else None + + with self._database.read() as connection: + summary = self._repository.latest_summary(connection, context.actual_date) + factor_row = connection.execute( + """ + SELECT factor_values.payload_json + FROM screener_factor_values AS factor_values + JOIN screener_factor_snapshots AS snapshots + ON snapshots.id = factor_values.snapshot_id + WHERE factor_values.identifier = ? AND snapshots.trade_date <= ? + ORDER BY snapshots.trade_date DESC, snapshots.id DESC LIMIT 1 + """, + (entity.identifier, context.actual_date), + ).fetchone() + factors = json.loads(str(factor_row["payload_json"])) if factor_row else {} + snapshot = json.loads(str(summary["payload_json"])) if summary else {} + event = ( + _entity_event(snapshot, entity.identifier, entity.code) + if entity_type == "stock" + else None + ) + metrics = [ + {"key": "open", "label": "开盘", "value": current.open, "unit": "元"}, + {"key": "high", "label": "最高", "value": current.high, "unit": "元"}, + {"key": "low", "label": "最低", "value": current.low, "unit": "元"}, + {"key": "amount", "label": "成交额", "value": current.amount, "unit": "元"}, + {"key": "volume", "label": "成交量", "value": current.volume, "unit": "股"}, + ] + for key, label, unit in ( + ("turnover_rate", "换手率", "%"), + ("return_5d", "近5日", "%"), + ("return_20d", "近20日", "%"), + ("total_mv_billion", "总市值", "亿"), + ("circ_mv_billion", "流通市值", "亿"), + ): + metrics.append({"key": key, "label": label, "value": factors.get(key), "unit": unit}) + money_flow = None + if entity_type == "stock": + money_flow = { + "available": any( + factors.get(key) is not None + for key in ("net_flow_million", "large_flow_million", "net_flow_5d_million") + ), + "net_million": factors.get("net_flow_million"), + "large_million": factors.get("large_flow_million"), + "net_5d_million": factors.get("net_flow_5d_million"), + "flow_to_circ_mv_5d": factors.get("flow_to_circ_mv_5d"), + } + return { + "entity": { + "entity_type": entity.entity_type, + "identifier": entity.identifier, + "code": entity.code, + "name": factors.get("name") or entity.name, + "sector": factors.get("sector") or entity.sector, + }, + "trade_date": current.time, + "observed_at": series.metadata.observed_at, + "price": current.close, + "previous_close": previous, + "change": round(change, 4) if change is not None else None, + "metrics": metrics, + "money_flow": money_flow, + "event": event, + } + def rotation_member_target( self, requested_date: str | None, sector_name: str ) -> tuple[str, str]: @@ -185,6 +265,24 @@ def _history_item(payload: dict[str, Any]) -> dict[str, Any]: } +def _entity_event( + snapshot: dict[str, Any], identifier: str, code: str +) -> dict[str, Any] | None: + for key, status in (("limits", "涨停"), ("broken", "炸板"), ("down_limits", "跌停")): + for row in snapshot.get(key) or []: + if str(row.get("identifier") or "") == identifier or str(row.get("code") or "") == code: + return { + "status": status, + "reason": str(row.get("reason") or ""), + "streak": row.get("streak"), + "first_time": row.get("first_time"), + "last_time": row.get("last_time"), + "open_times": row.get("open_times"), + "seal_amount": row.get("seal_amount"), + } + return None + + def _date(value: str) -> str: try: return date.fromisoformat(value).isoformat() diff --git a/next/docs/final/completion-audit.md b/next/docs/final/completion-audit.md new file mode 100644 index 0000000..9f0357d --- /dev/null +++ b/next/docs/final/completion-audit.md @@ -0,0 +1,81 @@ +# 重建版完成度审计 + +更新日期:2026-07-30 + +## 结论 + +重建版已经建立可维护的模块化单体骨架,并完成了账户、权限隔离、共享行情归档、主要市场工作区、 +问师、问天、复盘、确定性选股、迁移和备份等核心路径。但它尚未达到《小白复盘-完整产品规格说明书》 +定义的“功能完整迁移”,此前 `spec-coverage.md` 中“85/85”结论作废。 + +本文件只记录可由代码、确定性测试或真实浏览器断言证明的事实。页面可打开、组件名称存在、截图看似正常, +均不能替代产品行为验收。 + +## 已确认完成的基础 + +- 新旧运行时完全分离,新版不导入旧版 `server.py`、`database.py`、`app.js` 或旧样式覆盖层。 +- 账户、会话、管理员与会员双维度权限、私有数据隔离、系统凭据加密和模型密钥不回显已有服务端测试。 +- 行情供应商通过唯一 `DataGateway` 和来源策略进入业务层;公开展示源不能进入正式计算。 +- 情绪、竞价、观势、观气、观心、选股和复盘的核心确定性计算已有固定样本测试。 +- 16 个主工作区、策略跟踪内部页、账户弹窗和系统管理外壳均已建立。 +- 数据库迁移、旧库只读迁移、备份、校验恢复、生产静态文件服务和安全响应头已有自动测试。 +- PC 统一 Shell、设计令牌、日夜主题和独立移动端布局规则已经建立。 + +## 阻断“完整迁移”的产品缺口 + +### P0:用户可见功能缺失 + +1. **历史数据回补**:系统管理仍显示“暂不可用”,不符合行情管理的正式功能要求。 +2. **模型连通性测试**:模型池支持增删改和主辅选择,但每个模型的独立测试入口与服务端测试调用尚未实现。 +3. **事件原因治理**:涨停、炸板、跌停原因的盘后补充、管理员人工修订优先级和修订记录尚未实现。 +4. **自定义选股完整能力**:缺少自然语言转换受控公式和滚动回测;精选策略详情尚未完整显示评分权重、 + 执行频率和风险等级。 + +### P0:运行与数据真相缺失 + +1. **盘中行情刷新任务**:当前只启动盘后选股调度。交易日 9:15-11:35、12:55-15:05 的行情刷新、 + 约 8 秒节流、单任务互斥和最后成功快照保护尚未形成正式后台任务。 +2. **任务状态与审计**:行情刷新、竞价采集、盘后选股和事件补充没有统一的开始/完成/失败、覆盖率、 + 输出版本、耗时和重试记录。前端也无法显示真实任务状态。 +3. **阶段选股四步状态**:已去掉“无运行结果时回退第一条结果”的错误行为,但四步仍未逐步表达未执行、 + 执行中、失败和完成。 +4. **状态栏真相**:状态栏右侧仍是固定“等待行情数据”,没有反映真实快照或后台任务状态。 + +### P1:验收和交互覆盖不足 + +1. 固定案例 `C06`、`C08` 当前为未实现,不能标记通过。 +2. `C05`、`U01` 的主题无白闪需要首帧/加载态直接断言,不能只依赖夜间截图。 +3. `M08` 需要测量 7 板及更高时右侧结论布局,不应只断言文字存在。 +4. `W09` 需要直接检查夜间关键文字对比度和可见性,不能只依赖截图。 +5. `U04` 已增加 4K 内容最大宽度、基础字号和 Shell 高度断言,仍需最终人工视觉验收信息密度。 +6. 第 26 节要求的逐页权限、正常、空、缺失、加载、失败、持久化、日间、夜间、1080P、4K、390px + 覆盖尚未逐格完成;当前 E2E 不能等同于完整矩阵。 + +## 已在本轮纠正 + +- 阶段选股不再在当前阶段无结果时错误回退到第一条运行结果。 +- 未执行盘后任务时,阶段流程不再预先显示完成态。 +- 首次无快照、后台刷新不重绘、刷新后读取最新真实日期已有直接 E2E。 +- 上涨K线空心实体和上下影线不穿体已有几何断言。 +- 情绪退潮警示无选股建议、60日末行可达、低行数股池状态栏固定已有直接 E2E。 +- 阶段、精选、自定义三类结果切换和策略来源隔离已有直接 E2E。 +- 股票、板块、题材和指数共用唯一实体详情接口;详情涨跌幅按所选真实交易日及对应前收计算。 +- 个股详情已包含交易指标、资金流、事件逻辑、自选操作、观势入口和私有个股笔记;题材详情展示成分股。 +- 股池、天梯、轮动、题材、人气榜、龙虎榜、智能选股、自选、交易日志、策略跟踪和提醒中的股票代码 + 共用唯一日K/分时悬浮与详情入口;无悬停设备直接进入详情。 + +## 外部环境阻断项 + +- 当前本机没有可用 Docker 引擎,镜像尚未在真实 Docker 环境构建和启动。 +- NAS 生产容器尚未切换;持久化重启、健康检查和回退必须在 Docker/NAS 上验证。 +- 旧运行时在正式切换前继续保留。切换 NAS 和清理旧实现必须取得用户最终明确确认。 + +## 完成规则 + +只有以下条件全部满足后,才可恢复“重建完成”的表述: + +1. 本文件所有 P0 缺口完成并有直接测试。 +2. 85 个固定案例逐项标记为“确定性测试”“浏览器直接断言”或“人工视觉验收”;不得使用宽泛截图代替算法证明。 +3. 第 26 节页面覆盖矩阵逐格完成,人工验收项由用户确认。 +4. Docker 构建、启动、持久化重启、备份恢复和回退在实际容器环境通过。 +5. NAS 切换后执行最终冒烟,旧实现只按确认后的清理方案处理。 diff --git a/next/docs/final/spec-coverage.md b/next/docs/final/spec-coverage.md index d53f6b7..fe6aeba 100644 --- a/next/docs/final/spec-coverage.md +++ b/next/docs/final/spec-coverage.md @@ -1,4 +1,8 @@ -# 产品规格覆盖矩阵 +# 产品规格覆盖矩阵(历史阶段记录) + +> 本文件保留阶段 15 当时的证据映射,但不再代表最终完成结论。重新审计已确认 `C06`、`C08` +> 等存在真实功能缺口,部分截图证据也不足以证明对应行为。当前权威完成度见 +> `docs/final/completion-audit.md`;在该审计全部关闭前,不得引用本文件的“85/85”表述。 本矩阵以《小白复盘完整产品规格说明书》第25节的85个固定案例为唯一编号源。 “自动”表示确定性单元/API测试;“浏览器”表示Playwright交互、响应式断言和阶段截图共同验收。 diff --git a/next/frontend/src/app/views/EntityDetailView.vue b/next/frontend/src/app/views/EntityDetailView.vue index 714b5cd..4c45af1 100644 --- a/next/frontend/src/app/views/EntityDetailView.vue +++ b/next/frontend/src/app/views/EntityDetailView.vue @@ -1,12 +1,29 @@ diff --git a/next/frontend/src/pages/heaven/HeavenPage.vue b/next/frontend/src/pages/heaven/HeavenPage.vue index 372f007..4a32a1e 100644 --- a/next/frontend/src/pages/heaven/HeavenPage.vue +++ b/next/frontend/src/pages/heaven/HeavenPage.vue @@ -1,5 +1,6 @@ diff --git a/next/frontend/src/pages/review/ReviewPage.vue b/next/frontend/src/pages/review/ReviewPage.vue index c15fa90..08227f4 100644 --- a/next/frontend/src/pages/review/ReviewPage.vue +++ b/next/frontend/src/pages/review/ReviewPage.vue @@ -5,6 +5,7 @@ import { marketApi, type MarketEntity } from "../../shared/api/market"; import { reviewApi, type ReviewWorkspace, type TradeEntry, type WatchItem } from "../../shared/api/review"; import BaseDialog from "../../shared/components/BaseDialog.vue"; import EmptyState from "../../shared/components/EmptyState.vue"; +import MarketEntityLink from "../../shared/market/MarketEntityLink.vue"; import { useMarketStore } from "../../shared/stores/market"; import { useUiStore } from "../../shared/stores/ui"; import TradeDialog from "./TradeDialog.vue"; @@ -111,7 +112,7 @@ onMounted(load);