新增低于 前一交易日101% 价格开盘逻辑。新增下载文件接口。
This commit is contained in:
+2
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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: [{
|
||||
|
||||
@@ -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>
|
||||
@@ -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,9 +435,11 @@ const fetchDetailGroups = async (tradeDate) => {
|
||||
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
const [detailOverview, detailOpenLowerGroups] = await Promise.all([
|
||||
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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user