diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
| commit | 5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch) | |
| tree | 948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/components/DatasetWizard.svelte | |
| download | strategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz | |
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/components/DatasetWizard.svelte')
| -rw-r--r-- | frontend/src/components/DatasetWizard.svelte | 374 |
1 files changed, 374 insertions, 0 deletions
diff --git a/frontend/src/components/DatasetWizard.svelte b/frontend/src/components/DatasetWizard.svelte new file mode 100644 index 0000000..1e61d92 --- /dev/null +++ b/frontend/src/components/DatasetWizard.svelte @@ -0,0 +1,374 @@ +<script lang="ts"> + import Status from './Status.svelte'; + import { getCapabilities, searchInstruments, createDataset } from '../lib/client'; + import { extractStatusMessage } from '../lib/state'; + import { adjustmentLabel, assetTypeLabel, itemSourceLabel } from '../lib/format'; + import { createSearchRunner, SEARCH_EMPTY_MSG } from '../lib/instrumentSearch'; + import type { Capabilities, Dataset, Instrument } from '../lib/types'; + + interface Props { + onCreated?: (d: Dataset) => void; + } + let { onCreated }: Props = $props(); + + let caps = $state<Capabilities | null>(null); + let loadErr: string | null = $state(null); + + let query = $state(''); + let searchBusy = $state(false); + let searchItems: Instrument[] = $state([]); + let searchSource = $state(''); + let searchErr: string | null = $state(null); + + const searchRunner = createSearchRunner(searchInstruments, { + onBusy: () => { + searchBusy = true; + searchErr = null; + searchItems = []; + }, + onResult: (res) => { + searchItems = res.items; + searchSource = res.source; + if (res.items.length === 0 && !searchErr) { + searchErr = SEARCH_EMPTY_MSG; + } + }, + onError: (message) => { + searchItems = []; + searchErr = message; + }, + onFinish: () => { + searchBusy = false; + } + }); + + function doSearch() { + searchRunner.runNow(query); + } + + function onSearchInput() { + searchRunner.requestDebounced(query); + } + + let selected: Instrument[] = $state([]); + let symbol = $state(''); + let market = $state('SH'); + let assetType = $state('stock'); + let manualName = $state(''); + + let startDate = $state(''); + let endDate = $state(''); + let adjustment: 'none' | 'qfq' | 'hfq' = $state('none'); + let chosenRaw: string[] = $state([]); + let dsName = $state(''); + + let submitErr: string | null = $state(null); + let submitting = $state(false); + let formErr: string[] = $state([]); + let formOk: string | null = $state(null); + + const maxYears = $derived(caps?.limits.max_years ?? 15); + const maxSymbols = $derived(caps?.limits.max_symbols ?? 5); + const rawFieldChoices = $derived(caps?.fields.filter((f) => f.raw) ?? []); + const baseRequired = $derived(caps?.fields.filter((f) => !f.raw) ?? []); + + $effect(() => { + (async () => { + try { + caps = await getCapabilities(); + } catch (e) { + loadErr = extractStatusMessage(e); + } + })(); + }); + + function isRaw(code: string) { + if (!caps) return false; + return !caps.fields.some((f) => f.code === code && !f.raw); + } + + function toggleRawField(code: string) { + chosenRaw = chosenRaw.includes(code) ? chosenRaw.filter((c) => c !== code) : [...chosenRaw, code]; + } + + function sameInstrument(a: Instrument, b: Instrument) { + return a.market === b.market && a.symbol === b.symbol && a.asset_type === b.asset_type; + } + + function addSelection(inst: Instrument) { + if (selected.some((s) => sameInstrument(s, inst))) { + formErr = [`${inst.market}.${inst.symbol} 已在列表中`]; + return; + } + if (selected.length >= maxSymbols) { + formErr = [`最多选择 ${maxSymbols} 个标的(能力上限)`]; + return; + } + formErr = []; + selected = [...selected, inst]; + } + + function removeSelection(target: Instrument) { + selected = selected.filter((s) => !sameInstrument(s, target)); + } + + function addManual() { + const sym = symbol.trim(); + if (!sym) { + formErr = ['请输入确切的代码,例如 600000 或 510300']; + return; + } + const inst: Instrument = { + symbol: sym, + market: market.trim().toUpperCase(), + asset_type: assetType as Instrument['asset_type'], + name: manualName.trim() || null, + currency: null + }; + addSelection(inst); + symbol = ''; + manualName = ''; + } + + async function submit() { + const errs: string[] = []; + if (selected.length === 0) errs.push('至少选择一个标的'); + if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate)) errs.push('请选择开始日期'); + if (!/^\d{4}-\d{2}-\d{2}$/.test(endDate)) errs.push('请选择结束日期'); + if (/^\d{4}-\d{2}-\d{2}$/.test(startDate) && /^\d{4}-\d{2}-\d{2}$/.test(endDate)) { + const years = (new Date(endDate).getTime() - new Date(startDate).getTime()) / (365.25 * 86400000); + if (!(endDate > startDate)) errs.push('结束日期须晚于开始日期'); + else if (years > maxYears) errs.push(`范围不能超过 ${maxYears} 年(当前约 ${years.toFixed(1)} 年)`); + } + if (errs.length > 0) { + formErr = errs; + return; + } + formErr = []; + formOk = null; + submitting = true; + submitErr = null; + try { + const fields = ['open', 'high', 'low', 'close', 'volume'].concat(chosenRaw); + const ds = await createDataset({ + name: dsName.trim() || undefined, + instruments: selected, + start_date: startDate, + end_date: endDate, + frequency: 'daily', + adjustment, + fields + }); + formOk = `数据请求已提交,编号 ${ds.id.slice(0, 8)}…,可在下方列表查看获取进度`; + selected = []; + startDate = ''; + endDate = ''; + dsName = ''; + chosenRaw = []; + onCreated?.(ds); + } catch (e) { + submitErr = extractStatusMessage(e); + if ((e as { details?: { errors?: string[] } }).details?.errors) { + formErr = (e as { details: { errors: string[] } }).details.errors; + } + } finally { + submitting = false; + } + } +</script> + +<div class="card stack"> + <h2>新建数据集</h2> + + <Status busy={!caps && !loadErr} busyText="加载能力配置…" error={loadErr} empty={null} /> + + {#if caps} + <section class="stack"> + <h3 class="step-h">1) 选择标的 <span class="hint">最多 {maxSymbols} 个 · 日频 POC</span></h3> + <div class="row"> + <input + style="flex:2 1 200px" + placeholder="搜索代码或名称,如 600000、沪深300ETF" + bind:value={query} + oninput={onSearchInput} + onkeydown={(ev) => { + if (ev.key === 'Enter') doSearch(); + }} + aria-label="标的搜索" + /> + <button class="btn" disabled={searchBusy || !query.trim()} onclick={() => doSearch()}> + {searchBusy ? '搜索中…' : '搜索'} + </button> + </div> + <Status busy={searchBusy} busyText="搜索中…" error={searchErr} empty={null} /> + {#if searchErr && !searchBusy} + <button class="btn small" onclick={() => doSearch()}>重试</button> + {/if} + {#if searchItems.length > 0} + <table class="data"> + <thead> + <tr><th>代码</th><th>类型</th><th>名称</th><th>来源</th><th></th></tr> + </thead> + <tbody> + {#each searchItems as inst (inst.market + '.' + inst.symbol + inst.asset_type + '.' + (inst.source ?? 'unknown'))} + <tr> + <td class="num">{inst.market}.<strong>{inst.symbol}</strong></td> + <td>{assetTypeLabel[inst.asset_type] ?? inst.asset_type}</td> + <td>{inst.name ?? '—'}</td> + <td class="hint">{itemSourceLabel(inst.source, searchSource)}</td> + <td> + <button class="btn small" onclick={() => addSelection(inst)} disabled={inst.asset_type === 'index' && selected.some((s) => sameInstrument(s, inst))}> + {inst.asset_type === 'index' ? '加入(基准研究)' : '加入'} + </button> + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + + <details> + <summary>手动录入标的(目录未收录时)</summary> + <div class="card stack manual"> + <p class="hint">仅接受确切标识,不会推断市场或类型。请与供应商页面核对后再录入。</p> + <div class="row wrap"> + <label style="flex:1 1 130px"> + 代码 + <input bind:value={symbol} placeholder="600000 / 510300 / 000300" /> + </label> + <label style="flex:0 0 110px"> + 市场 + <select bind:value={market}> + {#each ['SH', 'SZ', 'BJ'] as m (m)} + <option value={m}>{m}</option> + {/each} + </select> + </label> + <label style="flex:0 0 120px"> + 类型 + <select bind:value={assetType}> + {#each caps.asset_types as t (t)} + <option value={t}>{assetTypeLabel[t] ?? t}</option> + {/each} + </select> + </label> + <label style="flex:1 1 160px"> + 名称(可选) + <input bind:value={manualName} placeholder="与供应商页面一致" /> + </label> + </div> + <div class="row"> + <button class="btn" onclick={addManual}>加入所选</button> + </div> + </div> + </details> + </section> + + <section> + <h3 class="step-h">2) 已选标的 <span class="hint">{selected.length}/{maxSymbols}</span></h3> + {#if selected.length === 0} + <p class="hint">尚未选择标的。指数仅可作研究中枢,不可直接交易。</p> + {:else} + <ul class="chip-list"> + {#each selected as s, i (s.market + '.' + s.symbol + s.asset_type)} + <li> + <span class="num">{s.market}.{s.symbol}</span> + {#if s.name}<span class="hint">{s.name}</span>{/if} + <span class="badge grey">{assetTypeLabel[s.asset_type] ?? s.asset_type}</span> + <button class="btn small ghost" aria-label={`移除 ${s.symbol}`} onclick={() => selected.splice(i, 1)}>✕</button> + </li> + {/each} + </ul> + {/if} + </section> + + <section class="grid3 wrap-3"> + <label> + 起始日期 + <input type="date" bind:value={startDate} /> + </label> + <label> + 结束日期 + <input type="date" bind:value={endDate} /> + </label> + <label style="min-width:160px"> + 复权 + <select bind:value={adjustment}> + {#each caps.adjustments as a (a.code)} + <option value={a.code}>{a.label}</option> + {/each} + </select> + {#if adjustment !== 'none'} + <p class="hint">{adjustmentLabel[adjustment]}:以复权口径返回</p> + {/if} + <p class="hint">日频 · 周期上限 {maxYears} 年</p> + </label> + </section> + + <section> + <label for="ds-name">数据集名称(可选,留空自动生成)</label> + <input id="ds-name" bind:value={dsName} placeholder="例如:沪深300 2019-2024 前复权" /> + </section> + + <details> + <summary>附加原始字段(可选)</summary> + <p class="hint">OHLCV 必需字段由引擎要求固定获取;附加字段保持原始口径,未请求的字段不会返回。</p> + <ul class="field-list"> + {#each rawFieldChoices as f (f.code)} + <li> + <label class="check"> + <input type="checkbox" checked={chosenRaw.includes(f.code)} onchange={() => toggleRawField(f.code)} /> + {f.label} <span class="hint">(未请求不返回)</span> + </label> + </li> + {/each} + </ul> + {#if caps.fields.some((f) => !f.raw)} + <p class="hint">必需字段:{caps.fields.filter((f) => !f.raw).map((f) => f.label).join(' / ')}</p> + {/if} + </details> + + {#if formErr.length > 0} + <div class="banner error" role="alert"> + <span aria-hidden="true">⚠</span> + <ul class="err-list"> + {#each formErr as msg, i (i)}<li>{msg}</li>{/each} + </ul> + </div> + {/if} + {#if submitErr} + <div class="banner error" role="alert"> + <span aria-hidden="true">⚠</span> + <span>数据请求提交失败:{submitErr}</span> + </div> + {/if} + <p class="hint formhint">数据请求会进入队列异步获取。配置仅保存你填写的请求,不预填演示数据。</p> + <div> + <button class="btn primary" disabled={submitting || selected.length === 0} onclick={submit}> + {submitting ? '提交中…' : '提交数据请求'} + </button> + </div> + {/if} + + <Status busy={false} error={null} empty={formOk} /> +</div> + +<style> + .manual { padding: 0.8rem; margin: 0.4rem 0; } + .manual input, .manual select { margin-top: 0.25rem; } + .chip-list { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.4rem; } + .chip-list li { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border: 1px solid var(--border-strong); + padding: 0.25rem 0.5rem; + border-radius: 999px; + background: var(--surface-2); + } + .field-list { list-style: none; padding: 0; margin: 0.3rem 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0.2rem; } + .check { font-size: 0.88rem; margin: 0; display: flex; gap: 0.35rem; align-items: center; } + .step-h { font-size: 0.95rem; } + .err-list { padding-left: 1.1rem; margin: 0; list-style: disc; } + .wrap-3 { align-items: start; } +</style> |
