diff options
Diffstat (limited to 'docs/worker.md')
| -rw-r--r-- | docs/worker.md | 149 |
1 files changed, 149 insertions, 0 deletions
diff --git a/docs/worker.md b/docs/worker.md new file mode 100644 index 0000000..6788486 --- /dev/null +++ b/docs/worker.md @@ -0,0 +1,149 @@ +# Python worker — implementation notes and handoff + +Owner: worker agent. Files owned: `worker/`, `tests/worker/`, `requirements-worker.txt`, +`.venv` (shared uv venv at project root, created via `uv venv .venv --python 3.11` + +`uv pip install --python .venv/bin/python -r requirements-worker.txt`), +`artifacts/worker/` evidence, this doc only. + +Status: working worker, 27/27 pytest green (network-free unit tests), real AKShare +fetch + real Backtrader run executed on host and inside Docker image +`strategy-lab-worker:local` (nonroot, read-only rootfs, `--network none` backtest). + +## Components +- `worker/data.py` — AKShare adapters: eastmoney (`stock_zh_a_hist`, + `fund_etf_hist_em`, `index_zh_a_hist`) and tencent (`stock_zh_a_hist_tx`, + `stock_zh_index_daily_tx`). Explicit source selection: + `source: 'eastmoney' | 'tencent' | 'auto'` (default auto). + - `auto` = eastmoney first, then TRANSPARENT same-symbol tencent fallback with + warning `provider_fallback: ...; served by tencent for the SAME symbol`. + Never a different symbol or silent provider/adjustment substitution. + - Explicit `source` never silently falls back; failures surface. + - Tencent adapter returns volume in shares already; no multipliers applied. + - Index data: adjustment must be `none` (provider tencent labels it 前复权; recorded as warning). +- `worker/normalize.py` — canonical frame (`date,open,high,low,close,volume` + + preserved raw fields e.g. 成交额/换手率 with units/precision untouched), symbol + stamping (never substituted), date sort/dedup, NaN-OHLCV rejection (gaps are + kept, never imputed), content hashes, coverage segments/gaps, manifest entries + (`immutable: true`, `path` internal-only — backend must rewrite before client exposure). +- `worker/backtest.py` — real Backtrader (1.9.78.123). User source defines class + `Strategy` subclassing `bt.Strategy`; executed via `exec` after `ast` syntax + check, in the isolated worker process only (Docker). Recording: + - feeds added with `name=<canonical instrument symbol>` → user uses + `self.getdatabyname("<symbol>")`; prices never substituted across feeds. + - A never-trading `RowsRecorder` strategy records per-bar + `{date, cash, equity, closes, positions}`. + - A generated subclass of the user's `Strategy` intercepts `notify_order` / + `notify_trade` (documented hooks) to record orders, executed fills + (trades: `{date,symbol,side,quantity,price,commission,value}`) and closed + round-trip trades. + - Execution rules (documented, tested): fills at NEXT bar open (never + signal-bar close), commission % of trade value, slippage % of fill price, + indicator warmup honored, T+1/lot/suspension/limits NOT simulated, + `index` feeds are nontradable proxies (server must reject direct index orders). + - `metrics.trade_count` = number of CLOSED round-trip trades (buy-only runs = 0), + fills recorded separately in `trades`. + - `max_drawdown` = negative loss fraction (e.g. -0.05 = 5% drawdown; **0.0 is a + valid value for flat equity**, null only when not computable); NaN/Inf never + emitted (null instead). `peak_rss_kb` = real process max RSS. +- Broker-level cn stock/ETF rules (`CnDailyRulesBroker`, in-process, tested): + - `asset_type=index` feeds are **rejected at the broker level for any order** + (warning `nontradable` + `order_state: rejected` record); allowed as benchmark feeds. + - buys rounded DOWN to whole lot=100; reject when < 100. Requested size kept + on `order.prereject_size` for audit; fill follows enforced size. + - sells rejected/trimmed when they would exceed owned shares minus pending + same-symbol buys (no naked short, no T+0 sale of same-cycle buys). + - T+1 at daily granularity emerges from next-bar-open fills: a sell submitted + on a buy's fill day executes next trading day, exactly the A-share rule. + Position can never go negative in the equity series. +- Result additions: `initial_cash` (from config capital), per-bar `positions` + dict in every equity bar (symbol→position size), and `benchmark` per equity + bar **normalized to initial capital** so equity and benchmark share one scale + (both start at `initial_cash`); raw benchmark closes stay in `equity[].closes` + under the benchmark symbol. Frontend Chart must NEVER mix `equity[].benchmark` + (capital scale) with `equity[].closes` (raw prices) on the same axis. +- `worker/main.py` — CLI: `fetch` / `backtest` / `search` / `probe`. + - fetch validates request (1..5 instruments, daily, none|qfq|hfq, fields + whitelist amount/turnover extras, 15-year POC bound, source whitelist) and is + bounded by SIGALRM wall clock, default 180s + (`STRATEGY_LAB_FETCH_WALL_SECS` env override). No unbounded network calls. + - raw provider response stored as an immutable content-addressed object at + `output/objects/raw_<slug>_<hash12>.json` (written BEFORE the normalized + transform; `raw_object_hash` in the manifest is the sha256 of the raw object) + - result.json `{status, manifest, preview, cache_key, warnings, elapsed_ms}`; + manifest per-object: provider/endpoint/params/akshare_version/fetched_at/ + schema_version/normalization_version/adjustment/requested_start/requested_end/ + actual_start/actual_end/row_count/columns/object_hash/raw_object_hash/warnings. + - cache key = sha256 of manifest objects content (stable content identity, + independent of user/request ids/fetch time). Same-key fetches of same + content produce identical hashes; backend copies/hash-verifies into + `data/objects` (shared physical object reuse; old snapshots never overwritten). + +## Docker contract +Image `strategy-lab-worker:local` from `worker/Dockerfile` (python:3.11-slim, +nonroot uid 10001, app at `/app`). Invocation used and verified: + + docker run --rm --user 10001:10001 --cap-drop ALL \ + --security-opt no-new-privileges --read-only --tmpfs /tmp \ + --network none --memory 512m --cpus 1 --pids-limit 128 \ + -v <req.json>:/input/request.json:ro -v <data>:/data:ro \ + -v <out>:/output -e HOME=/tmp --entrypoint python \ + strategy-lab-worker:local -m worker.main backtest --request /input/request.json --output /output + +- backtest: `--network none`. fetch: network enabled for adapter only. +- /output must be writable by uid 10001 (backend: pre-chmod or run with same uid). +- no Docker socket, no provider/auth env in container. + +## For backend integration +- fetch request: `{instruments[{symbol,market,asset_type,name?}], start_date, + end_date, frequency:'daily', adjustment, fields[], source?}`; instruments + symbol may be bare code or canonical `SH#600000`. +- fetch result.json status: ready|failed; failed carries `errors[{code,message}]`. +- backend: strip `path`-host info from client-exposed manifest; rewrite object + paths into stored immutable objects; mount only the referenced files per run + into `/data` read-only and put per-run request in `/input`. +- cache: hash result.manifest.objects content; backend should cache both raw and + normalized objects keyed by (provider, endpoint, params incl. adjust, + normalization_version); dataset manifest hash stays stable when content unchanged. +- backtest request: `{code, config:{capital,commission,slippage,benchmark_symbol?,parameters}, + dataset_manifest, data_root:'/data'}`. result.json: SPEC.md Result shape + + `closes` per equity bar, `closed_trades`, `execution_assumptions`. +- data warnings (coverage gaps, provider fallback, synthetic) MUST be surfaced + to the user and acknowledged per SPEC. + +## Real evidence in artifacts/worker (fresh, current source) +- Tests: `.venv/bin/python -m pytest tests/worker -q` → **35 passed** (adds + tests/worker/test_rules_regression.py: zero-drawdown 0.0, broker index-order + rejection, index-as-benchmark accepted, lot rounding 250→200, no naked short, + sell-on-same-cycle trim, cash+position reconciliation). +- `akshare-probe.json` — real probe: stock_zh_a_hist + fund_etf_hist_em OK; + stock_zh_index_daily_em + stock_info_a_code_name FAILED (network, honest). +- `real-fetch/` — 浦发银行 600000 unadjusted via tencent: 117 bars + (2024-01-02 6.63/6.60/vol 22,066,700 → 2024-06-28), raw object + `objects/raw_600000_<hash12>.json` + normalized CSV + manifest. +- `real-backtest/` — host run: 16 fills (900×16, lot-compliant), final equity + 118905.65, MDD -0.0525, Sharpe 2.82, initial_cash 100000 recorded, positions + per bar, peak RSS ~157-165 MB. +- `docker-run/` — container backtest (`--network none`, uid 10001, read-only, + cap-drop ALL, tmpfs /tmp) → identical metrics to host; container fetch run + produced object hash a63c2c71f518 identical to host → same-object sharing. +- `docker-run/request-benchmark.json` + `output/result.json` — honest + benchmark contract verified in real container run: benchmark starts at + 100000.0 and ends 124696.97 (normalized), raw close 8.23 kept separately in + `equity[-1].closes['600000']`, warning "benchmark normalized to initial capital". + +## Tests +`tests/worker/` — 27 tests, all synthetic fixtures carry `_synthetic`/`synthetic` +markers and are never production fallback: normalization correctness/rejection, +hash stability, deterministic accounting (hand-verified commission/slippage/ +next-bar fills), named-feed isolation, benchmark series, error paths (syntax, +missing Strategy class, missing data, lookahead-at-end), catalog concat identity +regression, auto-fallback honest warning, explicit-source no-fallback, CLI +provider/raw-retention/error paths. All providers fully mocked in unit tests; +no network. Run: `.venv/bin/python -m pytest tests/worker -q`. + +Limitations (honest): eastmoney currently blocked from this host; tencent lacks +listed-ETF daily adapter; index via tencent labeled 前复权; suspension/price +limits/intraday sequencing/liquidity NOT modeled; ETF sell lot approximated as +lot-100 buy rule; T+1 enforced at daily-bar granularity (no intraday detail); +search CLI uses catalog endpoints that can be slow/blocked (backend should call +with bounded timeout and show failure UI). No fake data or fabricated metrics. |
