summaryrefslogtreecommitdiff
path: root/frontend/src/components/DatasetWizard.svelte
blob: 1e61d92fb04596a909cbc1678dccd8468fdea07b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
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>