feat(web): 批量评估页——股票池/因子集/时间窗三步提交进任务中心;CI门禁加tests/factor [nas]
This commit is contained in:
@@ -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 · 剔上市<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="输入股票代码,用逗号或换行分隔 例如: 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 年,预计 1–3 小时,完成后结果进 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)',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user