feat(web): 批量评估页——股票池/因子集/时间窗三步提交进任务中心;CI门禁加tests/factor [nas]
CI/CD / test (push) Successful in 12s
CI/CD / nas-deploy (push) Successful in 34s
CI/CD / nas-verify (push) Successful in 11s

This commit is contained in:
2026-08-25 00:42:57 +08:00
parent 77e12f4fe0
commit cdaa88fea1
3 changed files with 247 additions and 30 deletions
+243 -27
View File
@@ -1,18 +1,158 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getFactors, submitBatchEval, type FactorItem } from '@/api/factor'
const router = useRouter()
const loading = ref(false)
const pool = ref<string>('all')
const categories = ref<string[]>(['alpha101', 'alpha158'])
const startDate = ref('2018-01-01')
const endDate = ref('2026-06-30')
const factors = ref<FactorItem[]>([])
const customSymbolsText = ref('')
async function submit() {
// 01 股票池: 'all' = 全A, 'custom' = 自定义, 'csi800' = 中证800(未就绪)
const poolType = ref<string>('all')
// 02 因子集: 按类目分组勾选
const selectedCategories = ref<Set<string>>(new Set(['alpha101', 'alpha158']))
// 03 时间窗
const dateRange = ref<[string, string]>(['2018-01-01', '2026-06-30'])
// 按 category 分组并计数
const categoryGroups = computed(() => {
const map = new Map<string, { count: number; items: FactorItem[] }>()
for (const f of factors.value) {
if (!map.has(f.category)) {
map.set(f.category, { count: 0, items: [] })
}
const group = map.get(f.category)!
group.count++
group.items.push(f)
}
return Array.from(map, ([cat, data]) => ({ cat, count: data.count, items: data.items }))
})
// 自动生成 label
const autoLabel = computed(() => {
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
return `batch-${y}${m}${d}`
})
// 解析自定义股票代码
const parsedCustomSymbols = computed(() => {
return customSymbolsText.value
.split(/[\s,\n]+/)
.map((s) => s.trim())
.filter(Boolean)
})
// 中证800是否置灰(未就绪)
const csi800Disabled = true
onMounted(async () => {
loading.value = true
// TODO: Implement batch evaluation submission (Task 12)
setTimeout(() => {
try {
factors.value = await getFactors()
} catch {
ElMessage.error('因子列表加载失败')
} finally {
loading.value = false
}, 1000)
}
})
// 切换股票池类型
function selectPool(type: string): void {
if (type === 'csi800' && csi800Disabled) {
return
}
poolType.value = type
}
// 切换类目勾选
function toggleCategory(cat: string): void {
if (selectedCategories.value.has(cat)) {
selectedCategories.value.delete(cat)
} else {
selectedCategories.value.add(cat)
}
// 强制响应式更新
selectedCategories.value = new Set(selectedCategories.value)
}
// 全选/清空类目
function setAllCategories(val: boolean): void {
if (val) {
// 只全选可用的类目(排除 gtja191 等未就绪的)
const available = categoryGroups.value
.filter((g) => g.cat !== 'gtja191')
.map((g) => g.cat)
selectedCategories.value = new Set(available)
} else {
selectedCategories.value.clear()
}
// 强制响应式更新
selectedCategories.value = new Set(selectedCategories.value)
}
// 提交前校验
function validate(): boolean {
// 校验因子集: 至少选一类
if (selectedCategories.value.size === 0) {
ElMessage.warning('请至少选择一个因子类目')
return false
}
// 校验自定义股票池: 若选中自定义,必须填写代码
if (poolType.value === 'custom' && parsedCustomSymbols.value.length === 0) {
ElMessage.warning('请填写自定义股票代码(逗号或换行分隔)')
return false
}
// 校验日期
if (!dateRange.value[0] || !dateRange.value[1]) {
ElMessage.warning('请选择完整的起止日期')
return false
}
return true
}
// 提交批量评估
async function submit(): Promise<void> {
if (!validate()) return
loading.value = true
try {
// 构造 symbols: 全A 传空数组,自定义传解析后的列表
const symbols = poolType.value === 'all' ? [] : parsedCustomSymbols.value
// categories: 传选中的类目列表
const categories = Array.from(selectedCategories.value)
// factors: 空(让后端按 categories 全量)
const req = {
categories,
factors: [],
symbols,
start: dateRange.value[0],
end: dateRange.value[1],
label: autoLabel.value,
}
const taskId = await submitBatchEval(req)
ElMessage.success(`批量评估任务已提交(task_id: ${taskId.slice(0, 8)}...),完成后将自动生成排行榜批次`)
// 跳任务中心
router.push('/backtest/history')
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : '提交失败'
ElMessage.error(`提交失败: ${msg}`)
} finally {
loading.value = false
}
}
</script>
@@ -29,39 +169,76 @@ async function submit() {
<h3><span class="step">01</span>股票池</h3>
<div class="cs">与实盘可交易域一致 · 退市股保留无幸存者偏差</div>
<div class="poolgrid">
<button class="pool on">
<button
class="pool"
:class="{ on: poolType === 'all' }"
:disabled="loading"
@click="selectPool('all')"
>
<span class="pt"><span class="radio"></span>全A 主板+创业板</span>
<span class="pd">剔除科创板/北交所 · ST · 剔上市&lt;120</span>
<span class="pc"> 4,382 </span>
</button>
<button class="pool">
<button
class="pool"
:class="{ on: poolType === 'csi800' }"
:disabled="csi800Disabled || loading"
@click="selectPool('csi800')"
style="opacity: 0.5; cursor: not-allowed"
>
<span class="pt"><span class="radio"></span>中证 800</span>
<span class="pd">成份股并集 · 按评估日动态取 · 快速迭代用</span>
<span class="pc"> 800 </span>
<span class="pc"> 800 · 未就绪</span>
</button>
<button class="pool">
<button
class="pool"
:class="{ on: poolType === 'custom' }"
:disabled="loading"
@click="selectPool('custom')"
>
<span class="pt"><span class="radio"></span>自定义列表</span>
<span class="pd">粘贴代码清单 · 沿用现有单因子分析入口</span>
<span class="pc">任意</span>
<span class="pc">{{ parsedCustomSymbols.length || '任意' }}</span>
</button>
</div>
<!-- 自定义股票池输入框 -->
<div v-if="poolType === 'custom'" style="margin-top: 16px">
<textarea
v-model="customSymbolsText"
class="custom-input"
placeholder="输入股票代码,用逗号或换行分隔&#10;例如: 600000, 000001, 300750"
:disabled="loading"
rows="4"
/>
<div class="cs" style="margin-top: 4px">
已识别 {{ parsedCustomSymbols.length }} 只股票
</div>
</div>
</div>
<div class="formcard">
<h3><span class="step">02</span>因子集</h3>
<div class="cs">按类批量勾选 · 未就绪的因子集显示占位</div>
<div class="checkrow">
<button class="checkchip on">
<span class="box"></span>alpha101 <span class="cnt">×100</span>
<!-- 动态渲染因子类目 -->
<button
v-for="group in categoryGroups"
:key="group.cat"
class="checkchip"
:class="{ on: selectedCategories.has(group.cat) }"
:disabled="loading"
@click="toggleCategory(group.cat)"
>
<span class="box"></span>{{ group.cat }} <span class="cnt">×{{ group.count }}</span>
</button>
<button class="checkchip on">
<span class="box"></span>alpha158 <span class="cnt">×158</span>
</div>
<!-- 全选/清空按钮 -->
<div style="margin-top: 12px; display: flex; gap: 8px">
<button class="btn-sm" :disabled="loading" @click="setAllCategories(true)">
全选
</button>
<button class="checkchip">
<span class="box"></span>内置基础 <span class="cnt">×7</span>
</button>
<button class="checkchip" style="opacity: 0.45">
<span class="box"></span>gtja191 <span class="cnt">×191 · 待移植</span>
<button class="btn-sm" :disabled="loading" @click="setAllCategories(false)">
清空
</button>
</div>
</div>
@@ -70,20 +247,25 @@ async function submit() {
<h3><span class="step">03</span>时间窗与口径</h3>
<div class="cs">日频因子值 · RankIC · 前瞻收益 1/5/10 </div>
<div class="daterow">
<input v-model="startDate" type="date" class="datebox" />
<input v-model="dateRange[0]" type="date" class="datebox" :disabled="loading" />
<span style="color: var(--text-3)"></span>
<input v-model="endDate" type="date" class="datebox" />
<input v-model="dateRange[1]" type="date" class="datebox" :disabled="loading" />
<span style="color: var(--text-3); font-family: var(--mono); font-size: 11px">
全量预估 ~2.5h (NAS) · 建议晚间错峰
全量 4400 × 8.5 预计 13 小时完成后结果进 IC 排行榜
</span>
</div>
</div>
<!-- Label 预览 -->
<div class="formcard" style="padding: 12px 16px; background: var(--panel)">
<div class="cs">自动生成批次标签: {{ autoLabel }}</div>
</div>
<div class="submitbar">
<button class="btn primary" :disabled="loading" @click="submit">
{{ loading ? '提交中' : '提交评估任务' }}
</button>
<span class="note">提交后进入任务中心排队 · 完成自动生成2026-08-25 批次排行榜</span>
<span class="note">提交后进入任务中心排队 · 完成自动生成{{ autoLabel }}排行榜</span>
</div>
</div>
</template>
@@ -335,4 +517,38 @@ async function submit() {
.btn.primary:hover:not(:disabled) {
background: #33ecff;
}
.custom-input {
width: 100%;
font-family: var(--mono);
font-size: 12px;
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 8px 12px;
background: var(--panel);
resize: vertical;
}
.btn-sm {
font-size: 11.5px;
font-weight: 600;
border-radius: var(--r-sm);
padding: 5px 12px;
border: 1px solid var(--border);
color: var(--text-2);
background: var(--bg-card);
transition: all 0.15s var(--ease);
cursor: pointer;
}
.btn-sm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-sm:hover:not(:disabled) {
border-color: var(--cyan-dim);
color: var(--brand);
}
</style>
@@ -80,7 +80,7 @@ function renderMonthlyChart() {
name: '月度 IC',
data: icValues,
itemStyle: {
color: (params) => (params.data as number) >= 0 ? 'var(--up)' : 'var(--down)',
color: (params: any) => (params.data as number) >= 0 ? 'var(--up)' : 'var(--down)',
},
},
{