新增低于 前一交易日101% 价格开盘逻辑。新增下载文件接口。

This commit is contained in:
2026-05-05 19:03:50 +08:00
parent 6f3908221c
commit 8d59acabc0
6 changed files with 415 additions and 11 deletions
+2 -1
View File
@@ -41,13 +41,14 @@
<script setup>
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeftBold, ArrowRightBold, Histogram, TrendCharts } from '@element-plus/icons-vue'
import { ArrowLeftBold, ArrowRightBold, Download, Histogram, TrendCharts } from '@element-plus/icons-vue'
import { fetchTradeCalendar } from '@/composables/useTradeCalendar'
const route = useRoute()
const router = useRouter()
const isSidebarCollapsed = ref(false)
const iconMap = {
Download,
Histogram,
TrendCharts
}
+18
View File
@@ -24,6 +24,24 @@ export function getLimitBoardOpenLowerGroups(params) {
})
}
export function getLimitBoardOpenThresholdGroups(params) {
return request({
url: '/limit-board/open-threshold-groups',
method: 'get',
params
})
}
export function downloadSecondBoardProfitCsv(params) {
return request({
url: '/download/limit-board/second-board-profit-csv',
method: 'get',
params,
responseType: 'blob',
timeout: 60000
})
}
// 新增:获取历史K线
export function getKlineData(code) {
return request({
+6
View File
@@ -16,6 +16,12 @@ const routes = [
name: 'StockLimitBoard',
component: () => import('@/views/stock/LimitBoard.vue'),
meta: { title: '涨停连板概览', icon: 'Histogram' }
},
{
path: '/stock/download',
name: 'StockDownload',
component: () => import('@/views/stock/Download.vue'),
meta: { title: '下载', icon: 'Download' }
}
]
+1 -1
View File
@@ -328,7 +328,7 @@ const openKlineDialog = async (row) => {
xAxis: { type: 'category', data: dates, scale: true, boundaryGap: false },
yAxis: { scale: true, splitArea: { show: true } },
dataZoom: [
{ type: 'inside', start: 80, end: 100 },
{ type: 'inside', start: 95, end: 100 },
{ show: true, type: 'slider', top: '92%' }
],
series: [{
+315
View File
@@ -0,0 +1,315 @@
<template>
<div class="download-page">
<section class="download-toolbar">
<div class="toolbar-copy">
<h1>数据下载</h1>
<p>按交易日期范围下载策略明细 CSV。</p>
</div>
</section>
<section class="download-panel">
<div class="panel-head">
<div>
<h2>二板早盘次日盈利</h2>
<p>下载符合二板早盘开盘阈值条件的股票明细,并查看后一交易日盈利情况。</p>
</div>
</div>
<el-form :inline="true" class="download-form">
<el-form-item label="起始日期">
<el-date-picker
v-model="startDate"
type="date"
value-format="YYYY-MM-DD"
:disabled-date="disableDates"
:editable="false"
placeholder="选择起始日期"
class="date-input"
/>
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker
v-model="endDate"
type="date"
value-format="YYYY-MM-DD"
:disabled-date="disableDates"
:editable="false"
placeholder="选择结束日期"
class="date-input"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:icon="DownloadIcon"
:loading="downloading"
:disabled="!canDownload"
@click="handleDownload"
>
下载 CSV
</el-button>
</el-form-item>
</el-form>
<div class="rule-grid">
<div class="rule-item">
<span class="rule-label">开盘条件</span>
<strong>低于前收 101%</strong>
</div>
<div class="rule-item">
<span class="rule-label">涨停时间</span>
<strong>早于 11:00</strong>
</div>
<div class="rule-item">
<span class="rule-label">盈利口径</span>
<strong>T 日收盘买入</strong>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import dayjs from 'dayjs'
import { ElMessage } from 'element-plus'
import { Download as DownloadIcon } from '@element-plus/icons-vue'
import { fetchTradeCalendar } from '@/composables/useTradeCalendar'
import { downloadSecondBoardProfitCsv } from '@/api/stock'
const DEFAULT_START_DATE = '2026-01-01'
const startDate = ref(DEFAULT_START_DATE)
const endDate = ref('')
const downloading = ref(false)
const openDatesSet = ref(new Set())
const maxTradeDate = ref('')
const canDownload = computed(
() => Boolean(startDate.value && endDate.value) && startDate.value <= endDate.value
)
onMounted(async () => {
try {
const res = await fetchTradeCalendar()
const { open_dates: openDates = [], max_date: maxDate = '' } = res || {}
openDatesSet.value = new Set(openDates)
maxTradeDate.value = maxDate
endDate.value = maxDate
startDate.value = getDefaultStartDate(openDates)
} catch (err) {
console.error('初始化下载页失败', err)
}
})
const clampToMaxDate = (date) => {
if (!date || !maxTradeDate.value || date <= maxTradeDate.value) return date
return maxTradeDate.value
}
const getDefaultStartDate = (openDates) => {
const fallbackDate = clampToMaxDate(DEFAULT_START_DATE)
const sortedOpenDates = [...openDates].sort()
const validOpenDates = sortedOpenDates.filter(
(date) => !maxTradeDate.value || date <= maxTradeDate.value
)
if (!validOpenDates.length || openDatesSet.value.has(fallbackDate)) return fallbackDate
return (
validOpenDates.find((date) => date >= fallbackDate) ||
validOpenDates[validOpenDates.length - 1] ||
fallbackDate
)
}
const disableDates = (time) => {
const date = dayjs(time).format('YYYY-MM-DD')
if (maxTradeDate.value && date > maxTradeDate.value) return true
return openDatesSet.value.size > 0 ? !openDatesSet.value.has(date) : false
}
const handleDownload = async () => {
if (!startDate.value || !endDate.value) {
ElMessage.warning('请选择下载日期范围')
return
}
if (endDate.value < startDate.value) {
ElMessage.warning('结束日期不能早于起始日期')
return
}
downloading.value = true
try {
const blob = await downloadSecondBoardProfitCsv({
start_date: startDate.value,
end_date: endDate.value
})
saveBlob(blob, getDownloadFilename())
ElMessage.success('CSV 下载已开始')
} catch (err) {
console.error('下载二板早盘次日盈利 CSV 失败', err)
} finally {
downloading.value = false
}
}
const saveBlob = (blob, filename) => {
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}
const getDownloadFilename = () => {
const start = startDate.value.replaceAll('-', '')
const end = endDate.value.replaceAll('-', '')
return `limit_second_board_profit_${start}_${end}_daily_detail.csv`
}
</script>
<style scoped>
.download-page {
min-height: 100vh;
padding: 20px;
box-sizing: border-box;
background: #f4f7f9;
}
.download-toolbar,
.download-panel {
background: #fff;
border: 1px solid #e5edf5;
border-radius: 12px;
box-shadow: 0 8px 22px rgba(31, 42, 55, 0.04);
}
.download-toolbar {
padding: 24px;
margin-bottom: 16px;
}
.toolbar-copy h1 {
margin: 0 0 8px;
font-size: 28px;
line-height: 36px;
font-weight: 700;
letter-spacing: 0;
color: #1f2a37;
}
.toolbar-copy p,
.panel-head p {
color: #667085;
font-size: 14px;
line-height: 22px;
}
.download-panel {
padding: 22px 24px 24px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.panel-head h2 {
margin: 0 0 6px;
font-size: 20px;
line-height: 28px;
font-weight: 700;
letter-spacing: 0;
color: #1f2a37;
}
.download-form {
display: flex;
flex-wrap: wrap;
gap: 10px 6px;
align-items: flex-end;
margin-bottom: 18px;
}
.date-input {
width: 156px;
}
.rule-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.rule-item {
min-height: 76px;
padding: 14px;
box-sizing: border-box;
border: 1px solid #e8eef5;
border-radius: 8px;
background: #f8fbff;
display: flex;
flex-direction: column;
justify-content: center;
gap: 6px;
}
.rule-label {
color: #7c8aa5;
font-size: 12px;
line-height: 18px;
}
.rule-item strong {
color: #1f2a37;
font-size: 16px;
line-height: 22px;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
:deep(.el-input__wrapper),
:deep(.el-input__inner) {
cursor: pointer;
}
@media (max-width: 820px) {
.download-page {
padding: 12px;
}
.download-toolbar,
.download-panel {
padding: 16px;
}
.toolbar-copy h1 {
font-size: 24px;
line-height: 32px;
}
.download-form,
:deep(.download-form .el-form-item),
:deep(.download-form .el-form-item__content),
.date-input,
:deep(.download-form .el-button) {
width: 100%;
}
.rule-grid {
grid-template-columns: 1fr;
}
}
</style>
+73 -9
View File
@@ -90,6 +90,7 @@
<el-tab-pane label="按板数" name="board" />
<el-tab-pane label="按板块" name="sector" />
<el-tab-pane :label="openLowerTabLabel" name="openLower" />
<el-tab-pane label="连板一下" name="openThreshold" />
</el-tabs>
<div v-if="displayDetailGroups.length" class="group-list">
@@ -147,6 +148,14 @@
<span>开盘 {{ formatPrice(stock.open) }}</span>
<span>前收 {{ formatPrice(stock.previous_close) }}</span>
</div>
<div
v-if="activeDetailTab === 'openThreshold'"
class="stock-open-threshold"
>
<span>开盘 {{ formatPrice(stock.open) }}</span>
<span>前收 {{ formatPrice(stock.previous_close) }}</span>
<span>阈值 {{ formatPrice(stock.open_threshold_price) }}</span>
</div>
</button>
</div>
</article>
@@ -180,6 +189,7 @@ import { fetchTradeCalendar } from "@/composables/useTradeCalendar";
import {
getKlineData,
getLimitBoardOpenLowerGroups,
getLimitBoardOpenThresholdGroups,
getLimitBoardOverview,
} from "@/api/stock";
@@ -220,6 +230,17 @@ const openLowerDetail = ref({
},
groups: [],
});
const openThresholdDetail = ref({
trade_date: "",
previous_trade_date: "",
summary: {
limit_count: 0,
open_within_threshold_count: 0,
open_at_or_above_threshold_count: 0,
threshold_ratio: 1.01,
},
groups: [],
});
const dialogVisible = ref(false);
const dialogTitle = ref("");
@@ -292,6 +313,8 @@ const displayDetailGroups = computed(() => {
if (activeDetailTab.value === "sector") return sectorDetailGroups.value;
if (activeDetailTab.value === "openLower")
return openLowerDisplayGroups.value;
if (activeDetailTab.value === "openThreshold")
return openThresholdDetail.value.groups;
return detailGroups.value.map((group) => ({
...group,
key: `board-${group.board_count ?? group.label}`,
@@ -313,6 +336,7 @@ const detailModeDescription = computed(
board: "按连板高度从高到低展示当日涨停股票。",
sector: "按所属行业或板块汇总展示当日涨停股票。",
openLower: `仅统计当日涨停且前一交易日也涨停的股票,首二一一红色优先,首板进二板黄色随后,前一交易日 ${openLowerDetail.value.previous_trade_date || "--"}。`,
openThreshold: `仅统计当日涨停且前一交易日也涨停的股票,并按开盘价是否低于前收 101% 分组,前一交易日 ${openThresholdDetail.value.previous_trade_date || "--"}。`,
})[activeDetailTab.value],
);
const openLowerTabLabel = computed(() => {
@@ -379,9 +403,14 @@ const disableDates = (time) => {
};
const fetchOverviewPage = async (tradeDate) => {
const [currentOverview, currentOpenLowerGroups] = await Promise.all([
const [
currentOverview,
currentOpenLowerGroups,
currentOpenThresholdGroups,
] = await Promise.all([
getLimitBoardOverview({ trade_date: tradeDate }),
getLimitBoardOpenLowerGroups({ trade_date: tradeDate }),
getLimitBoardOpenThresholdGroups({ trade_date: tradeDate }),
]);
overview.value = normalizeOverview(currentOverview, tradeDate);
@@ -389,6 +418,10 @@ const fetchOverviewPage = async (tradeDate) => {
currentOpenLowerGroups,
tradeDate,
);
openThresholdDetail.value = normalizeOpenThresholdGroups(
currentOpenThresholdGroups,
tradeDate,
);
strengthSeries.value = overview.value.recent_max_board_stats;
detailTradeDate.value = overview.value.trade_date;
detailGroups.value = overview.value.groups;
@@ -402,10 +435,12 @@ const fetchDetailGroups = async (tradeDate) => {
detailLoading.value = true;
try {
const [detailOverview, detailOpenLowerGroups] = await Promise.all([
getLimitBoardOverview({ trade_date: tradeDate }),
getLimitBoardOpenLowerGroups({ trade_date: tradeDate }),
]);
const [detailOverview, detailOpenLowerGroups, detailOpenThresholdGroups] =
await Promise.all([
getLimitBoardOverview({ trade_date: tradeDate }),
getLimitBoardOpenLowerGroups({ trade_date: tradeDate }),
getLimitBoardOpenThresholdGroups({ trade_date: tradeDate }),
]);
const normalized = normalizeOverview(detailOverview, tradeDate);
detailTradeDate.value = normalized.trade_date;
detailGroups.value = normalized.groups;
@@ -413,6 +448,10 @@ const fetchDetailGroups = async (tradeDate) => {
detailOpenLowerGroups,
tradeDate,
);
openThresholdDetail.value = normalizeOpenThresholdGroups(
detailOpenThresholdGroups,
tradeDate,
);
} catch (err) {
console.error("刷新涨停分组明细失败", err);
} finally {
@@ -461,6 +500,28 @@ const normalizeOpenLowerGroups = (data, fallbackTradeDate = "") => ({
: [],
});
const normalizeOpenThresholdGroups = (data, fallbackTradeDate = "") => ({
trade_date: data?.trade_date || fallbackTradeDate,
previous_trade_date: data?.previous_trade_date || "",
summary: {
limit_count: data?.summary?.limit_count || 0,
open_within_threshold_count:
data?.summary?.open_within_threshold_count || 0,
open_at_or_above_threshold_count:
data?.summary?.open_at_or_above_threshold_count || 0,
threshold_ratio: Number(data?.summary?.threshold_ratio || 1.01),
},
groups: Array.isArray(data?.groups)
? data.groups.map((group) => ({
key: `open-threshold-${group?.key || group?.label || "group"}`,
rawKey: group?.key || "",
label: group?.label || "--",
count: Number(group?.count || 0),
stocks: Array.isArray(group?.stocks) ? group.stocks : [],
}))
: [],
});
const handleQuery = async () => {
if (!selectedDate.value) return;
@@ -815,7 +876,7 @@ const openKlineDialog = async (stock) => {
},
],
dataZoom: [
{ type: "inside", xAxisIndex: [0, 1], start: 80, end: 100 },
{ type: "inside", xAxisIndex: [0, 1], start: 95, end: 100 },
{ show: true, type: "slider", xAxisIndex: [0, 1], top: "90%" },
],
series: [
@@ -1152,7 +1213,8 @@ const openKlineDialog = async (stock) => {
.stock-meta,
.stock-metrics,
.stock-limit-time,
.stock-open-compare {
.stock-open-compare,
.stock-open-threshold {
color: #607289;
font-size: 13px;
}
@@ -1166,7 +1228,8 @@ const openKlineDialog = async (stock) => {
}
.stock-limit-time,
.stock-open-compare {
.stock-open-compare,
.stock-open-threshold {
display: flex;
align-items: center;
justify-content: space-between;
@@ -1181,7 +1244,8 @@ const openKlineDialog = async (stock) => {
color: #7a8aa1;
}
.stock-open-compare span {
.stock-open-compare span,
.stock-open-threshold span {
color: #526982;
font-weight: 600;
}