rebuild(audit): complete entity detail and market preview
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 切换后执行最终冒烟,旧实现只按确认后的清理方案处理。
|
||||
@@ -1,4 +1,8 @@
|
||||
# 产品规格覆盖矩阵
|
||||
# 产品规格覆盖矩阵(历史阶段记录)
|
||||
|
||||
> 本文件保留阶段 15 当时的证据映射,但不再代表最终完成结论。重新审计已确认 `C06`、`C08`
|
||||
> 等存在真实功能缺口,部分截图证据也不足以证明对应行为。当前权威完成度见
|
||||
> `docs/final/completion-audit.md`;在该审计全部关闭前,不得引用本文件的“85/85”表述。
|
||||
|
||||
本矩阵以《小白复盘完整产品规格说明书》第25节的85个固定案例为唯一编号源。
|
||||
“自动”表示确定性单元/API测试;“浏览器”表示Playwright交互、响应式断言和阶段截图共同验收。
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import type { MarketEntity } from "../../shared/api/market";
|
||||
import { marketApi, type EntityDetail, type EntityMetric, type MarketEntity, type ThemeDetailData } from "../../shared/api/market";
|
||||
import { reviewApi } from "../../shared/api/review";
|
||||
import DataTable from "../../shared/components/DataTable.vue";
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import MarketPreviewPanel from "../../shared/market/MarketPreviewPanel.vue";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import StockNotes from "../../pages/review/StockNotes.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const market = useMarketStore();
|
||||
const ui = useUiStore();
|
||||
const detail = ref<EntityDetail | null>(null);
|
||||
const theme = ref<ThemeDetailData | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const watchlisted = ref(false);
|
||||
const watchBusy = ref(false);
|
||||
const sortKey = ref("change");
|
||||
const sortDirection = ref<"asc" | "desc">("desc");
|
||||
|
||||
const entity = computed<MarketEntity>(() => ({
|
||||
entity_type: String(route.params.entityType) as MarketEntity["entity_type"],
|
||||
identifier: String(route.params.identifier),
|
||||
@@ -14,15 +31,153 @@ const entity = computed<MarketEntity>(() => ({
|
||||
name: String(route.query.name ?? route.params.identifier),
|
||||
sector: route.query.sector ? String(route.query.sector) : null,
|
||||
}));
|
||||
const resolvedEntity = computed(() => detail.value?.entity ?? entity.value);
|
||||
const themeRows = computed(() => {
|
||||
const rows = theme.value?.members ?? [];
|
||||
const direction = sortDirection.value === "asc" ? 1 : -1;
|
||||
return [...rows].sort((left, right) => compare(left[sortKey.value], right[sortKey.value]) * direction);
|
||||
});
|
||||
const themeColumns = [
|
||||
{ key: "code", label: "代码", code: true, sortable: true },
|
||||
{ key: "name", label: "股票", sortable: true },
|
||||
{ key: "change", label: "涨跌幅(%)", numeric: true, sortable: true, format: decimal },
|
||||
{ key: "close", label: "收盘(元)", numeric: true, sortable: true, format: decimal },
|
||||
{ key: "amount", label: "成交额(亿)", numeric: true, sortable: true, format: amountCell },
|
||||
{ key: "quoted", label: "行情状态", format: (value: unknown) => value ? "正常交易" : "当日无行情" },
|
||||
];
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
detail.value = null;
|
||||
theme.value = null;
|
||||
try {
|
||||
const [marketDetail, review, themeDetail] = await Promise.all([
|
||||
marketApi.detail(entity.value, market.selectedDate),
|
||||
entity.value.entity_type === "stock" ? reviewApi.workspace(market.selectedDate) : null,
|
||||
entity.value.entity_type === "theme" ? marketApi.themeDetail(entity.value.identifier, market.selectedDate) : null,
|
||||
]);
|
||||
detail.value = marketDetail;
|
||||
theme.value = themeDetail;
|
||||
watchlisted.value = Boolean(review?.watchlist.some((item) => item.identifier === entity.value.identifier));
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : "行情详情读取失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleWatch(): Promise<void> {
|
||||
if (watchBusy.value) return;
|
||||
watchBusy.value = true;
|
||||
try {
|
||||
if (watchlisted.value) await reviewApi.removeWatch(resolvedEntity.value.identifier);
|
||||
else await reviewApi.addWatch(resolvedEntity.value.identifier);
|
||||
watchlisted.value = !watchlisted.value;
|
||||
ui.showToast(watchlisted.value ? "已加入自选" : "已移出自选");
|
||||
} catch (reason) {
|
||||
ui.showToast(reason instanceof Error ? reason.message : "自选操作失败");
|
||||
} finally {
|
||||
watchBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openTrend(): void {
|
||||
void router.push({ path: "/workspace/heaven", query: { mode: "trend", query: resolvedEntity.value.code } });
|
||||
}
|
||||
|
||||
function sort(key: string): void {
|
||||
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||||
else { sortKey.value = key; sortDirection.value = "desc"; }
|
||||
}
|
||||
|
||||
function formatMetric(metric: EntityMetric): string {
|
||||
if (metric.value === null || metric.value === undefined) return "";
|
||||
if (metric.key === "amount") return amount(metric.value);
|
||||
if (metric.key === "volume") return `${(metric.value / 10_000).toFixed(2)} 万`;
|
||||
return `${metric.value.toFixed(2)} ${metric.unit}`.trim();
|
||||
}
|
||||
function money(value: number | null | undefined): string {
|
||||
return value === null || value === undefined ? "" : `${(value * 100).toFixed(2)} 万`;
|
||||
}
|
||||
function decimal(value: unknown): string {
|
||||
const parsed = Number(value); return Number.isFinite(parsed) ? parsed.toFixed(2) : "";
|
||||
}
|
||||
function amount(value: unknown): string {
|
||||
const parsed = Number(value); return Number.isFinite(parsed) ? `${(parsed / 100_000_000).toFixed(2)} 亿` : "";
|
||||
}
|
||||
function amountCell(value: unknown): string {
|
||||
const parsed = Number(value); return Number.isFinite(parsed) ? (parsed / 100_000_000).toFixed(2) : "";
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
watch([() => route.params.identifier, () => market.selectedDate], load, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-frame entity-detail-page">
|
||||
<header class="page-header entity-heading">
|
||||
<div><h1>{{ entity.name }}</h1><p class="page-subtitle">{{ entity.code }}<template v-if="entity.sector"> · {{ entity.sector }}</template></p></div>
|
||||
<span class="tag">最新真实行情</span>
|
||||
</header>
|
||||
<MarketPreviewPanel :entity="entity" class="card entity-chart" />
|
||||
<StockNotes v-if="entity.entity_type === 'stock'" :code="entity.code" :name="entity.name" />
|
||||
<div v-if="loading" class="card workspace-state">正在读取完整行情详情</div>
|
||||
<EmptyState v-else-if="error" class="card" title="行情详情暂不可用" :description="error" />
|
||||
<template v-else-if="detail">
|
||||
<header class="page-header entity-detail-heading">
|
||||
<div>
|
||||
<span class="entity-detail-code">{{ detail.entity.code }}</span>
|
||||
<h1>{{ detail.entity.name }}</h1>
|
||||
<span v-if="detail.entity.sector" class="tag">{{ detail.entity.sector }}</span>
|
||||
</div>
|
||||
<div class="entity-detail-price">
|
||||
<strong class="numeric">{{ detail.price.toFixed(2) }}</strong>
|
||||
<span v-if="detail.change !== null" class="numeric" :class="detail.change >= 0 ? 'up' : 'down'">{{ detail.change >= 0 ? '+' : '' }}{{ detail.change.toFixed(2) }}%</span>
|
||||
<small>数据日期 {{ detail.trade_date }}</small>
|
||||
</div>
|
||||
<div v-if="detail.entity.entity_type === 'stock'" class="page-actions entity-detail-actions">
|
||||
<button class="btn" type="button" :disabled="watchBusy" @click="toggleWatch">{{ watchlisted ? "移出自选" : "加入自选" }}</button>
|
||||
<button class="btn btn-primary" type="button" @click="openTrend">进入观势</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="entity-detail-grid">
|
||||
<MarketPreviewPanel :entity="resolvedEntity" class="card entity-chart" />
|
||||
<article class="card entity-metrics-card">
|
||||
<header class="card-header"><h2>交易数据</h2><span class="faint">对应 {{ detail.trade_date }}</span></header>
|
||||
<dl class="entity-metrics">
|
||||
<div v-for="metric in detail.metrics" :key="metric.key"><dt>{{ metric.label }}</dt><dd class="numeric">{{ formatMetric(metric) }}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div v-if="detail.entity.entity_type === 'stock'" class="entity-stock-grid">
|
||||
<article class="card entity-flow-card">
|
||||
<header class="card-header"><h2>资金流</h2><span class="faint">同一正式数据口径</span></header>
|
||||
<dl v-if="detail.money_flow?.available" class="entity-metrics entity-flow-metrics">
|
||||
<div><dt>当日净流入</dt><dd class="numeric">{{ money(detail.money_flow.net_million) }}</dd></div>
|
||||
<div><dt>大单净流入</dt><dd class="numeric">{{ money(detail.money_flow.large_million) }}</dd></div>
|
||||
<div><dt>近5日净流入</dt><dd class="numeric">{{ money(detail.money_flow.net_5d_million) }}</dd></div>
|
||||
<div><dt>近5日/流通市值</dt><dd class="numeric">{{ detail.money_flow.flow_to_circ_mv_5d === null ? "" : `${detail.money_flow.flow_to_circ_mv_5d.toFixed(4)}%` }}</dd></div>
|
||||
</dl>
|
||||
<EmptyState v-else title="资金流暂不可用" description="当前日期尚无达到覆盖标准的同口径资金流数据。" />
|
||||
</article>
|
||||
<article class="card entity-event-card">
|
||||
<header class="card-header"><h2>事件逻辑</h2><span v-if="detail.event" class="tag">{{ detail.event.status }}</span></header>
|
||||
<div v-if="detail.event" class="entity-event-body">
|
||||
<strong>{{ detail.event.reason || `${detail.event.status}原因待补充` }}</strong>
|
||||
<dl><div><dt>连板</dt><dd>{{ detail.event.streak ?? "" }}</dd></div><div><dt>首次</dt><dd>{{ detail.event.first_time ?? "" }}</dd></div><div><dt>最后</dt><dd>{{ detail.event.last_time ?? "" }}</dd></div><div><dt>开板</dt><dd>{{ detail.event.open_times ?? "" }}</dd></div></dl>
|
||||
</div>
|
||||
<EmptyState v-else title="当日无特殊事件" description="该股票当日不在涨停、炸板或跌停事件池中。" />
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section v-if="detail.entity.entity_type === 'theme'" class="card entity-members-card">
|
||||
<header class="card-header"><h2>题材成分股</h2><span class="faint">{{ theme?.summary.quoted_count ?? 0 }} / {{ theme?.summary.member_count ?? 0 }}只有行情</span></header>
|
||||
<DataTable v-if="themeRows.length" :columns="themeColumns" :rows="themeRows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
|
||||
<EmptyState v-else title="暂无成分股" description="当前题材尚无可核验的成分股归档。" />
|
||||
</section>
|
||||
|
||||
<StockNotes v-if="detail.entity.entity_type === 'stock'" :code="detail.entity.code" :name="detail.entity.name" />
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { heavenApi, type HeavenReading, type HeavenSetup, type ReadingMode } from "../../shared/api/heaven";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
@@ -11,9 +12,10 @@ import InterpretDialog from "./InterpretDialog.vue";
|
||||
import TrendPanel from "./TrendPanel.vue";
|
||||
|
||||
const market = useMarketStore();
|
||||
const route = useRoute();
|
||||
const session = useSessionStore();
|
||||
const ui = useUiStore();
|
||||
const mode = ref<ReadingMode>("trend");
|
||||
const mode = ref<ReadingMode>(["trend", "fortune", "heart"].includes(String(route.query.mode)) ? String(route.query.mode) as ReadingMode : "trend");
|
||||
const setup = ref<HeavenSetup | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
@@ -75,7 +77,7 @@ watch([() => market.selectedDate, locked], load, { immediate: true });
|
||||
<div v-if="loading" class="card workspace-state">正在推演当日基础气机</div>
|
||||
<div v-else-if="error" class="notice notice-warning">{{ error }}</div>
|
||||
<div v-else :class="{ 'locked-content': locked }" :aria-disabled="locked">
|
||||
<TrendPanel v-if="mode === 'trend'" :disabled="locked" @interpret="openInterpret" />
|
||||
<TrendPanel v-if="mode === 'trend'" :disabled="locked" :initial-query="String(route.query.query || '')" @interpret="openInterpret" />
|
||||
<FortunePanel v-else-if="mode === 'fortune'" :field="setup?.fortune || null" :daily="setup?.daily_fortune || null" :disabled="locked" @interpret="openInterpret" @saved="saveFortune" />
|
||||
<HeartPanel v-else :disabled="locked" @interpret="openInterpret" />
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import { heavenApi, type HeavenReading } from "../../shared/api/heaven";
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import HexagramGraphic from "./HexagramGraphic.vue";
|
||||
|
||||
const props = defineProps<{ disabled: boolean }>();
|
||||
const props = defineProps<{ disabled: boolean; initialQuery?: string }>();
|
||||
const emit = defineEmits<{ interpret: [reading: HeavenReading] }>();
|
||||
const market = useMarketStore();
|
||||
const ui = useUiStore();
|
||||
@@ -49,6 +49,13 @@ function reading(): HeavenReading {
|
||||
created_at: "",
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const initial = props.initialQuery?.trim();
|
||||
if (!initial || props.disabled) return;
|
||||
query.value = initial;
|
||||
void load(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, reactive, ref } from "vue";
|
||||
|
||||
import type { MarketWorkspaceData } from "../../../shared/api/market";
|
||||
import MarketEntityLink from "../../../shared/market/MarketEntityLink.vue";
|
||||
|
||||
const props = defineProps<{ data: MarketWorkspaceData }>();
|
||||
const sortMode = ref<"time" | "open">("time");
|
||||
@@ -88,7 +89,7 @@ function exportCsv(): void {
|
||||
<header><div><strong>{{ group.level === 1 ? "首板" : `${group.level}板` }}</strong><span>{{ group.count }}只</span></div><small v-if="group.level > 1 && Number(group.count)">{{ group.adjacentRate === null ? "相邻梯队断层" : `相邻梯队 ${group.adjacentRate.toFixed(1)}%` }}</small></header>
|
||||
<div class="ladder-stocks">
|
||||
<article v-for="stock in group.visible" :key="String(stock.identifier)" class="ladder-stock">
|
||||
<div><strong>{{ stock.name }}</strong><span>{{ stock.code }}</span></div>
|
||||
<div><strong>{{ stock.name }}</strong><MarketEntityLink v-if="stock.identifier" :entity="{ entity_type: 'stock', identifier: String(stock.identifier), code: String(stock.code), name: String(stock.name), sector: stock.sector ? String(stock.sector) : null }" /></div>
|
||||
<p><b>{{ stock.sector || "其他" }}</b><span>{{ stock.first_time || "时间待校正" }}</span></p>
|
||||
<small>开板 {{ stock.open_times }}次 · 成交 {{ (Number(stock.amount ?? 0) / 100_000_000).toFixed(2) }}亿</small>
|
||||
</article>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { reviewApi, type AlertItem } from "../../shared/api/review";
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import MarketEntityLink from "../../shared/market/MarketEntityLink.vue";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
const ui = useUiStore();
|
||||
@@ -38,7 +39,7 @@ onMounted(load);
|
||||
<div class="alerts-toolbar"><div class="segmented"><button type="button" :class="{ active: !unreadOnly }" @click="select(false)">全部</button><button type="button" :class="{ active: unreadOnly }" @click="select(true)">未读</button></div><button class="btn btn-small" type="button" @click="markAll">全部已读</button></div>
|
||||
<form class="alert-create" @submit.prevent="create"><div class="alert-create-grid"><label class="field"><span class="field-label">标题</span><input v-model="form.title" class="input" maxlength="80" required /></label><label class="field"><span class="field-label">提醒日期</span><input v-model="form.remind_date" class="input" type="date" required /></label><label class="field"><span class="field-label">股票代码(可选)</span><input v-model="form.code" class="input" maxlength="12" /></label></div><label class="field"><span class="field-label">提醒内容(可选)</span><textarea v-model="form.content" class="textarea" maxlength="500"></textarea></label><div class="form-actions"><button class="btn btn-primary" type="submit">保存提醒</button></div></form>
|
||||
<div v-if="loading" class="workspace-state">正在读取提醒</div>
|
||||
<div v-else-if="items.length" class="alert-list"><article v-for="item in items" :key="item.id" :class="{ unread: !item.is_read && item.due }"><div><strong>{{ item.title }}</strong><span class="tag">{{ item.due ? (item.is_read ? "已读" : "未读") : "未到期" }}</span></div><p>{{ item.content }}</p><small>{{ item.available_date }}<template v-if="item.code"> · {{ item.code }}</template></small><div class="table-actions"><button v-if="item.due && !item.is_read" class="btn btn-small" type="button" @click="mark(item)">标为已读</button><button class="btn btn-small" type="button" @click="remove(item)">删除</button></div></article></div>
|
||||
<div v-else-if="items.length" class="alert-list"><article v-for="item in items" :key="item.id" :class="{ unread: !item.is_read && item.due }"><div><strong>{{ item.title }}</strong><span class="tag">{{ item.due ? (item.is_read ? "已读" : "未读") : "未到期" }}</span></div><p>{{ item.content }}</p><small>{{ item.available_date }}<template v-if="item.code"> · <MarketEntityLink :entity="{ entity_type: 'stock', identifier: item.code, code: item.code, name: item.code, sector: null }" /></template></small><div class="table-actions"><button v-if="item.due && !item.is_read" class="btn btn-small" type="button" @click="mark(item)">标为已读</button><button class="btn btn-small" type="button" @click="remove(item)">删除</button></div></article></div>
|
||||
<EmptyState v-else title="暂无提醒" description="可以创建站内提醒,策略跟踪反馈也会自动汇总到这里。" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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);
|
||||
<template v-else-if="data">
|
||||
<section class="card review-watch-card">
|
||||
<header class="card-header"><h2>自选追踪</h2><span class="tag">{{ data.watchlist.length }} 只</span></header>
|
||||
<div v-if="data.watchlist.length" class="data-table-wrap review-watch-table"><table class="data-table"><thead><tr><th>标记</th><th>代码</th><th>股票</th><th>板块</th><th class="numeric">今日涨幅(%)</th><th class="numeric">5日涨幅(%)</th><th class="numeric">竞价关注分</th><th>跟踪备注</th><th>操作</th></tr></thead><tbody><tr v-for="item in data.watchlist" :key="item.identifier"><td class="watch-star">★</td><td>{{ item.code }}</td><td>{{ item.name }}</td><td>{{ item.sector ?? "" }}</td><td class="numeric" :class="item.pct_chg !== null && item.pct_chg >= 0 ? 'up' : 'down'">{{ display(item.pct_chg) }}</td><td class="numeric" :class="item.return_5d !== null && item.return_5d >= 0 ? 'up' : 'down'">{{ display(item.return_5d) }}</td><td class="numeric">{{ display(item.attention_score) }}</td><td><input :value="item.remark" class="review-remark" maxlength="500" aria-label="跟踪备注" @change="saveRemark(item, $event)" /></td><td><div class="table-actions"><button class="btn btn-small" type="button" @click="removeWatch(item)">移除</button></div></td></tr></tbody></table></div>
|
||||
<div v-if="data.watchlist.length" class="data-table-wrap review-watch-table"><table class="data-table"><thead><tr><th>标记</th><th>代码</th><th>股票</th><th>板块</th><th class="numeric">今日涨幅(%)</th><th class="numeric">5日涨幅(%)</th><th class="numeric">竞价关注分</th><th>跟踪备注</th><th>操作</th></tr></thead><tbody><tr v-for="item in data.watchlist" :key="item.identifier"><td class="watch-star">★</td><td><MarketEntityLink :entity="{ entity_type: 'stock', identifier: item.identifier, code: item.code, name: item.name, sector: item.sector }" /></td><td>{{ item.name }}</td><td>{{ item.sector ?? "" }}</td><td class="numeric" :class="item.pct_chg !== null && item.pct_chg >= 0 ? 'up' : 'down'">{{ display(item.pct_chg) }}</td><td class="numeric" :class="item.return_5d !== null && item.return_5d >= 0 ? 'up' : 'down'">{{ display(item.return_5d) }}</td><td class="numeric">{{ display(item.attention_score) }}</td><td><input :value="item.remark" class="review-remark" maxlength="500" aria-label="跟踪备注" @change="saveRemark(item, $event)" /></td><td><div class="table-actions"><button class="btn btn-small" type="button" @click="removeWatch(item)">移除</button></div></td></tr></tbody></table></div>
|
||||
<EmptyState v-else title="暂无自选" description="添加股票后可集中查看涨幅、竞价关注分和跟踪备注。" />
|
||||
</section>
|
||||
|
||||
@@ -119,7 +120,7 @@ onMounted(load);
|
||||
<section class="card trade-card">
|
||||
<header class="card-header"><h2>交易日志</h2><span class="tag">{{ data.trades.length }} 条</span><button class="btn btn-primary btn-small review-card-action" type="button" @click="openTrade()">交易日志</button></header>
|
||||
<div class="review-summary"><div><span>总记录</span><strong>{{ summary?.total }}</strong></div><div><span>已实现</span><strong>{{ summary?.realized }}</strong></div><div><span>胜率</span><strong>{{ summary?.win_rate === null ? "" : display(summary?.win_rate, "%") }}</strong></div><div><span>累计盈亏</span><strong>{{ display(summary?.pnl_amount) }}</strong></div><div><span>平均仓位</span><strong>{{ summary?.average_position === null ? "" : display(summary?.average_position, "%") }}</strong></div></div>
|
||||
<div v-if="data.trades.length" class="trade-scroll"><table class="data-table"><thead><tr><th>日期</th><th>代码</th><th>股票</th><th>动作</th><th class="numeric">价格(元)</th><th class="numeric">仓位(%)</th><th class="numeric">盈亏(%)</th><th>操作</th></tr></thead><tbody><tr v-for="row in data.trades" :key="row.id"><td>{{ row.trade_date }}</td><td>{{ row.code }}</td><td>{{ row.name }}</td><td>{{ row.action_label }}</td><td class="numeric">{{ display(row.price) }}</td><td class="numeric">{{ display(row.position_pct) }}</td><td class="numeric" :class="row.pnl_pct !== null && row.pnl_pct >= 0 ? 'up' : 'down'">{{ display(row.pnl_pct) }}</td><td><div class="table-actions"><button class="btn btn-small" type="button" @click="openTrade(row)">编辑</button><button class="btn btn-small" type="button" @click="deleteTrade(row)">删除</button></div></td></tr></tbody></table></div>
|
||||
<div v-if="data.trades.length" class="trade-scroll"><table class="data-table"><thead><tr><th>日期</th><th>代码</th><th>股票</th><th>动作</th><th class="numeric">价格(元)</th><th class="numeric">仓位(%)</th><th class="numeric">盈亏(%)</th><th>操作</th></tr></thead><tbody><tr v-for="row in data.trades" :key="row.id"><td>{{ row.trade_date }}</td><td><MarketEntityLink :entity="{ entity_type: 'stock', identifier: row.code, code: row.code, name: row.name, sector: null }" /></td><td>{{ row.name }}</td><td>{{ row.action_label }}</td><td class="numeric">{{ display(row.price) }}</td><td class="numeric">{{ display(row.position_pct) }}</td><td class="numeric" :class="row.pnl_pct !== null && row.pnl_pct >= 0 ? 'up' : 'down'">{{ display(row.pnl_pct) }}</td><td><div class="table-actions"><button class="btn btn-small" type="button" @click="openTrade(row)">编辑</button><button class="btn btn-small" type="button" @click="deleteTrade(row)">删除</button></div></td></tr></tbody></table></div>
|
||||
<EmptyState v-else title="暂无交易日志" description="记录交易后,摘要与明细会保留在当前页面。" />
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Candidate, ScreenerRun } from "../../shared/api/screener";
|
||||
import MarketEntityLink from "../../shared/market/MarketEntityLink.vue";
|
||||
|
||||
defineProps<{ run?: ScreenerRun; locked?: boolean }>();
|
||||
const emit = defineEmits<{ track: [candidate: Candidate] }>();
|
||||
@@ -33,7 +34,7 @@ function number(value: number | null, digits = 2): string {
|
||||
<thead><tr><th>代码</th><th>股票</th><th>行业</th><th class="numeric">收盘价(元)</th><th class="numeric">涨跌幅(%)</th><th class="numeric">综合分</th><th>主要依据</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in run.items" :key="item.identifier">
|
||||
<td class="code-column">{{ item.code }}</td><td>{{ item.name }}</td><td>{{ item.sector }}</td>
|
||||
<td class="code-column"><MarketEntityLink :entity="{ entity_type: 'stock', identifier: item.identifier, code: item.code, name: item.name, sector: item.sector || null }" /></td><td>{{ item.name }}</td><td>{{ item.sector }}</td>
|
||||
<td class="numeric">{{ number(item.close) }}</td>
|
||||
<td class="numeric" :class="{ up: Number(item.pct_chg) > 0, down: Number(item.pct_chg) < 0 }">{{ number(item.pct_chg) }}</td>
|
||||
<td class="numeric"><strong>{{ number(item.score_display, 1) }}</strong></td>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import type { Candidate, ScreenerRun, ScreenerStrategy } from "../../shared/api/screener";
|
||||
import CandidateTable from "./CandidateTable.vue";
|
||||
@@ -7,8 +7,18 @@ import CandidateTable from "./CandidateTable.vue";
|
||||
const props = defineProps<{ strategies: ScreenerStrategy[]; runs: ScreenerRun[]; locked: boolean }>();
|
||||
const emit = defineEmits<{ track: [run: ScreenerRun, candidate: Candidate] }>();
|
||||
const selected = ref(props.runs[0]?.strategy_id ?? props.strategies[0]?.id ?? "");
|
||||
const run = computed(() => props.runs.find((item) => item.strategy_id === selected.value) ?? props.runs[0]);
|
||||
const run = computed(() => props.runs.find((item) => item.strategy_id === selected.value));
|
||||
const strategy = computed(() => props.strategies.find((item) => item.id === (run.value?.strategy_id ?? selected.value)));
|
||||
const hasRun = computed(() => Boolean(run.value));
|
||||
|
||||
watch(
|
||||
() => props.runs,
|
||||
(runs) => {
|
||||
if (!runs.some((item) => item.strategy_id === selected.value)) {
|
||||
selected.value = runs[0]?.strategy_id ?? props.strategies[0]?.id ?? "";
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,7 +29,7 @@ const strategy = computed(() => props.strategies.find((item) => item.id === (run
|
||||
<span class="tag">盘后自动</span>
|
||||
</header>
|
||||
<div class="stage-flow" aria-label="自动选股流程">
|
||||
<span class="done">阶段识别</span><i></i><span class="done">策略匹配</span><i></i><span class="done">自动计算</span><i></i><span>结果归档</span>
|
||||
<span :class="{ done: hasRun }">阶段识别</span><i></i><span :class="{ done: hasRun }">策略匹配</span><i></i><span :class="{ done: hasRun }">自动计算</span><i></i><span :class="{ done: hasRun }">结果归档</span>
|
||||
</div>
|
||||
<nav v-if="runs.length > 1" class="stage-run-tabs" aria-label="当日阶段策略">
|
||||
<button v-for="item in runs" :key="item.id" type="button" :class="{ active: selected === item.strategy_id }" @click="selected = item.strategy_id">{{ item.strategy_name }}</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from "vue-router";
|
||||
|
||||
import { screenerApi, type StrategyTrack } from "../../shared/api/screener";
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
import MarketEntityLink from "../../shared/market/MarketEntityLink.vue";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
const router = useRouter();
|
||||
@@ -31,6 +32,6 @@ onMounted(load);
|
||||
<div v-if="loading" class="card workspace-state">正在读取跟踪记录</div>
|
||||
<EmptyState v-else-if="error" class="card" title="跟踪记录暂不可用" :description="error" />
|
||||
<EmptyState v-else-if="!rows.length" class="card" title="暂无跟踪记录" description="从任一候选结果中点击“加入跟踪”后,记录会出现在这里。" />
|
||||
<section v-else class="card tracking-table"><div class="data-table-wrap"><table class="data-table"><thead><tr><th>代码</th><th>股票</th><th>策略</th><th>入选日期</th><th class="numeric">入选价(元)</th><th class="numeric">T+1开盘(%)</th><th class="numeric">T+1收盘(%)</th><th class="numeric">T+3收盘(%)</th><th class="numeric">T+5收盘(%)</th><th class="numeric">最大涨幅(%)</th><th class="numeric">最大回撤(%)</th><th>操作</th></tr></thead><tbody><tr v-for="row in rows" :key="row.id"><td>{{ row.code }}</td><td>{{ row.name }}</td><td>{{ row.strategy_name }}</td><td>{{ row.selection_date }}</td><td class="numeric">{{ number(row.entry_price) }}</td><td class="numeric">{{ number(row.t1_open_return) }}</td><td class="numeric">{{ number(row.t1_return) }}</td><td class="numeric">{{ number(row.t3_return) }}</td><td class="numeric">{{ number(row.t5_return) }}</td><td class="numeric up">{{ number(row.max_gain) }}</td><td class="numeric down">{{ number(row.max_drawdown) }}</td><td><button class="btn btn-small" type="button" @click="remove(row)">停止</button></td></tr></tbody></table></div></section>
|
||||
<section v-else class="card tracking-table"><div class="data-table-wrap"><table class="data-table"><thead><tr><th>代码</th><th>股票</th><th>策略</th><th>入选日期</th><th class="numeric">入选价(元)</th><th class="numeric">T+1开盘(%)</th><th class="numeric">T+1收盘(%)</th><th class="numeric">T+3收盘(%)</th><th class="numeric">T+5收盘(%)</th><th class="numeric">最大涨幅(%)</th><th class="numeric">最大回撤(%)</th><th>操作</th></tr></thead><tbody><tr v-for="row in rows" :key="row.id"><td><MarketEntityLink :entity="{ entity_type: 'stock', identifier: row.identifier, code: row.code, name: row.name, sector: row.sector }" /></td><td>{{ row.name }}</td><td>{{ row.strategy_name }}</td><td>{{ row.selection_date }}</td><td class="numeric">{{ number(row.entry_price) }}</td><td class="numeric">{{ number(row.t1_open_return) }}</td><td class="numeric">{{ number(row.t1_return) }}</td><td class="numeric">{{ number(row.t3_return) }}</td><td class="numeric">{{ number(row.t5_return) }}</td><td class="numeric up">{{ number(row.max_gain) }}</td><td class="numeric down">{{ number(row.max_drawdown) }}</td><td><button class="btn btn-small" type="button" @click="remove(row)">停止</button></td></tr></tbody></table></div></section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -56,6 +56,33 @@ export type ChartSeries = {
|
||||
points: ChartPoint[];
|
||||
};
|
||||
|
||||
export type EntityMetric = { key: string; label: string; value: number | null; unit: string };
|
||||
export type EntityDetail = {
|
||||
entity: MarketEntity;
|
||||
trade_date: string;
|
||||
observed_at: string;
|
||||
price: number;
|
||||
previous_close: number | null;
|
||||
change: number | null;
|
||||
metrics: EntityMetric[];
|
||||
money_flow: {
|
||||
available: boolean;
|
||||
net_million: number | null;
|
||||
large_million: number | null;
|
||||
net_5d_million: number | null;
|
||||
flow_to_circ_mv_5d: number | null;
|
||||
} | null;
|
||||
event: {
|
||||
status: string;
|
||||
reason: string;
|
||||
streak: number | null;
|
||||
first_time: string | null;
|
||||
last_time: string | null;
|
||||
open_times: number | null;
|
||||
seal_amount: number | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type MarketWorkspaceData = {
|
||||
trade_date: string | null;
|
||||
observed_at?: string;
|
||||
@@ -125,6 +152,11 @@ export const marketApi = {
|
||||
const identifier = encodeURIComponent(entity.identifier);
|
||||
return api.get<ChartSeries>(`/market/entities/${type}/${identifier}/charts/${interval}`);
|
||||
},
|
||||
detail(entity: MarketEntity, date: string): Promise<EntityDetail> {
|
||||
const type = encodeURIComponent(entity.entity_type);
|
||||
const identifier = encodeURIComponent(entity.identifier);
|
||||
return api.get<EntityDetail>(`/market/entities/${type}/${identifier}/detail?date=${encodeURIComponent(date)}`);
|
||||
},
|
||||
workspace(key: string, date: string): Promise<MarketWorkspaceData> {
|
||||
return api.get<MarketWorkspaceData>(
|
||||
`/market/workspaces/${encodeURIComponent(key)}?date=${encodeURIComponent(date)}`,
|
||||
|
||||
@@ -67,6 +67,7 @@ export type ScreenerWorkspace = {
|
||||
};
|
||||
export type StrategyTrack = {
|
||||
id: number;
|
||||
identifier: string;
|
||||
code: string;
|
||||
name: string;
|
||||
sector: string;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import MarketEntityLink from "../market/MarketEntityLink.vue";
|
||||
|
||||
type TableColumn = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -9,12 +11,13 @@ type TableColumn = {
|
||||
format?: (value: unknown, row: Record<string, unknown>) => string;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
columns: TableColumn[];
|
||||
rows: Record<string, unknown>[];
|
||||
sortKey: string;
|
||||
sortDirection: "asc" | "desc";
|
||||
}>();
|
||||
entityType?: "stock" | "sector" | "theme" | "index";
|
||||
}>(), { entityType: "stock" });
|
||||
const emit = defineEmits<{ sort: [key: string] }>();
|
||||
|
||||
function ariaSort(key: string, sortable?: boolean): "ascending" | "descending" | "none" | undefined {
|
||||
@@ -42,7 +45,12 @@ function ariaSort(key: string, sortable?: boolean): "ascending" | "descending" |
|
||||
<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" :data-label="column.label" :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] ?? "") }}
|
||||
<MarketEntityLink
|
||||
v-if="column.code && row.identifier && row.code && row.name"
|
||||
:entity="{ entity_type: entityType, identifier: String(row.identifier), code: String(row.code), name: String(row.name), sector: row.sector ? String(row.sector) : null }"
|
||||
:label="String(column.format ? column.format(row[column.key], row) : row[column.key])"
|
||||
/>
|
||||
<template v-else>{{ column.format ? column.format(row[column.key], row) : (row[column.key] ?? "") }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import type { MarketEntity } from "../api/market";
|
||||
import MarketPreviewPanel from "./MarketPreviewPanel.vue";
|
||||
|
||||
const props = defineProps<{ entity: MarketEntity; label?: string }>();
|
||||
const router = useRouter();
|
||||
const anchor = ref<HTMLElement | null>(null);
|
||||
const visible = ref(false);
|
||||
const position = ref({ left: 0, top: 0 });
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const text = computed(() => props.label ?? props.entity.code);
|
||||
|
||||
function show(): void {
|
||||
if (matchMedia("(hover: none)").matches) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
const rect = anchor.value?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const width = Math.min(480, window.innerWidth - 32);
|
||||
const height = 360;
|
||||
position.value = {
|
||||
left: Math.max(16, Math.min(rect.left, window.innerWidth - width - 16)),
|
||||
top: rect.bottom + height + 12 <= window.innerHeight
|
||||
? rect.bottom + 8
|
||||
: Math.max(8, rect.top - height - 8),
|
||||
};
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function scheduleClose(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => { visible.value = false; }, 180);
|
||||
}
|
||||
|
||||
function openDetail(): void {
|
||||
visible.value = false;
|
||||
void router.push({
|
||||
name: "entity-detail",
|
||||
params: { entityType: props.entity.entity_type, identifier: props.entity.identifier },
|
||||
query: { code: props.entity.code, name: props.entity.name, sector: props.entity.sector ?? undefined },
|
||||
});
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => timer && clearTimeout(timer));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button ref="anchor" class="market-entity-link" type="button" @mouseenter="show" @mouseleave="scheduleClose" @focus="show" @blur="scheduleClose" @click="openDetail">{{ text }}</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="market-hover-popover" :style="{ left: `${position.left}px`, top: `${position.top}px` }" @mouseenter="show" @mouseleave="scheduleClose">
|
||||
<MarketPreviewPanel :entity="entity" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -124,3 +124,143 @@
|
||||
.entity-chart {
|
||||
min-height: var(--s-400);
|
||||
}
|
||||
|
||||
.market-entity-link {
|
||||
padding: 0;
|
||||
color: var(--color-primary);
|
||||
background: var(--c-transparent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: inherit;
|
||||
}
|
||||
|
||||
.market-entity-link:hover {
|
||||
color: var(--color-primary-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.market-hover-popover {
|
||||
position: fixed;
|
||||
z-index: var(--z-popover);
|
||||
width: min(var(--s-480), calc(100vw - var(--s-32)));
|
||||
height: var(--s-360);
|
||||
overflow: hidden;
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
border-radius: var(--card-radius);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.market-hover-popover .market-preview {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.entity-detail-page {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--layout-gap);
|
||||
}
|
||||
|
||||
.entity-detail-heading {
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.entity-detail-heading > div:first-child {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.entity-detail-code {
|
||||
color: var(--color-text-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.entity-detail-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.entity-detail-price strong {
|
||||
font-size: var(--font-24);
|
||||
}
|
||||
|
||||
.entity-detail-price small {
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.entity-detail-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.entity-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--s-320);
|
||||
gap: var(--layout-gap);
|
||||
}
|
||||
|
||||
.entity-metrics-card,
|
||||
.entity-flow-card,
|
||||
.entity-event-card,
|
||||
.entity-members-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entity-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-1);
|
||||
background: var(--color-divider);
|
||||
}
|
||||
|
||||
.entity-metrics > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--s-4);
|
||||
padding: var(--s-10) var(--s-12);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.entity-metrics dt,
|
||||
.entity-event-body dt {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.entity-metrics dd,
|
||||
.entity-event-body dd {
|
||||
margin: 0;
|
||||
font-weight: var(--weight-600);
|
||||
}
|
||||
|
||||
.entity-stock-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--layout-gap);
|
||||
}
|
||||
|
||||
.entity-event-body {
|
||||
display: grid;
|
||||
gap: var(--s-16);
|
||||
padding: var(--s-16);
|
||||
}
|
||||
|
||||
.entity-event-body > strong {
|
||||
line-height: var(--s-20);
|
||||
}
|
||||
|
||||
.entity-event-body dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--s-8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.entity-event-body dl > div {
|
||||
display: grid;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,31 @@
|
||||
padding: var(--s-12) var(--s-10);
|
||||
}
|
||||
|
||||
.entity-detail-heading,
|
||||
.entity-detail-heading > div:first-child,
|
||||
.entity-detail-price,
|
||||
.entity-detail-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.entity-detail-actions {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.entity-detail-grid,
|
||||
.entity-stock-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.entity-metrics {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.market-hover-popover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-context-bar {
|
||||
display: grid;
|
||||
gap: var(--s-10);
|
||||
|
||||
@@ -130,3 +130,57 @@ test("regular users do not receive administrator controls and see intelligent lo
|
||||
await expect(page.getByText("智能选股仅对会员开放")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "查看会员状态" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("empty bootstrap, latest default and background refresh preserve the current workspace", async ({ page }) => {
|
||||
let synchronized = false;
|
||||
await page.route("**/api/market/summary", (route) => route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(synchronized
|
||||
? {
|
||||
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 },
|
||||
}
|
||||
: {
|
||||
context: {
|
||||
requested_date: "2026-07-30", actual_date: null, previous_date: null,
|
||||
observed_at: null, state: null, carried_forward: false, message: "等待管理员首次同步",
|
||||
},
|
||||
values: null,
|
||||
}),
|
||||
}));
|
||||
await page.route("**/api/market/workspaces/emotion?*", (route) => route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(synchronized
|
||||
? {
|
||||
trade_date: "2026-07-29", observed_at: "2026-07-29T15:00:00+08:00", carried_forward: true,
|
||||
message: "沿用最近真实收盘快照", overview: { limit_up: 68, limit_down: 4, broken: 24, seal_rate: 73.9, amount: 1628000000000 },
|
||||
sentiment: { score: 42, phase: "退潮", direction: "降温", confidence: 92, stats: {}, components: [] }, history: [],
|
||||
}
|
||||
: { trade_date: null, carried_forward: false, message: "等待管理员首次同步真实收盘行情", overview: {} }),
|
||||
}));
|
||||
await page.route("**/api/market/snapshot-sync?*", (route) => {
|
||||
synchronized = true;
|
||||
return route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "2026-07-29", temperature: 42 }) });
|
||||
});
|
||||
|
||||
await authenticate(page, "stage4admin", "Stage4-pass-123!");
|
||||
await expect(page).toHaveURL(/\/workspace\/emotion$/);
|
||||
await expect(page.getByText("等待管理员首次同步真实收盘行情")).toBeVisible();
|
||||
await expect(page.locator(".market-strip-row")).not.toContainText(/涨停\s+\d/);
|
||||
|
||||
await page.locator(".page-frame").evaluate((element) => { element.dataset.acceptanceMarker = "preserved"; });
|
||||
await page.getByRole("button", { name: "后台刷新" }).click();
|
||||
await expect(page.getByRole("status")).toContainText("刷新页面后读取新数据");
|
||||
await expect(page).toHaveURL(/\/workspace\/emotion$/);
|
||||
await expect(page.locator('[data-acceptance-marker="preserved"]')).toBeVisible();
|
||||
await expect(page.getByText("等待管理员首次同步真实收盘行情")).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { name: "情绪周期" })).toBeVisible();
|
||||
await expect(page.locator(".topbar .date-input")).toHaveValue("2026-07-29");
|
||||
await expect(page.locator(".market-strip-row")).toContainText("涨停 68");
|
||||
});
|
||||
|
||||
@@ -86,6 +86,35 @@ test("latest snapshot, grouped search, chart preview and entity detail", async (
|
||||
const interval = route.request().url().endsWith("/minute") ? "minute" : "day";
|
||||
return route.fulfill({ contentType: "application/json", body: JSON.stringify(chartPayload(interval)) });
|
||||
});
|
||||
await page.route("**/api/market/entities/stock/000001.SZ/charts/*", (route) => {
|
||||
const interval = route.request().url().endsWith("/minute") ? "minute" : "day";
|
||||
return route.fulfill({ contentType: "application/json", body: JSON.stringify({ ...chartPayload(interval), entity_type: "stock", identifier: "000001.SZ", code: "000001", name: "平安银行" }) });
|
||||
});
|
||||
await page.route("**/api/market/entities/*/*/detail?*", (route) => {
|
||||
const stock = route.request().url().includes("/stock/");
|
||||
return route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
entity: stock
|
||||
? { entity_type: "stock", identifier: "000001.SZ", code: "000001", name: "平安银行", sector: "银行" }
|
||||
: { entity_type: "index", identifier: "000001.SH", code: "000001", name: "上证指数", sector: null },
|
||||
trade_date: "2026-07-29", observed_at: "2026-07-29T15:00:00+08:00",
|
||||
price: stock ? 12.35 : 3604, previous_close: stock ? 12.05 : 3594,
|
||||
change: stock ? 2.4896 : 0.2782,
|
||||
metrics: [
|
||||
{ key: "open", label: "开盘", value: stock ? 12.1 : 3585, unit: "元" },
|
||||
{ key: "high", label: "最高", value: stock ? 12.5 : 3612, unit: "元" },
|
||||
{ key: "low", label: "最低", value: stock ? 12.0 : 3570, unit: "元" },
|
||||
{ key: "amount", label: "成交额", value: 1860000000, unit: "元" },
|
||||
],
|
||||
money_flow: stock ? { available: true, net_million: 12.5, large_million: 8.1, net_5d_million: 35.2, flow_to_circ_mv_5d: 0.0185 } : null,
|
||||
event: stock ? { status: "涨停", reason: "银行板块走强", streak: 1, first_time: "09:35", last_time: "14:20", open_times: 0, seal_amount: 50000000 } : null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/review?*", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ trade_date: "2026-07-29", watchlist: [], daily: null, history: [], trades: [], trade_summary: { total: 0, realized: 0, win_rate: null, pnl_amount: null, average_position: null } }) }));
|
||||
await page.route("**/api/review/stock-notes/*", (route) => route.fulfill({ contentType: "application/json", body: "[]" }));
|
||||
await page.route("**/api/review/watchlist", (route) => route.fulfill({ contentType: "application/json", body: JSON.stringify({ identifier: "000001.SZ", name: "平安银行" }) }));
|
||||
|
||||
await authenticate(page);
|
||||
await expect(page.locator(".market-strip-row")).toContainText("涨停 68");
|
||||
@@ -97,19 +126,57 @@ test("latest snapshot, grouped search, chart preview and entity detail", async (
|
||||
await expect(dialog.getByRole("button", { name: /上证指数/ })).toBeVisible();
|
||||
await expect(dialog.locator(".market-chart")).toBeVisible();
|
||||
await expect(dialog).toContainText("数据日期 2026-07-29");
|
||||
const risingCandle = dialog.locator(".candle-up").first();
|
||||
const candleGeometry = await risingCandle.evaluate((group) => {
|
||||
const lines = [...group.querySelectorAll("line")];
|
||||
const rect = group.querySelector("rect");
|
||||
return {
|
||||
upperEnd: Number(lines[0]?.getAttribute("y2")),
|
||||
bodyTop: Number(rect?.getAttribute("y")),
|
||||
lowerStart: Number(lines[1]?.getAttribute("y1")),
|
||||
bodyBottom: Number(rect?.getAttribute("y")) + Number(rect?.getAttribute("height")),
|
||||
bodyFill: getComputedStyle(rect).fill,
|
||||
bodyStroke: getComputedStyle(rect).stroke,
|
||||
};
|
||||
});
|
||||
expect(candleGeometry.upperEnd).toBeCloseTo(candleGeometry.bodyTop, 5);
|
||||
expect(candleGeometry.lowerStart).toBeCloseTo(candleGeometry.bodyBottom, 5);
|
||||
expect(candleGeometry.bodyFill).not.toBe(candleGeometry.bodyStroke);
|
||||
await page.screenshot({ path: path.join(evidence, "search-preview-light-1920x1080.jpg"), type: "jpeg", quality: 82 });
|
||||
|
||||
await dialog.getByRole("button", { name: /上证指数/ }).click();
|
||||
await expect(page).toHaveURL(/\/market\/index\/000001.SH/);
|
||||
await expect(page.getByRole("heading", { name: "上证指数" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "资金流" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "事件逻辑" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "个股复盘笔记" })).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "分时" }).click();
|
||||
await expect(page.locator(".chart-zero")).toHaveCount(1);
|
||||
await expect(page.locator(".preview-meta")).toContainText("09:30–15:00");
|
||||
|
||||
await page.getByRole("button", { name: "夜间" }).click();
|
||||
const darkSurfaces = await page.evaluate(() => ({
|
||||
chart: getComputedStyle(document.querySelector(".market-chart")).backgroundColor,
|
||||
panel: getComputedStyle(document.querySelector(".market-preview")).backgroundColor,
|
||||
canvas: getComputedStyle(document.documentElement).backgroundColor,
|
||||
}));
|
||||
expect(darkSurfaces.chart).toBe(darkSurfaces.panel);
|
||||
expect(darkSurfaces.chart).not.toBe("rgb(255, 255, 255)");
|
||||
expect(darkSurfaces.canvas).not.toBe("rgb(255, 255, 255)");
|
||||
await page.screenshot({ path: path.join(evidence, "entity-detail-dark-1920x1080.jpg"), type: "jpeg", quality: 82 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
|
||||
|
||||
await page.goto("/market/stock/000001.SZ?code=000001&name=%E5%B9%B3%E5%AE%89%E9%93%B6%E8%A1%8C§or=%E9%93%B6%E8%A1%8C");
|
||||
await expect(page.getByRole("heading", { name: "平安银行" })).toBeVisible();
|
||||
await expect(page.locator(".entity-detail-price")).toContainText("+2.49%");
|
||||
await expect(page.getByRole("heading", { name: "资金流" })).toBeVisible();
|
||||
await expect(page.getByText("银行板块走强")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "个股复盘笔记" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "加入自选" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "进入观势" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "加入自选" }).click();
|
||||
await expect(page.getByRole("button", { name: "移出自选" })).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.locator(".market-chart")).toBeVisible();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBe(0);
|
||||
|
||||
@@ -89,19 +89,38 @@ test("emotion and pool workspaces remain usable across desktop and mobile", asyn
|
||||
|
||||
await expect(page.getByRole("heading", { name: "情绪周期" })).toBeVisible();
|
||||
await expect(page.getByText("情绪指标继续走弱")).toBeVisible();
|
||||
await expect(page.locator(".phase-warning")).not.toContainText("筛选门槛");
|
||||
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);
|
||||
const finalHistoryRow = page.locator(".emotion-history-table tbody tr").last();
|
||||
await finalHistoryRow.scrollIntoViewIfNeeded();
|
||||
expect(await finalHistoryRow.evaluate((row) => row.getBoundingClientRect().bottom <= window.innerHeight - 30)).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 page.getByLabel("搜索股池").fill("样本股票5");
|
||||
await expect(page.locator(".data-table tbody tr")).toHaveCount(1);
|
||||
const statusbar = await page.locator(".statusbar").evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { position: style.position, bottom: Math.round(window.innerHeight - rect.bottom) };
|
||||
});
|
||||
expect(statusbar).toEqual({ position: "fixed", bottom: 0 });
|
||||
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);
|
||||
const density = await page.evaluate(() => ({
|
||||
contentWidth: document.querySelector(".page-frame").getBoundingClientRect().width,
|
||||
fontSize: getComputedStyle(document.body).fontSize,
|
||||
topbarHeight: document.querySelector(".topbar").getBoundingClientRect().height,
|
||||
}));
|
||||
expect(density.contentWidth).toBeLessThanOrEqual(2200);
|
||||
expect(density.fontSize).toBe("13px");
|
||||
expect(density.topbarHeight).toBe(46);
|
||||
await page.screenshot({ path: path.join(evidence, "pool-dark-3840x2160.jpg"), type: "jpeg", quality: 82 });
|
||||
|
||||
await page.getByRole("link", { name: /涨停表现/ }).click();
|
||||
|
||||
@@ -45,6 +45,14 @@ const stageStrategy = {
|
||||
formula,
|
||||
};
|
||||
|
||||
const stageStrategyTwo = {
|
||||
...stageStrategy,
|
||||
id: "stage-divergence",
|
||||
name: "分化去弱留强",
|
||||
display_name: "分化去弱留强",
|
||||
regimes: ["divergence"],
|
||||
};
|
||||
|
||||
const curated = [
|
||||
{ ...stageStrategy, id: "curated-01", kind: "curated", name: "连续分红质量", display_name: "连续分红质量" },
|
||||
{ ...stageStrategy, id: "curated-02", kind: "curated", name: "动态多因子(基础版)", display_name: "动态多因子(基础版)", formula: { ...formula, meta: { ...formula.meta, category: "多因子" } } },
|
||||
@@ -63,17 +71,21 @@ const candidate = {
|
||||
risk_flags: [],
|
||||
};
|
||||
|
||||
function run(id, mode, name, status = "completed") {
|
||||
function candidateNamed(code, name, reason) {
|
||||
return { ...candidate, identifier: `${code}.SZ`, code, name, reason };
|
||||
}
|
||||
|
||||
function run(id, mode, name, status = "completed", strategyId = "", item = candidate) {
|
||||
return {
|
||||
id,
|
||||
mode,
|
||||
strategy_id: mode === "custom" ? "custom-7" : mode === "stage" ? "stage-ice" : `curated-0${id - 1}`,
|
||||
strategy_id: strategyId || (mode === "custom" ? "custom-7" : mode === "stage" ? "stage-ice" : `curated-0${id - 1}`),
|
||||
strategy_name: name,
|
||||
selection_date: "2026-07-30",
|
||||
status,
|
||||
coverage: 1,
|
||||
missing_fields: [],
|
||||
items: status === "completed" ? [candidate] : [],
|
||||
items: status === "completed" ? [item] : [],
|
||||
error_message: "",
|
||||
};
|
||||
}
|
||||
@@ -81,7 +93,7 @@ function run(id, mode, name, status = "completed") {
|
||||
const catalog = {
|
||||
factor_groups: { "行情与动量": ["return_20d", "amount_billion"], "板块与行业": ["sector_strength"] },
|
||||
factors: { return_20d: "20日涨幅", amount_billion: "成交额", sector_strength: "板块强度" },
|
||||
stage: [stageStrategy],
|
||||
stage: [stageStrategy, stageStrategyTwo],
|
||||
curated,
|
||||
};
|
||||
|
||||
@@ -89,10 +101,16 @@ const workspace = {
|
||||
trade_date: "2026-07-30",
|
||||
message: "",
|
||||
catalog,
|
||||
stage_runs: [run(1, "stage", "冰点抗跌先手")],
|
||||
curated_runs: [run(2, "curated", "连续分红质量"), run(3, "curated", "动态多因子(基础版)", "no_signal")],
|
||||
stage_runs: [
|
||||
run(1, "stage", "冰点抗跌先手", "completed", "stage-ice", candidateNamed("000011", "冰点样本", "冰点阶段相对抗跌")),
|
||||
run(5, "stage", "分化去弱留强", "completed", "stage-divergence", candidateNamed("000012", "分化样本", "分化阶段承接较强")),
|
||||
],
|
||||
curated_runs: [
|
||||
run(2, "curated", "连续分红质量", "completed", "curated-01", candidateNamed("000021", "分红样本", "分红与质量条件满足")),
|
||||
run(3, "curated", "动态多因子(基础版)", "completed", "curated-02", candidateNamed("000022", "多因子样本", "综合因子得分居前")),
|
||||
],
|
||||
custom_strategies: [{ id: 7, name: "我的选股策略", version: 2, formula }],
|
||||
custom_runs: [run(4, "custom", "我的选股策略")],
|
||||
custom_runs: [run(4, "custom", "我的选股策略", "completed", "custom-7", candidateNamed("000031", "自定义样本", "用户条件确定性命中"))],
|
||||
};
|
||||
|
||||
const track = {
|
||||
@@ -131,7 +149,10 @@ test("stage 9 screening preserves deterministic modes, explicit tracking and res
|
||||
await page.goto("/workspace/screener");
|
||||
|
||||
await expect(page.getByText("当前阶段自动候选")).toBeVisible();
|
||||
await expect(page.getByText("中期动量与行业强度居前")).toBeVisible();
|
||||
await expect(page.locator(".screener-results")).toContainText("冰点样本");
|
||||
await page.getByRole("button", { name: "分化去弱留强", exact: true }).click();
|
||||
await expect(page.locator(".screener-results")).toContainText("分化样本");
|
||||
await expect(page.locator(".screener-results")).not.toContainText("冰点样本");
|
||||
await page.getByRole("button", { name: "加入跟踪" }).click();
|
||||
await expect(page.getByRole("status")).toContainText("已加入策略跟踪");
|
||||
await page.screenshot({ path: path.join(evidence, "stage-light-1920x1080.jpg"), type: "jpeg", quality: 82 });
|
||||
@@ -139,12 +160,18 @@ test("stage 9 screening preserves deterministic modes, explicit tracking and res
|
||||
await page.getByRole("button", { name: "策略选股" }).click();
|
||||
await expect(page.getByText("策略库")).toBeVisible();
|
||||
await expect(page.getByText("动态多因子(基础版)", { exact: true })).toBeVisible();
|
||||
await expect(page.locator(".screener-results")).toContainText("分红样本");
|
||||
await page.locator(".strategy-items").getByRole("button", { name: /动态多因子/ }).click();
|
||||
await expect(page.locator(".screener-results")).toContainText("多因子样本");
|
||||
await expect(page.locator(".screener-results")).not.toContainText("分红样本");
|
||||
await page.getByTitle("图标排列").click();
|
||||
await expect(page.locator(".strategy-items")).toHaveClass(/is-grid/);
|
||||
|
||||
await page.getByRole("button", { name: "自定义选股" }).click();
|
||||
await expect(page.getByText("合计 100%")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "我的选股策略 第 2 版" })).toBeVisible();
|
||||
await expect(page.locator(".screener-results")).toContainText("自定义样本");
|
||||
await expect(page.locator(".screener-results")).not.toContainText("多因子样本");
|
||||
await page.getByRole("button", { name: "夜间" }).click();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
|
||||
@@ -166,6 +193,7 @@ test("nonmembers see the same screening structure in a disabled state", async ({
|
||||
await expect(page.getByText("智能选股仅对会员开放")).toBeVisible();
|
||||
await expect(page.getByText("当前阶段自动候选")).toBeVisible();
|
||||
await expect(page.locator(".stage-overview")).toHaveAttribute("aria-disabled", "true");
|
||||
await expect(page.locator(".stage-flow .done")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "自定义选股" }).click();
|
||||
await expect(page.getByText("因子与权重")).toBeVisible();
|
||||
await expect(page.locator(".custom-builder")).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
@@ -204,6 +204,87 @@ def test_latest_daily_chart_drops_empty_premarket_bar(tmp_path) -> None:
|
||||
assert series.points[-1].amount == 13320
|
||||
|
||||
|
||||
def test_entity_detail_uses_selected_bar_previous_close_and_complete_stock_sections(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
market = gateway(tmp_path)
|
||||
database = market._database
|
||||
repository = MarketRepository()
|
||||
with database.transaction() as connection:
|
||||
connection.execute(
|
||||
"UPDATE market_summaries SET payload_json = ? WHERE trade_date = ?",
|
||||
(
|
||||
json.dumps(
|
||||
{
|
||||
"overview": {},
|
||||
"limits": [
|
||||
{
|
||||
"identifier": "000001.SZ", "code": "000001",
|
||||
"reason": "银行板块走强", "streak": 1,
|
||||
"first_time": "09:35", "last_time": "14:20", "open_times": 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
"2026-07-29",
|
||||
),
|
||||
)
|
||||
snapshot_id = connection.execute(
|
||||
"""
|
||||
INSERT INTO screener_factor_snapshots
|
||||
(trade_date, version, observed_at, state, source_set_json,
|
||||
coverage_json, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
,
|
||||
(
|
||||
"2026-07-29",
|
||||
"detail-v1",
|
||||
"2026-07-29T15:05:00+08:00",
|
||||
"final",
|
||||
'["tushare"]',
|
||||
"{}",
|
||||
"2026-07-29T15:05:00+08:00",
|
||||
),
|
||||
).lastrowid
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO screener_factor_values
|
||||
(snapshot_id, identifier, code, name, sector, listed_days, is_st, payload_json)
|
||||
VALUES (?, '000001.SZ', '000001', '平安银行', '银行', 1000, 0, ?)
|
||||
""",
|
||||
(
|
||||
snapshot_id,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "平安银行", "sector": "银行", "turnover_rate": 2.5,
|
||||
"return_5d": 3.2, "return_20d": 8.6, "total_mv_billion": 2100,
|
||||
"circ_mv_billion": 1900, "net_flow_million": 12.5,
|
||||
"large_flow_million": 8.1, "net_flow_5d_million": 35.2,
|
||||
"flow_to_circ_mv_5d": 0.0185,
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
detail = MarketSnapshotService(database, repository, market).entity_detail(
|
||||
"stock", "000001.SZ", "2026-07-29"
|
||||
)
|
||||
|
||||
assert detail["trade_date"] == "2026-07-29"
|
||||
assert detail["price"] == 11.1
|
||||
assert detail["previous_close"] == 10.8
|
||||
assert detail["change"] == pytest.approx(2.7778)
|
||||
assert detail["entity"]["name"] == "平安银行"
|
||||
assert detail["money_flow"]["available"] is True
|
||||
assert detail["money_flow"]["net_million"] == 12.5
|
||||
assert detail["event"] == {
|
||||
"status": "涨停", "reason": "银行板块走强", "streak": 1,
|
||||
"first_time": "09:35", "last_time": "14:20", "open_times": 0,
|
||||
"seal_amount": None,
|
||||
}
|
||||
|
||||
|
||||
def test_minute_chart_contract_has_real_session_bounds_and_hides_source(tmp_path) -> None:
|
||||
application = create_application(Settings.for_test(tmp_path))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user