rebuild(audit): complete entity detail and market preview

This commit is contained in:
leefer
2026-07-30 09:25:52 +08:00
parent d1b3b977d2
commit 4fc8691eee
27 changed files with 946 additions and 35 deletions
+3
View File
@@ -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,
+14
View File
@@ -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()
+14
View File
@@ -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)
+7
View File
@@ -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)
+98
View File
@@ -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()
+81
View File
@@ -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 切换后执行最终冒烟,旧实现只按确认后的清理方案处理。
+5 -1
View File
@@ -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>
+32
View File
@@ -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)}`,
+1
View File
@@ -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>
+140
View File
@@ -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);
+54
View File
@@ -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");
});
+67
View File
@@ -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:3015: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&sector=%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);
+20 -1
View File
@@ -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();
+36 -8
View File
@@ -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");
+81
View File
@@ -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))