feat(factor): New/Result两页终端风重构落地(方案B设计稿v2用户拍板'开工') [nas]
CI/CD / test (push) Successful in 3s
CI/CD / nas-deploy (push) Successful in 6s
CI/CD / nas-verify (push) Successful in 9s

后端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:
2026-08-30 09:59:38 +08:00
parent 271adedbb5
commit 557479b848
12 changed files with 851 additions and 230 deletions
+29
View File
@@ -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
}
+40
View File
@@ -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)
+136
View File
@@ -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,
+94 -78
View File
@@ -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 &amp; 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>
+98 -46
View File
@@ -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>
+26
View File
@@ -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:
+82
View File
@@ -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]
+28
View File
@@ -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
-99
View File
@@ -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())
+76
View File
@@ -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(" ") == []