feat(factor): New/Result两页终端风重构落地(方案B设计稿v2用户拍板'开工') [nas]
后端3小端点(sanguo_factor/universe_pools.py+routes):/factor/universe/pools
池清单(白名单6指数+当前成分数,in_current=1池语义≠回测并集口径)/pool/{key}
成分(code+name读constituent_unified)/search?q=(代码前缀OR名称子句DISTINCT
LIMIT10);cfg=None兜底同analyzer;+6单测+2端点测(⚠️首版误覆盖batch_eval的
universe.py,git恢复后改名universe_pools,292测全绿)
前端:①New.vue终端风重写——FactorPicker(搜索+三色分组点选+已选托盘,
240因子弃下拉)+UniversePicker(三层输入:预设池一键选[跨行业30前端常量+
6指数池走后端]/搜索combobox防抖250ms带名称候选/chips池可删+复制+清空,
批量粘贴折叠兜底)+原生date input禁未来+跨度显示+校验前置(禁用+原因文案);
要素零变化(query.factor预填/hydrate回填/≥1因子+≥2标的/混合分隔)。
②Result.vue外壳——5格metric-strip(最优ICIR正红负绿/最优因子青)+IC明细
终端表格(右对齐tabular-nums,|t|≥2加粗,最优因子行青标+左青条)+tears区
tabs终端化(TearsPanel本体不动)。③factorSamples.ts常量:跨行业30只
(code+name)详情页一键直达与New页预设共用同源(LeaderboardDetail改引,
删本地重复清单)。npm run build绿(vue-tsc+rolldown)
This commit is contained in:
@@ -153,3 +153,32 @@ export async function submitBatchEval(req: BatchEvalSubmit): Promise<string> {
|
||||
const { data } = await apiClient.post<{ task_id: string }>('/factor/eval/submit', req)
|
||||
return data.task_id
|
||||
}
|
||||
|
||||
|
||||
// —— 标的池(New 页三层输入) ——
|
||||
|
||||
export interface UniversePool {
|
||||
key: string
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface UniverseStock {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export async function getUniversePools(): Promise<UniversePool[]> {
|
||||
const { data } = await apiClient.get<UniversePool[]>('/factor/universe/pools')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getUniversePool(key: string): Promise<UniverseStock[]> {
|
||||
const { data } = await apiClient.get<UniverseStock[]>(`/factor/universe/pool/${key}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function searchUniverse(q: string): Promise<UniverseStock[]> {
|
||||
const { data } = await apiClient.get<UniverseStock[]>('/factor/universe/search', { params: { q } })
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/** 跨行业 30 只样本(详情页「生成 tears 报告」与 New 页标的池预设共用同一份) */
|
||||
export interface SampleStock {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export const TEARS_SAMPLE: SampleStock[] = [
|
||||
{ code: '600519', name: '贵州茅台' },
|
||||
{ code: '000858', name: '五粮液' },
|
||||
{ code: '600036', name: '招商银行' },
|
||||
{ code: '601318', name: '中国平安' },
|
||||
{ code: '000001', name: '平安银行' },
|
||||
{ code: '600000', name: '浦发银行' },
|
||||
{ code: '300750', name: '宁德时代' },
|
||||
{ code: '002594', name: '比亚迪' },
|
||||
{ code: '600276', name: '恒瑞医药' },
|
||||
{ code: '000538', name: '云南白药' },
|
||||
{ code: '600887', name: '伊利股份' },
|
||||
{ code: '000651', name: '格力电器' },
|
||||
{ code: '000333', name: '美的集团' },
|
||||
{ code: '600690', name: '海尔智家' },
|
||||
{ code: '000725', name: '京东方A' },
|
||||
{ code: '002415', name: '海康威视' },
|
||||
{ code: '600030', name: '中信证券' },
|
||||
{ code: '601166', name: '兴业银行' },
|
||||
{ code: '600028', name: '中国石化' },
|
||||
{ code: '601857', name: '中国石油' },
|
||||
{ code: '600585', name: '海螺水泥' },
|
||||
{ code: '000625', name: '长安汽车' },
|
||||
{ code: '601633', name: '长城汽车' },
|
||||
{ code: '002352', name: '顺丰控股' },
|
||||
{ code: '600104', name: '上汽集团' },
|
||||
{ code: '601398', name: '工商银行' },
|
||||
{ code: '600941', name: '中国移动' },
|
||||
{ code: '300059', name: '东方财富' },
|
||||
{ code: '002230', name: '科大讯飞' },
|
||||
{ code: '601888', name: '中国中免' },
|
||||
]
|
||||
|
||||
export const TEARS_SAMPLE_CODES = TEARS_SAMPLE.map((s) => s.code)
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 因子选择(设计稿 v1): 搜索 + 按类分组点选 + 已选托盘。
|
||||
* 240 个因子的多选下拉翻页不便 → 网格点选;托盘交互与排行榜「加入对比」一致。
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import type { FactorItem } from '@/api/factor'
|
||||
|
||||
const props = defineProps<{ factors: FactorItem[]; modelValue: string[] }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: string[]): void }>()
|
||||
|
||||
const q = ref('')
|
||||
const picked = ref<Set<string>>(new Set(props.modelValue))
|
||||
|
||||
const grouped = computed(() => {
|
||||
const map = new Map<string, FactorItem[]>()
|
||||
for (const f of props.factors) {
|
||||
if (!map.has(f.category)) map.set(f.category, [])
|
||||
map.get(f.category)!.push(f)
|
||||
}
|
||||
return Array.from(map, ([cat, items]) => ({ cat, items }))
|
||||
})
|
||||
|
||||
function visible(item: FactorItem): boolean {
|
||||
const v = q.value.trim().toLowerCase()
|
||||
if (!v) return true
|
||||
return item.name.toLowerCase().includes(v) || item.category.toLowerCase().includes(v)
|
||||
}
|
||||
|
||||
function visibleCount(): number {
|
||||
return props.factors.filter(visible).length
|
||||
}
|
||||
|
||||
function toggle(name: string): void {
|
||||
if (picked.value.has(name)) picked.value.delete(name)
|
||||
else picked.value.add(name)
|
||||
sync()
|
||||
}
|
||||
|
||||
function isSelected(name: string): boolean {
|
||||
return picked.value.has(name)
|
||||
}
|
||||
|
||||
function removeOne(name: string): void {
|
||||
picked.value.delete(name)
|
||||
sync()
|
||||
}
|
||||
|
||||
function clearAll(): void {
|
||||
picked.value.clear()
|
||||
sync()
|
||||
}
|
||||
|
||||
function sync(): void {
|
||||
emit('update:modelValue', [...picked.value])
|
||||
}
|
||||
|
||||
// 父组件异步加载因子列表后回填预选(query.factor 预填场景)
|
||||
function hydrate(): void {
|
||||
for (const n of props.modelValue) {
|
||||
if (!picked.value.has(n)) picked.value.add(n)
|
||||
}
|
||||
sync()
|
||||
}
|
||||
defineExpose({ hydrate })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fp">
|
||||
<div class="searchrow">
|
||||
<input v-model="q" type="text" spellcheck="false" placeholder="搜索因子名 / 类别,如 alpha16、KMID、alpha158 …">
|
||||
<span class="count">显示 {{ visibleCount() }} / {{ factors.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="groups">
|
||||
<div v-for="g in grouped" :key="g.cat" class="grp">
|
||||
<div class="gh">
|
||||
<span class="cat-chip" :class="`cat-${g.cat}`">{{ g.cat }}</span>
|
||||
<span class="gc">{{ g.items.length }} 个</span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<span
|
||||
v-for="f in g.items" v-show="visible(f)" :key="f.name"
|
||||
class="fxp" :class="[`sel-${g.cat}`, { sel: isSelected(f.name) }]"
|
||||
@click="toggle(f.name)"
|
||||
>{{ f.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="picked">
|
||||
<span class="pk">已选 {{ picked.size }}</span>
|
||||
<div class="chips">
|
||||
<span v-if="!picked.size" class="empty">点击上方因子加入,≥1 个可提交</span>
|
||||
<span v-for="n in picked" :key="n" class="pchip">
|
||||
{{ n }}<span class="x" title="移除" @click="removeOne(n)">✕</span>
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="picked.size" class="clear" @click="clearAll">清空</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fp { width: 100%; }
|
||||
.searchrow { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.searchrow input { flex: 1; min-width: 200px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text); font-family: var(--mono); font-size: 12px; padding: 6px 10px; }
|
||||
.searchrow input::placeholder { color: var(--text-3); }
|
||||
.searchrow input:focus { outline: 1px solid rgba(0,229,255,.5); }
|
||||
.count { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); white-space: nowrap; }
|
||||
.groups { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
@media (max-width: 980px) { .groups { grid-template-columns: 1fr; } }
|
||||
.grp .gh { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.grp .gc { font-family: var(--mono); font-size: 10px; color: var(--text-3); }
|
||||
.cat-chip { display: inline-block; padding: 1px 8px; border-radius: var(--r-sm); font-family: var(--mono); font-size: 10.5px; font-weight: 600; line-height: 17px; border: 1px solid transparent; }
|
||||
.cat-alpha101 { color: var(--brand); background: rgba(0,229,255,.1); border-color: rgba(0,229,255,.3); }
|
||||
.cat-alpha158 { color: var(--purple-dim); background: rgba(180,140,255,.1); border-color: rgba(180,140,255,.3); }
|
||||
.cat-builtin { color: var(--amber); background: rgba(255,176,0,.1); border-color: rgba(255,176,0,.3); }
|
||||
.cat-custom { color: var(--text-2); background: rgba(122,138,154,.1); border-color: rgba(122,138,154,.3); }
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.fxp { font-family: var(--mono); font-size: 11px; padding: 2px 9px; border-radius: var(--r-sm); border: 1px solid var(--border); background: var(--panel); color: var(--text-2); cursor: pointer; transition: all .12s ease; user-select: none; }
|
||||
.fxp:hover { color: var(--text); border-color: var(--text-3); }
|
||||
.fxp.sel.sel-alpha101 { color: var(--brand); border-color: rgba(0,229,255,.45); background: var(--cyan-soft); }
|
||||
.fxp.sel.sel-alpha158 { color: var(--purple-dim); border-color: rgba(180,140,255,.45); background: rgba(180,140,255,.12); }
|
||||
.fxp.sel.sel-builtin { color: var(--amber); border-color: rgba(255,176,0,.45); background: var(--amber-soft); }
|
||||
.fxp.sel.sel-custom { color: var(--text); border-color: var(--text-3); background: var(--bg-hover); }
|
||||
.picked { display: flex; align-items: center; gap: 8px; margin-top: 12px; border: 1px dashed rgba(0,229,255,.3); background: rgba(0,229,255,.04); border-radius: var(--r-md); padding: 8px 12px; flex-wrap: wrap; min-height: 38px; }
|
||||
.pk { font-family: var(--mono); font-size: 10.5px; color: var(--brand); letter-spacing: .1em; white-space: nowrap; }
|
||||
.chips { display: flex; gap: 5px; flex-wrap: wrap; flex: 1; }
|
||||
.empty { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); }
|
||||
.pchip { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11px; border: 1px solid var(--border); border-radius: var(--r-sm); padding: 2px 8px; background: var(--panel); }
|
||||
.pchip .x { cursor: pointer; color: var(--text-3); }
|
||||
.pchip .x:hover { color: var(--danger); }
|
||||
.clear { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); cursor: pointer; white-space: nowrap; }
|
||||
.clear:hover { color: var(--danger); }
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, watch, nextTick, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getEvalDetail, submitFactor, type EvalDetail } from '@/api/factor'
|
||||
import { TEARS_SAMPLE_CODES } from '@/constants/factorSamples'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { darkTitle, darkTooltip, darkGrid, darkAxis, BRAND } from '@/utils/echartsDark'
|
||||
import { useFactorCompareStore } from '@/stores/factorCompare'
|
||||
@@ -51,12 +52,6 @@ function toggleCompare() {
|
||||
// —— 一键生成 tears(方案A):排行榜数据没有分层序列,tears 要跑一次 alphalens 分析
|
||||
// (固定 30 只跨行业样本 × 近 2.5 年,分钟级),跑完结果页即新版 ECharts tears。
|
||||
|
||||
const TEARS_SAMPLE_SYMBOLS = [
|
||||
'600519', '000858', '600036', '601318', '000001', '600000', '300750', '002594',
|
||||
'600276', '000538', '600887', '000651', '000333', '600690', '000725', '002415',
|
||||
'600030', '601166', '600028', '601857', '600585', '000625', '601633', '002352',
|
||||
'600104', '601398', '600941', '300059', '002230', '601888',
|
||||
]
|
||||
const TEARS_SAMPLE_SPAN = { start: '2024-01-01', end: '2026-06-30' }
|
||||
|
||||
const tearsSubmitting = ref(false)
|
||||
@@ -66,7 +61,7 @@ async function generateTears() {
|
||||
tearsSubmitting.value = true
|
||||
try {
|
||||
const tid = await submitFactor({
|
||||
symbols: TEARS_SAMPLE_SYMBOLS,
|
||||
symbols: TEARS_SAMPLE_CODES,
|
||||
factor_names: [detail.value.factor],
|
||||
start: TEARS_SAMPLE_SPAN.start,
|
||||
end: TEARS_SAMPLE_SPAN.end,
|
||||
|
||||
@@ -1,46 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
/**
|
||||
* 因子分析提交页(终端风重构,方案B 设计稿 v2 拍板):
|
||||
* 自选因子 × 自选标的池 × 自选区间的自由实验台。
|
||||
* 要素与旧版零变化:query.factor 预填 / ≥1 因子 + ≥2 标的校验 / 混合分隔粘贴。
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getFactors, submitFactor, type FactorItem } from '@/api/factor'
|
||||
import { disableFutureDate } from '@/utils/dates'
|
||||
import { TEARS_SAMPLE_CODES } from '@/constants/factorSamples'
|
||||
import FactorPicker from './FactorPicker.vue'
|
||||
import UniversePicker from './UniversePicker.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const factors = ref<FactorItem[]>([])
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const pickerRef = ref<InstanceType<typeof FactorPicker> | null>(null)
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
const form = reactive({
|
||||
const form = ref({
|
||||
factor_names: [] as string[],
|
||||
symbolsText: '600000\n000001\n300750',
|
||||
start: '2024-01-01',
|
||||
end: '2024-06-30',
|
||||
symbols: [...TEARS_SAMPLE_CODES.slice(0, 8)],
|
||||
start: '2024-01-02',
|
||||
end: '2026-06-30',
|
||||
})
|
||||
|
||||
// 按 category 分组(el-option-group)
|
||||
const grouped = computed(() => {
|
||||
const map = new Map<string, FactorItem[]>()
|
||||
for (const f of factors.value) {
|
||||
if (!map.has(f.category)) map.set(f.category, [])
|
||||
map.get(f.category)!.push(f)
|
||||
}
|
||||
return Array.from(map, ([cat, items]) => ({ cat, items }))
|
||||
const spanDays = computed(() => {
|
||||
const a = new Date(form.value.start).getTime()
|
||||
const b = new Date(form.value.end).getTime()
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return null
|
||||
return Math.round((b - a) / 864e5)
|
||||
})
|
||||
|
||||
const symbolCount = computed(() =>
|
||||
form.symbolsText.split(/[\s,,]+/).map((s) => s.trim()).filter(Boolean).length,
|
||||
)
|
||||
const valid = computed(() => form.value.factor_names.length >= 1 && form.value.symbols.length >= 2)
|
||||
const validNote = computed(() => {
|
||||
if (!form.value.factor_names.length) return '待办:至少选 1 个因子'
|
||||
if (form.value.symbols.length < 2) return '待办:标的池 ≥2 只'
|
||||
return ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
factors.value = await getFactors()
|
||||
// 从排行榜详情「查看完整 tears 报告」跳入时预填该因子
|
||||
// 排行榜详情「自选样本分析」跳入时预填该因子
|
||||
const qf = route.query.factor as string | undefined
|
||||
if (qf && factors.value.some((f) => f.name === qf)) {
|
||||
form.factor_names = [qf]
|
||||
form.value.factor_names = [qf]
|
||||
}
|
||||
pickerRef.value?.hydrate()
|
||||
} catch {
|
||||
ElMessage.error('因子列表加载失败')
|
||||
} finally {
|
||||
@@ -49,18 +59,14 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
async function onSubmit(): Promise<void> {
|
||||
const symbols = form.symbolsText.split(/[\s,,]+/).map((s) => s.trim()).filter(Boolean)
|
||||
if (!form.factor_names.length || symbols.length < 2) {
|
||||
ElMessage.warning('至少选 1 个因子 + 2 个标的(IC 横截面需多标的)')
|
||||
return
|
||||
}
|
||||
if (!valid.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const tid = await submitFactor({
|
||||
symbols,
|
||||
factor_names: form.factor_names,
|
||||
start: form.start,
|
||||
end: form.end,
|
||||
symbols: form.value.symbols,
|
||||
factor_names: form.value.factor_names,
|
||||
start: form.value.start,
|
||||
end: form.value.end,
|
||||
})
|
||||
ElMessage.success('因子分析已提交')
|
||||
router.push(`/factor/progress/${tid}`)
|
||||
@@ -73,68 +79,78 @@ async function onSubmit(): Promise<void> {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page factor-new">
|
||||
<div v-loading="loading" class="page factor-new">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h2 class="page-title">因子分析</h2>
|
||||
<p class="page-subtitle">IC 横截面分析(≥2 标的)· 生成分层 tears 报告</p>
|
||||
<p class="page-sub">IC 横截面 · 分层 tears 报告 · 自由实验台(换池 / 分年 / 多因子并排归这里)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="blk" shadow="never" v-loading="loading">
|
||||
<template #header><span class="section-title">因子与标的</span></template>
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="因子">
|
||||
<el-select
|
||||
v-model="form.factor_names"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="选择因子(可多选,按类分组)"
|
||||
style="width: 480px"
|
||||
>
|
||||
<el-option-group v-for="g in grouped" :key="g.cat" :label="g.cat">
|
||||
<el-option v-for="f in g.items" :key="f.name" :label="f.name" :value="f.name" />
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
<span class="muted form-hint">已选 {{ form.factor_names.length }} 个</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="标的池">
|
||||
<el-input
|
||||
v-model="form.symbolsText"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="每行一个标的代码(≥2)"
|
||||
style="width: 360px"
|
||||
/>
|
||||
<span class="muted form-hint">支持空格/逗号/换行分隔,当前 {{ symbolCount }} 个</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<div class="pane">
|
||||
<div class="ph"><span class="pt">因子选择</span><span class="tag">FACTOR PICKER · 支持多选</span></div>
|
||||
<div class="pb">
|
||||
<FactorPicker ref="pickerRef" v-model="form.factor_names" :factors="factors" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header><span class="section-title">分析区间</span></template>
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="开始日期">
|
||||
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" :disabled-date="disableFutureDate" style="width: 220px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束日期">
|
||||
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" :disabled-date="disableFutureDate" style="width: 220px" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<div class="pane">
|
||||
<div class="ph"><span class="pt">标的池与区间</span><span class="tag">UNIVERSE & SPAN</span></div>
|
||||
<div class="pb">
|
||||
<div class="formrow">
|
||||
<div class="flab">标的池</div>
|
||||
<div class="fbody">
|
||||
<UniversePicker v-model="form.symbols" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="formrow gap-top">
|
||||
<div class="flab">分析区间</div>
|
||||
<div class="fbody">
|
||||
<div class="dates">
|
||||
<input v-model="form.start" type="date" :max="form.end">
|
||||
<span class="sep">→</span>
|
||||
<input v-model="form.end" type="date" :max="today">
|
||||
<span v-if="spanDays != null && spanDays >= 0" class="span-note">跨度 {{ spanDays }} 天 ≈ {{ (spanDays / 365).toFixed(1) }} 年</span>
|
||||
</div>
|
||||
<p class="span-hint">区间前半作因子计算预热,后半(test 段)进入 IC 与分层统计</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="submit-bar">
|
||||
<el-button type="primary" size="large" :loading="submitting" @click="onSubmit">
|
||||
提交分析
|
||||
</el-button>
|
||||
<button type="button" class="btn primary" :disabled="!valid || submitting" @click="onSubmit">
|
||||
{{ submitting ? '提交中…' : '提交分析 →' }}
|
||||
</button>
|
||||
<span v-if="validNote" class="valid-note">{{ validNote }}</span>
|
||||
<span class="hint">提交后进入进度页,完成自动跳转结果页</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.factor-new { display: flex; flex-direction: column; gap: 16px; }
|
||||
.blk { border: 1px solid var(--border-2); }
|
||||
.form-hint { margin-left: 10px; }
|
||||
.submit-bar { padding: 4px 0; }
|
||||
.factor-new { display: flex; flex-direction: column; gap: 14px; }
|
||||
.pane { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r-lg); min-width: 0; }
|
||||
.pane .ph { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--border-2); flex-wrap: wrap; }
|
||||
.pane .pt { font-size: 12.5px; font-weight: 700; letter-spacing: .06em; }
|
||||
.pane .tag { font-family: var(--mono); font-size: 9.5px; color: var(--text-3); letter-spacing: .1em; }
|
||||
.pane .pb { padding: 14px; }
|
||||
.formrow { display: flex; align-items: flex-start; gap: 14px; }
|
||||
.formrow.gap-top { margin-top: 14px; }
|
||||
.flab { width: 88px; flex-shrink: 0; padding-top: 7px; text-align: right; color: var(--text-2); font-size: 12.5px; }
|
||||
.fbody { flex: 1; min-width: 0; }
|
||||
.dates { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.dates input[type='date'] { background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text); font-family: var(--mono); font-size: 12px; padding: 6px 10px; color-scheme: dark; }
|
||||
.dates input[type='date']:focus { outline: 1px solid rgba(0,229,255,.5); }
|
||||
.sep { color: var(--text-3); font-family: var(--mono); font-size: 11px; }
|
||||
.span-note { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); }
|
||||
.span-hint { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); margin: 8px 0 0; }
|
||||
.submit-bar { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 2px 0; }
|
||||
.btn { font-size: 12.5px; font-weight: 600; border-radius: var(--r-md); padding: 8px 18px; border: 1px solid var(--border); color: var(--text-2); background: var(--bg-card); transition: all .15s ease; cursor: pointer; }
|
||||
.btn:hover { color: var(--text); border-color: var(--text-3); }
|
||||
.btn.primary { color: #032027; background: var(--brand); border-color: var(--brand); box-shadow: 0 0 0 1px rgba(0,229,255,.22), 0 0 18px rgba(0,229,255,.08); font-weight: 700; }
|
||||
.btn.primary:hover:not(:disabled) { background: #33ecff; }
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; box-shadow: none; }
|
||||
.valid-note { font-family: var(--mono); font-size: 11px; color: var(--amber); }
|
||||
.hint { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 因子分析结果页(终端风外壳重构,方案B 设计稿 v2 拍板):
|
||||
* 五格指标条 + IC 统计明细终端表格;分层 tears 区块(TearsPanel)
|
||||
* 已是新版终端风,本次不动。
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -70,6 +75,12 @@ function icClass(v: unknown): string {
|
||||
if (typeof v !== 'number') return ''
|
||||
return v >= 0 ? 'up' : 'down'
|
||||
}
|
||||
function isSig(v: unknown): boolean {
|
||||
return typeof v === 'number' && Math.abs(v) >= 2
|
||||
}
|
||||
function isBestRow(r: Record<string, unknown>): boolean {
|
||||
return summary.value != null && r.factor === summary.value.bestFactor
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -87,63 +98,104 @@ onMounted(async () => {
|
||||
<div v-loading="loading" class="page factor-result">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h2 class="page-title">因子分析结果 <span class="muted">{{ taskId }}</span></h2>
|
||||
<p class="page-subtitle">IC 横截面统计 · 分层 tears 报告</p>
|
||||
<h2 class="page-title">因子分析结果 <span class="tid">{{ taskId }}</span></h2>
|
||||
<p class="page-sub">IC 横截面统计 · 分层 tears 报告</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 汇总指标卡 -->
|
||||
<div class="metric-row" v-if="summary">
|
||||
<div class="metric-card"><div class="m-label">因子数</div><div class="m-value mono">{{ summary.factorCount }}</div></div>
|
||||
<div class="metric-card"><div class="m-label">最优 ICIR</div><div class="m-value mono up">{{ summary.bestIcir != null ? summary.bestIcir.toFixed(3) : '—' }}</div></div>
|
||||
<div class="metric-card"><div class="m-label">最优因子</div><div class="m-value" style="font-size:15px">{{ summary.bestFactor }}</div></div>
|
||||
<div class="metric-card"><div class="m-label">平均 |IC|</div><div class="m-value mono">{{ summary.avgAbsIc.toFixed(4) }}</div></div>
|
||||
<div class="metric-card"><div class="m-label">显著数 (|t|≥2)</div><div class="m-value mono">{{ summary.significant }} / {{ summary.total }}</div></div>
|
||||
<!-- 汇总指标条(五格,最优 ICIR 红涨色 / 最优因子青色) -->
|
||||
<div v-if="summary" class="metric-strip">
|
||||
<div class="metric"><div class="k">因子数</div><div class="v">{{ summary.factorCount }}</div></div>
|
||||
<div class="metric"><div class="k">最优 ICIR</div><div class="v" :class="summary.bestIcir != null && summary.bestIcir >= 0 ? 'up' : 'down'">{{ summary.bestIcir != null ? summary.bestIcir.toFixed(3) : '—' }}</div></div>
|
||||
<div class="metric"><div class="k">最优因子</div><div class="v best">{{ summary.bestFactor }}</div></div>
|
||||
<div class="metric"><div class="k">平均 |IC|</div><div class="v">{{ summary.avgAbsIc.toFixed(4) }}</div></div>
|
||||
<div class="metric"><div class="k">显著数 |t|≥2</div><div class="v">{{ summary.significant }} / {{ summary.total }}</div></div>
|
||||
</div>
|
||||
|
||||
<!-- IC 统计表 -->
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header><span class="section-title">IC 统计明细</span></template>
|
||||
<el-table :data="rows" size="small" empty-text="无 IC 数据">
|
||||
<el-table-column prop="factor" label="因子" min-width="140" />
|
||||
<el-table-column prop="period" label="周期" width="80" />
|
||||
<el-table-column label="IC 均值" width="100" align="right">
|
||||
<template #default="{ row }"><span class="mono" :class="icClass(row.mean)">{{ fmt(row.mean) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="IC 标准差" width="100" align="right">
|
||||
<template #default="{ row }"><span class="mono">{{ fmt(row.std) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="ICIR" width="90" align="right">
|
||||
<template #default="{ row }"><span class="mono" :class="icClass(row.icir)">{{ fmt(row.icir) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="t 统计" width="90" align="right">
|
||||
<template #default="{ row }"><span class="mono" :class="icClass(row.t_stat)">{{ fmt(row.t_stat) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="样本数" width="80" align="right">
|
||||
<template #default="{ row }"><span class="mono">{{ row.count }}</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<!-- IC 统计明细(终端表格:正红负绿,|t|≥2 加粗,最优因子行青标) -->
|
||||
<div class="pane">
|
||||
<div class="ph"><span class="pt">IC 统计明细</span><span class="tag">按因子分组 · 最优 ICIR 行青标 · IC/ICIR/t 正红负绿</span></div>
|
||||
<div class="pb tblwrap">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>因子</th><th>周期</th><th>IC 均值</th><th>IC 标准差</th>
|
||||
<th>ICIR</th><th>t 统计</th><th>样本数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in rows" :key="i" :class="{ 'best-row': isBestRow(r) }">
|
||||
<td>
|
||||
<span class="fname-s">{{ r.factor }}</span>
|
||||
<span v-if="isBestRow(r)" class="best-mark">◈ 最优</span>
|
||||
</td>
|
||||
<td class="dim mono">{{ r.period }}</td>
|
||||
<td class="mono" :class="icClass(r.mean)">{{ fmt(r.mean) }}</td>
|
||||
<td class="mono">{{ fmt(r.std) }}</td>
|
||||
<td class="mono" :class="[icClass(r.icir), { sig: isSig(r.t_stat) }]">{{ fmt(r.icir) }}</td>
|
||||
<td class="mono" :class="[icClass(r.t_stat), { sig: isSig(r.t_stat) }]">{{ fmt(r.t_stat) }}</td>
|
||||
<td class="dim mono">{{ r.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="!rows.length" class="empty">无 IC 数据</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- tears 报告(方案A:原生 ECharts 暗色渲染,旧任务自动回退 iframe alphalens 报告) -->
|
||||
<el-card class="blk" shadow="never" v-if="factors.length">
|
||||
<template #header><span class="section-title">分层 tears 报告</span></template>
|
||||
<el-tabs v-model="activeReport" type="card">
|
||||
<el-tab-pane v-for="f in factors" :key="f" :label="f" :name="f" lazy>
|
||||
<TearsPanel :task-id="taskId" :factor="f" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
<div v-if="factors.length" class="pane">
|
||||
<div class="ph"><span class="pt">分层 tears 报告</span><span class="tag">TEARS · 1/5/10D 联动</span></div>
|
||||
<div class="pb">
|
||||
<div class="tears-tabs">
|
||||
<button
|
||||
v-for="f in factors" :key="f" type="button" class="tt"
|
||||
:class="{ on: activeReport === f }" @click="activeReport = f"
|
||||
>{{ f }}</button>
|
||||
</div>
|
||||
<TearsPanel v-if="activeReport" :task-id="taskId" :factor="activeReport" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.factor-result { display: flex; flex-direction: column; gap: 16px; }
|
||||
.factor-result { display: flex; flex-direction: column; gap: 14px; }
|
||||
.tid { color: var(--text-3); font-family: var(--mono); font-size: 13px; font-weight: 400; }
|
||||
|
||||
.metric-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }
|
||||
.metric-card { background: var(--bg-card); border: 1px solid var(--border-2); border-radius: var(--r-md); padding: 14px 16px; }
|
||||
.m-label { font-size: 12px; color: var(--text-3); }
|
||||
.m-value { margin-top: 6px; font-size: 20px; font-weight: 700; color: var(--text); }
|
||||
.metric-strip { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--r-lg); overflow: hidden; }
|
||||
.metric { background: var(--bg-card); padding: 10px 12px; min-width: 0; }
|
||||
.metric .k { color: var(--text-3); font-family: var(--mono); font-size: 9.5px; letter-spacing: .12em; text-transform: uppercase; white-space: nowrap; }
|
||||
.metric .v { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 17px; font-weight: 600; margin-top: 2px; white-space: nowrap; }
|
||||
.metric .v.best { color: var(--brand); font-size: 15px; }
|
||||
.metric .v.up { color: var(--up); }
|
||||
.metric .v.down { color: var(--down); }
|
||||
|
||||
.blk { border: 1px solid var(--border-2); }
|
||||
.pane { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r-lg); min-width: 0; }
|
||||
.pane .ph { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--border-2); flex-wrap: wrap; }
|
||||
.pane .pt { font-size: 12.5px; font-weight: 700; letter-spacing: .06em; }
|
||||
.pane .tag { font-family: var(--mono); font-size: 9.5px; color: var(--text-3); letter-spacing: .1em; }
|
||||
.pane .pb { padding: 14px; }
|
||||
|
||||
.tblwrap { overflow-x: auto; }
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.tbl th { font-family: var(--mono); font-size: 9.5px; letter-spacing: .12em; text-transform: uppercase; color: var(--text-3); text-align: right; padding: 8px 10px; border-bottom: 1px solid var(--border); background: var(--bg-hover); white-space: nowrap; }
|
||||
.tbl th:first-child { text-align: left; }
|
||||
.tbl td { padding: 8px 10px; border-bottom: 1px solid var(--border-2); white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.tbl td:first-child { text-align: left; }
|
||||
.tbl tr:hover td { background: var(--bg-hover); }
|
||||
.mono { font-family: var(--mono); }
|
||||
.dim { color: var(--text-3); }
|
||||
.up { color: var(--up); }
|
||||
.down { color: var(--down); }
|
||||
.fname-s { color: var(--brand); font-family: var(--mono); }
|
||||
.sig { font-weight: 700; }
|
||||
.best-row td { background: rgba(0, 229, 255, .05); }
|
||||
.best-row td:first-child { box-shadow: inset 2px 0 0 var(--brand); }
|
||||
.best-mark { color: var(--brand); font-size: 10px; font-family: var(--mono); margin-left: 6px; opacity: .8; }
|
||||
.empty { color: var(--text-3); font-size: 12px; text-align: center; padding: 14px 0; margin: 0; }
|
||||
|
||||
.tears-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.tears-tabs .tt { font-family: var(--mono); font-size: 11px; padding: 3px 12px; border-radius: var(--r-sm); border: 1px solid var(--border); background: transparent; color: var(--text-2); cursor: pointer; transition: all .15s ease; }
|
||||
.tears-tabs .tt:hover { color: var(--text); border-color: var(--text-3); }
|
||||
.tears-tabs .tt.on { color: var(--brand); background: var(--cyan-soft); border-color: rgba(0, 229, 255, .35); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 标的池三层输入(设计稿 v2 拍板): 预设池一键选 + 搜索加自选 + chips 已选池。
|
||||
* 批量粘贴收进折叠兜底入口。v-model = 代码数组;名称仅显示,组件内部管理。
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getUniversePools, getUniversePool, searchUniverse,
|
||||
type UniversePool, type UniverseStock,
|
||||
} from '@/api/factor'
|
||||
import { TEARS_SAMPLE } from '@/constants/factorSamples'
|
||||
|
||||
const props = defineProps<{ modelValue: string[] }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: string[]): void }>()
|
||||
|
||||
// code -> name(仅显示;批量粘贴的未知代码回退显示代码本身)
|
||||
const names = ref<Record<string, string>>({})
|
||||
const pool = ref<Map<string, string>>(new Map())
|
||||
const codes = computed(() => [...pool.value.keys()])
|
||||
|
||||
function sync(): void {
|
||||
for (const [c, n] of pool.value) names.value[c] = n
|
||||
emit('update:modelValue', codes.value)
|
||||
}
|
||||
|
||||
function addAll(stocks: UniverseStock[]): void {
|
||||
for (const s of stocks) pool.value.set(s.code, s.name || s.code)
|
||||
activeKey.value = ''
|
||||
sync()
|
||||
}
|
||||
|
||||
// —— ① 预设池 ——
|
||||
const pools = ref<UniversePool[]>([])
|
||||
const activeKey = ref('')
|
||||
const loadingPool = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
// 跨行业 30 只为前端常量;指数池从后端读清单
|
||||
pools.value = [{ key: 'cross30', name: '跨行业 30 只', count: TEARS_SAMPLE.length }]
|
||||
try {
|
||||
const remote = await getUniversePools()
|
||||
pools.value = [...pools.value, ...remote]
|
||||
} catch {
|
||||
ElMessage.warning('指数池清单加载失败(可继续用搜索/粘贴)')
|
||||
}
|
||||
})
|
||||
|
||||
async function loadPool(key: string): Promise<void> {
|
||||
if (key === 'cross30') {
|
||||
addAll(TEARS_SAMPLE)
|
||||
activeKey.value = key
|
||||
return
|
||||
}
|
||||
loadingPool.value = key
|
||||
try {
|
||||
const stocks = await getUniversePool(key)
|
||||
if (!stocks.length) {
|
||||
ElMessage.warning('该池无当前成分数据')
|
||||
return
|
||||
}
|
||||
addAll(stocks)
|
||||
activeKey.value = key
|
||||
} catch {
|
||||
ElMessage.error('池成分加载失败')
|
||||
} finally {
|
||||
loadingPool.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// —— ② 搜索 combobox ——
|
||||
const q = ref('')
|
||||
const cand = ref<UniverseStock[]>([])
|
||||
const candOpen = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function onQuery(): void {
|
||||
if (timer) clearTimeout(timer)
|
||||
const v = q.value.trim()
|
||||
if (!v) {
|
||||
candOpen.value = false
|
||||
return
|
||||
}
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
cand.value = await searchUniverse(v)
|
||||
candOpen.value = true
|
||||
} catch {
|
||||
candOpen.value = false
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function pick(s: UniverseStock): void {
|
||||
if (pool.value.has(s.code)) return
|
||||
pool.value.set(s.code, s.name || s.code)
|
||||
activeKey.value = ''
|
||||
q.value = ''
|
||||
candOpen.value = false
|
||||
sync()
|
||||
}
|
||||
|
||||
// —— ③ chips 池管理 ——
|
||||
function removeOne(code: string): void {
|
||||
pool.value.delete(code)
|
||||
activeKey.value = ''
|
||||
sync()
|
||||
}
|
||||
|
||||
function clearAll(): void {
|
||||
pool.value.clear()
|
||||
activeKey.value = ''
|
||||
sync()
|
||||
}
|
||||
|
||||
async function copyList(): Promise<void> {
|
||||
if (!codes.value.length) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(codes.value.join('\n'))
|
||||
ElMessage.success(`已复制 ${codes.value.length} 个代码`)
|
||||
} catch {
|
||||
ElMessage.warning('复制失败(浏览器限制),可用批量粘贴框查看')
|
||||
}
|
||||
}
|
||||
|
||||
// —— 批量粘贴兜底 ——
|
||||
const bulkOpen = ref(false)
|
||||
const bulkText = ref('')
|
||||
|
||||
function parseBulk(): void {
|
||||
const items = bulkText.value.split(/[\s,,]+/).map((s) => s.trim()).filter(Boolean)
|
||||
if (!items.length) return
|
||||
for (const c of items) {
|
||||
if (!pool.value.has(c)) pool.value.set(c, names.value[c] || c)
|
||||
}
|
||||
activeKey.value = ''
|
||||
bulkText.value = ''
|
||||
sync()
|
||||
}
|
||||
|
||||
// —— 外部初始值(父传入默认 codes;名称可缺省) ——
|
||||
onMounted(() => {
|
||||
for (const c of props.modelValue) {
|
||||
if (!pool.value.has(c)) pool.value.set(c, names.value[c] || c)
|
||||
}
|
||||
sync()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="up">
|
||||
<div class="poolrow">
|
||||
<span class="plab">预设池</span>
|
||||
<button
|
||||
v-for="p in pools" :key="p.key" type="button" class="pool"
|
||||
:class="{ on: activeKey === p.key }" :disabled="loadingPool === p.key"
|
||||
@click="loadPool(p.key)"
|
||||
>{{ p.name }} <span class="pc">{{ p.count }}</span></button>
|
||||
</div>
|
||||
|
||||
<div class="symsearch">
|
||||
<input
|
||||
v-model="q" type="text" spellcheck="false" autocomplete="off"
|
||||
placeholder="搜索代码 / 名称加入自选,如 600519 或 茅台 …"
|
||||
@input="onQuery" @keydown.esc="candOpen = false" @focus="q && (candOpen = true)"
|
||||
>
|
||||
<div v-if="candOpen" class="cand">
|
||||
<template v-if="cand.length">
|
||||
<div
|
||||
v-for="s in cand" :key="s.code" class="ci" :class="{ sel: pool.has(s.code) }"
|
||||
@mousedown.prevent="pick(s)"
|
||||
><span class="cc">{{ s.code }}</span><span class="cn">{{ s.name }}</span><span v-if="pool.has(s.code)" class="in">已选</span></div>
|
||||
</template>
|
||||
<div v-else class="none">无匹配标的</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="poolbox">
|
||||
<span v-if="!codes.length" class="empty">用上方预设池或搜索加入标的(≥2 只)</span>
|
||||
<span v-for="c in codes" :key="c" class="schip">
|
||||
<span class="code">{{ c }}</span><span class="nm">{{ names[c] }}</span>
|
||||
<span class="x" title="移除" @click="removeOne(c)">✕</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<span class="badge">{{ codes.length }} 只</span>
|
||||
<span class="hint">IC 横截面需 ≥2 只;截面排名类因子(cs_rank 族)建议 300+ 只才不失真</span>
|
||||
<button type="button" class="mini" @click="bulkOpen = !bulkOpen">{{ bulkOpen ? '收起粘贴' : '批量粘贴' }}</button>
|
||||
<button type="button" class="mini" @click="copyList">复制清单</button>
|
||||
<button type="button" class="mini warn" @click="clearAll">清空</button>
|
||||
</div>
|
||||
|
||||
<div v-if="bulkOpen" class="bulkwrap">
|
||||
<textarea v-model="bulkText" spellcheck="false" placeholder="粘贴一列股票代码(空格 / 逗号 / 换行混合均可),点「解析加入」合并进已选池" />
|
||||
<div class="bulkact"><button type="button" class="mini" @click="parseBulk">解析加入</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.up { width: 100%; }
|
||||
.poolrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.plab { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); letter-spacing: .1em; }
|
||||
.pool { font-family: var(--mono); font-size: 11px; padding: 3px 12px; border-radius: var(--r-sm); border: 1px solid var(--border); background: var(--panel); color: var(--text-2); cursor: pointer; transition: all .12s ease; }
|
||||
.pool:hover { color: var(--brand); border-color: rgba(0,229,255,.4); }
|
||||
.pool.on { color: var(--brand); border-color: rgba(0,229,255,.45); background: var(--cyan-soft); }
|
||||
.pool:disabled { opacity: .5; cursor: wait; }
|
||||
.pool .pc { color: var(--text-3); font-size: 10px; margin-left: 2px; }
|
||||
.symsearch { position: relative; margin-bottom: 10px; }
|
||||
.symsearch input { width: 100%; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text); font-family: var(--mono); font-size: 12px; padding: 6px 10px; }
|
||||
.symsearch input::placeholder { color: var(--text-3); }
|
||||
.symsearch input:focus { outline: 1px solid rgba(0,229,255,.5); }
|
||||
.cand { position: absolute; top: calc(100% + 3px); left: 0; right: 0; z-index: 30; background: var(--bg-overlay); border: 1px solid var(--border); border-radius: var(--r-sm); box-shadow: var(--shadow); max-height: 264px; overflow-y: auto; }
|
||||
.ci { display: flex; align-items: center; gap: 10px; padding: 6px 10px; cursor: pointer; border-bottom: 1px solid var(--border-2); }
|
||||
.ci:last-child { border-bottom: none; }
|
||||
.ci:hover { background: var(--bg-hover); }
|
||||
.ci .cc { font-family: var(--mono); font-size: 12px; color: var(--brand); width: 64px; }
|
||||
.ci .cn { font-size: 12px; color: var(--text); }
|
||||
.ci .in { font-size: 10.5px; color: var(--text-3); margin-left: auto; }
|
||||
.ci.sel { opacity: .45; cursor: default; }
|
||||
.none { padding: 8px 10px; color: var(--text-3); font-size: 11.5px; }
|
||||
.poolbox { display: flex; flex-wrap: wrap; gap: 5px; border: 1px dashed var(--border); border-radius: var(--r-md); background: var(--panel); padding: 9px 10px; min-height: 44px; max-height: 180px; overflow-y: auto; }
|
||||
.empty { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); }
|
||||
.schip { display: inline-flex; align-items: center; gap: 7px; font-family: var(--mono); font-size: 11px; border: 1px solid var(--border); border-radius: var(--r-sm); padding: 2px 8px; background: var(--bg-card); }
|
||||
.schip .code { color: var(--brand); }
|
||||
.schip .nm { color: var(--text-2); font-family: var(--sans); font-size: 11px; }
|
||||
.schip .x { cursor: pointer; color: var(--text-3); }
|
||||
.schip .x:hover { color: var(--danger); }
|
||||
.meta { display: flex; align-items: center; gap: 10px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.badge { font-family: var(--mono); font-size: 10.5px; color: var(--brand); border: 1px solid rgba(0,229,255,.3); background: var(--cyan-soft); border-radius: var(--r-sm); padding: 2px 8px; }
|
||||
.hint { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); }
|
||||
.mini { font-family: var(--mono); font-size: 10.5px; padding: 3px 10px; border-radius: var(--r-sm); border: 1px dashed var(--border); background: transparent; color: var(--text-2); cursor: pointer; }
|
||||
.mini:hover { color: var(--brand); border-color: rgba(0,229,255,.4); }
|
||||
.mini.warn:hover { color: var(--danger); border-color: rgba(255,77,94,.4); }
|
||||
.bulkwrap { margin-top: 10px; }
|
||||
.bulkwrap textarea { width: 100%; min-height: 96px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--text); font-family: var(--mono); font-size: 12px; line-height: 1.8; padding: 8px 10px; resize: vertical; }
|
||||
.bulkwrap textarea:focus { outline: 1px solid rgba(0,229,255,.5); }
|
||||
.bulkact { margin-top: 8px; }
|
||||
</style>
|
||||
@@ -368,6 +368,32 @@ def factor_tears_json(task_id: str, factor: str):
|
||||
return FileResponse(path, media_type="application/json")
|
||||
|
||||
|
||||
# ===== 因子分析标的池(New 页三层输入) =====
|
||||
|
||||
@router.get("/factor/universe/pools", dependencies=[Depends(verify_token)])
|
||||
def universe_pools():
|
||||
"""预设池清单(白名单指数 + 当前成分数)。"""
|
||||
from sanguo_factor.universe_pools import list_pools
|
||||
|
||||
return list_pools()
|
||||
|
||||
|
||||
@router.get("/factor/universe/pool/{key}", dependencies=[Depends(verify_token)])
|
||||
def universe_pool(key: str):
|
||||
"""池成分(当前 in_current=1): [{code, name}]。"""
|
||||
from sanguo_factor.universe_pools import pool_stocks
|
||||
|
||||
return pool_stocks(key)
|
||||
|
||||
|
||||
@router.get("/factor/universe/search", dependencies=[Depends(verify_token)])
|
||||
def universe_search(q: str):
|
||||
"""标的搜索: 代码前缀 OR 名称子串, LIMIT 10。"""
|
||||
from sanguo_factor.universe_pools import search_stocks
|
||||
|
||||
return search_stocks(q)
|
||||
|
||||
|
||||
# ===== History + Optimization endpoints (S3) =====
|
||||
|
||||
def _to_bj(ts: str | None) -> str:
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""标的池查询(New 页三层输入的后端): 预设指数池 + 标的搜索.
|
||||
|
||||
数据源 constituent_unified(与组合回测同库同表)。池语义 = 当前成分
|
||||
(in_current=1)——"沪深300 池"给用户的就是当下的 300 只,历史并集(含被踢)
|
||||
是回测口径,不是选池口径。跨行业 30 只样本是前端常量(与详情页一键直达
|
||||
同源),不走本模块。
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
# 池白名单:顺序即前端展示顺序(index_code, 中文名)
|
||||
POOLS: list[tuple[str, str]] = [
|
||||
("000300", "沪深300"),
|
||||
("000905", "中证500"),
|
||||
("000852", "中证1000"),
|
||||
("932000", "中证2000"),
|
||||
("000016", "上证50"),
|
||||
("000985", "中证全A"),
|
||||
]
|
||||
|
||||
_POOL_NAMES = dict(POOLS)
|
||||
|
||||
|
||||
def _conn() -> sqlite3.Connection:
|
||||
"""连行情库(constituent_unified 所在)。cfg 兜底同 analyzer(None→默认配置)."""
|
||||
from sanguo_data.config import load_config, find_config_path
|
||||
|
||||
cfg = load_config(find_config_path())
|
||||
vnpy_db = getattr(cfg, "data_paths", {}).get("vnpy_db")
|
||||
if not vnpy_db:
|
||||
raise RuntimeError("config 缺 data_paths.vnpy_db — 检查 data_platform.yaml 初始化")
|
||||
return sqlite3.connect(vnpy_db, timeout=30)
|
||||
|
||||
|
||||
def list_pools() -> list[dict]:
|
||||
"""预设池清单(白名单顺序 + 当前成分数)."""
|
||||
out: list[dict] = []
|
||||
conn = _conn()
|
||||
try:
|
||||
for code, name in POOLS:
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) FROM constituent_unified "
|
||||
"WHERE index_code=? AND in_current=1",
|
||||
(code,),
|
||||
).fetchone()[0]
|
||||
out.append({"key": code, "name": name, "count": int(n)})
|
||||
finally:
|
||||
conn.close()
|
||||
return out
|
||||
|
||||
|
||||
def pool_stocks(key: str) -> list[dict]:
|
||||
"""池成分(当前, 代码升序): [{code, name}]。白名单外的 key 返回 [](合法缺失)."""
|
||||
if key not in _POOL_NAMES:
|
||||
return []
|
||||
conn = _conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT code, code_name FROM constituent_unified "
|
||||
"WHERE index_code=? AND in_current=1 ORDER BY code",
|
||||
(key,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [{"code": str(c), "name": str(n or c)} for c, n in rows]
|
||||
|
||||
|
||||
def search_stocks(q: str, limit: int = 10) -> list[dict]:
|
||||
"""标的搜索: 代码前缀 OR 名称子串(同 code 多指数去重), LIMIT."""
|
||||
v = q.strip()
|
||||
if not v:
|
||||
return []
|
||||
conn = _conn()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT code, code_name FROM constituent_unified "
|
||||
"WHERE code LIKE ? OR code_name LIKE ? "
|
||||
"ORDER BY code LIMIT ?",
|
||||
(f"{v}%", f"%{v}%", limit),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [{"code": str(c), "name": str(n or c)} for c, n in rows]
|
||||
@@ -102,3 +102,31 @@ def test_tears_json_served(client, token, tmp_path):
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("application/json")
|
||||
assert r.json()["factor"] == "ma5"
|
||||
|
||||
|
||||
def test_universe_pools(client, token, monkeypatch):
|
||||
"""/factor/universe/pools → 池清单(依赖 monkeypatch,不连真库)."""
|
||||
import sanguo_factor.universe_pools as uni
|
||||
|
||||
monkeypatch.setattr(uni, "list_pools",
|
||||
lambda: [{"key": "000300", "name": "沪深300", "count": 300}])
|
||||
r = client.get("/api/v1/factor/universe/pools", headers={"Authorization": f"Bearer {token}"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == [{"key": "000300", "name": "沪深300", "count": 300}]
|
||||
|
||||
|
||||
def test_universe_pool_and_search(client, token, monkeypatch):
|
||||
"""/factor/universe/pool/{key} 与 /search → [{code,name}]."""
|
||||
import sanguo_factor.universe_pools as uni
|
||||
|
||||
monkeypatch.setattr(uni, "pool_stocks",
|
||||
lambda key: [{"code": "600519", "name": "贵州茅台"}] if key == "000300" else [])
|
||||
monkeypatch.setattr(uni, "search_stocks",
|
||||
lambda q, limit=10: [{"code": "600519", "name": "贵州茅台"}] if "茅台" in q else [])
|
||||
h = {"Authorization": f"Bearer {token}"}
|
||||
r1 = client.get("/api/v1/factor/universe/pool/000300", headers=h)
|
||||
assert r1.status_code == 200 and r1.json()[0]["code"] == "600519"
|
||||
r2 = client.get("/api/v1/factor/universe/pool/999999", headers=h)
|
||||
assert r2.status_code == 200 and r2.json() == []
|
||||
r3 = client.get("/api/v1/factor/universe/search", params={"q": "茅台"}, headers=h)
|
||||
assert r3.status_code == 200 and len(r3.json()) == 1
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"""股票池加载:前缀过滤/时间缓冲/vwap/bar_idx/显式symbols/limit抽样."""
|
||||
import sqlite3
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0")))
|
||||
|
||||
import pytest
|
||||
from sanguo_factor.universe import load_universe_bars, evaluation_filter, WARMUP_BARS
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE dbbardata(
|
||||
symbol TEXT, exchange TEXT, datetime TEXT, interval TEXT,
|
||||
volume REAL, turnover REAL, open_interest REAL,
|
||||
open_price REAL, high_price REAL, low_price REAL, close_price REAL)
|
||||
"""
|
||||
|
||||
|
||||
def _mk_db(tmp_path, rows):
|
||||
db = str(tmp_path / "qt.db")
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute(_DDL)
|
||||
conn.executemany("INSERT INTO dbbardata VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows)
|
||||
conn.commit(); conn.close()
|
||||
return db
|
||||
|
||||
|
||||
def _row(sym, ex, day, close, volume=100.0, turnover=None):
|
||||
return (sym, ex, f"{day} 00:00:00", "d", volume,
|
||||
turnover if turnover is not None else close * volume, 0,
|
||||
close, close, close, close)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def db(tmp_path):
|
||||
from datetime import date, timedelta
|
||||
rows = []
|
||||
# 600000:130根预热(2017-08~2017-12,唯一日期) + 评估窗内 3 根
|
||||
for i in range(130):
|
||||
day = (date(2017, 8, 1) + timedelta(days=i)).isoformat()
|
||||
rows.append(_row("600000", "SSE", day, 10.0 + i * 0.01))
|
||||
for d in ("2018-01-02", "2018-01-03", "2018-01-04"):
|
||||
rows.append(_row("600000", "SSE", d, 11.0))
|
||||
# 000001:只有 2 根(次新,bar_idx<WARMUP 应被 evaluation_filter 排除)
|
||||
for d in ("2018-01-02", "2018-01-03"):
|
||||
rows.append(_row("000001", "SZSE", d, 5.0))
|
||||
# 应剔除:科创68/北交8开头/ETF 510300
|
||||
rows.append(_row("688001", "SSE", "2018-01-02", 20.0))
|
||||
rows.append(_row("830001", "BJSE", "2018-01-02", 3.0))
|
||||
rows.append(_row("510300", "SSE", "2018-01-02", 4.0))
|
||||
# 非日线 interval 应忽略
|
||||
rows.append(("600000", "SSE", "2018-01-02 09:35:00", "15m", 1, 1, 0, 1, 1, 1, 1))
|
||||
# 只有 15m 数据、无日线的 symbol:枚举会带上但数据查询 0 行,不应出现在结果
|
||||
rows.append(("159915", "SZSE", "2018-01-02 09:35:00", "15m", 100.0, 100000.0, 0, 1, 1, 1, 1))
|
||||
return _mk_db(tmp_path, rows)
|
||||
|
||||
|
||||
def test_load_filters_prefix_and_interval(db):
|
||||
df = load_universe_bars(db, "2018-01-01", "2018-01-31")
|
||||
syms = df["vt_symbol"].unique().to_list()
|
||||
assert set(syms) == {"600000.SSE", "000001.SZSE"}
|
||||
|
||||
|
||||
def test_load_lookback_buffer_and_vwap(db):
|
||||
df = load_universe_bars(db, "2018-01-01", "2018-01-31")
|
||||
# lookback 生效:2017-08 的 bar 也在(因子窗口预热需要)
|
||||
assert str(df["datetime"].min()).startswith("2017-08")
|
||||
# vwap = turnover/volume
|
||||
row = df.filter(df["vt_symbol"] == "600000.SSE").sort("datetime").row(0, named=True)
|
||||
assert row["vwap"] == pytest.approx(10.0)
|
||||
|
||||
|
||||
def test_bar_idx_per_symbol(db):
|
||||
df = load_universe_bars(db, "2018-01-01", "2018-01-31").sort(["vt_symbol", "datetime"])
|
||||
idx = df.filter(df["vt_symbol"] == "000001.SZSE")["bar_idx"].to_list()
|
||||
assert idx == [0, 1]
|
||||
|
||||
|
||||
def test_evaluation_filter_warmup(db):
|
||||
df = load_universe_bars(db, "2018-01-01", "2018-01-31")
|
||||
ev = evaluation_filter(df, "2018-01-01", "2018-01-31")
|
||||
# 000001 只有2根 < WARMUP_BARS → 全排除;600000 保留
|
||||
assert set(ev["vt_symbol"].unique().to_list()) == {"600000.SSE"}
|
||||
assert ev.height == 3
|
||||
|
||||
|
||||
def test_explicit_symbols(db):
|
||||
df = load_universe_bars(db, "2018-01-01", "2018-01-31", symbols=["000001"])
|
||||
assert set(df["vt_symbol"].unique().to_list()) == {"000001.SZSE"}
|
||||
|
||||
|
||||
def test_limit_deterministic(db):
|
||||
df1 = load_universe_bars(db, "2018-01-01", "2018-01-31", limit=1)
|
||||
df2 = load_universe_bars(db, "2018-01-01", "2018-01-31", limit=1)
|
||||
assert set(df1["vt_symbol"].unique()) == set(df2["vt_symbol"].unique())
|
||||
|
||||
|
||||
def test_minute_only_symbol_excluded(db):
|
||||
df = load_universe_bars(db, "2018-01-01", "2018-01-31")
|
||||
assert "159915.SZSE" not in set(df["vt_symbol"].unique().to_list())
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Tests for sanguo_factor.universe_pools - 标的池查询(New 页三层输入后端)."""
|
||||
import sqlite3
|
||||
import sys
|
||||
import os
|
||||
_VNPY_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0"))
|
||||
if _VNPY_SRC not in sys.path:
|
||||
sys.path.insert(0, _VNPY_SRC)
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_db(tmp_path, monkeypatch):
|
||||
"""tmp sqlite 造 constituent_unified 小样本,patch _conn."""
|
||||
db = tmp_path / "uni.db"
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute(
|
||||
"CREATE TABLE constituent_unified ("
|
||||
"index_code TEXT, code TEXT, code_name TEXT, in_current INT, was_removed INT)"
|
||||
)
|
||||
rows = [
|
||||
# 沪深300: 当前 2 只 + 被踢 1 只
|
||||
("000300", "600519", "贵州茅台", 1, 0),
|
||||
("000300", "000858", "五粮液", 1, 0),
|
||||
("000300", "600001", "邯郸钢铁", 0, 1),
|
||||
# 中证500: 当前 1 只(与 300 重叠一只以测 DISTINCT)
|
||||
("000905", "600519", "贵州茅台", 1, 0),
|
||||
("000905", "300750", "宁德时代", 1, 0),
|
||||
# 无名称条目 → name 回退 code
|
||||
("000905", "002594", None, 1, 0),
|
||||
]
|
||||
conn.executemany("INSERT INTO constituent_unified VALUES (?,?,?,?,?)", rows)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
import sanguo_factor.universe_pools as uni
|
||||
|
||||
monkeypatch.setattr(uni, "_conn", lambda: sqlite3.connect(str(db), timeout=5))
|
||||
return uni
|
||||
|
||||
|
||||
def test_list_pools_whitelist_order_and_current_count(fake_db):
|
||||
pools = fake_db.list_pools()
|
||||
keys = [p["key"] for p in pools]
|
||||
assert keys[0] == "000300" and "000905" in keys
|
||||
by_key = {p["key"]: p for p in pools}
|
||||
assert by_key["000300"]["count"] == 2 # 被踢的邯郸钢铁不计
|
||||
assert by_key["000905"]["count"] == 3
|
||||
|
||||
|
||||
def test_pool_stocks_current_only(fake_db):
|
||||
stocks = fake_db.pool_stocks("000300")
|
||||
assert [s["code"] for s in stocks] == ["000858", "600519"] # ORDER BY code,只当前
|
||||
assert stocks[1]["name"] == "贵州茅台"
|
||||
|
||||
|
||||
def test_pool_stocks_name_fallback_to_code(fake_db):
|
||||
stocks = {s["code"]: s for s in fake_db.pool_stocks("000905")}
|
||||
assert stocks["002594"]["name"] == "002594" # NULL 名称回退代码
|
||||
|
||||
|
||||
def test_pool_stocks_unknown_key_empty(fake_db):
|
||||
assert fake_db.pool_stocks("999999") == []
|
||||
|
||||
|
||||
def test_search_by_code_prefix_and_name_substr(fake_db):
|
||||
by_code = {s["code"] for s in fake_db.search_stocks("6005")}
|
||||
assert by_code == {"600519"}
|
||||
by_name = {s["code"] for s in fake_db.search_stocks("茅台")}
|
||||
assert by_name == {"600519"}
|
||||
# 同股多指数 → DISTINCT 去重
|
||||
assert len(fake_db.search_stocks("贵州")) == 1
|
||||
|
||||
|
||||
def test_search_blank_returns_empty(fake_db):
|
||||
assert fake_db.search_stocks(" ") == []
|
||||
Reference in New Issue
Block a user