rebuild(stage-7): deliver ladder and sector rotation

This commit is contained in:
leefer
2026-07-30 03:28:24 +08:00
parent 31a53de890
commit a76d344a98
28 changed files with 1161 additions and 13 deletions
@@ -18,7 +18,7 @@ const locked = computed(
() => ["screener", "mentor", "heaven"].includes(workspace.value.key) && !session.account?.smart_access,
);
const implementedMarket = computed(() =>
["emotion", "pool", "broken", "limit-down", "yesterday", "performance"].includes(
["emotion", "pool", "broken", "limit-down", "yesterday", "performance", "ladder", "rotation"].includes(
workspace.value.key,
),
);
@@ -5,6 +5,8 @@ import { marketApi, type MarketWorkspaceData } from "../../shared/api/market";
import EmptyState from "../../shared/components/EmptyState.vue";
import { useMarketStore } from "../../shared/stores/market";
import EmotionPage from "./emotion/EmotionPage.vue";
import LadderPage from "./structure/LadderPage.vue";
import RotationPage from "./structure/RotationPage.vue";
import PerformancePage from "./pools/PerformancePage.vue";
import PoolPage from "./pools/PoolPage.vue";
@@ -38,6 +40,8 @@ watch([() => props.workspaceKey, () => market.selectedDate], () => void load(),
<EmptyState v-else-if="error" title="页面数据暂不可用" :description="error" class="card" />
<EmptyState v-else-if="!data?.trade_date" title="暂无真实行情快照" :description="data?.message || '等待管理员首次同步真实收盘行情'" class="card" />
<EmotionPage v-else-if="workspaceKey === 'emotion'" :data="data" />
<LadderPage v-else-if="workspaceKey === 'ladder'" :data="data" />
<RotationPage v-else-if="workspaceKey === 'rotation'" :data="data" />
<PerformancePage v-else-if="workspaceKey === 'performance'" :data="data" />
<PoolPage v-else :kind="workspaceKey" :data="data" />
</main>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import type { MarketWorkspaceData } from "../../../shared/api/market";
const props = defineProps<{ data: MarketWorkspaceData }>();
const sortMode = ref<"time" | "open">("time");
const expanded = reactive(new Set<number>());
const sourceGroups = computed(() => props.data.items ?? []);
const maxLevel = computed(() => Math.max(0, ...sourceGroups.value.map((group) => Number(group.level ?? 0))));
const topLevel = computed(() => Math.max(5, maxLevel.value));
const groups = computed(() => {
const byLevel = new Map(sourceGroups.value.map((group) => [Number(group.level), group]));
const normalized = Array.from({ length: topLevel.value }, (_, index) => {
const level = topLevel.value - index;
const source = byLevel.get(level) ?? { level, label: level === 1 ? "首板" : `${level}`, count: 0, stocks: [] };
const stocks = [...((source.stocks as Record<string, unknown>[] | undefined) ?? [])].sort((left, right) => {
if (sortMode.value === "open") {
return Number(left.open_times ?? 0) - Number(right.open_times ?? 0)
|| String(left.first_time ?? "99:99").localeCompare(String(right.first_time ?? "99:99"));
}
return String(left.first_time ?? "99:99").localeCompare(String(right.first_time ?? "99:99"));
});
const limit = level <= 2 ? 8 : stocks.length;
return {
level,
label: String(source.label ?? (level === 1 ? "首板" : `${level}`)),
count: Number(source.count ?? stocks.length),
stocks,
visible: expanded.has(level) ? stocks : stocks.slice(0, limit),
hidden: Math.max(0, stocks.length - limit),
};
});
return normalized.map((group) => {
const lower = normalized.find((item) => item.level === group.level - 1);
return {
...group,
adjacentRate: lower?.count ? group.count / lower.count * 100 : null,
};
});
});
const completeness = computed(() => maxLevel.value
? sourceGroups.value.filter((group) => Number(group.count ?? 0) > 0).length / maxLevel.value * 100
: 0);
const priorPerformance = computed(() => props.data.history ?? []);
const priorHeight = computed(() => Math.max(0, ...priorPerformance.value.map((row) => Number(row.level ?? 0))));
const heightMessage = computed(() => {
if (!maxLevel.value) return "当前没有连板梯队。";
if (!priorPerformance.value.length) return "暂无昨日梯队样本,先观察当前结构。";
if (maxLevel.value > priorHeight.value) return `较昨日${priorHeight.value}板抬升,市场空间打开。`;
if (maxLevel.value < priorHeight.value) return `较昨日${priorHeight.value}板回落,市场空间压缩。`;
return "市场高度与昨日接近,继续观察梯队承接。";
});
function toggle(level: number): void {
if (expanded.has(level)) expanded.delete(level);
else expanded.add(level);
}
function exportCsv(): void {
const lines = [["高度", "代码", "股票", "板块", "首封", "开板次数", "成交额(亿)"]];
for (const group of groups.value) {
for (const stock of group.stocks) {
lines.push([
`${group.level}`, String(stock.code ?? ""), String(stock.name ?? ""),
String(stock.sector ?? ""), String(stock.first_time ?? ""), String(stock.open_times ?? ""),
(Number(stock.amount ?? 0) / 100_000_000).toFixed(2),
]);
}
}
const content = lines.map((line) => line.map((value) => `"${value.replaceAll('"', '""')}"`).join(",")).join("\r\n");
const link = document.createElement("a");
link.href = URL.createObjectURL(new Blob(["\ufeff", content], { type: "text/csv;charset=utf-8" }));
link.download = `市场天梯-${props.data.trade_date}.csv`;
link.click();
URL.revokeObjectURL(link.href);
}
</script>
<template>
<header class="page-header market-page-header">
<div><h1>市场天梯</h1><p class="page-subtitle">按连板高度观察空间与梯队完整度 · 数据日期 {{ data.trade_date }}</p></div>
<div class="page-actions"><div class="seg-control"><button type="button" :class="{ active: sortMode === 'time' }" @click="sortMode = 'time'">按封板时间</button><button type="button" :class="{ active: sortMode === 'open' }" @click="sortMode = 'open'">按开板次数</button></div><button class="btn btn-small" type="button" @click="exportCsv">导出 CSV</button></div>
</header>
<section class="ladder-layout">
<div class="card ladder-board">
<section v-for="group in groups" :key="group.level" class="ladder-level" :class="{ 'is-gap': !Number(group.count) }">
<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>
<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>
<p v-if="!group.visible.length" class="ladder-gap-note">该层暂时空缺</p>
<button v-if="group.stocks.length > 8 && group.level <= 2" class="ladder-expand" type="button" @click="toggle(group.level)">{{ expanded.has(group.level) ? "收起" : `展开更多${group.hidden}` }}</button>
</div>
</section>
</div>
<aside class="ladder-insights">
<article class="card"><header class="card-header"><h2>市场高度</h2></header><div class="ladder-insight-body"><strong>{{ maxLevel ? `${maxLevel}` : "--" }}</strong><p>{{ heightMessage }}</p></div></article>
<article class="card"><header class="card-header"><h2>梯队完整度</h2></header><div class="ladder-insight-body"><strong>{{ completeness.toFixed(1) }}%</strong><div class="score-track"><span :style="{ width: `${completeness}%` }"></span></div><p>从首板到最高板之间存在的真实高度比例</p></div></article>
<article class="card"><header class="card-header"><h2>昨日梯队 今日</h2></header><div class="ladder-rate-list"><div v-for="row in priorPerformance" :key="String(row.level)"><span>昨日{{ row.level }}板</span><div class="score-track"><span :style="{ width: `${row.advance_rate}%` }"></span></div><strong>{{ row.advance_rate }}%</strong></div></div></article>
</aside>
</section>
</template>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { marketApi, type MarketWorkspaceData, type RotationMembersData } from "../../../shared/api/market";
import DataTable from "../../../shared/components/DataTable.vue";
import EmptyState from "../../../shared/components/EmptyState.vue";
const props = defineProps<{ data: MarketWorkspaceData }>();
const order = ref<"oldest" | "latest">("oldest");
const selectedSector = ref("");
const selectedDate = ref("");
const members = ref<RotationMembersData | null>(null);
const memberLoading = ref(false);
const memberError = ref("");
const history = computed(() => order.value === "oldest" ? props.data.history ?? [] : [...(props.data.history ?? [])].reverse());
const appearances = computed(() => (props.data.history ?? []).map((day) => ({
trade_date: day.trade_date,
sector: ((day.sectors as Record<string, unknown>[] | undefined) ?? []).find((item) => item.name === selectedSector.value),
})).filter((item) => item.sector));
const memberColumns = [
{ key: "code", label: "代码", code: true, sortable: true },
{ key: "name", label: "股票", sortable: true },
{ key: "change", label: "涨跌幅(%", numeric: true, sortable: true, format: percent },
{ key: "open", label: "开盘(元)", numeric: true, sortable: true, format: decimal },
{ key: "close", label: "收盘(元)", numeric: true, sortable: true, format: decimal },
{ key: "amount", label: "成交额(亿)", numeric: true, sortable: true, format: amount },
{ key: "quoted", label: "行情状态", format: (value: unknown) => value ? "正常交易" : "当日无行情" },
];
const sortKey = ref("change");
const sortDirection = ref<"asc" | "desc">("desc");
const memberRows = computed(() => {
const rows = members.value?.items ?? [];
const direction = sortDirection.value === "asc" ? 1 : -1;
return [...rows].sort((left, right) => {
if (Boolean(left.quoted) !== Boolean(right.quoted)) return left.quoted ? -1 : 1;
return compare(left[sortKey.value], right[sortKey.value]) * direction;
});
});
async function selectSector(sector: Record<string, unknown>, tradeDate: unknown): Promise<void> {
const name = String(sector.name ?? "");
const date = String(tradeDate ?? "");
if (name === selectedSector.value && date === selectedDate.value) {
selectedSector.value = ""; selectedDate.value = ""; members.value = null; memberError.value = "";
return;
}
selectedSector.value = name;
selectedDate.value = date;
memberLoading.value = true;
memberError.value = "";
try {
members.value = await marketApi.rotationMembers(name, date);
} catch (error) {
members.value = null;
memberError.value = error instanceof Error ? error.message : "板块成分股读取失败";
} finally {
memberLoading.value = false;
}
}
function sort(key: string): void {
if (sortKey.value === key) sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
else { sortKey.value = key; sortDirection.value = "desc"; }
}
function exportCsv(): void {
const lines = [["日期", "排名", "板块", "涨停家数", "强度", "领涨股"]];
for (const day of props.data.history ?? []) {
for (const sector of (day.sectors as Record<string, unknown>[] | undefined) ?? []) {
lines.push([String(day.trade_date ?? ""), String(sector.rank ?? ""), String(sector.name ?? ""), String(sector.count ?? ""), String(sector.strength ?? ""), String(sector.leader ?? "")]);
}
}
const content = lines.map((line) => line.map((value) => `"${value.replaceAll('"', '""')}"`).join(",")).join("\r\n");
const link = document.createElement("a");
link.href = URL.createObjectURL(new Blob(["\ufeff", content], { type: "text/csv;charset=utf-8" }));
link.download = `板块轮动-${props.data.trade_date}.csv`;
link.click(); URL.revokeObjectURL(link.href);
}
function compare(left: unknown, right: unknown): number {
const a = Number(left); const b = Number(right);
if (Number.isFinite(a) && Number.isFinite(b)) return a - b;
return String(left ?? "").localeCompare(String(right ?? ""), "zh-CN");
}
function numeric(value: unknown, digits: number): string {
if (value === null || value === undefined || value === "") return "";
const number = Number(value);
return Number.isFinite(number) ? number.toFixed(digits) : "";
}
function percent(value: unknown): string { return numeric(value, 2); }
function decimal(value: unknown): string { return numeric(value, 2); }
function amount(value: unknown): string {
if (value === null || value === undefined || value === "") return "";
const number = Number(value);
return Number.isFinite(number) ? (number / 100_000_000).toFixed(2) : "";
}
</script>
<template>
<header class="page-header market-page-header">
<div><h1>板块轮动</h1><p class="page-subtitle">最近9个交易日每日Top12热点 · 数据截至 {{ data.trade_date }}</p></div>
<div class="page-actions"><div class="seg-control"><button type="button" :class="{ active: order === 'oldest' }" @click="order = 'oldest'">由远到近</button><button type="button" :class="{ active: order === 'latest' }" @click="order = 'latest'">由近到远</button></div><button class="btn btn-small" type="button" @click="exportCsv">导出 CSV</button></div>
</header>
<section class="card rotation-trajectory">
<header class="card-header"><h2>热点轨迹</h2><span class="faint">{{ selectedSector ? `已联动高亮 ${selectedSector}` : "点击任意板块追踪其连续性" }}</span><span class="tag rotation-top-tag">每日Top12热点</span></header>
<div class="rotation-legend"><span><i class="heat-high"></i>强度高90+</span><span><i class="heat-medium"></i>强度中7089</span><span><i class="heat-low"></i>强度低70以下</span></div>
<div v-if="selectedSector" class="rotation-tracker"><strong>{{ selectedSector }}</strong><span>近9日在榜 {{ appearances.length }}天 · 最高排名 {{ appearances.length ? Math.min(...appearances.map((item) => Number(item.sector?.rank ?? 99))) : "--" }}</span><button class="btn btn-small" type="button" @click="selectedSector = ''; selectedDate = ''; members = null">取消追踪</button></div>
<div class="rotation-days">
<article v-for="day in history" :key="String(day.trade_date)" class="rotation-day">
<header><time>{{ String(day.trade_date).slice(5) }}</time><span>{{ ((day.sectors as unknown[]) ?? []).length }}个热点</span></header>
<div>
<button v-for="sector in (day.sectors as Record<string, unknown>[]) ?? []" :key="String(sector.name)" type="button" class="rotation-sector" :class="[{ selected: selectedSector === sector.name }, Number(sector.strength) >= 90 ? 'heat-high' : Number(sector.strength) >= 70 ? 'heat-medium' : 'heat-low']" @click="selectSector(sector, day.trade_date)">
<span>{{ sector.rank }}</span><strong>{{ sector.name }}</strong><small>{{ sector.count }} · {{ Number(sector.strength).toFixed(0) }}</small>
</button>
</div>
</article>
</div>
</section>
<section class="card rotation-members">
<header class="card-header"><h2>{{ members?.sector_name ? `${members.sector_name}成分股` : "板块成分股" }}</h2><span class="faint">{{ members ? `${members.trade_date} · ${members.quoted_count}/${members.member_count}` : selectedSector || "点击上方任意板块查看成分股" }}</span></header>
<div v-if="memberLoading" class="workspace-state">正在核验板块成分行情</div>
<EmptyState v-else-if="memberError" title="成分行情暂不可用" :description="memberError" />
<DataTable v-else-if="memberRows.length" :columns="memberColumns" :rows="memberRows" :sort-key="sortKey" :sort-direction="sortDirection" @sort="sort" />
<EmptyState v-else title="尚未选择板块" description="点击热点轨迹中的板块后显示目标日期的申万成分股。" />
</section>
</template>
+15
View File
@@ -67,6 +67,16 @@ export type MarketWorkspaceData = {
items?: Record<string, unknown>[];
};
export type RotationMembersData = {
trade_date: string;
sector_name: string;
sector_code: string;
member_count: number;
quoted_count: number;
coverage: number;
items: Record<string, unknown>[];
};
export const marketApi = {
summary(date?: string): Promise<MarketSummary> {
const query = date ? `?date=${encodeURIComponent(date)}` : "";
@@ -88,4 +98,9 @@ export const marketApi = {
syncSnapshot(date: string): Promise<{ trade_date: string; temperature: number }> {
return api.post(`/market/snapshot-sync?date=${encodeURIComponent(date)}`);
},
rotationMembers(sector: string, date: string): Promise<RotationMembersData> {
return api.get<RotationMembersData>(
`/market/rotation-members?sector=${encodeURIComponent(sector)}&date=${encodeURIComponent(date)}`,
);
},
};
@@ -116,3 +116,300 @@
.breadth-up { background: var(--color-up); }
.breadth-flat { background: var(--color-text-faint); }
.breadth-down { background: var(--color-down); }
.page-actions {
display: flex;
align-items: center;
gap: var(--s-8);
}
.page-actions .seg-control {
margin-left: 0;
}
.ladder-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--s-320);
gap: var(--layout-gap);
align-items: start;
}
.ladder-board {
overflow: hidden;
}
.ladder-level {
display: grid;
grid-template-columns: var(--s-200) minmax(0, 1fr);
border-bottom: var(--s-1) solid var(--color-divider);
}
.ladder-level:last-child {
border-bottom: 0;
}
.ladder-level > header {
display: grid;
align-content: center;
gap: var(--s-6);
padding: var(--s-12) var(--s-14);
border-right: var(--s-1) solid var(--color-divider);
background: var(--color-primary-soft);
}
.ladder-level.is-gap > header,
.ladder-level.is-gap .ladder-stocks {
background: var(--color-surface-muted);
}
.ladder-level > header div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--s-8);
}
.ladder-level > header strong {
color: var(--color-primary);
font-size: var(--font-15);
}
.ladder-level > header span,
.ladder-level > header small {
color: var(--color-text-secondary);
font-size: var(--font-11);
}
.ladder-stocks {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--s-200), 1fr));
gap: var(--s-8);
padding: var(--s-10);
}
.ladder-stock {
min-width: 0;
display: grid;
gap: var(--s-6);
padding: var(--s-9);
border: var(--s-1) solid var(--color-border);
border-radius: var(--control-radius);
background: var(--color-surface);
transition: border-color var(--duration-fast) var(--ease-standard),
background var(--duration-fast) var(--ease-standard),
transform var(--duration-fast) var(--ease-standard);
}
.ladder-stock:hover {
border-color: var(--color-primary-border);
background: var(--color-primary-soft);
transform: translateY(calc(var(--s-1) * -1));
}
.ladder-stock > div,
.ladder-stock > p {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-8);
}
.ladder-stock strong,
.ladder-stock b {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ladder-stock span,
.ladder-stock small {
color: var(--color-text-secondary);
font-size: var(--font-11);
}
.ladder-gap-note {
align-self: center;
color: var(--color-text-faint);
}
.ladder-expand {
min-height: var(--s-44);
border: var(--s-1) dashed var(--color-border);
border-radius: var(--control-radius);
color: var(--color-primary);
background: var(--color-surface-muted);
}
.ladder-insights {
display: grid;
gap: var(--layout-gap);
}
.ladder-insight-body {
display: grid;
gap: var(--s-10);
padding: var(--s-14);
}
.ladder-insight-body > strong {
color: var(--color-primary);
font-size: var(--font-18);
}
.ladder-insight-body p {
color: var(--color-text-secondary);
font-size: var(--font-12);
line-height: var(--s-18);
}
.ladder-rate-list {
display: grid;
gap: var(--s-9);
padding: var(--s-12) var(--s-14);
}
.ladder-rate-list > div {
display: grid;
grid-template-columns: var(--s-64) minmax(0, 1fr) var(--s-44);
align-items: center;
gap: var(--s-8);
color: var(--color-text-secondary);
font-size: var(--font-11);
}
.ladder-rate-list strong {
text-align: right;
font-variant-numeric: tabular-nums;
}
.rotation-trajectory,
.rotation-members {
min-width: 0;
overflow: hidden;
}
.rotation-top-tag {
margin-left: auto;
}
.rotation-legend {
min-height: var(--s-34);
display: flex;
align-items: center;
gap: var(--s-16);
padding: var(--s-6) var(--s-14);
border-bottom: var(--s-1) solid var(--color-divider);
color: var(--color-text-secondary);
font-size: var(--font-11);
}
.rotation-legend span {
display: inline-flex;
align-items: center;
gap: var(--s-4);
}
.rotation-legend i {
width: var(--s-14);
height: var(--s-9);
border-radius: var(--radius-4);
}
.rotation-tracker {
min-height: var(--s-46);
display: flex;
align-items: center;
gap: var(--s-14);
padding: var(--s-7) var(--s-14);
border-bottom: var(--s-1) solid var(--color-primary-border);
background: var(--color-primary-soft);
}
.rotation-tracker strong {
color: var(--color-primary);
}
.rotation-tracker span {
flex: 1;
color: var(--color-text-secondary);
font-size: var(--font-11);
}
.rotation-days {
display: grid;
grid-template-columns: repeat(9, minmax(0, 1fr));
}
.rotation-day {
min-width: 0;
border-right: var(--s-1) solid var(--color-divider);
}
.rotation-day:last-child {
border-right: 0;
}
.rotation-day > header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--s-4);
padding: var(--s-8);
border-bottom: var(--s-1) solid var(--color-divider);
background: var(--color-surface-muted);
}
.rotation-day > header time {
font-weight: var(--weight-700);
}
.rotation-day > header span {
color: var(--color-text-faint);
font-size: var(--font-10-5);
}
.rotation-day > div {
display: grid;
}
.rotation-sector {
min-width: 0;
display: grid;
grid-template-columns: var(--s-20) minmax(0, 1fr);
gap: var(--s-2) var(--s-6);
padding: var(--s-7) var(--s-8);
border-bottom: var(--s-1) solid var(--color-divider);
text-align: left;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.rotation-sector > span {
grid-row: span 2;
align-self: center;
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.rotation-sector strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-11);
}
.rotation-sector small {
color: var(--color-text-secondary);
font-size: var(--font-10-5);
}
.rotation-sector.selected {
color: var(--color-primary);
outline: var(--s-1) solid var(--color-primary);
outline-offset: calc(var(--s-1) * -1);
opacity: 1;
}
.heat-high { background: var(--color-up-soft); }
.heat-medium { background: var(--color-warning-soft); }
.heat-low { background: var(--color-primary-soft); }
@@ -197,4 +197,51 @@
.performance-conclusion {
position: static;
}
.market-page-header .page-actions {
width: 100%;
align-items: stretch;
flex-direction: column;
}
.market-page-header .page-actions .seg-control {
width: 100%;
}
.market-page-header .page-actions .seg-control button {
flex: 1;
}
.ladder-layout,
.ladder-level {
grid-template-columns: minmax(0, 1fr);
}
.ladder-level > header {
border-right: 0;
border-bottom: var(--s-1) solid var(--color-divider);
}
.ladder-stocks {
grid-template-columns: minmax(0, 1fr);
}
.rotation-legend {
align-items: flex-start;
flex-direction: column;
}
.rotation-tracker {
align-items: stretch;
flex-direction: column;
}
.rotation-days {
grid-template-columns: minmax(0, 1fr);
}
.rotation-day {
border-right: 0;
border-bottom: var(--s-1) solid var(--color-divider);
}
}