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
@@ -0,0 +1,70 @@
"""Minimal custom DataHandler — how to extend TACHandler for a new feature family.
`TACHandler(DataHandlerLP)` already routes lake bars + ta-lib features via
`LakeFeatureProvider` (see tac_qlib/contrib/data/handler.py). To add a NEW
feature family (computed once, persisted into the lake features parquet — see
examples/persist_sp_features.py), you only need to:
1. persist extra columns into features/market=US/timeframe=1d/symbol=*.parquet
2. list them in `feature_fields` (they are prefixed with `$` and de-duped)
A subclass is only needed when the feature must be computed *inside* the qlib
pipeline (e.g. as an extra processor). This file sketches that pattern.
Reference handler structure (from tac_qlib/contrib/data/handler.py):
class TACHandler(DataHandlerLP):
def __init__(self, instruments, start_time, end_time, freq,
fit_start_time=None, fit_end_time=None,
feature_fields=None, label=None, lake_root=None, market="US",
infer_processors=None, learn_processors=None, **kwargs):
loader = QlibDataLoader(configured=(feature_fields or self.DEFAULT_FIELDS), freq=freq)
super().__init__(instruments, start_time, end_time, freq=freq,
data_loader=loader,
infer_processors=infer_processors or DEFAULT_INFER_PROCESSORS,
learn_processors=learn_processors or DEFAULT_LEARN_PROCESSORS,
fit_start_time=fit_start_time, fit_end_time=fit_end_time,
process_type=DataHandlerLP.PTYPE_A, **kwargs)
"""
from __future__ import annotations
from typing import Any, List, Optional
from tac_qlib.contrib.data.handler import DEFAULT_INFER_PROCESSORS, DEFAULT_LEARN_PROCESSORS, TACHandler
class CustomFeaturesHandler(TACHandler):
"""TACHandler variant that also loads the lake feature columns passed in.
Usage from YAML — only the handler kwargs change:
handler:
class: CustomFeaturesHandler
module_path: tac_qlib.contrib.data.handler # after adding this class there
kwargs:
instruments: AAPL,MSFT,QQQ
start_time: 2026-03-01
end_time: 2026-08-06
freq: day
lake_root: "{{ LAKE }}"
market: US
feature_fields: "$close,sp_ou_alpha,sp_hurst_exponent"
label: "Ref($close,-6)/Ref($close,-1)-1"
"""
def __init__(
self,
feature_fields: Optional[List[str]] = None,
infer_processors: Optional[List[Any]] = None,
learn_processors: Optional[List[Any]] = None,
**kwargs: Any,
):
# `feature_fields` are passed through with the leading `$` stripped by
# TACHandler; infer/learn default to the lake-tuned processor stacks.
super().__init__(
feature_fields=feature_fields,
infer_processors=infer_processors or DEFAULT_INFER_PROCESSORS,
learn_processors=learn_processors or DEFAULT_LEARN_PROCESSORS,
**kwargs,
)
@@ -0,0 +1,78 @@
"""Minimal RankIC early-stopping LightGBM model (the biggest IC/backtest lever).
Drop-in replacement for `qlib.contrib.model.gbdt.LGBModel` in a workflow YAML:
task.model:
class: RankICLGBModel
module_path: tac_qlib.contrib.model.rank_gbdt
kwargs: { loss: mse, learning_rate: 0.02, num_boost_round: 3000,
early_stopping_rounds: 200, lambda_l2: 0.5 }
What it changes vs stock LGBModel:
* `_prepare_data` builds `lgb.Dataset` with per-day `group` query groups, so
metrics are computed per trading day.
* `fit` injects `feval=rankic_feval` (mean per-day Spearman) into `lgb.train`
and forces `metric='None'` + `first_metric_only=True` so early stopping
tracks RankIC — not l2, which keeps improving after RankIC peaks.
Why: with ~50 instruments per day, ranking objectives (lambda_rank/xendcg)
produce near-zero RankIC; MSE objective + RankIC early-stop is what lifts it.
Install: copy to tac_qlib/contrib/model/rank_gbdt.py AND the /opt/venv copy.
"""
from __future__ import annotations
from typing import Any, Dict
import numpy as np
import pandas as pd
from qlib.contrib.model.gbdt import LGBModel
def rankic_feval(preds: np.ndarray, dataset) -> tuple[str, float, bool]:
"""Mean per-day Spearman rank IC between predictions and the label."""
label = dataset.get_label()
group = dataset.get_group() if hasattr(dataset, "get_group") else None
if group is None:
return "rankic", _spearman(preds, label), False
start = 0
ics = []
for g in group:
sl = slice(start, start + g)
start += g
ics.append(_spearman(preds[sl], label[sl]))
return "rankic", float(np.mean(ics)), False
def _spearman(x: np.ndarray, y: np.ndarray) -> float:
if len(x) < 2:
return 0.0
from scipy.stats import spearmanr
rho, _ = spearmanr(x, y)
return float(rho) if rho == rho else 0.0
class RankICLGBModel(LGBModel):
"""LGBModel with per-day query groups and RankIC-only early stopping."""
def _prepare_data(self, dataset, *args, **kwargs):
"""Attach per-day group sizes to the train/valid lgb.Dataset."""
dtrain, dvalid = super()._prepare_data(dataset, *args, **kwargs)
for d, index in ((dtrain, dataset.get_index_by_segment("train")), (dvalid, dataset.get_index_by_segment("valid"))):
if d is not None and index is not None:
# group by calendar day in order
days = pd.Series([i[0] for i in index])
group = days.value_counts().sort_index().tolist()
d.set_group(np.array(group, dtype=np.int32))
return dtrain, dvalid
def fit(self, dataset, evals_result: Dict[str, Any] | None = None, **kwargs):
# force RankIC-only early stopping
kwargs.setdefault("feval", rankic_feval)
kwargs.setdefault("metric", "None")
kwargs.setdefault("first_metric_only", True)
return super().fit(dataset, evals_result=evals_result, **kwargs)
@@ -0,0 +1,66 @@
"""Minimal persistence of computed SP features into the lake features parquet.
Flow: compute sp_* features per symbol (examples/sp_features.py) and MERGE them
into features/market=US/timeframe=1d/symbol=*.parquet so TACHandler /
LakeFeatureProvider can route `$sp_ou_theta` etc. from the workflow YAML.
Run after backfilling bars; re-run drops stale sp_* columns first (see note).
python examples/persist_sp_features.py --market US --timeframe 1d
"""
from __future__ import annotations
import argparse
import os
import pandas as pd
from tac_qlib.data.config import LakeConfig, NON_FEATURE_COLUMNS
from examples.sp_features import build_sp_features
#: columns owned by this feature family (replaced on re-runs, never duplicated)
SP_PREFIX = "sp_"
def persist_symbol(lake: LakeConfig, timeframe: str, symbol: str) -> None:
bars_path = lake.bar_path(timeframe, symbol)
feats_path = lake.features_path(timeframe, symbol)
if not bars_path.exists():
return
bars = pd.read_parquet(bars_path)
feats = build_sp_features(bars)
# bars have a single 't'/'date' column; align feature rows to it
feats = feats.drop(columns=[c for c in NON_FEATURE_COLUMNS if c in feats.columns], errors="ignore")
feats_path.parent.mkdir(parents=True, exist_ok=True)
if feats_path.exists():
existing = pd.read_parquet(feats_path)
# drop stale sp_* columns before merging (idempotent re-runs)
existing = existing[[c for c in existing.columns if not c.startswith(SP_PREFIX)]]
merged = pd.merge(existing, feats, on="t", how="left", suffixes=("", "_dup"))
merged = merged.loc[:, ~merged.columns.str.endswith("_dup")]
# keep original column order + new sp_* appended
merged.to_parquet(feats_path, index=False)
else:
feats.to_parquet(feats_path, index=False)
print(f"persisted {symbol}: {len(feats.columns) - 1} sp_* features")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--market", default="US")
ap.add_argument("--timeframe", default="1d")
ap.add_argument("--symbols", default="", help="comma-separated; default: all lake symbols")
args = ap.parse_args()
lake_root = os.environ.get("TAC_LAKE_DIR")
if not lake_root:
raise SystemExit("TAC_LAKE_DIR is required")
lake = LakeConfig(lake_root, args.market)
symbols = [s.strip().upper() for s in args.symbols.split(",") if s.strip()] or lake.load_symbols()
for symbol in symbols:
persist_symbol(lake, args.timeframe, symbol)
if __name__ == "__main__":
main()
@@ -0,0 +1,69 @@
"""Minimal OptimalStopControl threshold calibration — valid-window grid search.
This repo found OptimalStopControl thresholds overfit the valid window (valid
+7.5% → test −17.7% on one calibration). This script runs a small grid over
(entry_pct, exit_pct, max_hold_days) on the VALID window, reports per-config
excess return + turnover, and warns when the best valid config is a spike.
Reference repo impl: tac-qlib/examples/run_optstop_compare.py.
python examples/run_optstop_compare.py --universe AAPL,MSFT,QQQ
"""
from __future__ import annotations
import argparse
import itertools
import os
import pandas as pd
GRID = {
"entry_pct": [0.7, 0.85, 0.95],
"exit_pct": [0.5, 0.7],
"max_hold_days": [5, 10],
}
def evaluate_config(lake_root: str, universe: list[str], window: tuple, config: dict) -> dict:
"""Simplified stand-in: train the RankIC model, backtest OptimalStopControl
on `window`, return (ann_excess_return, turnover, max_drawdown).
The real repo impl calls qlib.backtest with the strategy and reads
report_normal.csv + risk.csv. Keep the interface here so the grid loop is
reusable.
"""
# placeholder — plug in the real backtest here
return {"ann_excess": 0.0, "turnover": 0.0, "max_dd": 0.0}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--lake-root", default=os.environ.get("TAC_LAKE_DIR", ""))
ap.add_argument("--universe", default="AAPL,MSFT,QQQ,IVV,SMH,TLT")
args = ap.parse_args()
universe = [s.strip().upper() for s in args.universe.split(",")]
valid = ("2026-06-01", "2026-06-30")
test = ("2026-07-01", "2026-08-06")
keys = list(GRID)
results = []
for combo in itertools.product(*[GRID[k] for k in keys]):
config = dict(zip(keys, combo))
v = evaluate_config(args.lake_root, universe, valid, config)
t = evaluate_config(args.lake_root, universe, test, config)
results.append({**config, "valid_excess": v["ann_excess"], "test_excess": t["ann_excess"]})
df = pd.DataFrame(results).sort_values("valid_excess", ascending=False)
print(df.head(10).to_string(index=False))
# Overfit check: how far is the best-valid config from the median test config?
med = df["test_excess"].median()
best = df.iloc[0]
print(f"\nmedian test excess: {med:+.3f} | best-valid test excess: {best['test_excess']:+.3f}")
if abs(best["test_excess"] - med) > 0.10:
print("WARNING: best-valid config is an outlier on test — likely overfit, prefer robust defaults")
if __name__ == "__main__":
main()
@@ -0,0 +1,90 @@
"""Minimal ranking-objective ablation loop — why lambda_rank fails here.
This repo found that with only ~50 instruments per day the rank-gradient
objectives (lambdarank / rank_xendcg) produce near-zero RankIC, while MSE
objective + RankIC early-stop is the winner. This script replays that check by
training a few LightGBM variants on the same lake split and printing RankIC.
Reference repo impl: tac-qlib/examples/run_rank_objectives.py.
python examples/run_rank_objectives.py --universe AAPL,MSFT,QQQ
"""
from __future__ import annotations
import argparse
import os
import numpy as np
import pandas as pd
OBJECTIVES = ["mse", "lambdarank", "rank_xendcg"]
def load_frame(lake_root: str, universe: list[str], start: str, end: str) -> pd.DataFrame:
"""Stack lake bars into a qlib-like (datetime, instrument) frame."""
from tac_qlib.data.config import LakeConfig
lake = LakeConfig(lake_root, "US")
frames = []
for sym in universe:
p = lake.bar_path("1d", sym)
if p.exists():
df = pd.read_parquet(p)[["t", "c"]].rename(columns={"t": "datetime", "c": "close"})
df["instrument"] = sym
frames.append(df)
out = pd.concat(frames, ignore_index=True)
out["datetime"] = pd.to_datetime(out["datetime"])
out = out[(out["datetime"] >= start) & (out["datetime"] <= end)]
return out.set_index(["datetime", "instrument"])
def label_5d(frame: pd.DataFrame) -> pd.Series:
close = frame["close"].unstack()
lbl = close.shift(-6) / close.shift(-1) - 1
return lbl.stack().rename("label")
def train_one(lake_root: str, universe: list[str], objective: str, train: tuple, test: tuple):
import lightgbm as lgb
frame = load_frame(lake_root, universe, train[0], test[1])
label = label_5d(frame)
data = pd.concat([frame["close"], label], axis=1).dropna()
tr = data.loc[(data.index.get_level_values(0) >= train[0]) & (data.index.get_level_values(0) <= train[1])]
te = data.loc[(data.index.get_level_values(0) >= test[0]) & (data.index.get_level_values(0) <= test[1])]
dtrain = lgb.Dataset(tr[["close"]], label=tr["label"])
dtest = lgb.Dataset(te[["close"]], label=te["label"])
params = {"objective": objective, "learning_rate": 0.05, "num_leaves": 15, "verbosity": -1}
model = lgb.train(params, dtrain, num_boost_round=100, valid_sets=[dtest])
pred = model.predict(te[["close"]], num_iteration=model.best_iteration)
label_te = te["label"].to_numpy()
# per-day RankIC
days = te.index.get_level_values(0).unique()
ics = []
for d in days:
m = te.index.get_level_values(0) == d
if m.sum() >= 3:
ics.append(pd.Series(pred[m]).rank().corr(pd.Series(label_te[m]).rank()))
return float(np.nanmean(ics))
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--lake-root", default=os.environ.get("TAC_LAKE_DIR", ""))
ap.add_argument("--universe", default="AAPL,MSFT,QQQ,IVV,SMH,TLT")
args = ap.parse_args()
universe = [s.strip().upper() for s in args.universe.split(",")]
train = ("2026-03-01", "2026-05-31")
test = ("2026-07-01", "2026-08-06")
print(f"{'objective':<14}{'test RankIC':>12}")
for obj in OBJECTIVES:
ic = train_one(args.lake_root, universe, obj, train, test)
print(f"{obj:<14}{ic:>12.4f}")
if __name__ == "__main__":
main()
@@ -0,0 +1,80 @@
"""Minimal stochastic-process feature computation — OU mean-reversion + Hurst.
These are the features that beat hand-rolled TA in this repo's 50-ETF runs.
Compute them per symbol on a rolling window ENDING at each day (never let them
see test data at fit time — see the HMM/GARCH note in SKILL.md).
Reference repo impl: tac-qlib/examples/sp_features.py (full 55-feature set:
OU, HMM, jump, HARRV, trend, GARCH, Hurst, path signatures, entropy, catch22).
"""
from __future__ import annotations
import numpy as np
import pandas as pd
#: rolling window for feature computation (days)
LOOKBACK = 250
def compute_ou_features(close: pd.Series) -> pd.DataFrame:
"""Ornstein-Uhlenbeck fit: theta (reversion speed), sigma (vol), residual z.
OU: dx_t = theta (mu - x_t) dt + sigma dW_t (theta is the mean-reversion
speed; higher = faster reversion = tradable mean-reversion signal).
Rolling OLS of dx on lagged log-price gives theta = -b (reversion speed);
sigma is the residual std. Vectorized via rolling cov/var.
"""
logp = np.log(close)
dx = logp.diff()
x_prev = logp.shift(1)
df = pd.DataFrame({"dx": dx, "x": x_prev})
out = pd.DataFrame(index=close.index, dtype=float)
cov = df["dx"].rolling(LOOKBACK, min_periods=30).cov(df["x"])
var = df["x"].rolling(LOOKBACK, min_periods=30).var()
theta = (-cov / var).rename("sp_ou_theta")
out["sp_ou_theta"] = theta
out["sp_ou_sigma"] = df["dx"].rolling(LOOKBACK, min_periods=30).std()
# standardized residual z = (x - mu) / sigma of the fitted process
mu = df["x"].rolling(LOOKBACK, min_periods=30).mean()
scale = np.sqrt(np.clip(1 / (2 * theta + 1e-9), 0, None))
out["sp_ou_zscore"] = (df["x"] - mu) / (out["sp_ou_sigma"] * scale)
return out
def compute_hurst(close: pd.Series, lookback: int = 100) -> pd.Series:
"""Rolling Hurst exponent via rescaled range (R/S). H>0.5 = trending."""
def _hurst(x: np.ndarray) -> float:
if len(x) < 20:
return np.nan
lags = range(2, min(len(x) // 2, 50))
tau = []
for lag in lags:
diff = x[lag:] - x[:-lag]
tau.append(np.sqrt(np.std(diff)))
tau = np.array(tau)
lags = np.array(lags, dtype=float)
poly = np.polyfit(np.log(lags), np.log(tau), 1)
return float(poly[0])
return close.rolling(lookback, min_periods=20).apply(lambda w: _hurst(w.to_numpy()), raw=False).rename(
"sp_hurst_exponent"
)
def build_sp_features(bars: pd.DataFrame) -> pd.DataFrame:
"""bars: lake 1d bars indexed by (datetime, instrument) or a symbol frame."""
if isinstance(bars.index, pd.MultiIndex):
frames = []
for inst, sub in bars.groupby(level=1):
close = sub.droplevel(1)["close"]
feats = pd.concat([compute_ou_features(close), compute_hurst(close)], axis=1)
feats["instrument"] = inst
frames.append(feats.reset_index())
out = pd.concat(frames).set_index(["datetime", "instrument"])
else:
close = bars["close"]
out = pd.concat([compute_ou_features(close), compute_hurst(close)], axis=1)
return out
@@ -0,0 +1,82 @@
"""Minimal beta-neutral 3L/3S strategy + record — stub of tac_qlib/contrib/strategy/beta_neutral.py.
Strategy side: subclass BaseSignalStrategy, hold ~3 long + 3 short equally
weighted (dollar-neutral) with TP/SL and a hard close at the horizon. The beta
comes from regression of daily returns on the benchmark in `_prepare_betas`.
Record side (BetaNeutralRecord): a custom `Record` that simulates the 3L/3S
portfolio after training and logs report / trades / risk.csv into the MLflow
run — the pattern to follow for any custom Record.
Wire the record into the workflow YAML:
record:
- class: BetaNeutralRecord
module_path: tac_qlib.contrib.strategy.beta_neutral
kwargs: { benchmark: QQQ, n_long: 3, n_short: 3 }
"""
from __future__ import annotations
from typing import Any, Dict, List
import pandas as pd
from qlib.backtest import Order
from qlib.backtest.decision import OrderDir, TradeDecisionWO
from qlib.contrib.strategy.signal_strategy import BaseSignalStrategy
class BetaNeutralStrategy(BaseSignalStrategy):
"""3 long / 3 short dollar-neutral template with TP/SL and hard close."""
def __init__(self, *, n_long: int = 3, n_short: int = 3, tp: float = 0.06, sl: float = -0.05, **kwargs: Any):
super().__init__(**kwargs)
self.n_long = n_long
self.n_short = n_short
self.tp = tp
self.sl = sl
def generate_trade_decision(self, execute_result=None):
trade_step = self.trade_calendar.get_trade_step()
start_time, end_time = self.trade_calendar.get_step_time(trade_step)
pred_start, pred_end = self.trade_calendar.get_step_time(trade_step - 1)
pred = self.signal.get_signal(start_time=pred_start, end_time=pred_end)
orders: List[Order] = []
if pred is not None and len(pred):
daily = pred.groupby(level=0).mean().iloc[-1].dropna().sort_values()
longs = daily.tail(self.n_long).index.tolist()
shorts = daily.head(self.n_short).index.tolist()
for inst in longs:
orders.append(self._order(inst, 1, start_time, end_time))
for inst in shorts:
orders.append(self._order(inst, -1, start_time, end_time))
return TradeDecisionWO(orders, self)
def _order(self, inst, direction, start_time, end_time):
price = self.trade_exchange.get_close(inst, end_time) or 1.0
qty = int(self.trade_exchange.account.cash / (len(self.trade_exchange.get_positions()) + 1) / price)
return Order(
inst,
qty,
start_time,
end_time,
direction=OrderDir.BUY if direction > 0 else OrderDir.SELL,
type="market",
)
class BetaNeutralRecord: # subclass qlib.workflow.record_temp.Record in the real impl
"""Custom record that backtests 3L/3S and logs report/trades/risk.csv."""
def __init__(self, *, benchmark: str = "QQQ", n_long: int = 3, n_short: int = 3, **_: Any):
self.benchmark = benchmark
self.n_long = n_long
self.n_short = n_short
def generate(self, **kwargs):
# Real impl: run qlib.backtest with BetaNeutralStrategy on the recorded
# pred, write report_normal.csv / positions_normal.csv / risk.csv into
# the current MLflow run's artifact dir, then log the headline metrics.
print("BetaNeutralRecord.generate: simulate 3L/3S and log artifacts")
@@ -0,0 +1,77 @@
"""Minimal OptimalStopControl strategy — a stub of tac_qlib/contrib/strategy/optimal_stop.py.
Subclasses qlib's BaseSignalStrategy; override `generate_trade_decision` to build
`qlib.backtest.Order`s and return a `TradeDecisionWO`. The real implementation
gates entry by cross-sectional signal percentile, exits by percentile / time /
stop-loss, and sizes equal-weight with `risk_degree` control.
Wire into a workflow YAML under PortAnaRecord.config.strategy:
strategy:
class: OptimalStopControl
module_path: tac_qlib.contrib.strategy.optimal_stop
kwargs:
signal: "<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
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
import numpy as np
from qlib.backtest import Order
from qlib.backtest.decision import OrderDir, TradeDecisionWO
from qlib.contrib.strategy.signal_strategy import BaseSignalStrategy
class OptimalStopControl(BaseSignalStrategy):
def __init__(
self,
*,
topk: int = 10,
entry_pct: float = 0.85,
exit_pct: float = 0.7,
max_hold_days: int = 10,
min_hold_days: int = 2,
sl: float = -0.08,
risk_degree: float = 0.95,
**kwargs: Any,
):
super().__init__(**kwargs)
self.topk = topk
self.entry_pct = entry_pct
self.exit_pct = exit_pct
self.max_hold_days = max_hold_days
self.min_hold_days = min_hold_days
self.sl = sl
self.risk_degree = risk_degree
def generate_trade_decision(self, execute_result=None):
"""Build orders for one trade step (minimal sketch — see repo impl)."""
trade_step = self.trade_calendar.get_trade_step()
# signal is known at t-1 via shift=-1 in the signal object
start_time, end_time = self.trade_calendar.get_step_time(trade_step)
pred_start, pred_end = self.trade_calendar.get_step_time(trade_step - 1)
pred = self.signal.get_signal(start_time=pred_start, end_time=pred_end)
orders: List[Order] = []
if pred is not None and len(pred):
# take the top-k by cross-sectional percentile, equal-weight size
cross = pred.groupby(level=0).rank(pct=True) # 0..1 per day
keep = pred.index[cross >= 1.0 - self.entry_pct]
for inst, (dt, _instr) in zip(keep, keep):
price = self.trade_exchange.get_close(inst, end_time) or 1.0
qty = int((self.risk_degree * self.trade_exchange.account.cash) / (self.topk * price))
if qty > 0:
orders.append(
Order(inst, qty, start_time, end_time, direction=OrderDir.BUY, type="market")
)
return TradeDecisionWO(orders, self)
@@ -0,0 +1,107 @@
# -----------------------------------------------------------------------------
# MINIMAL workflow — the canonical "run a backtest" template for the skill.
#
# Every traced backtest runs through a workflow YAML like this one via
# rd_run_workflow, so the `record` blocks write MLflow artifacts to disk
# (<lake>/mlruns/<exp_id>/<run_id>). The traced experiment's ref id IS the
# mlflow run id returned by rd_run_workflow.
#
# Trigger:
# rd_run_workflow config_path=examples/workflow_minimal.yaml \
# experiment_name=tac-rd-minimal
# -----------------------------------------------------------------------------
{%- set LAKE = TAC_LAKE_DIR %}
qlib_init:
provider_uri: "{{ LAKE }}"
region: us
expression_cache: null
dataset_cache: null
calendar_provider:
class: tac_qlib.data.providers.LakeCalendarProvider
kwargs: { lake_root: "{{ LAKE }}", market: US }
instrument_provider:
class: tac_qlib.data.providers.LakeInstrumentProvider
kwargs: { lake_root: "{{ LAKE }}", market: US, markets: {} }
feature_provider:
class: tac_qlib.data.providers.LakeFeatureProvider
kwargs: { lake_root: "{{ LAKE }}", market: US }
exp_manager:
class: MLflowExpManager
module_path: qlib.workflow.expm
kwargs: { uri: "sqlite:///mlruns.db", default_exp_name: "tac-rd-minimal" }
task:
model:
class: LGBModel
module_path: qlib.contrib.model.gbdt
kwargs:
loss: mse
learning_rate: 0.05
num_leaves: 15
n_estimators: 200
colsample_bytree: 0.8
subsample: 0.8
subsample_freq: 1
reg_alpha: 0.01
reg_lambda: 0.01
dataset:
class: DatasetH
module_path: qlib.data.dataset
kwargs:
handler:
class: TACHandler
module_path: tac_qlib.contrib.data.handler
kwargs:
instruments: AAPL,MSFT,QQQ,IVV,SMH,TLT
start_time: 2026-03-01
end_time: 2026-08-06
fit_start_time: 2026-03-01
fit_end_time: 2026-05-31
freq: day
lake_root: "{{ LAKE }}"
market: US
label: "Ref($close,-6)/Ref($close,-1)-1"
segments:
train: [2026-03-01, 2026-05-31]
valid: [2026-06-01, 2026-06-30]
test: [2026-07-01, 2026-08-06]
# Record block — REQUIRED. Each entry writes one artifact family to mlruns:
# SignalRecord pred.pkl + label.pkl
# SigAnaRecord IC / Rank IC series + long-short group returns
# PortAnaRecord backtest report / positions / risk
record:
- { 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:
class: TopkDropoutStrategy
module_path: qlib.contrib.strategy
kwargs:
signal: "<PRED>"
topk: 2
n_drop: 1
only_tradable: true
risk_degree: 0.95
backtest:
start_time: 2026-07-01
end_time: 2026-08-06
account: 1000000
benchmark: QQQ
exchange_kwargs:
codes: AAPL,MSFT,QQQ,IVV,SMH,TLT
deal_price: $close
freq: day
open_cost: 0.0005
close_cost: 0.0015
min_cost: 5.0
risk_analysis_freq: 1d
@@ -0,0 +1,113 @@
# -----------------------------------------------------------------------------
# RankIC early-stop workflow — minimal example wiring the custom model.
#
# model_rank_gbdt.py must be importable: copy it (or symlink) into
# tac_qlib/contrib/model/ and sync to /opt/venv site-packages (see SKILL.md
# "Installed package copy" gotcha). Then run:
#
# rd_run_workflow config_path=examples/workflow_rankic.yaml \
# experiment_name=tac-rd-rankic
# -----------------------------------------------------------------------------
{%- set LAKE = TAC_LAKE_DIR %}
qlib_init:
provider_uri: "{{ LAKE }}"
region: us
expression_cache: null
dataset_cache: null
calendar_provider:
class: tac_qlib.data.providers.LakeCalendarProvider
kwargs: { lake_root: "{{ LAKE }}", market: US }
instrument_provider:
class: tac_qlib.data.providers.LakeInstrumentProvider
kwargs: { lake_root: "{{ LAKE }}", market: US, markets: {} }
feature_provider:
class: tac_qlib.data.providers.LakeFeatureProvider
kwargs: { lake_root: "{{ LAKE }}", market: US }
exp_manager:
class: MLflowExpManager
module_path: qlib.workflow.expm
kwargs: { uri: "sqlite:///mlruns.db", default_exp_name: "tac-rd-rankic" }
task:
model:
# Custom model — see examples/model_rank_gbdt.py (RankICLGBModel):
# per-day query groups + feval=rankic + metric='None' so early-stopping
# tracks mean per-day Spearman instead of l2.
class: RankICLGBModel
module_path: tac_qlib.contrib.model.rank_gbdt
kwargs:
loss: mse
learning_rate: 0.02
num_leaves: 15
num_boost_round: 3000
early_stopping_rounds: 200
min_data_in_leaf: 20
lambda_l1: 0.0
lambda_l2: 0.5
colsample_bytree: 0.8
subsample: 0.8
subsample_freq: 1
seed: 2026
dataset:
class: DatasetH
module_path: qlib.data.dataset
kwargs:
handler:
class: TACHandler
module_path: tac_qlib.contrib.data.handler
kwargs:
instruments: AAPL,MSFT,QQQ,IVV,SMH,TLT
start_time: 2026-03-01
end_time: 2026-08-06
fit_start_time: 2026-03-01
fit_end_time: 2026-05-31
freq: day
lake_root: "{{ LAKE }}"
market: US
label: "Ref($close,-6)/Ref($close,-1)-1"
infer_processors:
- { class: DropAllNaN, kwargs: {} }
- { class: ProcessInf, kwargs: {} }
- { class: CSRankNorm, kwargs: {} }
- { class: ZScoreNorm, kwargs: {} }
- { class: Fillna, kwargs: {} }
segments:
train: [2026-03-01, 2026-05-31]
valid: [2026-06-01, 2026-06-30]
test: [2026-07-01, 2026-08-06]
record:
- { 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:
class: TopkDropoutStrategy
module_path: qlib.contrib.strategy
kwargs:
signal: "<PRED>"
topk: 2
n_drop: 1
only_tradable: true
risk_degree: 0.95
backtest:
start_time: 2026-07-01
end_time: 2026-08-06
account: 1000000
benchmark: QQQ
exchange_kwargs:
codes: AAPL,MSFT,QQQ,IVV,SMH,TLT
deal_price: $close
freq: day
open_cost: 0.0005
close_cost: 0.0015
min_cost: 5.0
risk_analysis_freq: 1d