book: scaffold + ch00 (execution trail as spine) — evidence exp 8-31, round 3

This commit is contained in:
TradeAC Book Agent
2026-08-18 22:35:23 +00:00
commit c93424e76c
83 changed files with 17676 additions and 0 deletions
+529
View File
@@ -0,0 +1,529 @@
---
name: tac-qlib-custom
description: "Guide agents to customize and extend Qlib on the TradeAC R&D stack — how to configure workflow YAMLs (qlib_init, model, dataset/handler, processors, records, PortAnaRecord strategies), how to extend Qlib classes wired into those workflows (custom Model, BaseStrategy, DataHandler, Record), and the empirically-tested knobs from this repo (RankIC early-stopping, stochastic-control strategies, stochastic-process features, catch22/GARCH/Hurst/signature). Also encodes the experiment traceability loop: every backtest runs as a workflow-with-recorder, is recorded in the Postgres experiments table (rationale/details/evaluation/metrics with pgvector embeddings, evolution chain) and on a per-experiment git branch that is committed + pushed. Companion to tradeac-rd (MCP run tools) and tradeac-lake (parquet lake)."
---
# tac-qlib-custom
Customizing and extending Qlib on the TradeAC stack. This skill encodes what was
learned from actual experiments in this repo: how a workflow YAML maps to Qlib
classes, how to write a custom class that the YAML can load, and which training /
strategy / feature knobs measurably moved IC, RankIC and the backtest.
Read `tac-qlib/skills/tradeac-rd/SKILL.md` for the MCP run/inspect tools and
`tac-qlib/README.md` for the package layout. The venv is `/app/.venv`
(qlib 0.1.dev2066); `tac_qlib` is installed into the venv's `site-packages`
(editable copy under `/opt/venv/.../tac_qlib/`), so **any new module must be
copied to `/opt/venv/lib/python3.12/site-packages/tac_qlib/...` too** (or use an
editable install) before `rd_run_workflow` can import it.
## MCP-first policy
- **Drive every backtest and run through the `tac-qlib-rd` MCP tools** (`rd_run_workflow`,
`rd_train`, `rd_predict`, `rd_exp_*`) and the tac-engine lake tools for data prep. Do not
reimplement them with ad-hoc scripts (custom qlib glue, own mlruns readers, direct
JSON-RPC/stdio clients).
- **NEVER script directly against the MCP server** (spawning `tac_qlib.rd_server` /
`tac-engine`, bash/curl/stdio) unless a tool genuinely can't do the job — then **stop and
ask the user to confirm first**.
- The traceability bookkeeping (Postgres `rd_experiments` row + pgvector embeddings +
branch-per-experiment git) is exposed as the **`rd_trace_*` MCP tools** on the tac-qlib-rd
server — use those, not bash scripts. Data prep, training, evaluation and backtests also go
through MCP tools.
- If the venv is missing a runtime dep (`duckdb`, `pyarrow`, feature libs), lazy-install it
(`uv pip install --python $VIRTUAL_ENV/bin/python <pkg>`) instead of switching tools.
## Secrets policy
- NEVER write secrets into files: DB passwords, API keys, OAuth tokens, or
credential-bearing URLs (`DATABASE_URL`, `GIT_PASS`, `EMBEDDING_API_KEY`) in
workflow YAMLs, scripts, configs, notes or committed code.
- NEVER read `*.env` / `.env.*` directly (`cat`/`tail`/`grep`/`sed`/`head` on
`.env`). That pulls secrets into this session and leaks them to any agent
sharing it.
- When a tool or command needs an env var, ASK the user to set it in the
environment (shell/container env, or the user-owned `.env`) and reference it
by name (`$VAR`), never by value. If it's missing, report which variable is
required instead of reading it yourself.
- Tracking store: use `uri: "sqlite:///mlruns.db"` (relative) in workflows —
`rd_run_workflow` normalizes it to Postgres when `$DATABASE_URL` is set, else
the lake sqlite. Never hardcode a `postgres://user:pass@…` URI.
- If you find a committed secret, flag it, remove it, and replace it with a
placeholder. (The `rd_trace_*` MCP tools' commit guard blocks adding
credential-shaped lines.)
## How a workflow YAML maps to Qlib classes
A workflow YAML (`tac-qlib/workflows/*.yaml`) is rendered by Jinja (vars like
`{{ LAKE }}` from `TAC_LAKE_DIR`) then executed by `qrun` / `rd_run_workflow`.
Every block is a Qlib class reference resolved by `module_path` + `class`:
```yaml
{%- set LAKE = TAC_LAKE_DIR %}
qlib_init:
provider_uri: "{{ LAKE }}"
region: us
calendar_provider: # custom tac-qlib providers read the parquet lake
class: LakeCalendarProvider
module_path: tac_qlib.data.providers
instrument_provider: # ... (markets: {} => lake universe)
feature_provider: # LakeFeatureProvider: routes $open..$volume from bars,
class: LakeFeatureProvider # $<ta-lib/sp_*> from features parquet, $amount derived
exp_manager:
class: MLflowExpManager
module_path: qlib.workflow.expm
kwargs: { uri: "sqlite:///{{ LAKE }}/mlruns.db", default_exp_name: "my-exp" }
task:
model: # <MODEL BLOCK> — custom model → new module_path
class: RankICLGBModel
module_path: tac_qlib.contrib.model.rank_gbdt
kwargs: { loss: mse, learning_rate: 0.02, num_leaves: 31, ... }
dataset:
class: DatasetH
module_path: qlib.data.dataset
kwargs:
handler: # <HANDLER BLOCK> — feature selection + processors live here
class: TACHandler
module_path: tac_qlib.contrib.data.handler
kwargs:
instruments: "SPY,QQQ,..."
start_time: 2015-01-03
end_time: 2026-08-10
fit_start_time: 2015-01-03 # processors fit on this window
fit_end_time: 2025-09-01
freq: day
lake_root: "{{ LAKE }}"
market: US
label: "Ref($close,-6)/Ref($close,-1)-1" # 5d forward return
feature_fields: "$open,$high,$low,$close,$vwap,$volume,sp_ret,sp_ou_zscore,..."
infer_processors: # feature-time transforms, fit on fit_*
- { class: DropAllNaN, kwargs: {} }
- { class: ProcessInf, kwargs: {} }
- { class: CSRankNorm, kwargs: {} } # per-day cross-sectional rank
- { class: ZScoreNorm, kwargs: {} }
- { class: Fillna, kwargs: {} }
segments:
train: [2015-01-03, 2025-09-01]
valid: [2025-09-03, 2026-01-03]
test: [2026-01-04, 2026-08-10]
record: # each entry records one artifact type to the run
- { class: SignalRecord, module_path: qlib.workflow.record_temp, kwargs: {} }
- { class: SigAnaRecord, module_path: qlib.workflow.record_temp,
kwargs: { ana_long_short: true, ann_scaler: 252 } }
- { class: PortAnaRecord, module_path: qlib.workflow.record_temp,
kwargs: { config: { strategy: <STRATEGY BLOCK>, backtest: {...} }, risk_analysis_freq: 1d } }
```
`rd_run_workflow config_path=<yaml> experiment_name=<exp>` runs it; the MCP call
may time out for long runs (RankIC tuning, heavy feature sets) — the run keeps
executing; poll via `rd_exp_list` / `rd_exp_get_run` on the returned experiment.
## Experiment traceability (DB + git + embeddings)
Every backtest you run as an agent MUST be tracked: it runs as a workflow with the
`record` block (SignalRecord/SigAnaRecord/PortAnaRecord → MLflow artifacts on disk
under `<lake>/mlruns/<exp_id>/<run_id>`), and a row is written to the Postgres
`experiments` table plus a git branch per experiment. The `tac-app` UI owns the
schema (Drizzle migrations in `tac-app/drizzle/`); this skill's `lib/` scripts are
the executor the agent drives.
**Trigger the lineage as part of the run — automatically, not on prompt.** Any
time you execute a qlib workflow (`rd_run_workflow`) or a train/predict pipeline
on this stack, the traceability bookkeeping is part of that run, not a separate
step the user must ask for: open the traced experiment with `rd_trace_start`
before running, commit intermediates with `rd_trace_commit`, and close it with
`rd_trace_finish` after — without waiting to be prompted (see "The
per-experiment procedure" below).
### Env vars
| Var | Purpose |
|-----|---------|
| `DATABASE_URL` | Postgres URL for the `rd_experiments` table AND the MLflow tracking store (set in repo `.env`) |
| `EMBEDDING_API_BASE_URL` | embedding POST endpoint (e.g. `https://embd.h.lizhao.net/embeddings`) |
| `EMBEDDING_API_KEY` | basic-auth credential (`user:pass` form is supported) |
| `GIT_USER` / `GIT_PASS` | git remote credentials for push/fetch |
| `GIT_REPO_URL` | experiment git repo tracked by the `experiments` submodule (branches are pushed here) |
| `TAC_LAKE_DIR` | lake root (mlruns artifact files live under it) |
The experiment repo is the **`experiments` git submodule** at the workspace root
(`<repo-root>/experiments`), always tracking `$GIT_REPO_URL`. `rd_trace_init`
creates/validates it; it errors if `experiments/` exists but points at a
different URL. There is no `TAC_EXP_GIT_DIR` — the submodule path IS the
experiment repo, and ALL experiment/backtest changes (workflow YAMLs, notes,
outputs) must live inside it, never in the parent tradeac repo.
### The `rd_experiments` table
Owned by tac-app's Drizzle schema (`tac-app/src/db/schema.ts`); `rd_trace_init`
can `init` it idempotently. The table is named **`rd_experiments`** (NOT
`experiments`) because MLflow's Postgres tracking store creates its own
`experiments` table in the same database. Key columns: `id` (PK), `rational` +
`rational_embedding` (pgvector `vector(384)`), `details` + `details_embedding`,
`evaluation`, `metrics` (jsonb), `evolved_from` (FK → rd_experiments.id),
`start_ts`/`end_ts`, `git_branch`, `experiment_ref_id`, `mlruns_dir`, `status`.
`experiment_ref_id` holds the **mlflow run id** returned by `rd_run_workflow` and
is an FK to MLflow's `runs(run_uuid)` (added by `rd_trace_init` after the
mlflow store tables exist — MLflow creates `runs` lazily).
Tracking store: **Postgres `$DATABASE_URL`** (MLflow's own tables) when set,
falling back to the unified lake sqlite `sqlite:///<lake>/mlruns.db`. Artifact
files always stay on disk under `<lake>/mlruns/<exp_id>/<run_id>/artifacts`.
Embedding model: `michaelfeil/bge-small-en-v1.5` (384-dim, **512-token context**).
Rational/details are written paper-summary style (≤512 tokens) and embedded verbatim —
NEVER truncate; if a text is longer, summarize it first (the embed helper rejects
over-limit input).
### Git repo + branch-per-experiment
The experiment repo is the `experiments` submodule at the workspace root
(`<repo-root>/experiments`, tracking `$GIT_REPO_URL`). The `rd_trace_*` MCP
tools handle it, and every git operation is scoped to that submodule —
experiments NEVER stage or push parent-repo (tradeac) files.
- `rd_trace_init` creates/validates the submodule and the base branch. If
`experiments/` does not exist it runs `git clone $GIT_REPO_URL experiments`;
if it exists but tracks a different URL, init errors out.
- Base branch: `main` (or `master`). If the submodule is empty, a seed commit is
made and pushed so there are commits to fork from.
- Every experiment runs on its own branch `exp/<id>-<slug>`.
- `evolved_from` resolution (in order):
1. If the wizard prompt explicitly says `evolved_from=<id>` (run wizard click on an
existing experiment) — use that id directly.
2. Otherwise `--evolved-from auto`: the user prompt / rational is embedded and
cosine-searched over the `experiments.rational_embedding` column; the top hit
above the similarity threshold (0.5) becomes `evolved_from`.
3. Otherwise (first experiment, or a new chat with no predecessor) — no evolved_from;
fork from `main`'s latest commits.
- The new branch is forked from the **evolved-from experiment's branch** (its latest
commits), or from `main` when there is no predecessor — so experiment lineages form
a git branch chain.
- On every finish, and for intermediate steps, changes are committed + pushed.
### Custom code is part of the lineage (code snapshot)
Custom contrib modules (`tac_qlib/contrib/model/`, `tac_qlib/contrib/strategy/`,
`tac_qlib/contrib/data/`, `tac_qlib/data/providers.py`) live in the **parent**
tradeac repo, not in the `experiments/` submodule — so they are normally invisible
to the experiment branch and a descendant forking from it would reinvent them.
The lineage tooling fixes this: **every experiment branch carries a `code/`
snapshot of exactly the qlib extension code that run depended on**, so descendants
reuse it instead of re-authoring it.
- `rd_trace_start` and `rd_trace_finish` automatically snapshot the default paths
(`tac-qlib/tac_qlib/contrib`, `tac-qlib/tac_qlib/data`) into
`<experiments>/code/<parent-relative-path>` on the experiment branch.
- `rd_trace_snapshot` snapshots mid-run (e.g. after writing a
new custom model) without waiting for finish.
- The snapshot also writes `code/MANIFEST.txt` recording the **parent-repo HEAD
commit** and the per-file blob hashes it was taken from — so a run can be traced
back to the exact parent commit that produced its custom code.
- Descendants: the custom modules your run needs are under `code/tac_qlib/...` on the
evolved-from branch. Reuse them (copy/`git show`) instead of writing new ones; check
`code/MANIFEST.txt` to see which parent commit they came from and port fixes back.
- Guardrail exception: parent-repo changes under `tac_qlib/tac_qlib/contrib` and
`tac_qlib/tac_qlib/data` are **expected** (they are the snapshotted code);
`parent_changes` reports them as a note, not a violation. Any OTHER parent change
is still a guardrail violation.
Guardrail — experiments must NOT introduce side effects to the parent repo:
- Write workflow YAMLs, notes and experiment outputs ONLY inside
`<repo-root>/experiments/` (they are committed on the experiment branch).
- Never `git add`/commit/stage anything in the parent tradeac repo.
- Run `rd_trace_guard` to list any parent
changes outside the submodule pointer; `rd_trace_finish` also surfaces them.
Revert any accidental parent edits before finishing.
- If an experiment reveals a PRODUCT change (workflow template, skill, tac-app),
propose it separately for the tradeac repo — do not mix it into the experiment
branch.
The `rd_trace_*` MCP tools perform git operations with the mandated credential
helper (from `GIT_USER` / `GIT_PASS`), so you do not need to construct it by hand.
### The per-experiment procedure
**Use the `rd_trace_*` MCP tools (tac-qlib-rd)** — they replace the old
`trace.sh`/`trace_db.py` scripts. The server is long-lived (psycopg imported
once, DB connection reused per call) and every tool returns one JSON object, so
no output parsing is needed:
```text
# 0. ensure ready (rd_experiments table + experiments git repo + base main)
rd_trace_init
# 1. start — inserts the row, resolves evolved_from, forks+pushes the branch.
# Returns {experiment_id, branch, evolved_from, base_branch} as JSON.
rd_trace_start rational="5-day forward label, RankIC early stop, 50-ETF universe" \
details="LGBModel mse lr=0.02 num_leaves=15 num_boost_round=3000; TopkDropout topk=2; benchmark QQQ" \
experiment_name="tac-rd-expN" \
evolved_from="auto" \
session_id="<this chat's opencode session id, if started from a chat>"
# -> {"experiment_id": N, "branch": "exp/N-...", "evolved_from": ..., "base_branch": ...}
# 2. write the workflow YAML INSIDE the experiments submodule
# (e.g. <repo-root>/experiments/workflows/<exp>/workflow.yaml), then commit it:
rd_trace_commit experiment_id=<N> message="add workflow yaml"
# 2b. if the workflow uses a NEW custom module, snapshot it onto the branch
# (start/finish auto-snapshot contrib+data; do this to capture mid-run):
rd_trace_snapshot experiment_id=<N> # default contrib+data
# or: rd_trace_snapshot experiment_id=<N> paths="tac-qlib/tac_qlib/contrib/model/rank_gbdt.py"
# 3. run the backtest through the WORKFLOW with the recorder (MUST write mlruns):
rd_run_workflow config_path=<repo-root>/experiments/workflows/<exp>/workflow.yaml experiment_name=tac-rd-expN
# -> returns run_id (= experiment_ref_id) + metrics
# 4. inspect with rd_exp_result / rd_exp_blotter, then finish — updates the row
# (re-embeds rational/details, sets metrics/eval/end_ts), snapshots the custom
# code, and commits+pushes. finish also surfaces parent-repo side effects.
rd_trace_finish experiment_id=<N> \
ref_id=<mlflow-run-id> \
evaluation="IC 0.0645, RankIC 0.075; net excess +0.85% ann" \
metrics='{"IC":0.0645,"RankIC":0.075,"ann_excess":0.85}' \
mlruns_dir=<lake>/mlruns/<exp_id>/<run_id>
```
Helpers (MCP tools): `rd_trace_search` (semantic), `rd_trace_get` (one row),
`rd_trace_list`, `rd_trace_mlruns_dir` (resolves the mlruns dir for an
experiment name), `rd_trace_guard` (parent-repo side-effect check).
Rules:
- **Always** run backtests as workflows with the `record` block (req 2) — never a bare
`rd_backtest` for a traced experiment.
- **Always** open the lineage (`rd_trace_start`) BEFORE the run and **Always**
`rd_trace_finish` + push after it completes (req 5) — this happens as part of the run,
do not wait for the user to ask; intermediate `rd_trace_commit` is encouraged (req 5).
- **Always** snapshot the custom qlib code (`rd_trace_snapshot`, or rely on the
auto-snapshot at start/finish) so the experiment branch carries the exact contrib/data
modules the run used — descendants fork and reuse `code/` instead of reinventing it.
- Keep rational/details ≤ 512 tokens (paper-summary style) so embeddings are exact —
no truncation.
- **Confine experiments to the `experiments/` submodule** — never write to, stage, or
commit parent tradeac repo files; run `rd_trace_guard` to check for side effects.
(Custom code edits under `tac-qlib/tac_qlib/contrib` and `.../data` are the sanctioned
exception — they are the snapshotted modules; see "Custom code is part of the lineage".)
- **Follow the Secrets policy above** — no secrets in files, no reading `.env*`, ask the
user to set env vars; use `uri: "sqlite:///mlruns.db"` for the tracking store.
- Workflow YAMLs are jinja-rendered with `os.environ` as the context, so env-var
placeholders work (`{%- set LAKE = TAC_LAKE_DIR %}` then `{{ LAKE }}`). Use them for
paths/config — never for secrets that get committed.
## Extending Qlib — the 4 class families you can override
### 1. Custom Model (train-time) — `tac_qlib/contrib/model/`
Subclass `qlib.contrib.model.gbdt.LGBModel` (or `qlib.model.base.BaseModel`) and
implement `fit(dataset, ...)` + `predict(dataset)`. `LGBModel.fit` calls
`self._prepare_data(dataset)` → `lgb.Dataset`s, then `lgb.train` with
`early_stopping` on the valid set. Override points that matter:
- `_prepare_data` → build the `lgb.Dataset` with `group=` (per-day query groups)
when you need ranking metrics per trading day.
- `fit` → change what early-stops training (the biggest IC/backtest lever, see §Knobs).
- `predict` → return the Series keyed (datetime, instrument).
Reference: `tac_qlib/tac_qlib/contrib/model/rank_gbdt.py` — `RankICLGBModel`
subclasses `LGBModel`, adds per-day `group` in `_prepare_data`, injects
`feval=rankic_feval` (mean per-day Spearman) into `lgb.train`, and forces
`metric='None'` + `first_metric_only=True` so early-stopping tracks RankIC only.
### 2. Custom Strategy (backtest-time) — `tac_qlib/contrib/strategy/`
Subclass `qlib.contrib.strategy.signal_strategy.BaseSignalStrategy` (which wraps
`qlib.strategy.base.BaseStrategy`) and implement:
```python
def generate_trade_decision(self, execute_result=None):
# trade_step, trade_start/end = self.trade_calendar.get_step_time(trade_step)
# pred = self.signal.get_signal(start_time=pred_shift, end_time=pred_shift) # shift=-1 => signal known at t-1
# self.trade_position / self.trade_exchange / self.trade_calendar injected by the executor
# build qlib.backtest.Order(stock_id, amount, start_time, end_time, direction=Order.BUY/SELL)
# return TradeDecisionWO(orders, self)
```
Wire it into the YAML under `PortAnaRecord.config.strategy`:
```yaml
strategy:
class: OptimalStopControl
module_path: tac_qlib.contrib.strategy.optimal_stop
kwargs:
signal: "<PRED>" # placeholder replaced with the recorded pred
topk: 10
entry_pct: 0.85
exit_pct: 0.7
max_hold_days: 10
min_hold_days: 2
sl: -0.08
risk_degree: 0.95
```
Reference: `tac_qlib/tac_qlib/contrib/strategy/optimal_stop.py`
(`OptimalStopControl` — entry gated by cross-sectional signal percentile, exits
by percentile/time/stop-loss, equal-weight control sizing).
### 3. Custom DataHandler / processors — `tac_qlib/contrib/data/handler.py`
`TACHandler(DataHandlerLP)` already wraps the lake via `QlibDataLoader` +
`LakeFeatureProvider`. Key config surface (all usable from YAML without new code):
- `feature_fields` — explicit list; the handler prefixes `$` and de-dups. Anything
the provider can route is usable: bar fields, `$amount` (v*vw), and any column
present in the lake `features/.../symbol=*.parquet` files.
- `infer_processors` / `learn_processors` — add `CSRankNorm`, `CSZScoreNorm`
(label), `ZScoreNorm`, `DropnaLabel`, `Fillna`, etc. `DropAllNaN` is a
tac-qlib processor (drops all-NaN columns on the fit window).
- `label` — any qlib expression, e.g. `Ref($close,-6)/Ref($close,-1)-1`.
To add a *new feature family*: compute it once (see `examples/sp_features.py` +
`examples/persist_sp_features.py`), persist extra columns into
`features/market=US/timeframe=1d/symbol=*.parquet` (drop stale `sp_*` columns
first on re-runs), then reference them in `feature_fields`.
**The Rust engine already ships the SP feature pipeline as a lake MCP tool**:
`get_lake_sp` (tac-engine, stochastic-rs) computes `sp_ou_*`, `sp_hmm_*`,
`sp_jump_*`, `sp_rv*`/`sp_vol_ratio_*` (+ `sp_rv_ac1`, `sp_rv_cv_22`),
`sp_max_up`/`sp_max_down`, `sp_trend_slope_*`, `sp_logp`,
`sp_hurst_exponent`, `sp_sig_*` (levels 1/2 at lag 1 and 5),
`sp_rskew_*`/`sp_rkurt_*`/`sp_dsv_*` (realized moments via stochastic-rs
`realized`) + `sp_ret` from lake bars and persists them into
the feature parquets (replacing stale `sp_*`), all in one call:
```json
{"symbol": "AAPL", "timeframe": "1d", "start": "2015-01-03", "end": "2026-08-10", "fit_end": "2025-09-01"}
```
`fit_end` pins the Gaussian-HMM fit to the train window (no lookahead), matching
the `FIT_END` convention. **Deferred families** (`garch`, `entropy`, `catch22`)
are still computed with the Python `sp_features.py` path until their ports land.
Note two deliberate differences vs the Python reference: the Rust HMM uses the
causal *forward filter* (`filtered_state_probs`) rather than hmmlearn's smoothed
`predict_proba`, and `hurst` is estimated on the returns series directly
(`take_differences=false`) rather than the reference's double-differenced
`kind="random_walk"` — regime *state* assignments agree, probability levels are
comparable but not identical.
### 4. Custom Record (artifact writers)
Subclass `qlib.workflow.record_temp.SignalRecord` / a `Record` and log metrics +
artifacts into the MLflow run. There is no shipped example Record in `contrib/`
yet — write one against the pattern in `qlib.workflow.record_temp` when a
workflow needs a bespoke simulator (e.g. beta-neutral 3L/3S) that
`PortAnaRecord` doesn't cover.
## Empirical knobs that moved the numbers (measured on the 50-ETF lake)
All experiments used: 50-ETF universe, train 2015-01-03..2025-09-01 / valid
2025-09-03..2026-01-03 / test 2026-01-04..2026-08-10, benchmark SPY, TopkDropout
or OptimalStopControl, costs open 0.0005 / close 0.0015 / min 5.
> **Rank-dimension reminder**: when the goal is to improve the *ranking* quality of
> a signal (RankIC, long-short spread, top-decile precision), do NOT reinvent the
> stack — use the contrib modules already shipped and verified in this repo:
> `tac_qlib.contrib.model.rank_gbdt.RankICLGBModel` (early-stops training on
> per-day cross-sectional RankIC, `metric='None'` + `first_metric_only`) and
> `tac_qlib.contrib.strategy.optimal_stop.OptimalStopControl` (entry/exit gated by
> signal percentile instead of raw levels). Both are loadable from a workflow YAML
> via `module_path` — see the canonical `tac-qlib/workflows/workflow_lgb_sp5d_rankic.yaml`
> (rank dimension: model) and `workflow_lgb_sp5d_optstop.yaml` (rank dimension:
> portfolio construction). Verified end-to-end on 2026-01-04..2026-08-10:
> RankIC 0.071 / net-of-cost excess +20.7% ann (IR 0.70) vs SPY. Only write a new
> custom Model/Strategy when these proven paths are insufficient.
### Label
- **5-day forward return `Ref($close,-6)/Ref($close,-1)-1` ≫ 2-day.** IC nearly
tripled (0.0207 → 0.0645 standalone; the biggest single lever found). The 2-day
target is too noisy.
### Features
- **Stochastic-process features beat hand-rolled TA.** 55-feature set: OU
(`sp_ou_*`), 2-state HMM (`sp_hmm_*`), jump intensity (`sp_jump_*`, incl.
`sp_max_up`/`sp_max_down`), HARRV vol (`sp_rv*` + `sp_rv_ac1`/`sp_rv_cv_22`),
trend (`sp_trend_slope_*`, `sp_logp`), GARCH (`sp_garch_*`), Hurst
(`sp_hurst_exponent`), path signatures (`sp_sig_*`, lag 1 & 5), entropy
(`sp_ent_*`), realized moments (`sp_rskew_*`/`sp_rkurt_*`/`sp_dsv_*`),
catch22 (`sp_c22_*`). IC 0.036 → 0.047 vs the 19-feature v1.
- **Do NOT add ta-lib indicators on top** (SP+TA, 74 feats): IC dropped
0.047 → 0.031, RankIC 0.047 → 0.020. They're redundant with rv22/hmm/garch/catch22
and dilute CSRankNorm + LGBM.
- **CSRankNorm** (per-day cross-sectional rank) is important for the rank signal.
- Warm-up rows persist as all-NaN feature rows — expected; DropAllNaN/DropnaLabel
handle them.
### Model / training loop
- **LambdaRank / rank_xendcg objectives FAIL here** (RankIC → ~0): with only ~50
"documents" per query the rank gradient is noise.
- **Early-stopping metric beats objective.** MSE objective + early-stop on a
**RankIC feval** (mean per-day Spearman) lifted RankIC 0.047 → 0.075 (standalone).
- **The workflow gap was qlib's training loop**: `lgb.train` default
`first_metric_only=False` + `metric=l2` keeps training while l2 improves after
RankIC peaks. `RankICLGBModel` sets `metric='None'` + `first_metric_only=True`
so early-stopping tracks RankIC only.
- **RankIC-only early stop + bigger/smaller budget is the win**: `num_boost_round
3000`, `learning_rate 0.02`, `early_stopping_rounds 200`, `min_data_in_leaf 20`,
`lambda_l2 0.5` → test excess **+9.1% ann w/o cost (IR 1.03, maxDD −3.8%)** and
**+0.85% ann after costs** — the only config that beat SPY net. Note IC/RankIC
themselves were slightly lower (0.042) than the 500-tree run (0.051); the tuned
budget selects the iteration maximizing *valid* RankIC, converting to realized
excess return.
### Strategy / portfolio construction
- **Long-only construction leaves the edge on the table.** The SP-5d signal has
long-short **+31.6% ann (Sharpe 2.51)**, but TopkDropout long-only ≈ flat vs SPY,
and OptimalStopControl underperformed (valid-window threshold overfit: valid
+7.5% → test −17.7% on one calibration).
- **Costs eat most of the gross edge** (+9.1% → +0.85% net). Reduce turnover or go
long-short to widen the net edge.
- OptimalStopControl thresholds must be calibrated on the *valid* window and are
sensitive to overfit — prefer robust defaults or penalize turnover in selection.
## Gotchas
- **Installed package copy**: `tac_qlib` in the venv is a copy under
`/opt/venv/lib/python3.12/site-packages/tac_qlib/`. After editing any
`tac_qlib/contrib/**` module, `cp` it there or the workflow imports the stale
version. New subpackages need `mkdir -p` first.
- `qlib.backtest` exports `Order` but not `OrderDir`/`Position` at top level —
import `Order` from `qlib.backtest`, `OrderDir`/`TradeDecisionWO` from
`qlib.backtest.decision`, `Position` from `qlib.backtest.position`.
- `qlib.backtest.high_performance_ds` may not export `Order` in this build — don't
import from it.
- HMM / GARCH / catch22 features must not see test data at fit time: fit the HMM
on the train window only (`fit_end=FIT_END`), and compute rolling windows ending
at each day. GARCH/entropy use a stride + forward-fill for speed (~5x).
- `pycatch22`, `arch`, `hurst`, `antropy`, `hmmlearn` are required for the full
feature set; install with `uv pip install --python /app/.venv/bin/python <pkg>`
(a C compiler is needed for `pycatch22`). `duckdb` and `pyarrow` are declared in
`tac-qlib/pyproject.toml`; if a workflow import fails on either, lazy-install with
`uv pip install --python /app/.venv/bin/python duckdb pyarrow`.
- `rd_run_workflow` defaults to `wait=false`: it returns immediately with
`status: started` and the workflow runs in a background thread — poll
`rd_exp_get_run` / `rd_exp_list` for the newest run of the experiment
(status `RUNNING` until it finishes), then reuse its `run_id`. Pass
`wait=true` only for small windows that finish within the MCP call timeout.
- After fixing a YAML model/handler change, remember both `/app/tac-qlib/...` and
the `/opt/venv` copy stay in sync.
## Files this skill is based on
Minimal, runnable examples live next to this skill in `examples/` — they are the
canonical reference for every artifact the skill describes:
- Workflows (full `record` block → MLflow on disk):
- `examples/workflow_minimal.yaml` — the canonical backtest template (req: every
traced backtest runs through a workflow like this via `rd_run_workflow`)
- `examples/workflow_rankic.yaml` — RankIC-early-stop model wired in
- Repo workflows for reference: `tac-qlib/workflows/workflow_lgb_taclake.yaml`,
`tune_run1_wider_5d.yaml`, `tune_run2_regularized.yaml`, `tune_run3_label5d_clean_universe.yaml`,
`tune_run4_fix_universe_longtrain.yaml`, `tune_run5_longtest.yaml`
- Models: `examples/model_rank_gbdt.py` (`RankICLGBModel`: per-day groups +
`feval=rankic` + `metric='None'`). Repo: `tac_qlib/contrib/model/rank_gbdt.py`
- Strategies: `examples/strategy_optimal_stop.py` (`OptimalStopControl`),
`examples/strategy_beta_neutral.py` (doc-only 3L/3S stub — pattern for a
custom strategy + Record; not wired into the package)
- Handler: `examples/handler.py` (how to subclass `TACHandler`); repo:
`tac_qlib/contrib/data/handler.py`; providers: `tac_qlib/data/providers.py`
- Feature engineering: `examples/sp_features.py` (OU + Hurst) and
`examples/persist_sp_features.py` (persist `sp_*` into the lake features parquet)
- Ranking experiments: `examples/run_rank_objectives.py` (mse vs lambdarank vs
rank_xendcg ablation on the lake)
- Optstop calibration: `examples/run_optstop_compare.py` (valid-window grid +
overfit warning)
- Traceability tooling: the `rd_trace_*` MCP tools (tac-qlib-rd,
`tac_qlib/trace.py`) — see the traceability section above