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
|
# Production instrument-search fix — handoff notes
Date: 2026-09-17. Scope: production search stall on `/api/instruments?q=...`
(0 bytes in ~20s). All work confined to this repo; nothing deployed, user DB
untouched, no services restarted.
## Root causes fixed
1. **worker/data.py** — the old `_catalog_frame` fetched the ENTIRE A-share
stock catalog (`stock_info_a_code_name`) and the ENTIRE ETF live snapshot
(`fund_etf_spot_em`) via AKShare on every search query. Those calls are
unbounded and stalled inside the worker container until the Rust-side
runner timeout fired, so the browser's fetch starved and kept spinning.
Replaced with **bounded direct provider suggestion search**:
- Eastmoney `https://searchapi.eastmoney.com/api/suggest/get`
(`input=<q>`, `type=14`, bounded `count`), JSON, per-call timeout **4s**.
- Tencent `https://smartbox.gtimg.cn/s3/?q=<q>&t=all`, text hint
`v_hint="sh~600000~name~py~GP-A^..."`, per-call timeout **4s**.
- No synthetic data, no full catalogs anywhere in the search path anymore.
2. **worker (main.py/data.py) envelope** — search now prints one JSON object
envelope `{"source","status","items","error","providers"}` with explicit
`status: ready|failed`. `run_search` exits non-zero on failure.
3. **server/src/worker.rs** — container runtime bound reduced **120s → 12s**
(outer bound incl. container startup; provider calls are ≤2×4s inside).
The stdout is now parsed as the full JSON envelope; the old code extracted
`first[...]..last[...]` which silently turned a failed-status envelope into
an empty success. A `failed` status now surfaces as a real error
(`search_unavailable`, provider error code in the message), and **only
ready responses enter the server-side TTL cache**.
4. **server/src/main.rs** — `instruments()` success source label is now
`provider_suggest`; failures keep returning HTTP 200 with
`status: unavailable: ...` so the existing UI contract is unchanged.
5. **Frontend** (`DatasetWizard.svelte` + new `lib/instrumentSearch.ts`):
- 350 ms debounce on input, prior in-flight queries are **aborted**
(AbortController) and stale responses can never repaint.
- Hard **AbortController timer (12s)** bound; hung requests surface
搜索超时,请重试 instead of an eternal spinner.
- Non-`ok` envelope status / provider failure shows
搜索数据源暂时不可用,请稍后重试 with a **重试** button; the spinner is
always cleared (`onFinish` in `finally`).
- Result rows carry per-item **provenance** (东财 / 腾讯).
## Asset-class rules (validated identity, never guessed from code shape)
Live-probed provider classifications (2026-09-17, both endpoints):
| Provider | Field | accepted |
|-----------|------------------|-----------------------------|
| Eastmoney | `Classify` | `AStock`→stock, `Index`→index, `Fund`→etf (cross-confirmed, see below) |
| Eastmoney | `Classify` (excluded) | `Bond`,`HK`,`OTCFUND`, others |
| Eastmoney | `MktNum` | `1`→SH, `0`→SZ (others dropped) |
| Tencent | hint tag | `GP-A`→stock, `ETF`→etf, `ZS`→index; `LOF`/others excluded |
| Tencent | hint market | `sh`/`sz` only (`bj` dropped) |
- An asset's class comes only from the provider's own classification field —
a numeric code like `000300` is never inferred to be an index by shape
(test: `test_search_numeric_code_not_auto_index`).
- **Verified live**: Eastmoney `Classify=Fund` is ambiguous — ETF `510300`
and LOF `160706` share the same `Classify` and `SecurityType`. Therefore an
Eastmoney *Fund* item is only emitted when Tencent presents the same code
with tag `ETF`. Tencent alone classifies ETF/LOF unambiguously. When
Tencent unavailable, Eastmoney-only fund suggestions are suppressed
(`test_search_etf_ambiguous_fund_excluded_without_tencent_confirmation`,
`test_search_lof_not_present_when_eastmoney_only`). Guaranteed: no LOF
ever surfaces as an ETF.
- Per-item provenance is preserved: identical codes may appear once per
actual source (`source: eastmoney` / `source: tencent`).
## Live verification (fresh container `strategy-lab-worker:local`, rebuilt bd9e9f06d56b)
```
docker run --rm --network=bridge strategy-lab-worker:local \
python -m worker.main search --query 600000 --limit 5
→ {"status":"ready","items":[{"symbol":"600000","...":"浦发银行","asset_type":"stock",...}],...}
wall time ≈1.9s (container start included; previously 0 bytes / 20+s)
q=510300 → etf SH#510300 (eastmoney+tencent)
q=沪深300 → index SH#000300 / SZ#399300 (+ ETFs), providers ok
q=160706 → {"items":[]} (LOF correctly suppressed, providers ok, status ready)
```
## Tests actually run and passing
- `server`: `cargo test` → **54 passed**; `cargo build --release` OK.
- `worker`: `.venv/bin/python -m pytest tests` (repo root) → **49 passed**,
of which new/updated in `tests/worker/test_data.py` (real shapes for
stock/ETF/index, Chinese query `浦发银行`/`沪深300`, unsupported classes
bond/HK/OTC/LOF, provider failure + `requests.Timeout` → explicit failed
status, partial-provider warning) and `tests/worker/test_fetch_cli.py::test_search_cli_envelope_contract`.
- `frontend`: `npm run test` (vitest) → **45 passed** across 13 files,
including new `src/lib/instrumentSearch.test.ts` (success, unavailable
status → Chinese message, provider rejection, hard-timer abort → 超时
wording with spinner cleared, **stale-prior-query never repaints and never
hangs**, dispose invalidates); `npm run check` → 0 errors/warnings;
`npm run build` → OK.
## Deployment state
- Worker image rebuilt exactly as `strategy-lab-worker:local` (context repo
root, `-f worker/Dockerfile`): id `bd9e9f06d56b`.
- Frontend `dist/` rebuilt from the fixed sources.
- Server binary built release but **not deployed / no restarts performed**.
- Rollback note: previous worker image tag remains on the host; server
changes are code-only (no config/DB changes).
## Known limitation (documented honestly)
When Tencent is unavailable and only Eastmoney responds, fund-class
suggestions are suppressed (stock/index still return). This trades a
slightly narrower ETF list for a hard guarantee that no LOF is presented
as an ETF without provider confirmation.
|