book: scaffold + ch00 (execution trail as spine) — evidence exp 8-31, round 3
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
# tac-qlib
|
||||
|
||||
Run stock [qlib](https://github.com/microsoft/qlib) ML workflows (LightGBM → signal → backtest) directly on the
|
||||
TradeAC parquet lake. No CSV/bin dump, no data conversion: the lake's calendar, instrument master, OHLCV bars and
|
||||
pre-computed ta-lib features plug into qlib as first-class data providers, and a custom `DataHandlerLP`
|
||||
(`TACHandler`) exposes them through the normal qlib dataset/processor pipeline.
|
||||
|
||||
```
|
||||
$ qrun workflows/workflow_lgb_taclake.yaml --experiment_name tac-lake-lgb
|
||||
```
|
||||
|
||||
trains a LightGBM, records predictions/labels, evaluates the signal (IC/RankIC), runs a daily
|
||||
`TopkDropoutStrategy` backtest with cost model and risk analysis, and logs everything to mlflow (sqlite).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
tac-qlib/
|
||||
├── tac_qlib/
|
||||
│ ├── qlib_init.py # qlib_init() drop-in wired to the lake providers
|
||||
│ ├── data/
|
||||
│ │ ├── config.py # LakeConfig: paths + metadata readers, freq↔timeframe map
|
||||
│ │ └── providers.py # LakeCalendarProvider / LakeInstrumentProvider / LakeFeatureProvider
|
||||
│ └── contrib/data/
|
||||
│ └── handler.py # TACHandler (DataHandlerLP) + DropAllNaN processor
|
||||
├── workflows/
|
||||
│ └── workflow_lgb_taclake.yaml # qrun workflow: train -> signal -> backtest
|
||||
├── examples/
|
||||
│ └── run_backtest.py # same loop as the workflow, plain Python (no yaml)
|
||||
└── tests/
|
||||
└── test_lake_providers.py # plain-assert smoke tests
|
||||
```
|
||||
|
||||
## Requirements / install
|
||||
|
||||
- Python 3.12, `qlib` nightly (`0.1.dev2066` in the repo venv), pandas/pyarrow, lightgbm.
|
||||
- The TradeAC lake (see below). `TAC_LAKE_DIR` is **mandatory** (no default) — set it to the
|
||||
lake root, or pass the `lake_root` kwargs.
|
||||
|
||||
## The lake (data layout)
|
||||
|
||||
```
|
||||
$TAC_LAKE_DIR/
|
||||
├── market=US/
|
||||
│ └── timeframe=1d/
|
||||
│ └── symbol=AAPL.parquet # OHLCV bars: t, date, o, h, l, c, v, n, vw
|
||||
├── features/
|
||||
│ └── market=US/timeframe=1d/
|
||||
│ └── symbol=AAPL.parquet # ta-lib indicators, wide format: t, sma_5, rsi_14, ...
|
||||
├── calendar.parquet # trading days per market
|
||||
├── coverage.parquet # per (market,timeframe,symbol) loaded windows
|
||||
└── symbols.parquet # asset master
|
||||
```
|
||||
|
||||
Field routing (`tac_qlib/data/config.py`):
|
||||
|
||||
- `$open $high $low $close $volume $vwap` → bar parquet columns; `$amount` = `v * vw`, `$avg_amount` = `vw`.
|
||||
- `$factor $change $trade_unit $suspend_flag` → all-NaN (not stored; the backtest Exchange only needs `$close`).
|
||||
- anything else (e.g. `$rsi_14`, `$sma_20`) → a ta-lib column in the features parquet.
|
||||
|
||||
## Step 1 — Prepare data
|
||||
|
||||
The lake is populated and backfilled with the tac-engine MCP lake tools (see
|
||||
`tac-engine/skills/tradeac-lake`). Typical sequence:
|
||||
|
||||
1. Seed the trading calendar from historical auctions (so the 1d completeness check has an expected day set):
|
||||
`backfill_lake_calendar(symbols="AAPL,MSFT,...")`.
|
||||
2. Backfill bars: `get_lake_bars(symbols="AAPL,MSFT,...", timeframe="1d", start="2026-02-09")` (lazy: missing
|
||||
windows are fetched from Alpaca and persisted; `sip`/`iex` auto-fallback on 403).
|
||||
3. Persist features: `get_lake_ta(symbol="AAPL", timeframe="1d", indicators="sma_5,sma_20,rsi_14,macd,bb,atr_14", persist=true)`.
|
||||
Only indicators that exist in *every* features file are auto-loaded by the handler; add columns per symbol by
|
||||
re-running `get_lake_ta`.
|
||||
4. `get_lake_symbols` / `get_lake_coverage` to verify the universe and loaded windows.
|
||||
|
||||
`TACHandler` discovers the feature columns itself (`get_common_feature_fields` = the intersection of columns
|
||||
across all features files), so no config change is needed as the lake grows.
|
||||
|
||||
## Step 2 — Preprocess
|
||||
|
||||
Preprocessing happens in `TACHandler` (a `DataHandlerLP`), composed from standard qlib processors:
|
||||
|
||||
- **infer** (`DEFAULT_INFER_PROCESSORS`), applied to the input features:
|
||||
1. `DropAllNaN` — drops columns that are all-NaN over the fit window (fixes the lake's fully-empty ta-lib
|
||||
columns, e.g. a `stoch_*` output that is NaN from the start). The drop set is fixed in `fit()` and applied
|
||||
identically to train/valid/test so feature columns never diverge.
|
||||
2. `ProcessInf`, `ZScoreNorm` (fit on the fit window), `Fillna`.
|
||||
- **learn** (`DEFAULT_LEARN_PROCESSORS`), applied to the label: `DropnaLabel`, `CSZScoreNorm`.
|
||||
|
||||
Handler kwargs (used by both the workflow yaml and the Python API):
|
||||
|
||||
| kwarg | default | meaning |
|
||||
|---|---|---|
|
||||
| `instruments` | `all` | universe; list, `all`, or a named pool from `markets:` |
|
||||
| `start_time` / `end_time` | – | queried window (must be within the lake calendar) |
|
||||
| `fit_start_time` / `fit_end_time` | start/end | window the fit-able processors (ZScoreNorm, DropAllNaN) fit on |
|
||||
| `freq` | `day` | maps to the lake timeframe (`day`→`1d`, `1min`→`1m`, …) |
|
||||
| `feature_fields` | auto | raw OHLCV + common ta-lib columns; or an explicit list |
|
||||
| `label` | `Ref($close,-2)/Ref($close,-1)-1` | qlib expression for the target |
|
||||
| `lake_root` / `market` | `$TAC_LAKE_DIR` / `US` | lake location (required) / market partition |
|
||||
|
||||
Only daily (`1d`) is currently supported by the calendar provider; intraday freq raises `NotImplementedError`.
|
||||
|
||||
## Step 3 — Train
|
||||
|
||||
Either write the model task in yaml and run qrun (see *Glue with qrun*), or train in Python:
|
||||
|
||||
```python
|
||||
from qlib.data.dataset import DatasetH
|
||||
from tac_qlib.qlib_init import qlib_init
|
||||
from tac_qlib.contrib.data.handler import TACHandler
|
||||
from qlib.contrib.model.gbdt import LGBModel
|
||||
from qlib.workflow import R
|
||||
|
||||
qlib_init(provider_uri=os.environ["TAC_LAKE_DIR"], market="US", freq="day")
|
||||
|
||||
handler = TACHandler(
|
||||
instruments="all",
|
||||
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=os.environ["TAC_LAKE_DIR"], market="US",
|
||||
)
|
||||
dataset = DatasetH(handler=handler, segments={
|
||||
"train": ("2026-03-01", "2026-05-31"),
|
||||
"valid": ("2026-06-01", "2026-06-30"),
|
||||
"test": ("2026-07-01", "2026-08-06"),
|
||||
})
|
||||
|
||||
model = LGBModel(n_estimators=200, learning_rate=0.05, num_leaves=15, ...)
|
||||
with R.start(experiment_name="tac-lake-demo"):
|
||||
model.fit(dataset) # trains on the train segment
|
||||
```
|
||||
|
||||
## Step 4 — Test / evaluate the signal
|
||||
|
||||
`model.predict(dataset)` returns the prediction on the **test** segment (a `(datetime, instrument)` Series).
|
||||
Evaluate it with qlib's `SigAnaRecord` / `sig_analysis`:
|
||||
|
||||
```python
|
||||
from qlib.workflow.record_temp import SigAnaRecord
|
||||
from qlib.contrib.evaluate import signal_analysis
|
||||
|
||||
pred = model.predict(dataset) # "score" column
|
||||
label = dataset.prepare("test", col_set="label", data_key=DataHandlerLP.DK_I)["LABEL0"]
|
||||
# per-day + overall IC / ICIR / RankIC / RankICIR
|
||||
report = signal_analysis(pred, label)
|
||||
```
|
||||
|
||||
In the workflow this is automatic (`SigAnaRecord`): the run logs IC 0.0072 / ICIR 0.016 /
|
||||
RankIC 0.0138 / RankICIR 0.034 for the default split — weak but the plumbing is verified.
|
||||
|
||||
## Step 5 — Backtesting
|
||||
|
||||
```python
|
||||
from qlib.contrib.evaluate import backtest_daily, risk_analysis
|
||||
from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy
|
||||
|
||||
strategy = TopkDropoutStrategy(signal=pred, topk=2, n_drop=1, only_tradable=True, risk_degree=0.95)
|
||||
report_normal, positions_normal = backtest_daily(
|
||||
start_time="2026-07-01", end_time="2026-08-06",
|
||||
strategy=strategy, account=1_000_000, benchmark=None, # lake has no index quotes
|
||||
exchange_kwargs={"codes": universe, "deal_price": "$close", "freq": "day",
|
||||
"open_cost": 0.0005, "close_cost": 0.0015, "min_cost": 5.0},
|
||||
)
|
||||
risk = risk_analysis(report_normal["return"], freq="day")
|
||||
```
|
||||
|
||||
- `TopkDropoutStrategy` is the default mapping *prediction → positions* (hold top-k, drop `n_drop` per day).
|
||||
For other sizing frameworks — equal/score-weighting, softmax, z-score, fractional Kelly, mean-variance —
|
||||
subclass `qlib.contrib.strategy.SignalStrategy` and implement `generate_trade_decision` (see
|
||||
`../.tmp/signalTrade.md` for the recipe catalogue).
|
||||
- The Exchange needs `$close`; other fields the backtest probes (`$factor`, `$trade_unit`) are all-NaN and fine.
|
||||
- Benchmark: pick any symbol the lake holds (e.g. `benchmark: AAPL`); null benchmark triggers benign
|
||||
"Mean of empty slice" warnings from the risk analysis.
|
||||
|
||||
## Step 6 — Predict
|
||||
|
||||
`SignalRecord` already saved `pred.pkl` (test segment) during the qrun run. For predictions on arbitrary data:
|
||||
|
||||
```python
|
||||
pred = model.predict(dataset) # predict on the "test" segment
|
||||
pred.to_frame("score").to_pickle("pred.pkl") # (datetime, instrument) x ["score"]
|
||||
```
|
||||
|
||||
To predict a live/rolling window instead of the configured test segment, point a handler's `segments["test"]`
|
||||
at the window of interest, or call `model.predict(dataset, segment="test")` after overriding the segment.
|
||||
|
||||
## Glue everything with qrun
|
||||
|
||||
`workflows/workflow_lgb_taclake.yaml` wires the whole chain (init → train → signal record → signal analysis →
|
||||
backtest + risk analysis) into one qrun invocation:
|
||||
|
||||
```bash
|
||||
cd tac-qlib
|
||||
qrun workflows/workflow_lgb_taclake.yaml --experiment_name tac-lake-lgb
|
||||
# custom lake root:
|
||||
TAC_LAKE_DIR=/path/to/lake qrun workflows/workflow_lgb_taclake.yaml --experiment_name tac-lake-lgb
|
||||
```
|
||||
|
||||
YAML anatomy:
|
||||
|
||||
- `qlib_init` — points `provider_uri` at the lake and installs the lake providers by their full class paths
|
||||
(`tac_qlib.data.providers.Lake*Provider`), plus an `exp_manager` backed by `sqlite:///<lake>/mlruns.db`
|
||||
(avoids mlflow's filesystem-backend maintenance-mode opt-in). The unified R&D store lives under the lake
|
||||
root: `mlruns.db` + `mlruns/<exp>/<run>/`. Override the tracking URI with `MLRUNS_URI`.
|
||||
- `task.model` — `LGBModel` hyperparameters.
|
||||
- `task.dataset` — `DatasetH` over `TACHandler`; `segments.train/valid/test` split the window;
|
||||
`fit_start_time`/`fit_end_time` pin the processor fit window to train.
|
||||
- `task.record` — ordered records:
|
||||
1. `SignalRecord` → writes `pred.pkl` (and `label.pkl`).
|
||||
2. `SigAnaRecord` → `sig_analysis/{ic,ric}.pkl` (IC/ICIR/RankIC/RankICIR).
|
||||
3. `PortAnaRecord` → daily `TopkDropoutStrategy` backtest + `risk_analysis_freq: 1d` →
|
||||
`portfolio_analysis/*.pkl` (report, positions, indicators, risk metrics, benchmark & cost-adjusted excess returns).
|
||||
|
||||
Run artifacts land under the mlflow run: `<lake>/mlruns/<exp>/<run>/artifacts/*.pkl` (metadata in `<lake>/mlruns.db`).
|
||||
|
||||
Template notes:
|
||||
|
||||
- The header uses jinja2 (`{%- set LAKE = TAC_LAKE_DIR %}`) — `TAC_LAKE_DIR` is **required** and names the
|
||||
lake root. Do **not** use `-%}` on the closing tag — it strips the newline and glues
|
||||
`qlib_init:` onto the comment line (YAML parse error).
|
||||
- `qrun` is `qlib.cli.run:run` (fire): positional CONFIG_PATH + `--experiment_name` / `--uri_folder`. No
|
||||
`--config` flag.
|
||||
|
||||
## Manual (no-yaml) path
|
||||
|
||||
`examples/run_backtest.py` runs the identical loop in plain Python (good for parametrizing universe, features,
|
||||
label, topk, costs):
|
||||
|
||||
```bash
|
||||
.venv/bin/python tac-qlib/examples/run_backtest.py
|
||||
.venv/bin/python tac-qlib/examples/run_backtest.py --features '$close,$rsi_14,$sma_5,$macd' \
|
||||
--universe AAPL,MSFT,TSLA,USO,SLV,TLT --topk 2 --n-drop 1 --output ./backtest_out
|
||||
```
|
||||
|
||||
Writes `pred.pkl`, `report_normal.csv`, `positions_normal.csv`, `risk.csv` to the output dir.
|
||||
|
||||
## Reference
|
||||
|
||||
- `tac_qlib/data/providers.py` — the three lake providers; they match qlib's provider interface
|
||||
(`feature()` keyed by calendar position, `list_instruments()` with listing spans, `load_calendar()`), so the
|
||||
expression engine, `DatasetH` and the backtest `Exchange` work unchanged.
|
||||
- `tac_qlib/contrib/data/handler.py` — `TACHandler` (DataHandlerLP over `QlibDataLoader`),
|
||||
`DropAllNaN`, `get_common_feature_fields`, `discover_feature_fields`.
|
||||
- `tac_qlib/data/config.py` — `LakeConfig` path/reader helpers, `FREQ_TO_TIMEFRAME`, `BAR_FIELD_MAP`,
|
||||
`resolve_lake_root` (`$TAC_LAKE_DIR`, required — fails fast if unset).
|
||||
- Tests (no pytest; plain asserts):
|
||||
|
||||
```bash
|
||||
.venv/bin/python tac-qlib/tests/test_lake_providers.py
|
||||
```
|
||||
@@ -0,0 +1,149 @@
|
||||
"""End-to-end example: train a LightGBM on TradeAC lake data and backtest it.
|
||||
|
||||
Reads OHLCV + ta-lib features straight from the TradeAC parquet lake through the
|
||||
tac-qlib providers and the ``TACHandler``, then runs the standard qlib research
|
||||
loop (LightGBM + TopkDropoutStrategy + daily backtest).
|
||||
|
||||
Usage::
|
||||
|
||||
.venv/bin/python tac-qlib/examples/run_backtest.py # defaults
|
||||
.venv/bin/python tac-qlib/examples/run_backtest.py --features '$close,$rsi_14,$sma_5,$macd' \\
|
||||
--universe AAPL,MSFT,TSLA,USO,SLV,TLT --output ./backtest_out
|
||||
|
||||
The lake has ~5 months of 1d bars (2026-02-09 .. 2026-08-06); the default split is
|
||||
train 2026-03-01..2026-05-31 / valid 2026-06-01..2026-06-30 / test 2026-07-01..2026-08-06.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--lake-root", default=os.environ.get("TAC_LAKE_DIR"))
|
||||
p.add_argument("--market", default="US")
|
||||
p.add_argument("--universe", default="AAPL,MSFT,TSLA,USO,SLV,TLT",
|
||||
help="comma-separated instruments (default: the 1d-bar symbols)")
|
||||
p.add_argument("--features", default="$open,$high,$low,$close,$vwap,$volume,$amount",
|
||||
help="comma-separated feature fields ($-prefixed)")
|
||||
p.add_argument("--label", default="Ref($close,-2)/$close-1")
|
||||
p.add_argument("--train-start", default="2026-03-01")
|
||||
p.add_argument("--train-end", default="2026-05-31")
|
||||
p.add_argument("--valid-end", default="2026-06-30")
|
||||
p.add_argument("--test-end", default="2026-08-06")
|
||||
p.add_argument("--topk", type=int, default=2)
|
||||
p.add_argument("--n-drop", type=int, default=1)
|
||||
p.add_argument("--init-cash", type=float, default=1_000_000.0)
|
||||
p.add_argument("--output", default="backtest_output")
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
logging.getLogger("lightgbm").setLevel(logging.WARNING)
|
||||
os.environ.setdefault("MLFLOW_ALLOW_FILE_STORE", "true") # qlib's mlflow file store opt-in
|
||||
|
||||
universe = [s.strip().upper() for s in args.universe.split(",") if s.strip()]
|
||||
feature_fields = [f.strip() for f in args.features.split(",") if f.strip()]
|
||||
|
||||
from tac_qlib.qlib_init import qlib_init
|
||||
|
||||
qlib_init(provider_uri=args.lake_root, market=args.market, freq="day")
|
||||
|
||||
from qlib.data.dataset import DatasetH
|
||||
from tac_qlib.contrib.data.handler import TACHandler
|
||||
|
||||
valid_start = str(pd.Timestamp(args.train_end) + pd.Timedelta(days=1)).split()[0]
|
||||
test_start = str(pd.Timestamp(args.valid_end) + pd.Timedelta(days=1)).split()[0]
|
||||
|
||||
# ---- dataset ---------------------------------------------------------
|
||||
handler = TACHandler(
|
||||
instruments=universe,
|
||||
start_time=args.train_start,
|
||||
end_time=args.test_end,
|
||||
freq="day",
|
||||
fit_start_time=args.train_start,
|
||||
fit_end_time=args.train_end,
|
||||
feature_fields=feature_fields,
|
||||
label=args.label,
|
||||
lake_root=args.lake_root,
|
||||
market=args.market,
|
||||
)
|
||||
dataset = DatasetH(
|
||||
handler=handler,
|
||||
segments={
|
||||
"train": (args.train_start, args.train_end),
|
||||
"valid": (valid_start, args.valid_end),
|
||||
"test": (test_start, args.test_end),
|
||||
},
|
||||
)
|
||||
|
||||
# ---- train ------------------------------------------------------------
|
||||
from qlib.contrib.model.gbdt import LGBModel
|
||||
|
||||
model = LGBModel(n_estimators=200, learning_rate=0.05, num_leaves=15, colsample_bytree=0.8,
|
||||
subsample=0.8, subsample_freq=1, reg_alpha=0.01, reg_lambda=0.01)
|
||||
|
||||
t0 = time.time()
|
||||
from qlib.workflow import R
|
||||
|
||||
with R.start(experiment_name="tac-lake-demo"):
|
||||
model.fit(dataset)
|
||||
print(f"[train] fitted LGBModel in {time.time() - t0:.1f}s")
|
||||
|
||||
# ---- predict ----------------------------------------------------------
|
||||
pred = model.predict(dataset) # (datetime, instrument) MultiIndex Series
|
||||
print(f"[predict] {len(pred)} signals on test segment {test_start}..{args.test_end}")
|
||||
print(pred.head(5))
|
||||
|
||||
# ---- backtest ---------------------------------------------------------
|
||||
from qlib.contrib.evaluate import backtest_daily, risk_analysis
|
||||
from qlib.contrib.strategy.signal_strategy import TopkDropoutStrategy
|
||||
|
||||
strategy = TopkDropoutStrategy(signal=pred, topk=args.topk, n_drop=args.n_drop,
|
||||
only_tradable=True, risk_degree=0.95)
|
||||
t0 = time.time()
|
||||
report_normal, positions_normal = backtest_daily(
|
||||
start_time=test_start,
|
||||
end_time=args.test_end,
|
||||
strategy=strategy,
|
||||
account=args.init_cash,
|
||||
benchmark=None, # the lake has no index quotes
|
||||
exchange_kwargs={
|
||||
"codes": universe,
|
||||
"deal_price": "$close",
|
||||
"freq": "day",
|
||||
"open_cost": 0.0005,
|
||||
"close_cost": 0.0015,
|
||||
"min_cost": 5.0,
|
||||
},
|
||||
)
|
||||
print(f"[backtest] ran in {time.time() - t0:.1f}s over {len(report_normal)} trading days")
|
||||
|
||||
risk = risk_analysis(report_normal["return"], freq="day")
|
||||
print("\n=== backtest risk analysis ===")
|
||||
print(risk.round(6).to_string())
|
||||
|
||||
# ---- save -------------------------------------------------------------
|
||||
out = Path(args.output)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
pred.to_frame("score").to_pickle(out / "pred.pkl")
|
||||
report_normal.to_csv(out / "report_normal.csv")
|
||||
pd.DataFrame({ts: pos.get_stock_amount_dict() for ts, pos in positions_normal.items()}).T.to_csv(
|
||||
out / "positions_normal.csv"
|
||||
)
|
||||
risk.to_csv(out / "risk.csv")
|
||||
print(f"\nsaved artifacts to {out}/ (pred.pkl, report_normal.csv, positions_normal.csv, risk.csv)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tac-qlib"
|
||||
version = "0.1.0"
|
||||
description = "TradeAC qlib integration: read the parquet+DuckDB lake (OHLCV bars + ta-lib features) from within the qlib research workflow"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"pyarrow",
|
||||
"duckdb",
|
||||
"pandas>=1.1",
|
||||
"pyqlib",
|
||||
"mcp[cli]",
|
||||
"python-dotenv",
|
||||
"psycopg[binary]",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
packages = [
|
||||
"tac_qlib",
|
||||
"tac_qlib.data",
|
||||
"tac_qlib.contrib",
|
||||
"tac_qlib.contrib.data",
|
||||
"tac_qlib.contrib.model",
|
||||
"tac_qlib.contrib.strategy",
|
||||
]
|
||||
@@ -0,0 +1,325 @@
|
||||
---
|
||||
name: tac-algo-trade
|
||||
description: Guide agents to run the TradeAC scheduled algo-trading flow end-to-end. First backfill the data lake for all symbols up to the latest completed trading day, then — given a reference MLflow run (experiment_name + run_id) — re-train the same model configuration on a rolling window (4 years up to the latest completed trading day), generate fresh signals, run the selected strategy into a target order list, and place the orders on the Alpaca paper account — chaining every step from the previous one's output. Uses the `tac-engine` lake MCP tools (get_lake_bars, backfill_lake_calendar, get_lake_coverage) for data, the `tac-qlib-rd` MCP tools (rd_train, rd_predict, rd_strategy_targets, rd_exp_*) for the quant side, and the `tac-engine` MCP tools (place_order, list_orders, list_positions, get_news, ...) for execution.
|
||||
---
|
||||
|
||||
# tac-algo-trade
|
||||
|
||||
Scheduled algo trading for the TradeAC paper account. Each scheduled execution (1) backfills the lake so it is current through the latest completed trading day, (2) re-trains the reference run's configuration on the most recent 4 years of lake data, (3) predicts, (4) derives an order list from the strategy, and (5) places orders on Alpaca.
|
||||
|
||||
This is the skill the app's scheduler invokes (`/dashboard/scheduler`). It chains strict step-to-step outputs: **do not skip ahead, do not fabricate outputs — every step consumes the artifact path returned by the previous one.**
|
||||
|
||||
## MCP tools
|
||||
|
||||
- Data side: `tac-engine` lake tools (see `tac-engine/skills/tradeac-lake/SKILL.md`) — `get_lake_coverage`, `backfill_lake_calendar`, `get_lake_bars` (lazy backfill), `get_lake_status`.
|
||||
- Quant side: `tac-qlib-rd` (see `tradeac-rd/SKILL.md`) — `rd_status`, `rd_exp_get_experiment`, `rd_exp_input`, `rd_train`, `rd_predict`, `rd_strategy_targets`, `rd_exp_get_run`.
|
||||
- Execution side: `tac-engine` (see `tac-engine/skills/tradeac-alpaca/SKILL.md`) — `get_account`, `list_positions`, `list_orders`, `place_order`, `get_stock_snapshot`, `get_stock_latest_quotes`, `get_news`.
|
||||
- Round book: `tac-rd-book` — the execution trail (see the "Round book" section below). `round_create` / `round_update`, `fact_record`, `intent_set`, `decision_record`, `round_sync_fills`, `round_update_status`, `book_reconcile`, `trail_funnel`.
|
||||
|
||||
All steps use the MCP tools directly. Never hand-compute scores, read mlruns files directly (`mlruns.db` / pickles — the store is Postgres via `DATABASE_URL` when set), or script the MCP servers yourself. This is a **paper** account — trade normally, size each order per the strategy's target weight × live equity (whole shares), capped by available buying power, and skip anything untradeable.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `experiment_name` — MLflow experiment of the reference run.
|
||||
- `run_id` — the reference run inside that experiment (its saved `config` artifact is the source of truth for the whole pipeline).
|
||||
- `strategy` (optional) — a workflow YAML from `tac-qlib/workflows/*.yaml` defining the strategy sizing (e.g. `topk` / `n_drop` / `risk_degree`, benchmark, costs). Default: the reference run's own backtest config.
|
||||
- Time context: today's date in the scheduling city's timezone.
|
||||
|
||||
## Step 1 — Backfill the lake (data currency)
|
||||
|
||||
The retrain must see every symbol up to the latest available bar — do **not** train on stale data.
|
||||
|
||||
1. `get_lake_coverage` `{"market":"US","timeframe":"1d"}` → read each symbol's loaded window; the **last loaded date** across the universe is your backfill start.
|
||||
2. `backfill_lake_calendar` `{"market":"US","symbols":"all","start":<backfill start>,"end":<today>}` → seed the trading-day set first so the 1d completeness check knows which days to expect.
|
||||
3. `get_lake_bars` `{"market":"US","symbols":"all","timeframe":"1d","start":<backfill start>,"end":<today>,"lazy":true,"quiet":true}` → backfill every symbol's gap (Alpaca historical bars) and persist to the lake. Use `quiet: true` so the tool returns a per-symbol `{count, first_t, last_t}` summary instead of echoing back thousands of bar rows. Alpaca has no bar for today until the session closes, so the latest bar landed is the **latest completed trading day** `D` (for a Monday run this is Friday).
|
||||
|
||||
Confirm with `rd_status` (calendar range + coverage) that the lake is populated through `D`. **Output: `D`, the latest completed trading day.**
|
||||
|
||||
## Step 2 — Inspect the reference run
|
||||
|
||||
`rd_exp_get_experiment` with `experiment_id` (or `rd_exp_input` with `run_id`) → extract from the run's `config` artifact:
|
||||
|
||||
- handler config: `universe` (instruments), `features`, `label`, `freq`
|
||||
- model kwargs: `learning_rate`, `num_leaves`, `n_estimators`, `colsample_bytree`, `subsample`, `subsample_freq`, `reg_alpha`, `reg_lambda`, `seed`
|
||||
- strategy sizing: `topk` / `n_drop` / `risk_degree`, costs, benchmark
|
||||
|
||||
Record these — they define the retrain. **Output: config values above.**
|
||||
|
||||
## Step 3 — Open the traced experiment (git lineage)
|
||||
|
||||
Every scheduled run is a **traced experiment** on the tac-qlib-custom lineage: a row in the
|
||||
`rd_experiments` table plus a per-experiment git branch in the `experiments` submodule,
|
||||
forked from the predecessor's branch. **This is part of the run — do it automatically, do
|
||||
not wait for the user to prompt** (see `tac-qlib/skills/tac-qlib-custom/SKILL.md`,
|
||||
"Experiment traceability", for the full procedure and env vars).
|
||||
|
||||
1. Resolve the predecessor: if the reference run (`experiment_name` / `run_id` from Step 2)
|
||||
is itself traced, reuse its traced id as `evolved_from`; otherwise use `--evolved-from auto`
|
||||
(semantic search over existing rationals).
|
||||
2. Open the trace — this inserts the row, forks the branch from the predecessor and pushes it (via the `rd_trace_*` MCP tools on tac-qlib-rd):
|
||||
|
||||
```
|
||||
rd_trace_init
|
||||
rd_trace_start rational="scheduled algo retrain on <D>: <ref exp>/<ref run> re-trained on 4y -> live paper orders" \
|
||||
details="<universe / features / label / model / strategy sizing from the reference run config>" \
|
||||
experiment_name=<THE RUN'S experiment name — see naming below> \
|
||||
evolved_from=<predecessor id or auto> \
|
||||
session_id="<this chat's opencode session id>"
|
||||
# -> {"experiment_id": N, "branch": "...", "evolved_from": ..., "base_branch": ...}
|
||||
```
|
||||
|
||||
**Experiment naming (unique per run):** every retrain runs into its OWN
|
||||
experiment — `<reference experiment name>-<epoch seconds>` (e.g.
|
||||
`tac-basic-short-1786883261`). The scheduler prompt names the exact
|
||||
experiment for you; use that name for `rd_trace_start experiment_name`,
|
||||
`rd_train`'s `experiment_name`, and the round's `experiment_name`. **Never**
|
||||
reuse the reference experiment name for this run's trace node — reusing it
|
||||
creates duplicate lineage entries with the same name and a wrong parent
|
||||
chain (seen with `tac-basic-short`).
|
||||
|
||||
The tool returns `experiment_id` / `branch` as JSON — record them; every
|
||||
later `rd_trace_*` call uses the id. Commit the run's files (workflow YAML /
|
||||
notes) with `rd_trace_commit experiment_id=<N> message="..."` as you go.
|
||||
|
||||
3. **Round window** — the execution trail for `D`:
|
||||
- **If the scheduler pre-created it** (your instructions name a `ROUND_ID` / `target_date` / `source`) — **skip `round_create`** and use that `ROUND_ID`. If the `D` you computed in Step 1 differs from the given `target_date`, correct it first with `round_update {round_id:<ROUND_ID>, target_date:<D>}` (weekday rule can't see NYSE holidays; the agent reconciles).
|
||||
- Otherwise create it yourself (idempotent: a second scheduled run for the same day reuses the open window):
|
||||
|
||||
```
|
||||
round_create {target_date:<D>, signal_date:<D>, source:"scheduled",
|
||||
rd_experiment_id:<EXPERIMENT_ID>, experiment_name:<the run's unique experiment name>}
|
||||
# -> round_id (record it; every round-book call below uses it)
|
||||
```
|
||||
|
||||
**Output: `EXPERIMENT_ID` (and its branch), `ROUND_ID`.**
|
||||
|
||||
## Step 4 — Re-train with the rolling window
|
||||
|
||||
Call `rd_train` with the **exact same configuration** from Step 2, only the dates change:
|
||||
|
||||
- `train_start` = 4 years before `D` (same day-of-month), `train_end` = `D`
|
||||
- **Validation is optional** — qlib supports omitting it, so omit `valid_start`/`valid_end`/`test_start`/`test_end` (pass them empty). If the tool/your run requires a holdout for sanity, use a short recent `valid` window only; never reserve data the live model needs.
|
||||
- `record_analysis=false` (we only need the model; no SignalRecord/PortAnaRecord on a holdout we don't use)
|
||||
- `wait=false` (recommended) — `rd_train` returns immediately and the fit runs in the background; poll `rd_exp_get_run` (or `rd_exp_list` filtered to the experiment) until the newest run's status is `FINISHED`, then take its `run_id`. With `wait=true` the call blocks until the fit completes — fine when the window is small, but a 4y LightGBM fit can outlive the MCP call timeout, which forced manual recovery in an earlier run.
|
||||
- `out_dir` — the working directory for this run (e.g. `tac-algo-output`)
|
||||
- `experiment_name` — the **run's unique experiment name** (the scheduler prompt names it: `<reference experiment name>-<epoch seconds>`). This is the SAME name used for `rd_trace_start experiment_name` and the round's `experiment_name`. Do not reuse the reference experiment name.
|
||||
|
||||
Keep the same `universe`, `features`, `label`, and every model hyper-parameter. **Output: the new run's `model_path` (and its `run_id`).**
|
||||
|
||||
> If a 4-year window is slower than the schedule allows, use the largest trailing window you can complete and say so in the summary — never silently shrink the horizon.
|
||||
|
||||
Pin the new training run to the round window:
|
||||
|
||||
```
|
||||
round_update {round_id:<ROUND_ID>, run_id:<new run_id>, model_path:<params.pkl path>}
|
||||
```
|
||||
|
||||
## Step 5 — Generate predictions (the signal)
|
||||
|
||||
Call `rd_predict` with `model_path` = the path returned by Step 4 (preferred over `run_id` since it is the freshly-trained artifact):
|
||||
|
||||
- `test_start` = `D`, `test_end` = `D` (the just-completed trading day — this is the signal we trade on)
|
||||
- same `universe` / `features` / `label` as Step 2
|
||||
- `out_dir` = the same working directory
|
||||
|
||||
**Output: `pred_path` (pred.pkl) and the score ranking.** The model's predicted score per instrument IS the alpha signal for day `D` — top-scored names are candidates.
|
||||
|
||||
Record the signal into the round book (one `fact_record` per top-scored name, plus the strategy config and the market snapshot at prediction time):
|
||||
|
||||
```
|
||||
fact_record {round_id:<ROUND_ID>, kind:"signal_score", symbol:<ticker>, payload:{"pred":<score>, "rank":<rank>}, source:"rd_predict"}
|
||||
fact_record {round_id:<ROUND_ID>, kind:"strategy_config", payload:{...strategy sizing...}, source:"reference config"}
|
||||
fact_record {round_id:<ROUND_ID>, kind:"market_snapshot", payload:{<ticker>: {last:<px>, change_pct:<%>, vol:<vol>, updated:<ts>}, ...}, source:"get_stock_snapshots / get_stock_latest_quotes"}
|
||||
```
|
||||
|
||||
`market_snapshot` freezes the market state **when the prediction was made** — the latest price / % change / volume per universe name, so the signal can later be judged against what the market looked like at that moment.
|
||||
|
||||
## Step 6 — Run the configured strategy, derive the target order list
|
||||
|
||||
**First pull the current portfolio — it is an input to the strategy step** (the order list is a delta, not a full rebuild):
|
||||
|
||||
- `get_account` → cash / buying power **and total equity** (equity sizes the positions; buying power caps total buys)
|
||||
- `list_positions` → current holdings and their market value
|
||||
|
||||
Then run the strategy **exactly as it was configured in the reference run** — this works for any model/strategy, not just TopkDropout. The reference run's saved `config` artifact (from `rd_exp_input`, Step 2) carries the strategy configuration from its backtest/record block (e.g. `TopkDropoutStrategy` kwargs: `topk`, `n_drop`, `risk_degree`, or any custom strategy's own kwargs, plus costs, `account`, `benchmark`). **Use those values — not tool defaults.** The model's score is the signal the strategy consumes; the strategy's config decides allocation.
|
||||
|
||||
Call `rd_strategy_targets` with:
|
||||
|
||||
- `pred_path` = the signal from Step 5
|
||||
- the run-configured `topk` / `n_drop` / `risk_degree` (from the reference run config)
|
||||
- `account` = the **live account equity** from `get_account` (a new account is not a $1M book — sizing against `$1M` when equity is far smaller produces oversized orders)
|
||||
- `prices` = a JSON `{symbol: price}` of latest quotes (from `get_stock_latest_quotes`) so the tool floors each order to whole shares (`qty`) and reports `expected_price` / `invested`
|
||||
- `risk_limits` = the round's risk-limit spec JSON (see below) — the SAME spec that `rd_backtest` uses, so live gating is provable against backtest
|
||||
- `equity` / `peak_equity` = live equity and its trailing peak (from `get_portfolio_history`) when `risk_limits.drawdown_pause_pct` is set
|
||||
|
||||
The tool applies the exact TopkDropout selection on day `D`: rank the cross-sectional scores, **drop the top `n_drop`**, take the next `topk` as buys, sized at `account × risk_degree / topk` per name. It then applies `risk_limits` as pre-gates — liquidity floor (drops names with avg daily dollar volume below `liquidity_floor_adv`), per-name `size_cap_pct` of equity, `concentration_cap_pct` of equity on total deployed, and `drawdown_pause_pct` (equity ≤ (1−pause)×peak ⇒ no buys). **Output: the deterministic target buy list** (`symbol`, `rank`, `score`, `side`, `notional`, `qty`), the full `ranking`, and `risk_limits_applied` (which limits cut what — record it). **No manual strategy replication** (an earlier run's hand-rolled sizing silently dropped the n_drop and bought the wrong names).
|
||||
|
||||
> If the strategy in the run/workflow config does not fit TopkDropout's `topk`/`n_drop`/`risk_degree`, apply the strategy's own rules to the Step 5 scores directly to derive the target portfolio, still bounded by `get_account` buying power and today's `list_positions`.
|
||||
|
||||
Then convert the target portfolio into an order list against the current holdings:
|
||||
|
||||
- For each target ticker compute the **delta** vs. what the account already holds: buy the shortfall, sell the excess. Do not blindly re-buy names already held, and do not sell names that are not in the portfolio.
|
||||
- **Fresh account (no positions):** the target portfolio is entirely new buys — emit no sell orders, and size each buy from the tool's `qty` (or `notional` ÷ latest quote), capped by buying power.
|
||||
- Skip any ticker whose delta is ~0 (already at target) so you don't churn held names.
|
||||
- Cap total buy size to available buying power. Drop any ticker with no score in Step 5 or no tradable quote.
|
||||
|
||||
**Output: the explicit order list** (ticker, side, qty, order type).
|
||||
|
||||
**Write the target into the round book** — this is the intent the round reconciles against (versions auto-increment; a second strategy pass for the same round supersedes the first):
|
||||
|
||||
```
|
||||
fact_record {round_id:<ROUND_ID>, kind:"account_state", payload:{"equity":<live equity>, "buying_power":<bp>}, source:"get_account"}
|
||||
fact_record {round_id:<ROUND_ID>, kind:"position_state", symbol:<ticker>, payload:{"shares":<held>}, source:"list_positions"}
|
||||
fact_record {round_id:<ROUND_ID>, kind:"risk_check", payload:{"risk_limits":{...spec...}, "applied":{...risk_limits_applied from the tool...}, "equity":<equity>, "peak_equity":<peak>}, source:"rd_strategy_targets"}
|
||||
round_update {round_id:<ROUND_ID>, account_equity_at_sizing:<live equity>, strategy_snapshot:{topk, n_drop, risk_degree, costs, benchmark, risk_limits:{liquidity_floor_adv?, size_cap_pct?, concentration_cap_pct?, drawdown_pause_pct?}}}
|
||||
intent_set {round_id:<ROUND_ID>, target_portfolio:[{symbol, side, qty, notional, expected_price, score, rank}...],
|
||||
raw_strategy_output:{...the strategy output as computed...}, reason:"topk<N> from <ref run>"}
|
||||
```
|
||||
|
||||
**Risk-limit spec (B)**: the round's `risk_limits` (a JSON map with any of `liquidity_floor_adv`, `size_cap_pct`, `concentration_cap_pct`, `drawdown_pause_pct`) is the single source of truth — **the same spec is passed to `rd_backtest` when calibrating** (B2), folded into `rd_train`'s PortAnaRecord via `risk_degree`, and consulted by `rd_strategy_targets` live. Store it verbatim in `strategy_snapshot.risk_limits`. When the tool's `risk_limits_applied` reports a limit that cut targets (dropped liquidity / capped sizing / drawdown pause), record it — the audit trail proves the limit fired live exactly as the calibration predicted. If `drawdown_pause_pct` fired and produced an empty target list, **settle the round as open→settled with no orders** rather than forcing buys (that is the intended behavior).
|
||||
|
||||
**Record the evidence behind each selected name** — the feature snapshot and the decision rationale, so the fact table can answer *why this symbol was ranked top-K*:
|
||||
|
||||
- `symbol_features` — the model-input feature values that produced the score on day `D` (the top features by `rd_exp_model` importance, plus the handful most relevant for that name — e.g. trend slopes, RSI, volume/vol ratios, MACD):
|
||||
```
|
||||
get_lake_ta {symbol:<ticker>, timeframe:"1d", start:<~60d before D>, end:<D>, persist:true, quiet:true} # (re)compute TA + sp_* columns up to D
|
||||
get_lake_features {symbol:<ticker>, timeframe:"1d", start:<D>, end:<D>} # read the D row; if 0 rows, the persisted features are stale -> persist first as above
|
||||
rd_exp_model {run_id:<new training run_id>, tree_id:0, max_depth:4} # feature_importances + tree nodes
|
||||
fact_record {round_id:<ROUND_ID>, kind:"symbol_features", symbol:<ticker>,
|
||||
payload:{"score":<score>, "rank":<rank>, "features":{<top feature>:<value>, ...}}, source:"get_lake_features / rd_exp_model"}
|
||||
```
|
||||
`get_lake_features` returns 0 rows for day `D` when the persisted feature files were last written before `D` (they are per-symbol parquet files that only extend to the last time they were computed). In that case **first persist** with `get_lake_ta ... persist:true` (and `get_lake_sp` when the model uses `sp_*` columns — the rd_train feature list from Step 2 tells you which), then read `get_lake_features` for `D` again — it must return a row per ticker.
|
||||
- `decision_justification` — **concise** (under 500 words total, aim for 2–4 sentences per name): why the model ranked the symbol top-K. Ground it in the actual data — the `rd_exp_model` tree path (which feature conditions led the row down the high-score branch) and the `symbol_features` values — not generic commentary:
|
||||
```
|
||||
fact_record {round_id:<ROUND_ID>, kind:"decision_justification", symbol:<ticker>,
|
||||
payload:{"score":<score>, "rank":<rank>, "why": "<2-4 sentences, e.g. 'strong 5d trend slope + rising volume ratio put TSLA above $sp_trend_slope_60 threshold, sending it down the high-score branch (leaf value +0.0545); RSI recovering but not overbought.'>"},
|
||||
source:"rd_exp_model tree + feature snapshot"}
|
||||
```
|
||||
|
||||
## Step 7 — Execution context + news sentiment gate
|
||||
|
||||
Before placing anything, per candidate ticker:
|
||||
|
||||
1. `get_account` (buying power), `list_orders` (open orders), `list_positions` (current holdings).
|
||||
2. `get_stock_snapshot` / `get_stock_latest_quotes` → sanity-check each quote: skip tickers with no quote, a stale/illiquid quote (wide spread or near-zero volume), or a halt. Use the latest quote, not just the model score, for sizing and order type.
|
||||
3. `get_news` with `symbols=<ticker>`, `limit=20`, `include_content=true` → assign a sentiment score **−3 (strongly negative) … +3 (strongly positive)**.
|
||||
|
||||
**Sentiment gate:** if sentiment strongly contradicts the signal — a **BUY** with sentiment ≤ −2 or a **SELL** with sentiment ≥ +2 — **cancel** that order and record it as `cancelled: sentiment conflict`. Tickers with no news or neutral sentiment (−1..+1) trade normally.
|
||||
|
||||
Record the evidence per candidate into the round book (so the reconcile step can explain every skip):
|
||||
|
||||
```
|
||||
fact_record {round_id:<ROUND_ID>, kind:"quote", symbol:<ticker>, payload:{bid, ask, last, spread_bps}, source:"get_stock_snapshot"}
|
||||
fact_record {round_id:<ROUND_ID>, kind:"news_sentiment", symbol:<ticker>, payload:{"sentiment":<−3..+3>, "headline":<top headline>}, source:"get_news"}
|
||||
```
|
||||
|
||||
## Step 8 — Place orders on Alpaca
|
||||
|
||||
For each surviving order in the Step 6 list (respecting the gate): call the `tac-engine` `place_order` tool with the ticker, side, qty and order type. Then verify with `list_orders` / `list_positions` that the intended changes went through.
|
||||
|
||||
**Record every decision in the round book** — placed orders AND deliberate skips, each with its reason (this is what the reconcile / funnel view reads):
|
||||
|
||||
```
|
||||
# each placed order (order id from the place_order response):
|
||||
decision_record {round_id:<ROUND_ID>, symbol:<ticker>, side:<buy|sell>, qty:<qty>, order_type:<type>,
|
||||
expected_price:<last quote px>, status:"placed", reason:"placed",
|
||||
intent_id:<intent id from intent_set>, alpaca_order_id:<alpaca order id>, client_order_id:<cl id>}
|
||||
# each gate cancel / skip (delta≈0, no quote, illiquid, halt, bp cap, sentiment conflict, no score, risk limit):
|
||||
decision_record {round_id:<ROUND_ID>, symbol:<ticker>, side:<side>, qty:<qty>, status:"skipped",
|
||||
reason:"sentiment_conflict"|"illiquid"|"no_quote"|"halt"|"delta_zero"|"bp_cap"|"no_score"|"risk_limit",
|
||||
reason_detail:<short why>, intent_id:<intent id>}
|
||||
```
|
||||
|
||||
**Sync fills** — pull Alpaca's order state into the round (pass the `list_orders` output as `orders` so no API call is needed; unmatched orders are reported back):
|
||||
|
||||
```
|
||||
round_sync_fills {round_id:<ROUND_ID>, orders:[{id, client_order_id, symbol, side, qty, filled_qty, filled_avg_price, status}...]}
|
||||
```
|
||||
|
||||
## Step 9 — Evidence check, close the traced experiment + summarize
|
||||
|
||||
**Evidence gate — run this BEFORE committing/closing. Do not skip, do not "summarize only".** Query the round and confirm every evidence kind is present; record anything missing right now, then re-query:
|
||||
|
||||
```
|
||||
fact_query {round_id:<ROUND_ID>} # or per-kind: fact_query {round_id:<ROUND_ID>, kind:"<kind>"}
|
||||
```
|
||||
|
||||
For each of the per-universe kinds (`signal_score`, `market_snapshot`, `quote`, `news_sentiment`, `symbol_features`, `decision_justification`) count that you recorded one per symbol you processed; `strategy_config`, `account_state`, `position_state` once each. If any kind is missing or any target symbol is missing from a kind, **go back and `fact_record` it now** (use the persist→read recipe in Step 6 for `symbol_features`). Only when every kind above is present, proceed:
|
||||
|
||||
1. Commit the run artifacts to the experiment branch: `rd_trace_commit experiment_id=<EXPERIMENT_ID> message="algo run <D>: orders placed"`.
|
||||
2. Close the lineage — re-embeds the rational/details, records metrics/evaluation, commits + pushes:
|
||||
```
|
||||
rd_trace_finish experiment_id=<EXPERIMENT_ID> \
|
||||
ref_id=<new training run_id from Step 4> \
|
||||
evaluation="<outcome of today's trade: target vs placed, cancellations>" \
|
||||
metrics='{"n_buys":N,"n_sells":M,"n_cancelled":K}' \
|
||||
mlruns_dir=<lake>/mlruns/<exp_id>/<run_id>
|
||||
```
|
||||
3. **Settle the round** — reconcile and close the window:
|
||||
```
|
||||
book_reconcile {round_id:<ROUND_ID>} # residual vs target, per-symbol reasons
|
||||
trail_funnel {round_id:<ROUND_ID>} # targets -> decided -> placed -> filled, skips by reason
|
||||
round_update_status {round_id:<ROUND_ID>, status:"settled", summary_metrics:{...funnel + invested...}}
|
||||
```
|
||||
4. **Close the loop** — record the round's execution economics for the next run's tuning:
|
||||
- `round_metrics` → the round's invested notional, turnover, slippage bps, estimated cost, cost-as-% of gross (the `fetchPriorRoundFeedback` in the scheduler injects these into the NEXT run's prompt automatically).
|
||||
- If the round had fills, run `rd_factor_attribution` over the round window (pass the `get_portfolio_history` equity curve as `portfolio_equity`, benchmark e.g. `IVV`, realized slippage+cost bps from `round_metrics`, expected values from the calibration) and record the result:
|
||||
```
|
||||
fact_record {round_id:<ROUND_ID>, kind:"attribution", payload:{beta, alpha_annualized_pct, pnl_beta, pnl_alpha, drift_alarm}, source:"rd_factor_attribution"}
|
||||
```
|
||||
- A `drift_alarm` in the attribution means live execution cost is deviating from the backtest assumption — re-run `rd_risk_calibrate` before the next round and tighten sizing/limits.
|
||||
5. End your reply with the compact summary: date `D`, reference run (`experiment_name` / `run_id`), new training run (`run_id` / `model_path`), window (4y → `D`), number of scores, top names, per-ticker sentiment scores, what was bought/sold, which orders were cancelled by the sentiment gate (and why), and any skipped trades (with reasons).
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
experiment_name=tac-rd run_id=<ref-uuid> strategy=tune_run1_wider_5d.yaml
|
||||
1. get_lake_coverage {US,1d} -> last loaded date; backfill_lake_calendar; get_lake_bars lazy -> lake current -> D
|
||||
2. rd_exp_input run_id=<ref-uuid> -> universe=all, features=KR..(ta fields), label=Ref($close,-2)/Ref($close,-1)-1, lr=0.05, leaves=15 ... topk/n_drop from the run's backtest config
|
||||
3. EXP_NEW=<ref exp>-<epoch seconds> # unique per run (scheduler names it)
|
||||
rd_trace_init && rd_trace_start experiment_name=$EXP_NEW evolved_from=auto -> experiment_id / branch
|
||||
# scheduler usually pre-creates the round (ROUND_ID in the instructions) -> skip round_create, use it
|
||||
round_create {target_date:<D>, signal_date:<D>, source:"scheduled", rd_experiment_id:<EXPERIMENT_ID>, experiment_name:$EXP_NEW} -> ROUND_ID
|
||||
4. rd_train experiment_name=$EXP_NEW train_start=<D-4y> train_end=<D> record_analysis=false wait=false out_dir=tac-algo-output
|
||||
# -> returns immediately; poll rd_exp_get_run until status FINISHED -> run_id <new-uuid>, model_path tac-algo-output/params.pkl
|
||||
round_update {round_id:<ROUND_ID>, run_id:<new-uuid>, model_path:"tac-algo-output/params.pkl"}
|
||||
5. rd_predict model_path=tac-algo-output/params.pkl test_start=<D> test_end=<D>
|
||||
# -> pred_path tac-algo-output/pred.pkl, score head ...
|
||||
fact_record {kind:"signal_score", symbol:<ticker>, payload:{pred, rank}} per top name
|
||||
6. get_account + list_positions # current portfolio as strategy input; account=live equity
|
||||
get_stock_latest_quotes -> prices JSON for sizing
|
||||
rd_strategy_targets pred_path=tac-algo-output/pred.pkl signal_date=<D> \
|
||||
topk=<from run config> n_drop=<from run config> risk_degree=<from run config> account=<live equity> prices='{...}' \
|
||||
risk_limits='{"liquidity_floor_adv":5000000,"size_cap_pct":8,"concentration_cap_pct":30}' equity=<equity> peak_equity=<peak>
|
||||
# -> deterministic target buys (symbol/rank/score/notional/qty); delta vs list_positions -> order list (fresh account = all buys)
|
||||
fact_record {kind:"risk_check", payload:{risk_limits:{...}, applied:{...risk_limits_applied...}, equity, peak_equity}}
|
||||
round_update {round_id:<ROUND_ID>, account_equity_at_sizing:<equity>, strategy_snapshot:{topk, n_drop, risk_degree, costs, benchmark, risk_limits:{...}}}
|
||||
intent_set {round_id:<ROUND_ID>, target_portfolio:[{symbol, side, qty, expected_price, score, rank}]} -> intent_id
|
||||
7. get_news per ticker -> sentiment gate; fact_record quote + news_sentiment per ticker
|
||||
8. place_order ... per surviving delta; decision_record per placed + skipped (with reason)
|
||||
round_sync_fills {round_id:<ROUND_ID>, orders:[...list_orders output...]}
|
||||
9. rd_trace_commit experiment_id=<EXPERIMENT_ID> + rd_trace_finish experiment_id=<EXPERIMENT_ID> ref_id=<new-uuid>
|
||||
book_reconcile + trail_funnel; round_update_status {status:"settled", summary_metrics:{...}}; summary
|
||||
```
|
||||
|
||||
## Round book — the execution trail
|
||||
|
||||
Every scheduled run writes its decision→fill trail to Postgres via the `tac-rd-book`
|
||||
tools, mirroring the `/dashboard/rounds` UI. The round is the link between the scheduler
|
||||
run, the traced experiment, and the actual account activity:
|
||||
|
||||
```
|
||||
scheduler_runs ──► ROUND ──► rd_experiments
|
||||
│ fact_events evidence: signal_score / market_snapshot / quote / news_sentiment / account_state / position_state / symbol_features / decision_justification / risk_check
|
||||
│ round_intents versioned target portfolios (new version supersedes old)
|
||||
│ round_decisions per-symbol: placed OR skipped, each with a reason (incl. risk_limit)
|
||||
└──► round_orders execution rows (Alpaca order id + fills), synced via round_sync_fills
|
||||
```
|
||||
|
||||
`book_reconcile` returns the per-symbol residual (target qty − filled qty, with the reason
|
||||
it did not fill) plus cash/BP impact, slippage bps and estimated cost — that is the answer
|
||||
to "why is the account not at the target portfolio". `round_metrics` reports the round
|
||||
roll-ups (invested notional, turnover, slippage bps, estimated cost, cost-as-% of gross);
|
||||
`trail_funnel` gives the counts (targets → decided → placed → filled, skips by reason).
|
||||
All surface unchanged in the UI. When a round fires `drawdown_pause_pct`, its `round_metrics`
|
||||
will show `invested_notional: 0` — that is the pause working, not a broken round.
|
||||
@@ -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
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: tradeac-rd-explain
|
||||
description: Guide agents to retrieve, visualise and interpret TradeAC R&D workflow data — from the input qrun YAML to final IC / backtest metrics — via the tac-qlib-rd MCP tools (rd_exp_*) and the built-in R&D dashboard (/dashboard/rd). Use when asked about experiments, mlruns runs, workflow inputs, model hyper-parameters, IC/Rank IC evaluation, or backtest results.
|
||||
---
|
||||
|
||||
# tradeac-rd-explain
|
||||
|
||||
Every `qrun` workflow run is recorded into **mlflow** in the unified R&D store under the lake
|
||||
root — sqlite `mlruns.db` + artifact files under `mlruns/<experiment_id>/<run_uuid>/` in
|
||||
`$TAC_LAKE_DIR`. This skill tells you how to pull that data out with the
|
||||
`rd_exp_*` MCP tools (from `tac_qlib.rd_server`), how to read the raw files directly, and how to
|
||||
visualise/interpret everything — either from the built-in TradeAC UI or from the raw data.
|
||||
|
||||
Quick map of the R&D data:
|
||||
|
||||
| Step | Where it lives | `rd_exp_*` tool |
|
||||
|------|----------------|-----------------|
|
||||
| Input config (rendered YAML) | artifact `config` on the run | `rd_exp_input` |
|
||||
| Runs / experiments list | `mlruns.db` → `experiments`, `runs`, `tags`, `params`, `metrics` | `rd_exp_list`, `rd_exp_get_experiment`, `rd_exp_get_run` |
|
||||
| Predictions & labels | artifacts `pred.pkl`, `label.pkl` | `rd_exp_result` |
|
||||
| IC / Rank IC | artifacts `ic.pkl`, `ric.pkl` + metrics `IC`, `ICIR`, `Rank IC`, `Rank ICIR` | `rd_exp_result` |
|
||||
| Group returns | artifacts `long_short_r.pkl`, `long_avg_r.pkl` | `rd_exp_result` |
|
||||
| Backtest / risk | artifacts `portfolio_analysis/report_normal_1d.pkl`, `port_analysis_1d.pkl` + `1day.*` metrics | `rd_exp_result` |
|
||||
| Model + hyper-params | artifact `params.pkl` (qlib model), config `task.model` | `rd_exp_model` |
|
||||
| Hypothesis / evaluation notes | sidecar `rd-notes.json` | `rd_exp_get_notes` / `rd_exp_set_notes` |
|
||||
|
||||
## MCP-first policy
|
||||
|
||||
- **Use the `rd_exp_*` MCP tools to read all of the above** — do not reinvent them with `sqlite3`/pickle/pandas scripts. The tools are the canonical, JSON-safe way to pull experiment data (they fall back to the raw files automatically).
|
||||
- **NEVER script directly against the MCP server** (spawning `tac_qlib.rd_server`, stdio JSON-RPC, bash/curl) unless a tool genuinely can't do the job — then **stop and ask the user to confirm first**.
|
||||
- The raw-file/sqlite snippets in §3 below are **only** for cases where the MCP surface is unavailable or the user explicitly asks for a direct peek.
|
||||
- If the venv is missing a runtime dep (`duckdb`, `pyarrow`, `sqlite3`), lazy-install it (`uv pip install --python $VIRTUAL_ENV/bin/python duckdb pyarrow`) rather than working around it.
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
- The tac-qlib-rd MCP server is registered in `opencode.json` (`.venv/bin/python -m tac_qlib.rd_server`).
|
||||
- The server resolves the unified R&D store from the lake root, so `uri` defaults to `sqlite:///<lake>/mlruns.db` (overridable via `MLRUNS_URI`).
|
||||
- Runs must exist first: use `rd_run_workflow` (or `rd_train` + records) to create them.
|
||||
|
||||
## Secrets policy
|
||||
|
||||
- NEVER write secrets into files: DB passwords, API keys, OAuth tokens, or credential-bearing URLs (`DATABASE_URL`, `MLRUNS_URI`) in 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.
|
||||
- If you find a committed secret, flag it, remove it, and replace it with a placeholder.
|
||||
|
||||
## 2. Getting the data
|
||||
|
||||
### 2.1 List experiments and their runs
|
||||
|
||||
```text
|
||||
rd_exp_list
|
||||
# -> [{experiment_id, name, run_count, latest_run: {run_id, status, headline_metrics}}]
|
||||
|
||||
rd_exp_get_experiment experiment_id=1
|
||||
# -> experiment meta + every run: run_id, status, start/end, git, metrics, params, tags, notes, artifacts
|
||||
```
|
||||
|
||||
### 2.2 Input configuration (what went in)
|
||||
|
||||
```text
|
||||
rd_exp_input experiment_id=1 run_id=<run_uuid>
|
||||
```
|
||||
|
||||
Returns the **saved `config` artifact** (the fully-rendered workflow YAML: `qlib_init`,
|
||||
`task.model.kwargs` hyper-parameters, `task.dataset.kwargs.handler` universe/window/features,
|
||||
`segments`, `record` list) plus the resolved `universe` and `feature_fields`. If a run has no
|
||||
`config` artifact (e.g. older `rd_train` runs) the tool falls back to reconstructing from
|
||||
recorded params/tags and marks `source: "reconstructed"` / `"partial"`.
|
||||
|
||||
> Rule of thumb: **the `config` artifact is the most complete input record**; the sqlite
|
||||
> `params` table alone (only `cmd-sys.argv`) is not enough to reconstruct the input.
|
||||
|
||||
### 2.3 Results & evaluation (what came out)
|
||||
|
||||
```text
|
||||
rd_exp_result experiment_id=1 run_id=<run_uuid>
|
||||
```
|
||||
|
||||
Returns: headline `metrics` (IC / ICIR / Rank IC / Rank ICIR, `l2.train`/`l2.valid`, `1day.*`
|
||||
risk metrics), per-day `ic_series` (`[{date, ic, ric}]`), `pred_stats`, `group_returns`
|
||||
(`long_short` / `long_avg`), and the `backtest` report (per-day cumulative return vs benchmark)
|
||||
+ `risk` table.
|
||||
|
||||
### 2.4 Model & hyper-parameters
|
||||
|
||||
```text
|
||||
rd_exp_model experiment_id=1 run_id=<run_uuid>
|
||||
rd_exp_model experiment_id=1 run_id=<run_uuid> tree_id=7
|
||||
```
|
||||
|
||||
Returns `hyperparams` (from config, preferred), `feature_names`, `feature_importances`,
|
||||
`num_trees`, `best_iteration`, and a **pruned top-layers tree** for LightGBM:
|
||||
`tree: {nodes: [{id, depth, feature, threshold, gain, leaf_value, node_count, left, right}]}`.
|
||||
|
||||
### 2.5 Notes (hypothesis / evaluation)
|
||||
|
||||
```text
|
||||
rd_exp_get_notes experiment_id=1 run_id=<run_uuid>
|
||||
rd_exp_set_notes experiment_id=1 run_id=<run_uuid> hypothesis="..." evaluation="..."
|
||||
# persisted to mlruns/<experiment_id>/<run_uuid>/rd-notes.json
|
||||
```
|
||||
|
||||
## 3. Reading the raw files directly
|
||||
|
||||
Everything above is a JSON view of these files (all under `$TAC_LAKE_DIR`):
|
||||
|
||||
- `mlruns.db` (sqlite) — `experiments`, `runs`, `tags`, `params`, `metrics`, `latest_metrics`.
|
||||
Quick peek: `sqlite3 $TAC_LAKE_DIR/mlruns.db "SELECT * FROM latest_metrics;"`.
|
||||
- `mlruns/<experiment_id>/<run_uuid>/artifacts/` — pickle files:
|
||||
- `config` → the input YAML (dict); carries the resolved `feature_fields`
|
||||
- `params.pkl` → the trained model (qlib `LGBModel`; `.model` is a `lightgbm.Booster`)
|
||||
- `pred.pkl`, `label.pkl`, `ic.pkl`, `ric.pkl`, `long_short_r.pkl`, `long_avg_r.pkl`
|
||||
- `portfolio_analysis/report_normal_1d.pkl`, `port_analysis_1d.pkl`
|
||||
- `mlruns/<experiment_id>/<run_uuid>/rd-notes.json` — hypothesis/evaluation notes.
|
||||
|
||||
In Python:
|
||||
|
||||
```python
|
||||
import os
|
||||
import pickle
|
||||
from pathlib import Path
|
||||
|
||||
run_dir = Path(os.environ["TAC_LAKE_DIR"]) / "mlruns/1/<run_uuid>"
|
||||
cfg = pickle.loads((run_dir / "artifacts/config").read_bytes()) # input config dict
|
||||
ic = pickle.loads((run_dir / "artifacts/ic.pkl").read_bytes()) # per-day IC Series
|
||||
import lightgbm
|
||||
model = pickle.loads((run_dir / "artifacts/params.pkl").read_bytes()) # needs qlib import
|
||||
tree = model.model.dump_model()["tree_info"] # LightGBM trees
|
||||
```
|
||||
|
||||
## 4. Visualising & interpreting
|
||||
|
||||
### 4.1 Built-in TradeAC UI
|
||||
|
||||
Open the dashboard: `/dashboard/rd` lists experiments + runs with headline metrics and
|
||||
`Input` / `Result` / `Model` action buttons:
|
||||
|
||||
- `/dashboard/rd/input?expId=<id>` — universe, windows, features, model settings (tables).
|
||||
- `/dashboard/rd/result?expId=<id>` — ECharts IC/Rank IC, cumulative group returns, backtest vs
|
||||
benchmark, per-day IC table, training-loss curves.
|
||||
- `/dashboard/rd/model?expId=<id>` — hyper-parameter table, feature importances, LightGBM tree
|
||||
viewer (pick a tree id).
|
||||
|
||||
### 4.2 Interpreting the numbers
|
||||
|
||||
- **IC / ICIR**: mean per-day IC (predictive power of the signal); ICIR = mean/std × √252.
|
||||
|IC| ≥ ~0.02 daily with stable sign is notable for cross-sectional signals; ICIR ≥ 1 is decent,
|
||||
≥ 2 strong. Rank IC is the Spearman version (more robust to outliers).
|
||||
- **Training loss (`l2.train`/`l2.valid`)**: watch the gap — widening gap ⇒ overfitting;
|
||||
valid flat/rising ⇒ underfitting or stale features.
|
||||
- **Group returns (`long_short_r`)**: cumulative return of top-decile-minus-bottom-decile signal
|
||||
baskets; steady positive slope = the ranking carries money.
|
||||
- **Backtest risk** (`annualized_return`, `information_ratio`, `max_drawdown`): IR = excess
|
||||
return / tracking error; max drawdown shows path risk. Compare against the benchmark column
|
||||
in the cumulative chart.
|
||||
- **Tree viewer**: root splits on the strongest features (high gain). Repeated use of a feature
|
||||
across the top layers ⇒ it dominates; suspicious thresholds near feature extremes often
|
||||
indicate leakage/sample bias.
|
||||
|
||||
## 5. Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
|---------|-------------|
|
||||
| `experiment_id` not found | Check `rd_exp_list`; ids are the mlflow `experiment_id`, not the name. |
|
||||
| `no recorder` / empty input | Run lacks a `config` artifact (pre-fix `rd_train`). Re-run via `rd_run_workflow` or `rd_train` on the fixed server to record config. |
|
||||
| Pickle errors on `params.pkl` | Ensure qlib + lightgbm importable (server venv). Tool returns a warning and skips the artifact rather than failing. |
|
||||
| Empty result series | Records were not run (only `rd_train`). Use `rd_run_workflow` or add `SignalRecord`/`SigAnaRecord`/`PortAnaRecord`. |
|
||||
@@ -0,0 +1,296 @@
|
||||
---
|
||||
name: tradeac-rd
|
||||
description: Guide agents to run quant R&D on the TradeAC data lake with a Qlib-based research server exposed over MCP (tac-qlib-rd). Train GBDT (LightGBM/XGBoost) and Linear/QDA ML models on lake bars + TA features, generate cross-sectional alpha predictions, evaluate IC/Rank IC, run TopkDropout backtests with benchmark comparison, and execute one-shot YAML workflows — all through `tac_qlib.rd_server`, an MCP server in the repo venv.
|
||||
---
|
||||
|
||||
# tradeac-rd
|
||||
|
||||
Quant R&D server for the TradeAC data lake. Wraps [Qlib](https://github.com/microsoft/qlib) in a local **MCP server** (`tac_qlib.rd_server` in the repo `.venv`) and uses custom qlib data providers that read directly from the lake (see `tac-engine/skills/tradeac-lake/SKILL.md` for the lake itself, and `tac-qlib/README.md` for the package).
|
||||
|
||||
Registered in `opencode.json` as `tac-qlib-rd` — the tools below are available directly once opencode is restarted.
|
||||
|
||||
## MCP-first policy
|
||||
|
||||
- **Prefer the tac-qlib-rd MCP tools** (`rd_train`, `rd_predict`, `rd_evaluate`, `rd_backtest`, `rd_strategy_targets`, `rd_run_workflow`, `rd_exp_*`, `rd_status`) over writing scripts that reimplement the R&D loop (custom qlib glue, own train/predict/eval/backtest, hand-rolled mlruns readers, own JSON-RPC clients).
|
||||
- **NEVER script directly against the MCP server** (spawning `python -m tac_qlib.rd_server`, driving it via bash/curl/stdio) unless a tool genuinely can't do the job — then **stop and ask the user to confirm first**.
|
||||
- Data prep (bars/features backfill) is done with the tac-engine lake MCP tools — see `tac-engine/skills/tradeac-lake/SKILL.md`. Inspect runs with `rd_exp_*` instead of reading `mlruns.db`/pickles directly.
|
||||
- If the venv is missing a runtime dep (e.g. `duckdb`, `pyarrow`), lazy-install it (`uv pip install --python $VIRTUAL_ENV/bin/python duckdb pyarrow`) instead of switching to another tool.
|
||||
|
||||
## The R&D loop
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `rd_train` | Fit a model on lake data + TA features, log to MLflow, return run metadata. |
|
||||
| `rd_predict` | Generate out-of-sample predictions from a trained model (by `model_path` or `run_id`). |
|
||||
| `rd_evaluate` | IC / Rank IC stats of a `pred.pkl` vs `label.pkl`. |
|
||||
| `rd_backtest` | TopkDropout backtest of predictions vs a benchmark, with risk metrics + artifacts. |
|
||||
| `rd_strategy_targets` | Turn a prediction's signal day into a deterministic target buy list (TopkDropout selection + sizing). |
|
||||
| `rd_run_workflow` | One-shot: run an entire YAML workflow (train → predict → sig-ana → backtest) and return metrics + artifacts. |
|
||||
| `rd_exp_list` | List MLflow experiments with run ids on the local sqlite store. |
|
||||
| `rd_exp_get_experiment` | Experiment detail: all runs (meta, metrics, notes, artifact files). |
|
||||
| `rd_exp_get_run` | Single run meta + latest metrics. |
|
||||
| `rd_exp_input` | What went into a run: qlib_init, model kwargs, dataset handler kwargs, segments, features, universe, label. |
|
||||
| `rd_exp_result` | What came out: headline IC/ICIR/Rank IC/Rank ICIR, per-day IC series, group returns, prediction stats, backtest report + risk (benchmark-relative). |
|
||||
| `rd_exp_model` | Trained model: class, hyperparameters, LightGBM tree/feature importance. |
|
||||
| `rd_exp_blotter` | Execution log: account P&L summary, daily equity, current positions, trade table, signal blotter. |
|
||||
| `rd_exp_get_notes` / `rd_exp_set_notes` | Read / write hypothesis + evaluation notes on a run. |
|
||||
| `rd_exp_delete` | **HARD-delete** an MLflow experiment: all runs (metrics/params/tags), the traced `rd_experiments` rows that reference them (FK is `ON DELETE CASCADE`), and on-disk artifacts. Irreversible — confirm with the user first. |
|
||||
| `rd_exp_delete_run` | **HARD-delete** a single MLflow run + its traced `rd_experiments` row + artifacts. Irreversible — confirm with the user first. |
|
||||
| `rd_status` | Lake + qlib readiness: data window, symbols, persisted features, qlib version. |
|
||||
|
||||
The standard flow is `rd_train` → `rd_predict` → `rd_evaluate` → `rd_backtest`; `rd_run_workflow` replaces all of it with a YAML config. The `rd_exp_*` inspection tools read saved MLflow artifacts, so every page in the R&D app (`/rd/input`, `/rd/result`, `/rd/model`, `/rd/blotter`) is backed by an MCP call (`rd_exp_input`, `rd_exp_result`, `rd_exp_model`, `rd_exp_blotter`) keyed by `experiment_id` + `run_id`.
|
||||
|
||||
## Setup / env
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|-----|---------|---------|
|
||||
| `TAC_LAKE_DIR` | **required** (no default) | lake root (bars + features + metadata). Local dev: absolute path (e.g. `/home/data/lake`). |
|
||||
| `TAC_RD_MARKET` | `US` | market partition for lake reads |
|
||||
| `DATABASE_URL` | – | Postgres tracking store for MLflow (its own `experiments`/`runs`/… tables) when set |
|
||||
| `MLRUNS_URI` | postgres (`$DATABASE_URL`) or `sqlite:///<lake>/mlruns.db` | MLflow tracking URI override. Artifact files always live under `<lake>/mlruns/<exp_id>/<run_uuid>/` |
|
||||
|
||||
The server lives in the repo `.venv`; the MCP config is already registered. Restart opencode after editing `opencode.json`.
|
||||
|
||||
## Secrets policy
|
||||
|
||||
- NEVER write secrets into files: DB passwords, API keys, OAuth tokens, or credential-bearing URLs (`DATABASE_URL`, `MLRUNS_URI`, `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.
|
||||
|
||||
## `rd_train`
|
||||
|
||||
Args: `universe` (comma-separated), `train_start/valid_end/test_end` (`YYYY-MM-DD`), `experiment_name`, `out_dir`, `model` (`lgb` default, `xgb`, `linear`, `qda`), optional `label` (default `Ref($close,-2)/Ref($close,-1)-1`, the next-day return), `topk`/`n_drop` for later backtests.
|
||||
|
||||
- Loads 1d bars + all persisted TA features (`features/` dir) for the universe from the lake.
|
||||
- Splits into train / valid / test; fits on train with early stopping on valid.
|
||||
- Logs the run to MLflow (`run_id`), saves `params.pkl` (model) + `pred.pkl` + `label.pkl` to `out_dir`.
|
||||
- With `record_analysis=true` (default) also runs SignalRecord / SigAnaRecord (`ana_long_short`) / PortAnaRecord inside the run, so the result page gets IC/Rank IC series, long-short group returns, monthly IC and the portfolio backtest. `benchmark`, `topk`, `n_drop`, `account`, `risk_degree`, `open_cost`/`close_cost`/`min_cost` tune that backtest.
|
||||
- Returns `run_id`, `status`, `fit_seconds`, `feature_fields`, `label`, per-segment rows/date/instrument counts, and artifact paths.
|
||||
- **`wait=false`** runs the fit in a background thread and returns immediately (`status: started`, `background: true`) — poll `rd_exp_get_run` / `rd_exp_list` for the newest run of `experiment_name` until its status is `FINISHED`, then use that `run_id`. Use it for slow windows (e.g. a 4y retrain) where a blocking MCP call can time out.
|
||||
|
||||
> If the lake lacks bars or features for `universe`, backfill first via the tac-engine `get_lake_bars` / `get_lake_ta` tools, or raise the training start date.
|
||||
|
||||
## `rd_predict`
|
||||
|
||||
Args: `universe`, `model_path` **or** `run_id`+`experiment_name` (artifact `params.pkl` is loaded from MLflow), same date ranges as `rd_train`, `out_dir`, optional `top` (number of top-scored rows in the `head` list).
|
||||
|
||||
- Rebuilds the same feature matrix for `test_start..test_end`, produces scores.
|
||||
- Writes `pred.pkl` (scores) and `label.pkl` (labels) to `out_dir`.
|
||||
- Returns paths, `count`, `date_min/max`, instruments, score distribution stats, and a small `head`.
|
||||
|
||||
## `rd_evaluate`
|
||||
|
||||
Args: `pred_path`, `label_path` (the two pkl files from `rd_train`/`rd_predict`).
|
||||
|
||||
- Returns `IC` and `RankIC` tables (`days`, `mean`, `std`, `ann_vol`, `ir`, `skew`, `kurt`, `maxdd`) and a `headline` (`IC`, `ICIR`, `Rank IC`, `Rank ICIR`).
|
||||
|
||||
## `rd_backtest`
|
||||
|
||||
Args: `pred_path`, `start_time`/`end_time`, `topk`, `n_drop`, `benchmark`, optional `out_dir`.
|
||||
|
||||
- TopkDropoutStrategy (topk long, n_drop drop), $100k account, `risk_degree 0.95`, day freq, benchmark comparison.
|
||||
- Returns `start_time`, `end_time`, `trading_days`, `risk` (`mean`, `std`, `annualized_return`, `information_ratio`, `max_drawdown`), `benchmark`, and artifact paths (`report_normal.csv`, `positions_normal.csv`, `risk.csv`).
|
||||
|
||||
## `rd_strategy_targets`
|
||||
|
||||
Args: `pred_path`, optional `signal_date` (defaults to the last day in the prediction), `account`, `risk_degree`, `topk`, `n_drop`, optional `prices` (JSON `{symbol: price}`).
|
||||
|
||||
- Applies the **exact TopkDropout selection** for one signal day: rank the cross-sectional scores, drop the top `n_drop`, take the next `topk` as buys, sized at `account × risk_degree / topk` per name. Use this to chain a prediction straight into an order list — no manual strategy replication.
|
||||
- With `prices`, floors each order to whole shares (`qty`) and reports `expected_price` / `invested`.
|
||||
- Returns `signal_date`, `per_name_notional`, the deterministic `targets` list (`symbol`, `rank`, `score`, `side`, `notional`, `qty`), and the top-20 `ranking` for context. If fewer than `topk + n_drop` names have a score that day it returns empty `targets` with a `reason`.
|
||||
|
||||
## `rd_run_workflow`
|
||||
|
||||
Args: `config_path` (YAML, see `tac-qlib/workflows/workflow_lgb_taclake.yaml`), `experiment_name`, optional `wait` (default `false`), optional `run_in_new_process` (default `false`).
|
||||
|
||||
- Runs the full pipeline (qlib `signal` + `records`), returns `run_id`, `status`, the resolved `qlib_init`/`model`/`dataset`/`records` config, and `metrics` (train/valid loss, IC/ICIR/Rank IC/Rank ICIR, and the `1day.*` backtest metrics).
|
||||
- `wait=false` (default) returns immediately with `status: started`; the workflow runs in a background thread — poll `rd_exp_get_run` / `rd_exp_list` for the newest run of `experiment_name` until it finishes. `wait=true` blocks until completion (only for small windows that finish inside the MCP call timeout).
|
||||
- `run_in_new_process=true` runs the workflow in a **separate OS process** instead of a thread. qlib `init` sets process-global state, so this is the safe mode for concurrent or long workflows — it isolates crashes, releases memory on exit, and avoids the thread-safety race. stdout/stderr are redirected to `<lake>/logs/rd-workflow-<exp>-<ts>.log` (returned as `log_path`; the child must never write to the MCP stdio pipe). Polling works identically because the child writes to the same mlflow store. The process `pid` is returned.
|
||||
|
||||
## Tracing every run started from a chat (REQUIRED)
|
||||
|
||||
**Every experiment you start from this chat must be traced FIRST.** The R&D
|
||||
lineage (`/rd/lineage`) and the round book build on the `rd_experiments` table —
|
||||
an experiment created by `rd_run_workflow` / `rd_train` without a
|
||||
`rd_trace_start` is invisible there (no lineage node, no chat link). So before
|
||||
triggering any run, use the `rd_trace_*` MCP tools (tac-qlib-rd):
|
||||
|
||||
1. Open the trace BEFORE the run (see `tac-qlib/skills/tac-qlib-custom/SKILL.md`,
|
||||
"Experiment traceability" — the skill that owns the trace flow):
|
||||
```
|
||||
rd_trace_start rational="<what this run tests, in one line>" \
|
||||
details="<universe / features / label / model / strategy sizing>" \
|
||||
experiment_name=<the experiment you will run into> \
|
||||
evolved_from=<predecessor traced id or auto> \
|
||||
session_id="<this chat's opencode session id>"
|
||||
# -> {"experiment_id": N, "branch": "...", "evolved_from": ..., "base_branch": ...}
|
||||
```
|
||||
2. Run the workflow into that **same** `experiment_name`:
|
||||
```
|
||||
rd_run_workflow config_path=<yaml> experiment_name=<the experiment name>
|
||||
```
|
||||
3. On success, **finish the trace** (links the run, copies metrics/evaluation):
|
||||
```
|
||||
rd_trace_finish experiment_id=<N> ref_id=<run_id> \
|
||||
evaluation="<outcome>" metrics='{...headline...}' \
|
||||
mlruns_dir=<lake>/mlruns/<experiment_id>/<run_id>
|
||||
```
|
||||
|
||||
If you are NOT tracing (quick throwaway exploration), say so explicitly and note
|
||||
the run will not appear in the lineage graph. The default for any run started
|
||||
from a chat is to trace it.
|
||||
|
||||
## Building a workflow YAML and triggering a run
|
||||
|
||||
A workflow YAML is a qrun config: `qlib_init` (lake providers + MLflow exp manager), `task.model`, `task.dataset`, and `task.record`. Copy `tac-qlib/workflows/workflow_lgb_taclake.yaml` as the template.
|
||||
|
||||
```yaml
|
||||
# jinja is available: {%- set LAKE = TAC_LAKE_DIR %} (TAC_LAKE_DIR is required)
|
||||
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"}}
|
||||
|
||||
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,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ # universe
|
||||
start_time: 2000-01-03 # lake look-back for features
|
||||
end_time: 2026-08-06
|
||||
fit_start_time: 2026-03-01 # normalization fit window
|
||||
fit_end_time: 2026-05-31
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
label: "Ref($close,-2)/Ref($close,-1)-1" # next-day return
|
||||
segments: # train/valid/test split
|
||||
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 # any symbol in the lake; empty = no benchmark
|
||||
exchange_kwargs:
|
||||
codes: AAPL,MSFT,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
```
|
||||
|
||||
Then trigger it (each call = one new run in the named experiment):
|
||||
|
||||
```text
|
||||
rd_run_workflow config_path=tac-qlib/workflows/tune_run1_wider_5d.yaml experiment_name=tac-rd-tune
|
||||
# -> run_id <uuid>; save it, then inspect via rd_exp_*.
|
||||
```
|
||||
|
||||
The saved `config` artifact (same shape as above) is what `rd_exp_input` returns, so runs are reproducible from their YAML.
|
||||
|
||||
## Inspecting a run given experiment_id + run_id
|
||||
|
||||
The URL on the R&D app is `/rd/input|result|model|blotter?expId=<id>&run=<uuid>`; the underlying MCP calls are:
|
||||
|
||||
| You want | MCP call | Args |
|
||||
|----------|----------|------|
|
||||
| Full results (IC/ICIR/Rank IC, group returns, backtest risk) | `rd_exp_result` | `experiment_id`, `run_id` |
|
||||
| Input config (universe, windows, features, label, model) | `rd_exp_input` | `experiment_id`, `run_id` |
|
||||
| Execution blotter (P&L, positions, trades, signals) | `rd_exp_blotter` | `experiment_id`, `run_id` |
|
||||
| Model (hyperparams, tree, importances) | `rd_exp_model` | `experiment_id`, `run_id` |
|
||||
| Run notes | `rd_exp_get_notes` / `rd_exp_set_notes` | `experiment_id`, `run_id` (+ `hypothesis`/`evaluation`) |
|
||||
|
||||
Get the run ids first: `rd_exp_list` → pick an experiment → `rd_exp_get_experiment` returns its runs (meta + latest metrics), or `rd_exp_get_run run_id=<uuid>` for one run.
|
||||
|
||||
## Evaluating a run and proposing the next one
|
||||
|
||||
Treat each run as one hypothesis. To evaluate and iterate:
|
||||
|
||||
1. **Read the input** (`rd_exp_input`): universe, train/valid/test windows, label expression, features, model + hyperparams. Note what was held fixed vs changed.
|
||||
2. **Read the signal metrics** (`rd_exp_result.headline`): IC (predictive power), ICIR (stability — |ICIR| ≥ 0.5 strong, 0.2–0.5 weak but persistent, < 0.2 noise), Rank IC/Rank ICIR. A decent IC with Rank IC ≈ 0 means the ranking is noisy even if the mean cross-section is predictive.
|
||||
3. **Read the backtest** (`rd_exp_result.backtest`): `annualized_return`, `information_ratio`, `max_drawdown` are **excess vs the benchmark** (qlib mean-daily × 238). Compare against `return_annualized` (raw strategy) and `benchmark_annualized`; check the benchmark is a sensible peer (a single high-flying stock like AAPL is a brutal benchmark for an ETF universe).
|
||||
4. **Read the blotter** (`rd_exp_blotter.summary`): `n_trades`/`trading_days` reveal turnover; `total_cost` vs account is the cost drag; positions show concentration. High turnover + low topk on correlated names = cost-heavy, undiversified book.
|
||||
5. **Diagnose** and pick ONE lever for the next run — change one thing, hold the rest fixed so the comparison is clean:
|
||||
- *Weak/noisy signal* (ICIR < 0.3, Rank IC ≈ 0): longer label horizon (e.g. 5-day `Ref($close,-6)/Ref($close,-1)-1`), stronger regularization (`reg_alpha`/`reg_lambda` up, `subsample`/`colsample` down), or a cleaner universe (drop leveraged/duplicate names).
|
||||
- *Good signal, bad book* (high IC but poor excess return): raise `topk` for diversification, tune `n_drop` for rotation, reduce turnover, check `total_cost`.
|
||||
- *Benchmark mismatch*: pick an index ETF (QQQ/IVV) the universe tracks instead of a single stock.
|
||||
- *Data window*: a 3-month fit window is short; consider rolling/expanding if the lake history allows.
|
||||
6. **Write the next run as a YAML** (see section above), **trace it first** (`rd_trace_start experiment_name=<exp>`), then trigger with `rd_run_workflow` into that **same new experiment** (e.g. `tac-rd-tune`), and `rd_trace_finish experiment_id=<N> ref_id=<run_id>` when it succeeds. Then `rd_exp_get_experiment` to compare run-to-run. Record the hypothesis/evaluation via `rd_exp_set_notes`.
|
||||
|
||||
Example: the baseline `Exp-1 Run-f29f5446` shows IC 0.071 / ICIR 0.17 / Rank IC 0.014 with excess return −0.94 ann (IR −2.23) vs a +89% ann benchmark — the 1-day signal is unstable, the topk=2 book turned 24 trades in 27 days (~1.1% cost drag) on correlated ETFs + leveraged hedges, and AAPL is an unfair benchmark. Two improvement runs are ready in `tac-qlib/workflows/tune_run1_wider_5d.yaml` (5-day label, topk=5, deduped 10-name universe, benchmark QQQ) and `tune_run2_regularized.yaml` (stronger regularization, topk=3/n_drop=2, same-day label) — trigger both into `experiment_name=tac-rd-tune` and compare.
|
||||
|
||||
## Example session
|
||||
|
||||
```text
|
||||
# 1) train
|
||||
rd_train universe=AAPL,MSFT,TSLA,USO,SLV,TLT train_start=2026-03-01 train_end=2026-05-31
|
||||
valid_start=2026-06-01 valid_end=2026-06-30 test_start=2026-07-01 test_end=2026-08-06
|
||||
experiment_name=tac-rd-mcp out_dir=/tmp/rd_out
|
||||
# -> run_id ...
|
||||
|
||||
# 2) predict on the test window (from the mlflow run)
|
||||
rd_predict universe=AAPL,MSFT,TSLA,USO,SLV,TLT run_id=<run_id> experiment_name=tac-rd-mcp
|
||||
train_start=2026-03-01 train_end=2026-05-31 valid_start=2026-06-01 valid_end=2026-06-30
|
||||
test_start=2026-07-01 test_end=2026-08-06 out_dir=/tmp/rd_out top=5
|
||||
|
||||
# 3) evaluate the alpha
|
||||
rd_evaluate pred_path=/tmp/rd_out/pred.pkl label_path=/tmp/rd_out/label.pkl
|
||||
|
||||
# 4) backtest the signal
|
||||
rd_backtest pred_path=/tmp/rd_out/pred.pkl start_time=2026-07-01 end_time=2026-08-06 topk=2 n_drop=1 benchmark=AAPL
|
||||
|
||||
# 5) one-shot equivalent — trace first, then run, then finish
|
||||
rd_trace_start --rational "<hypothesis>" --experiment-name tac-rd-one-shot --evolved-from auto
|
||||
rd_run_workflow config_path=tac-qlib/workflows/workflow_lgb_taclake.yaml experiment_name=tac-rd-one-shot
|
||||
rd_trace_finish --id <EXPERIMENT_ID> --ref-id <run_id> --evaluation "<outcome>"
|
||||
|
||||
# 6) inspect that run later — given experiment_id + run_id (the /rd pages call exactly these)
|
||||
rd_exp_get_experiment experiment_id=1 # -> runs with meta + latest metrics
|
||||
rd_exp_input experiment_id=1 run_id=<run_id> # what went in: universe, windows, features, label, model
|
||||
rd_exp_result experiment_id=1 run_id=<run_id> # what came out: IC/ICIR/Rank IC, backtest risk
|
||||
rd_exp_blotter experiment_id=1 run_id=<run_id> # execution: P&L, positions, trades, signals
|
||||
rd_exp_model experiment_id=1 run_id=<run_id> # hyperparameters + tree / importances
|
||||
rd_exp_set_notes experiment_id=1 run_id=<run_id> hypothesis="5d label + topk5" evaluation="ICIR 0.5, ann +12%"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The server reads the lake lazily via the custom `LakeCalendarProvider` / `LakeInstrumentProvider` / `LakeFeatureProvider`; if data is missing the relevant provider raises a clear error — backfill through the tac-engine lake tools first.
|
||||
- All tools return JSON via stdio (MCP). Diagnostics/logs go to stderr.
|
||||
- MLflow runs are stored in the tracking store at `$DATABASE_URL` (Postgres) when
|
||||
set, else the unified lake sqlite `mlruns.db`; artifact files always live under
|
||||
`<lake>/mlruns/<exp_id>/<run_uuid>/`. Override the tracking URI with `MLRUNS_URI` if needed.
|
||||
- `rd_run_workflow` / `rd_train` pin each experiment's MLflow `artifact_location` to `<lake>/mlruns` so DB and artifacts stay co-located even when the server process runs from another cwd. Readers (`rd_exp_*`) resolve each run's artifact dir from its recorded `artifact_uri`, falling back to the side-by-side `<lake>/mlruns` layout — so runs whose artifacts were written elsewhere (e.g. `<cwd>/mlruns`) still display.
|
||||
@@ -0,0 +1,18 @@
|
||||
"""tac-qlib: read the TradeAC parquet lake from within the qlib research workflow.
|
||||
|
||||
This package is fully decoupled from the upstream ``qlib`` checkout. It provides:
|
||||
|
||||
- ``tac_qlib.qlib_init.qlib_init``: drop-in ``qlib.init`` configured against the lake.
|
||||
- ``tac_qlib.data.providers``: qlib data providers (calendar / instruments / features)
|
||||
backed by the lake parquet files, so ``qlib.init`` + ``D.features`` work without any
|
||||
``*.bin`` data.
|
||||
- ``tac_qlib.contrib.data.handler``: a ``DataHandlerLP`` subclass (``TACHandler``) that
|
||||
builds a train/test dataset from raw OHLCV + pre-computed ta-lib features.
|
||||
|
||||
Upstream ``qlib/`` is never modified.
|
||||
"""
|
||||
|
||||
from .qlib_init import qlib_init, provider_config
|
||||
|
||||
__all__ = ["qlib_init", "provider_config"]
|
||||
__version__ = "0.1.0"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,388 @@
|
||||
"""Round book MCP server (stdio transport) — the execution trail for algo rounds.
|
||||
|
||||
Exposes the round-book tools over MCP so the agent (tac-algo-trade skill) and
|
||||
the R&D UI can read AND write the same execution trail in Postgres:
|
||||
|
||||
scheduler_runs ──► ROUND ──► rd_experiments
|
||||
|
||||
round_create / round_update / round_update_status / round_list / round_get windows
|
||||
fact_record / fact_query evidence
|
||||
intent_set / intent_get / intent_list target portfolios
|
||||
decision_record / decision_query / order_link gates + orders
|
||||
round_sync_fills Alpaca fills
|
||||
book_reconcile / trail_query / trail_funnel / round_metrics investigation
|
||||
|
||||
Run::
|
||||
|
||||
.venv/bin/python -m tac_qlib.book_server # stdio MCP server
|
||||
|
||||
All tools return JSON-safe dicts. Logging goes to stderr; stdout is reserved
|
||||
for the MCP protocol. DB access is via tac_qlib.book_db (psycopg + DATABASE_URL);
|
||||
fill sync additionally needs APCA_API_KEY_ID / APCA_API_SECRET_KEY when the
|
||||
agent does not pass `orders` explicitly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import json
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
from tac_qlib import book_db
|
||||
|
||||
book_db._load_repo_env()
|
||||
|
||||
server = MCPServer(
|
||||
name="tac-rd-book",
|
||||
title="TradeAC round book",
|
||||
instructions=(
|
||||
"Execution trail for algo trading rounds on the TradeAC stack: create "
|
||||
"round windows, record evidence facts, set versioned target intents, "
|
||||
"record placed/skipped decisions, link Alpaca orders, sync fills, and "
|
||||
"reconcile / trace the funnel. Backed by Postgres (DATABASE_URL)."
|
||||
),
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
|
||||
def _log(message: str) -> None:
|
||||
print(f"[tac-rd-book] {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def _as_obj(value: Any) -> Any:
|
||||
"""Accept structured MCP input as JSON strings or as already-parsed dicts/lists."""
|
||||
if isinstance(value, str):
|
||||
if not value.strip():
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _obj(value: Any) -> Optional[Dict[str, Any]]:
|
||||
parsed = _as_obj(value)
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def _arr(value: Any) -> Optional[List[Any]]:
|
||||
parsed = _as_obj(value)
|
||||
return parsed if isinstance(parsed, list) else None
|
||||
|
||||
|
||||
def _open_round(func):
|
||||
"""Ensure the round tables exist before any round-book operation.
|
||||
Uses ``functools.wraps`` so ``inspect.signature`` follows ``__wrapped__``
|
||||
and the MCP tool schema keeps the real typed parameters."""
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
book_db.ensure_schema()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_log(f"schema check failed: {exc}")
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- windows
|
||||
|
||||
@_open_round
|
||||
def round_create(
|
||||
target_date: str,
|
||||
source: str = "scheduled",
|
||||
signal_date: str = "",
|
||||
scheduler_run_id: int = 0,
|
||||
rd_experiment_id: int = 0,
|
||||
experiment_name: str = "",
|
||||
run_id: str = "",
|
||||
model_path: str = "",
|
||||
strategy_snapshot: str = "{}",
|
||||
account_equity_at_sizing: float = 0.0,
|
||||
) -> dict:
|
||||
"""Open a round window for a target trading date. Idempotent per
|
||||
(source, target_date): an already-open round for the same window is
|
||||
returned unchanged (``reused=True``). Returns the full round row."""
|
||||
snap = _obj(strategy_snapshot) or {}
|
||||
return book_db.create_round(
|
||||
source=source,
|
||||
target_date=target_date,
|
||||
signal_date=signal_date or None,
|
||||
scheduler_run_id=scheduler_run_id or None,
|
||||
rd_experiment_id=rd_experiment_id or None,
|
||||
experiment_name=experiment_name or None,
|
||||
run_id=run_id or None,
|
||||
model_path=model_path or None,
|
||||
strategy_snapshot=snap,
|
||||
account_equity_at_sizing=account_equity_at_sizing or None,
|
||||
)
|
||||
|
||||
|
||||
def round_update(
|
||||
round_id: int,
|
||||
source: str = "",
|
||||
target_date: str = "",
|
||||
signal_date: str = "",
|
||||
scheduler_run_id: int = 0,
|
||||
rd_experiment_id: int = 0,
|
||||
experiment_name: str = "",
|
||||
run_id: str = "",
|
||||
model_path: str = "",
|
||||
strategy_snapshot: str = "",
|
||||
account_equity_at_sizing: float = 0.0,
|
||||
) -> dict:
|
||||
"""Update a round window's metadata — e.g. pin the new training run
|
||||
(``run_id`` / ``model_path``) and strategy snapshot after the retrain.
|
||||
Empty / zero values leave the field unchanged."""
|
||||
return book_db.update_round(
|
||||
round_id,
|
||||
source=source or None,
|
||||
target_date=target_date or None,
|
||||
signal_date=signal_date or None,
|
||||
scheduler_run_id=scheduler_run_id or None,
|
||||
rd_experiment_id=rd_experiment_id or None,
|
||||
experiment_name=experiment_name or None,
|
||||
run_id=run_id or None,
|
||||
model_path=model_path or None,
|
||||
strategy_snapshot=_obj(strategy_snapshot) if strategy_snapshot else None,
|
||||
account_equity_at_sizing=account_equity_at_sizing or None,
|
||||
)
|
||||
|
||||
|
||||
def round_update_status(
|
||||
round_id: int,
|
||||
status: str = "",
|
||||
locked_intent_id: int = 0,
|
||||
summary_metrics: str = "{}",
|
||||
feedback_note: str = "",
|
||||
) -> dict:
|
||||
"""Advance a round (open → locked → settled | aborted). ``locked_intent_id``
|
||||
pins the intent reconciliation uses. ``summary_metrics`` / ``feedback_note``
|
||||
update the round summary."""
|
||||
return book_db.update_round_status(
|
||||
round_id,
|
||||
status=status or None,
|
||||
locked_intent_id=locked_intent_id or None,
|
||||
summary_metrics=_obj(summary_metrics),
|
||||
feedback_note=feedback_note or None,
|
||||
)
|
||||
|
||||
|
||||
def round_list(
|
||||
source: str = "",
|
||||
target_date: str = "",
|
||||
status: str = "",
|
||||
limit: int = 20,
|
||||
include_detail: bool = False,
|
||||
) -> dict:
|
||||
"""List round windows (newest first), optionally filtered by source /
|
||||
target_date / status. ``include_detail`` attaches each round's funnel
|
||||
counts + roll-up metrics (used by the /dashboard/rounds list)."""
|
||||
return {
|
||||
"rounds": book_db.list_rounds(
|
||||
source=source or None,
|
||||
target_date=target_date or None,
|
||||
status=status or None,
|
||||
limit=limit,
|
||||
with_detail=bool(include_detail),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def round_get(round_id: int) -> dict:
|
||||
"""Full detail of one round: window row + intents, decisions, orders, facts,
|
||||
funnel and reconciliation — everything the UI's round detail page needs."""
|
||||
round_row = book_db.get_round(round_id)
|
||||
if round_row is None:
|
||||
return {"error": f"round {round_id} not found"}
|
||||
return {
|
||||
"round": round_row,
|
||||
"intents": book_db.list_intents(round_id),
|
||||
"decisions": book_db.query_decisions(round_id),
|
||||
"orders": book_db.list_orders(round_id),
|
||||
"facts": book_db.query_facts(round_id, limit=500),
|
||||
"funnel": book_db.funnel(round_id),
|
||||
"reconcile": book_db.reconcile(round_id),
|
||||
"metrics": book_db.metrics(round_id),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- facts
|
||||
|
||||
def fact_record(round_id: int, kind: str, payload: str = "{}", symbol: str = "", source: str = "") -> dict:
|
||||
"""Append an evidence event (signal_score, quote, news_sentiment,
|
||||
account_state, position_state, strategy_config, ...) to a round."""
|
||||
return book_db.record_fact(
|
||||
round_id,
|
||||
kind=kind,
|
||||
payload=_obj(payload),
|
||||
symbol=symbol or None,
|
||||
source=source or None,
|
||||
)
|
||||
|
||||
|
||||
def fact_query(round_id: int, kind: str = "", symbol: str = "", limit: int = 200) -> dict:
|
||||
"""Query a round's recorded facts (newest first), optionally filtered by kind/symbol."""
|
||||
return {"facts": book_db.query_facts(round_id, kind=kind or None, symbol=symbol or None, limit=limit)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- intents
|
||||
|
||||
def intent_set(round_id: int, target_portfolio: str, raw_strategy_output: str = "{}", reason: str = "") -> dict:
|
||||
"""Write the next target-portfolio version for a round (auto-increments and
|
||||
supersedes the previous active version). ``target_portfolio`` is a JSON
|
||||
array of {symbol, side, qty, notional, expected_price, score, rank, weight}."""
|
||||
return book_db.set_intent(
|
||||
round_id,
|
||||
target_portfolio=_arr(target_portfolio) or [],
|
||||
raw_strategy_output=_obj(raw_strategy_output),
|
||||
reason=reason or None,
|
||||
)
|
||||
|
||||
|
||||
def intent_get(round_id: int, version: int = 0) -> dict:
|
||||
"""Get a round's intent — the given version, or the active (max) version
|
||||
when ``version`` is omitted."""
|
||||
intent = book_db.get_intent(round_id, version=version or None)
|
||||
return {"intent": intent} if intent else {"intent": None, "error": f"no intent for round {round_id}"}
|
||||
|
||||
|
||||
def intent_list(round_id: int) -> dict:
|
||||
"""List every target-portfolio version for a round (oldest first)."""
|
||||
return {"intents": book_db.list_intents(round_id)}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- decisions / orders
|
||||
|
||||
def decision_record(
|
||||
round_id: int,
|
||||
symbol: str,
|
||||
side: str,
|
||||
qty: float = 0.0,
|
||||
order_type: str = "",
|
||||
expected_price: float = 0.0,
|
||||
status: str = "intended",
|
||||
reason: str = "",
|
||||
reason_detail: str = "",
|
||||
intent_id: int = 0,
|
||||
supersedes_decision_id: int = 0,
|
||||
alpaca_order_id: str = "",
|
||||
client_order_id: str = "",
|
||||
) -> dict:
|
||||
"""Record one per-symbol decision by the gates. Use status ``skipped`` /
|
||||
``rejected`` with a ``reason`` for deliberate skips; placed orders carry
|
||||
``alpaca_order_id`` / ``client_order_id`` (an execution row is created).
|
||||
Passing ``supersedes_decision_id`` marks the previous decision superseded."""
|
||||
return book_db.record_decision(
|
||||
round_id,
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
qty=qty or None,
|
||||
order_type=order_type or None,
|
||||
expected_price=expected_price or None,
|
||||
status=status,
|
||||
reason=reason or None,
|
||||
reason_detail=reason_detail or None,
|
||||
intent_id=intent_id or None,
|
||||
supersedes_decision_id=supersedes_decision_id or None,
|
||||
alpaca_order_id=alpaca_order_id or None,
|
||||
client_order_id=client_order_id or None,
|
||||
)
|
||||
|
||||
|
||||
def decision_query(round_id: int, symbol: str = "", include_superseded: bool = True) -> dict:
|
||||
"""List a round's decisions, optionally filtered by symbol."""
|
||||
return {"decisions": book_db.query_decisions(round_id, symbol=symbol or None, include_superseded=include_superseded)}
|
||||
|
||||
|
||||
def order_link(
|
||||
round_id: int,
|
||||
decision_id: int,
|
||||
alpaca_order_id: str = "",
|
||||
client_order_id: str = "",
|
||||
qty_filled: float = -1.0,
|
||||
avg_fill_price: float = -1.0,
|
||||
status: str = "",
|
||||
) -> dict:
|
||||
"""Create or update the execution row for a placed decision (idempotent per
|
||||
decision). Use ``qty_filled=-1`` to leave the value unchanged."""
|
||||
return book_db.link_order(
|
||||
round_id,
|
||||
decision_id=decision_id,
|
||||
alpaca_order_id=alpaca_order_id or None,
|
||||
client_order_id=client_order_id or None,
|
||||
qty_filled=qty_filled if qty_filled >= 0 else None,
|
||||
avg_fill_price=avg_fill_price if avg_fill_price >= 0 else None,
|
||||
status=status or None,
|
||||
)
|
||||
|
||||
|
||||
def round_sync_fills(round_id: int, orders: str = "", feed: str = "iex") -> dict:
|
||||
"""Pull Alpaca order state into the round. Pass ``orders`` as a JSON array
|
||||
(tac-engine ``list_orders`` output) or omit it to fetch from Alpaca with
|
||||
APCA_API_* env vars. Marks orders superseded when the effective intent no
|
||||
longer targets their symbol+side. Returns updated / superseded / unmatched."""
|
||||
parsed = _arr(orders) if isinstance(orders, str) else orders
|
||||
return book_db.sync_fills(round_id, orders=parsed if isinstance(parsed, list) else None, feed=feed or "iex")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- investigation
|
||||
|
||||
def book_reconcile(round_id: int) -> dict:
|
||||
"""Reconcile the round: effective intent targets vs decisions vs fills, with
|
||||
per-symbol residuals and roll-ups (cash/BP impact, slippage bps, cost)."""
|
||||
return book_db.reconcile(round_id)
|
||||
|
||||
|
||||
def trail_query(round_id: int, symbol: str = "") -> dict:
|
||||
"""Per-symbol waterfall: intent target → decision → order → fill."""
|
||||
return {"trail": book_db.trail(round_id, symbol=symbol or None)}
|
||||
|
||||
|
||||
def trail_funnel(round_id: int) -> dict:
|
||||
"""Decision funnel counts for a round: targets → decided → placed → filled,
|
||||
plus skipped-reason breakdown and superseded count."""
|
||||
return book_db.funnel(round_id)
|
||||
|
||||
|
||||
def round_metrics(round_id: int) -> dict:
|
||||
"""Roll-up metrics: placed/filled order counts, invested notional, turnover."""
|
||||
return book_db.metrics(round_id)
|
||||
|
||||
|
||||
def register_tools(mcp_server: MCPServer) -> None:
|
||||
"""Attach all round-book tools to an ``MCPServer`` instance."""
|
||||
for fn in (
|
||||
round_create,
|
||||
round_update,
|
||||
round_update_status,
|
||||
round_list,
|
||||
round_get,
|
||||
fact_record,
|
||||
fact_query,
|
||||
intent_set,
|
||||
intent_get,
|
||||
intent_list,
|
||||
decision_record,
|
||||
decision_query,
|
||||
order_link,
|
||||
round_sync_fills,
|
||||
book_reconcile,
|
||||
trail_query,
|
||||
trail_funnel,
|
||||
round_metrics,
|
||||
):
|
||||
mcp_server.tool(structured_output=False)(fn)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
register_tools(server)
|
||||
server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,11 @@
|
||||
from . import data # noqa: F401 (registers tac_qlib.contrib.data)
|
||||
from . import model, strategy # noqa: F401
|
||||
from .data import TACHandler # noqa: F401
|
||||
from .model import RankICLGBModel # noqa: F401
|
||||
from .strategy import OptimalStopControl # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"TACHandler",
|
||||
"RankICLGBModel",
|
||||
"OptimalStopControl",
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .handler import TACHandler
|
||||
|
||||
__all__ = ["TACHandler"]
|
||||
@@ -0,0 +1,254 @@
|
||||
"""TACHandler: a qlib DataHandlerLP that builds datasets from the TradeAC lake.
|
||||
|
||||
This is the "custom DataHandler" entry point (Option B): the handler is referenced from the
|
||||
workflow yaml's ``dataset.handler`` and reads OHLCV + pre-computed ta-lib features straight
|
||||
from the lake parquet files through ``QLibDataLoader`` + the tac_qlib feature provider.
|
||||
|
||||
The standard qlib processor pipeline (``infer_processors`` / ``learn_processors``) still runs
|
||||
on top, so existing recipes such as ``DropnaLabel``, ``CSZScoreNorm`` or ``RobustZScoreNorm``
|
||||
keep working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from inspect import getfullargspec
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
from qlib.data.dataset import processor as processor_module
|
||||
from qlib.data.dataset.handler import DataHandlerLP
|
||||
from qlib.utils import get_callable_kwargs
|
||||
|
||||
from ...data.config import (
|
||||
LakeConfig,
|
||||
timeframe_for_freq,
|
||||
NON_FEATURE_COLUMNS,
|
||||
)
|
||||
|
||||
DEFAULT_INFER_PROCESSORS = [
|
||||
{"class": "DropAllNaN", "kwargs": {}},
|
||||
{"class": "ProcessInf", "kwargs": {}},
|
||||
{"class": "ZScoreNorm", "kwargs": {}},
|
||||
{"class": "Fillna", "kwargs": {}},
|
||||
]
|
||||
DEFAULT_LEARN_PROCESSORS = [
|
||||
{"class": "DropnaLabel"},
|
||||
{"class": "CSZScoreNorm", "kwargs": {"fields_group": "label"}},
|
||||
]
|
||||
|
||||
#: always include raw OHLCV; ta-lib columns are discovered from the lake and appended.
|
||||
RAW_FEATURE_FIELDS = ("$open", "$high", "$low", "$close", "$vwap", "$volume")
|
||||
|
||||
DEFAULT_LABEL = "Ref($close,-2)/Ref($close,-1)-1"
|
||||
|
||||
|
||||
def check_transform_proc(proc_l, fit_start_time, fit_end_time):
|
||||
"""Port of ``qlib.contrib.data.handler.check_transform_proc`` (inject fit window into procs)."""
|
||||
new_l = []
|
||||
for p in proc_l:
|
||||
if not isinstance(p, processor_module.Processor):
|
||||
klass, pkwargs = get_callable_kwargs(p, processor_module)
|
||||
args = getfullargspec(klass).args
|
||||
if "fit_start_time" in args and "fit_end_time" in args:
|
||||
assert fit_start_time is not None and fit_end_time is not None, (
|
||||
"Make sure `fit_start_time` and `fit_end_time` are not None."
|
||||
)
|
||||
pkwargs.update({"fit_start_time": fit_start_time, "fit_end_time": fit_end_time})
|
||||
proc_config = {"class": klass.__name__, "kwargs": pkwargs}
|
||||
if isinstance(p, dict) and "module_path" in p:
|
||||
proc_config["module_path"] = p["module_path"]
|
||||
new_l.append(proc_config)
|
||||
else:
|
||||
new_l.append(p)
|
||||
return new_l
|
||||
|
||||
|
||||
def get_common_feature_fields(lake_root=None, market="US", timeframe="1d") -> List[str]:
|
||||
"""Discover feature columns present in *every* feature file of the lake.
|
||||
|
||||
Walks the `family=ta|sp` partition layout (plus any legacy flat files).
|
||||
TA and SP columns are disjoint by construction, so the common set is
|
||||
computed per family (columns shared by all symbol files of that family),
|
||||
then the per-family results are unioned. Returns sorted field names
|
||||
(without the ``$`` prefix). Empty if no features are persisted.
|
||||
"""
|
||||
cfg = LakeConfig(lake_root, market)
|
||||
feat_dir = cfg.features_dir(timeframe)
|
||||
if not feat_dir.exists():
|
||||
return []
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
def _family_common(fam_dir: Path) -> set:
|
||||
common = None
|
||||
for p in sorted(fam_dir.glob("symbol=*.parquet")):
|
||||
try:
|
||||
cols = set(pq.read_schema(p).names) - set(NON_FEATURE_COLUMNS)
|
||||
except Exception: # pragma: no cover - skip unreadable files
|
||||
continue
|
||||
common = cols if common is None else (common & cols)
|
||||
if not common:
|
||||
break
|
||||
return common or set()
|
||||
|
||||
common: set = set()
|
||||
# family tier: features/market=*/timeframe=*/family=*/symbol=*.parquet
|
||||
for fam in ("ta", "sp"):
|
||||
fam_dir = feat_dir / f"family={fam}"
|
||||
if fam_dir.is_dir():
|
||||
common |= _family_common(fam_dir)
|
||||
# legacy flat: features/market=*/timeframe=*/symbol=*.parquet
|
||||
if (feat_dir / "family=ta").exists() or (feat_dir / "family=sp").exists():
|
||||
pass # family layout already covered
|
||||
else:
|
||||
common |= _family_common(feat_dir)
|
||||
return sorted(common)
|
||||
|
||||
|
||||
class DropAllNaN(processor_module.Processor):
|
||||
"""Drop feature columns that are all-NaN over the fit window.
|
||||
|
||||
The lake can hold fully-empty indicator columns (e.g. a ta-lib output that was NaN
|
||||
from the start). Such columns carry no learnable signal and make ``ZScoreNorm.fit``
|
||||
warn on empty slices, so we drop them before any other processor runs. The drop set
|
||||
is fixed on the fit window once (during ``fit``), then applied consistently to every
|
||||
segment so train/valid/test keep identical feature columns.
|
||||
"""
|
||||
|
||||
def __init__(self, fit_start_time=None, fit_end_time=None):
|
||||
self.fit_start_time = fit_start_time
|
||||
self.fit_end_time = fit_end_time
|
||||
self.cols_to_drop = []
|
||||
|
||||
def fit(self, df=None):
|
||||
if df is None or len(df) == 0:
|
||||
return self
|
||||
window = df
|
||||
if self.fit_start_time is not None and self.fit_end_time is not None:
|
||||
try:
|
||||
from qlib.data.dataset.utils import fetch_df_by_index
|
||||
|
||||
window = fetch_df_by_index(
|
||||
df, slice(self.fit_start_time, self.fit_end_time), level="datetime"
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
window = df
|
||||
if len(window) == 0:
|
||||
return self
|
||||
self.cols_to_drop = [c for c in window.columns if window[c].isna().all()]
|
||||
return self
|
||||
|
||||
def __call__(self, df):
|
||||
if self.cols_to_drop:
|
||||
return df.drop(columns=self.cols_to_drop, errors="ignore")
|
||||
return df
|
||||
|
||||
|
||||
class TACHandler(DataHandlerLP):
|
||||
"""DataHandlerLP backed by the TradeAC parquet lake.
|
||||
|
||||
Parameters mirror ``Alpha158``: ``instruments``/``start_time``/``end_time``/``freq`` define
|
||||
the queried window; ``feature_fields`` selects the features (default: raw OHLCV + all common
|
||||
ta-lib columns found in the lake); ``label`` is a qlib expression for the target.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
instruments="all",
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
freq="day",
|
||||
infer_processors=DEFAULT_INFER_PROCESSORS,
|
||||
learn_processors=DEFAULT_LEARN_PROCESSORS,
|
||||
fit_start_time=None,
|
||||
fit_end_time=None,
|
||||
process_type=DataHandlerLP.PTYPE_A,
|
||||
filter_pipe=None,
|
||||
feature_fields=None,
|
||||
label=DEFAULT_LABEL,
|
||||
lake_root=None,
|
||||
market="US",
|
||||
**kwargs,
|
||||
):
|
||||
# default the processor fit window to the queried window (like Alpha158 without a split)
|
||||
if fit_start_time is None:
|
||||
fit_start_time = start_time
|
||||
if fit_end_time is None:
|
||||
fit_end_time = end_time
|
||||
|
||||
infer_processors = check_transform_proc(infer_processors, fit_start_time, fit_end_time)
|
||||
learn_processors = check_transform_proc(learn_processors, fit_start_time, fit_end_time)
|
||||
|
||||
feature_fields = self._normalize_feature_fields(feature_fields, freq, lake_root, market)
|
||||
if not feature_fields:
|
||||
raise ValueError(
|
||||
"no feature fields available for the lake; set `feature_fields` explicitly "
|
||||
"(e.g. ['$close', '$rsi_14', '$sma_20'])"
|
||||
)
|
||||
|
||||
label_expr, label_names = self._normalize_label(label)
|
||||
|
||||
data_loader = {
|
||||
"class": "QlibDataLoader",
|
||||
"kwargs": {
|
||||
"config": {
|
||||
"feature": (feature_fields, feature_fields),
|
||||
"label": (label_expr, label_names),
|
||||
},
|
||||
"filter_pipe": filter_pipe,
|
||||
"freq": freq,
|
||||
},
|
||||
}
|
||||
super().__init__(
|
||||
instruments=instruments,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
data_loader=data_loader,
|
||||
infer_processors=infer_processors,
|
||||
learn_processors=learn_processors,
|
||||
process_type=process_type,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ config
|
||||
@staticmethod
|
||||
def _normalize_feature_fields(feature_fields, freq, lake_root, market) -> List[str]:
|
||||
if feature_fields is None:
|
||||
common = get_common_feature_fields(lake_root, market, timeframe_for_freq(freq))
|
||||
feature_fields = list(RAW_FEATURE_FIELDS) + ["$" + f for f in common if "$" + f not in RAW_FEATURE_FIELDS]
|
||||
elif isinstance(feature_fields, str):
|
||||
feature_fields = [f.strip() for f in feature_fields.split(",") if f.strip()]
|
||||
fields = [f if f.startswith("$") else "$" + f for f in feature_fields]
|
||||
# de-dup while preserving order
|
||||
seen, out = set(), []
|
||||
for f in fields:
|
||||
if f not in seen:
|
||||
seen.add(f)
|
||||
out.append(f)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _normalize_label(label) -> Tuple[List[str], List[str]]:
|
||||
if isinstance(label, str):
|
||||
return [label], ["LABEL0"]
|
||||
if isinstance(label, (list, tuple)):
|
||||
if len(label) == 2 and isinstance(label[0], str):
|
||||
return [label[0]], list(label[1]) if isinstance(label[1], (list, tuple)) else [label[1]]
|
||||
return list(label), ["LABEL%d" % i for i in range(len(label))]
|
||||
raise TypeError(f"unsupported label config: {label!r}")
|
||||
|
||||
# ------------------------------------------------------------------ utils
|
||||
def get_label_config(self):
|
||||
return DEFAULT_LABEL
|
||||
|
||||
@staticmethod
|
||||
def discover_feature_fields(lake_root=None, market="US", freq="day") -> List[str]:
|
||||
return get_common_feature_fields(lake_root, market, timeframe_for_freq(freq))
|
||||
|
||||
|
||||
__all__ = ["TACHandler", "DropAllNaN", "get_common_feature_fields"]
|
||||
|
||||
|
||||
# Make `DropAllNaN` resolvable by bare name from processor configs (e.g. the default
|
||||
# ``infer_processors`` and workflow yamls that reference it without a ``module_path``),
|
||||
# mirroring how qlib registers its own processors in ``qlib.data.dataset.processor``.
|
||||
processor_module.DropAllNaN = DropAllNaN
|
||||
@@ -0,0 +1,4 @@
|
||||
from .rank_ensemble import RankICEnsembleLGBModel # noqa: F401
|
||||
from .rank_gbdt import RankICLGBModel, rankic_feval # noqa: F401
|
||||
|
||||
__all__ = ["RankICLGBModel", "rankic_feval", "RankICEnsembleLGBModel"]
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Seed-ensembled LightGBM that early-stops on cross-sectional RankIC.
|
||||
|
||||
``RankICEnsembleLGBModel`` wraps ``RankICLGBModel`` (per-day RankIC feval +
|
||||
``metric='None'`` + ``first_metric_only`` early stopping) over a seed ensemble:
|
||||
one sub-model is trained per seed with identical hyper-parameters, and
|
||||
predictions are averaged across seeds. This is the model class the
|
||||
``tac-rd-rank-ensemble-isolated`` reference run wires into its workflow
|
||||
(``module_path: tac_qlib.contrib.model.rank_ensemble``).
|
||||
|
||||
The ensemble inherits the RankIC early-stopping behaviour of the single-seed
|
||||
model (valid RankIC drives the stopping iteration) while the seed averaging
|
||||
stabilizes the prediction against any single seed's early-stopping path.
|
||||
|
||||
Training is parallelized: the seed sub-models train in a thread pool —
|
||||
``lgb.train`` is C++ and releases the GIL, so concurrent seeds do not block on
|
||||
the GIL (5 seeds ~40min/5 on this box). Measured on a 6-physical-core / 12 SMT
|
||||
host: the seeds scale ~2x, not linearly — the runs are memory-bandwidth bound
|
||||
and each Booster caps its threads at ``cores // workers`` so 5 concurrent
|
||||
boosters don't oversubscribe; larger-core hosts scale better. The qlib data
|
||||
pipeline is warmed once on the calling thread (fills the handler cache), and
|
||||
each worker then prepares its **own** ``lgb.Dataset`` (independent handle, so
|
||||
no concurrent ``construct()`` on a shared handle — LightGBM's ``Dataset`` is
|
||||
not thread-safe to build). qlib's ``R`` recorder is also not thread-safe, so
|
||||
the per-seed evaluation curves are logged on the calling thread after the pool
|
||||
finishes.
|
||||
|
||||
Wired into a workflow yaml like:
|
||||
|
||||
model:
|
||||
class: RankICEnsembleLGBModel
|
||||
module_path: tac_qlib.contrib.model.rank_ensemble
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.02
|
||||
num_leaves: 31
|
||||
n_estimators: 3000
|
||||
num_boost_round: 3000
|
||||
early_stopping_rounds: 200
|
||||
min_data_in_leaf: 20
|
||||
lambda_l2: 0.5
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.1
|
||||
reg_lambda: 1.0
|
||||
seeds: "42,7,2026,99,123"
|
||||
parallel: 5
|
||||
|
||||
Any ``**kwargs`` other than ``seeds``/``parallel`` are forwarded unchanged to
|
||||
every ``RankICLGBModel`` sub-model (same params, different ``seed``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from qlib.data.dataset import DatasetH
|
||||
from qlib.data.dataset.handler import DataHandlerLP
|
||||
|
||||
from tac_qlib.contrib.model.rank_gbdt import RankICLGBModel
|
||||
|
||||
__all__ = ["RankICEnsembleLGBModel"]
|
||||
|
||||
|
||||
class RankICEnsembleLGBModel(RankICLGBModel):
|
||||
"""Seed ensemble of RankIC-early-stopping LightGBM models.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
seeds : comma-separated integers, one sub-model per seed.
|
||||
parallel : number of seeds to train concurrently. ``0`` (default) = auto
|
||||
(all seeds, bounded by the available cores); ``1`` = sequential.
|
||||
**kwargs : forwarded to every ``RankICLGBModel`` sub-model (model
|
||||
hyper-parameters). ``seeds``/``parallel`` are consumed here and not
|
||||
forwarded.
|
||||
"""
|
||||
|
||||
def __init__(self, seeds: str = "42", parallel: int = 0, **kwargs):
|
||||
self.seeds = [int(s.strip()) for s in str(seeds).split(",") if s.strip()]
|
||||
if not self.seeds:
|
||||
raise ValueError("seeds must contain at least one integer")
|
||||
self.parallel = int(parallel)
|
||||
# drop seed/parallel handling from the base kwargs, keep everything else
|
||||
self._model_kwargs = dict(kwargs)
|
||||
super().__init__(**self._model_kwargs)
|
||||
self._models: List[RankICLGBModel] = []
|
||||
|
||||
# --------------------------------------------------------------- helpers
|
||||
@staticmethod
|
||||
def _cores() -> int:
|
||||
try:
|
||||
return max(1, len(os.sched_getaffinity(0)))
|
||||
except AttributeError:
|
||||
return max(1, os.cpu_count() or 1)
|
||||
|
||||
def _worker_count(self) -> int:
|
||||
if self.parallel > 0:
|
||||
return min(len(self.seeds), self.parallel)
|
||||
return min(len(self.seeds), self._cores())
|
||||
|
||||
# ------------------------------------------------------------------ fit
|
||||
def fit(
|
||||
self,
|
||||
dataset: DatasetH,
|
||||
num_boost_round: Optional[int] = None,
|
||||
early_stopping_rounds: Optional[int] = None,
|
||||
verbose_eval: int = 20,
|
||||
evals_result=None,
|
||||
reweighter=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Train one RankICLGBModel per seed and keep them for prediction.
|
||||
|
||||
The qlib data pipeline is warmed once on this thread (handler cache),
|
||||
then each seed sub-model trains in a parallel worker thread on its own
|
||||
``lgb.Dataset`` (LightGBM releases the GIL in ``lgb.train``). Evals
|
||||
are logged on this thread after the pool (qlib's ``R`` is not
|
||||
thread-safe).
|
||||
"""
|
||||
n_round = num_boost_round or self.num_boost_round
|
||||
n_es = early_stopping_rounds or self.early_stopping_rounds
|
||||
|
||||
if len(self.seeds) == 1:
|
||||
m = RankICLGBModel(seed=self.seeds[0], **self._model_kwargs)
|
||||
m.fit(
|
||||
dataset,
|
||||
num_boost_round=n_round,
|
||||
early_stopping_rounds=n_es,
|
||||
verbose_eval=verbose_eval,
|
||||
evals_result=evals_result,
|
||||
reweighter=reweighter,
|
||||
**kwargs,
|
||||
)
|
||||
self._models = [m]
|
||||
return
|
||||
|
||||
# Warm the qlib handler cache once on this thread so the workers'
|
||||
# concurrent prepare() calls only hit cached frames (no first-write race).
|
||||
proto = RankICLGBModel(seed=self.seeds[0], **self._model_kwargs)
|
||||
proto._prepare_data(dataset, reweighter)
|
||||
|
||||
workers = self._worker_count()
|
||||
# Cap per-Booster threads so concurrent seeds don't oversubscribe
|
||||
# (LightGBM's num_threads=0 uses ALL cores per Booster).
|
||||
per_booster = max(1, self._cores() // workers)
|
||||
|
||||
def fit_seed(seed):
|
||||
m = RankICLGBModel(seed=seed, **self._model_kwargs)
|
||||
if workers > 1 and "num_threads" not in m.params:
|
||||
m.params["num_threads"] = per_booster
|
||||
ds_l = m._prepare_data(dataset, reweighter)
|
||||
booster, evals, names = m._train_from_datasets(
|
||||
ds_l,
|
||||
num_boost_round=n_round,
|
||||
early_stopping_rounds=n_es,
|
||||
verbose_eval=verbose_eval,
|
||||
**kwargs,
|
||||
)
|
||||
m.model = booster
|
||||
return m, evals, names
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
results = list(ex.map(fit_seed, self.seeds))
|
||||
|
||||
self._models = [m for m, _, _ in results]
|
||||
|
||||
# Merge + log evals on the main thread (qlib's R is not thread-safe).
|
||||
if evals_result is not None:
|
||||
for m, evals, names in results:
|
||||
for k in names:
|
||||
for key, val in evals.get(k, {}).items():
|
||||
evals_result.setdefault(f"{k}.seed{m.params['seed']}", {})[key] = val
|
||||
for m, evals, names in results:
|
||||
self._log_evals(evals, names, prefix=f"seed{m.params['seed']}.")
|
||||
|
||||
# -------------------------------------------------------------- predict
|
||||
def predict(self, dataset: DatasetH, segment="test") -> pd.Series:
|
||||
"""Average the per-seed predictions over the given segment."""
|
||||
if not self._models:
|
||||
raise ValueError("model is not fitted yet!")
|
||||
preds = [m.predict(dataset, segment=segment) for m in self._models]
|
||||
if len(preds) == 1:
|
||||
return preds[0]
|
||||
frame = pd.concat(preds, axis=1)
|
||||
return frame.mean(axis=1)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""LGBModel variant that early-stops on cross-sectional RankIC instead of l2.
|
||||
|
||||
Standard qlib ``LGBModel`` early-stops on the regression loss (mse). For
|
||||
cross-sectional alpha signals the quantity we actually care about is the per-day
|
||||
rank correlation (Rank IC), which mse early-stopping does not optimize for.
|
||||
Experiments on the 50-ETF lake (SP-5d 55-feature panel) show that early-stopping
|
||||
on a custom RankIC feval lifts RankIC 0.047 -> 0.075 vs. the mse-stopped model.
|
||||
|
||||
This class reuses ``LGBModel``'s data preparation but:
|
||||
|
||||
- tags each ``lgb.Dataset`` with per-day query ``group`` sizes so a ranking
|
||||
metric can be computed per trading day;
|
||||
- injects a custom ``feval`` (mean per-day Spearman of pred vs label) into
|
||||
``lgb.train``; early stopping then selects the iteration that maximizes
|
||||
RankIC on the valid set;
|
||||
- forces ``metric='None'`` + ``first_metric_only=True`` so early-stopping
|
||||
tracks RankIC only (not the regression loss).
|
||||
|
||||
Wired into a workflow yaml like:
|
||||
|
||||
model:
|
||||
class: RankICLGBModel
|
||||
module_path: tac_qlib.contrib.model.rank_gbdt
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.03
|
||||
num_leaves: 31
|
||||
n_estimators: 500
|
||||
...
|
||||
|
||||
The rank feval is used for early-stopping selection only; the objective stays
|
||||
the configured loss (default mse). Set ``rank_eval=False`` to fall back to the
|
||||
plain LGBModel behaviour (early-stop on the loss).
|
||||
|
||||
Generic: works for any cross-sectional panel whose qlib dataset index has a
|
||||
``datetime`` level (each level value = one query group). The per-day groups are
|
||||
derived automatically, so no universe-specific configuration is needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import lightgbm as lgb
|
||||
|
||||
from qlib.data.dataset import DatasetH
|
||||
from qlib.data.dataset.handler import DataHandlerLP
|
||||
from qlib.contrib.model.gbdt import LGBModel
|
||||
from qlib.workflow import R
|
||||
|
||||
__all__ = ["RankICLGBModel", "rankic_feval"]
|
||||
|
||||
|
||||
def _per_day_spearman(preds: np.ndarray, labels: np.ndarray, group: np.ndarray) -> float:
|
||||
"""Mean per-day Spearman rank correlation of preds vs labels.
|
||||
|
||||
``group`` holds the number of rows of each trading day (query group), in
|
||||
order. Days with <3 valid rows or a constant pred/label are skipped.
|
||||
"""
|
||||
if group is None or len(group) == 0:
|
||||
return 0.0
|
||||
offs = np.concatenate([[0], np.cumsum(group.astype(int))])
|
||||
vals = []
|
||||
for i in range(len(group)):
|
||||
s = slice(offs[i], offs[i + 1])
|
||||
p, l = preds[s], labels[s]
|
||||
if len(p) < 3 or np.std(p) == 0 or np.std(l) == 0:
|
||||
continue
|
||||
vals.append(np.corrcoef(pd.Series(p).rank(), pd.Series(l).rank())[0, 1])
|
||||
return float(np.mean(vals)) if vals else 0.0
|
||||
|
||||
|
||||
def rankic_feval(preds, dataset):
|
||||
"""LightGBM feval: mean RankIC (higher is better in lgb convention)."""
|
||||
labels = dataset.get_label()
|
||||
group = dataset.get_group()
|
||||
ric = _per_day_spearman(preds, labels, group)
|
||||
return "rankic", ric, True # (name, value, higher_is_better)
|
||||
|
||||
|
||||
class RankICLGBModel(LGBModel):
|
||||
"""LGBModel that early-stops on per-day RankIC via a custom feval."""
|
||||
|
||||
def __init__(self, rank_eval: bool = True, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.rank_eval = rank_eval
|
||||
|
||||
def _prepare_data(self, dataset: DatasetH, reweighter=None) -> List[Tuple[lgb.Dataset, str]]:
|
||||
ds_l = []
|
||||
assert "train" in dataset.segments
|
||||
for key in ["train", "valid"]:
|
||||
if key in dataset.segments:
|
||||
df = dataset.prepare(key, col_set=["feature", "label"], data_key=DataHandlerLP.DK_L)
|
||||
if df.empty:
|
||||
raise ValueError("Empty data from dataset, please check your dataset config.")
|
||||
x, y = df["feature"], df["label"]
|
||||
if y.values.ndim == 2 and y.values.shape[1] == 1:
|
||||
y = np.squeeze(y.values)
|
||||
else:
|
||||
raise ValueError("LightGBM doesn't support multi-label training")
|
||||
|
||||
if reweighter is None:
|
||||
w = None
|
||||
elif hasattr(reweighter, "reweight"):
|
||||
w = reweighter.reweight(df)
|
||||
else:
|
||||
raise ValueError("Unsupported reweighter type.")
|
||||
|
||||
# per-day query groups: each trading day is one group
|
||||
if self.rank_eval and isinstance(df.index, pd.MultiIndex) and "datetime" in df.index.names:
|
||||
group = df.groupby(level="datetime").size().to_numpy(dtype=np.int32)
|
||||
else:
|
||||
group = None
|
||||
|
||||
d = lgb.Dataset(x.values, label=y, weight=w, group=group, free_raw_data=False)
|
||||
ds_l.append((d, key))
|
||||
return ds_l
|
||||
|
||||
def _train_from_datasets(
|
||||
self,
|
||||
ds_l: List[Tuple[lgb.Dataset, str]],
|
||||
num_boost_round: Optional[int] = None,
|
||||
early_stopping_rounds: Optional[int] = None,
|
||||
verbose_eval: int = 20,
|
||||
evals_result=None,
|
||||
**kwargs,
|
||||
) -> Tuple[lgb.Booster, dict, List[str]]:
|
||||
"""Train a Booster from already-prepared ``lgb.Dataset`` objects.
|
||||
|
||||
Pure training — no ``R.log_metrics`` — so it can be called from worker
|
||||
threads (qlib's ``R`` recorder is not thread-safe; the caller decides
|
||||
when/where to log). Returns ``(booster, evals_result, segment_names)``.
|
||||
"""
|
||||
if evals_result is None:
|
||||
evals_result = {}
|
||||
ds, names = list(zip(*ds_l))
|
||||
|
||||
callbacks = [
|
||||
lgb.early_stopping(
|
||||
self.early_stopping_rounds if early_stopping_rounds is None else early_stopping_rounds
|
||||
),
|
||||
lgb.log_evaluation(period=verbose_eval),
|
||||
lgb.record_evaluation(evals_result),
|
||||
]
|
||||
if self.rank_eval:
|
||||
# early-stopping must be driven ONLY by the RankIC feval, not l2.
|
||||
# metric='None' suppresses the default l2 metric; first_metric_only
|
||||
# makes early_stopping track the single remaining (rankic) metric.
|
||||
self.params["metric"] = "None"
|
||||
self.params["first_metric_only"] = True
|
||||
feval = rankic_feval
|
||||
else:
|
||||
self.params.pop("metric", None)
|
||||
self.params.pop("first_metric_only", None)
|
||||
feval = None
|
||||
|
||||
booster = lgb.train(
|
||||
self.params,
|
||||
ds[0],
|
||||
num_boost_round=self.num_boost_round if num_boost_round is None else num_boost_round,
|
||||
valid_sets=ds,
|
||||
valid_names=names,
|
||||
feval=feval,
|
||||
callbacks=callbacks,
|
||||
**kwargs,
|
||||
)
|
||||
return booster, evals_result, list(names)
|
||||
|
||||
def _log_evals(self, evals_result, names: List[str], prefix: str = "") -> None:
|
||||
"""Log recorded evaluation curves to qlib's active recorder."""
|
||||
for k in names:
|
||||
for key, val in evals_result.get(k, {}).items():
|
||||
name = f"{prefix}{key}.{k}"
|
||||
for epoch, m in enumerate(val):
|
||||
R.log_metrics(**{name.replace("@", "_"): m}, step=epoch)
|
||||
|
||||
def fit(
|
||||
self,
|
||||
dataset: DatasetH,
|
||||
num_boost_round: Optional[int] = None,
|
||||
early_stopping_rounds: Optional[int] = None,
|
||||
verbose_eval: int = 20,
|
||||
evals_result=None,
|
||||
reweighter=None,
|
||||
**kwargs,
|
||||
):
|
||||
if evals_result is None:
|
||||
evals_result = {}
|
||||
ds_l = self._prepare_data(dataset, reweighter)
|
||||
self.model, evals_result, names = self._train_from_datasets(
|
||||
ds_l,
|
||||
num_boost_round=num_boost_round,
|
||||
early_stopping_rounds=early_stopping_rounds,
|
||||
verbose_eval=verbose_eval,
|
||||
evals_result=evals_result,
|
||||
**kwargs,
|
||||
)
|
||||
self._log_evals(evals_result, names)
|
||||
@@ -0,0 +1,3 @@
|
||||
from .optimal_stop import OptimalStopControl # noqa: F401
|
||||
|
||||
__all__ = ["OptimalStopControl"]
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Optimal-stopping / stochastic-control strategy for cross-sectional signals.
|
||||
|
||||
Entry is a control policy: a symbol opens a position only when its cross-sectional
|
||||
signal percentile is at or above ``entry_pct`` (i.e. it is one of the top-ranked
|
||||
names) and the portfolio has fewer than ``topk`` open positions.
|
||||
|
||||
Exit is an optimal-stopping rule: a held position is stopped (closed) when its
|
||||
signal percentile falls below ``exit_pct`` (the continuation value of holding is
|
||||
no longer worth the risk), OR after ``max_hold_days`` (time stop / finite
|
||||
horizon), OR when the position P&L breaches ``sl`` (loss control) and the
|
||||
position has been held at least ``min_hold_days``.
|
||||
|
||||
Sizing is fixed ``notional`` per position (equal-weight control), unlike the
|
||||
TopkDropout cash-allocation heuristic.
|
||||
|
||||
Wired into qrun workflows like any ``BaseStrategy`` (see ``PortAnaRecord``
|
||||
config). Mirrors the API usage of qlib's ``TopkDropoutStrategy``: ``Order``/
|
||||
``OrderDir`` from ``qlib.backtest.decision``, ``trade_calendar`` /
|
||||
``trade_exchange`` / ``trade_position`` injected by the backtest executor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import 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
|
||||
|
||||
__all__ = ["OptimalStopControl"]
|
||||
|
||||
DEFAULT_NOTIONAL = 20_000.0
|
||||
DEFAULT_ENTRY_PCT = 0.80
|
||||
DEFAULT_EXIT_PCT = 0.50
|
||||
DEFAULT_MAX_HOLD_DAYS = 10
|
||||
DEFAULT_MIN_HOLD_DAYS = 2
|
||||
DEFAULT_SL = -0.06
|
||||
|
||||
|
||||
class OptimalStopControl(BaseSignalStrategy):
|
||||
"""Optimal-stopping long-only strategy over a cross-sectional signal.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
topk : max number of concurrent positions.
|
||||
entry_pct : min cross-sectional score percentile required to OPEN (0..1).
|
||||
exit_pct : held positions are stopped when score percentile < exit_pct.
|
||||
max_hold_days : hard time stop (finite-horizon close).
|
||||
min_hold_days : minimum holding days before stop-loss is evaluated.
|
||||
notional : $ per position (equal-weight control).
|
||||
sl : stop-loss threshold as fraction of entry price (<= 0), disabled if 0.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
signal=None,
|
||||
topk: int = 10,
|
||||
entry_pct: float = DEFAULT_ENTRY_PCT,
|
||||
exit_pct: float = DEFAULT_EXIT_PCT,
|
||||
max_hold_days: int = DEFAULT_MAX_HOLD_DAYS,
|
||||
min_hold_days: int = DEFAULT_MIN_HOLD_DAYS,
|
||||
notional: float = DEFAULT_NOTIONAL,
|
||||
sl: float = DEFAULT_SL,
|
||||
risk_degree: float = 0.95,
|
||||
trade_exchange=None,
|
||||
level_infra=None,
|
||||
common_infra=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
signal=signal,
|
||||
trade_exchange=trade_exchange,
|
||||
level_infra=level_infra,
|
||||
common_infra=common_infra,
|
||||
**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.notional = notional
|
||||
self.sl = sl
|
||||
|
||||
# ------------------------------------------------------------------ utils
|
||||
@staticmethod
|
||||
def _pct_rank(score: pd.Series) -> pd.Series:
|
||||
return score.rank(pct=True)
|
||||
|
||||
def _entry_price(self, pos) -> float:
|
||||
# Position stores avg entry price under key "price" (see Position.position)
|
||||
price = pos.position.get("price")
|
||||
if price is None:
|
||||
price = pos.get_stock_amount("price")
|
||||
return float(price)
|
||||
|
||||
def _pnl_pct(self, pos, mark: float) -> float:
|
||||
entry = self._entry_price(pos)
|
||||
if not entry or entry != entry:
|
||||
return 0.0
|
||||
return mark / entry - 1.0
|
||||
|
||||
def _is_tradable(self, code, start, end, direction) -> bool:
|
||||
try:
|
||||
return self.trade_exchange.is_stock_tradable(
|
||||
stock_id=code, start_time=start, end_time=end, direction=direction
|
||||
)
|
||||
except TypeError: # some exchanges take no direction kwarg
|
||||
return self.trade_exchange.is_stock_tradable(stock_id=code, start_time=start, end_time=end)
|
||||
|
||||
# ------------------------------------------------------------ decision
|
||||
def generate_trade_decision(self, execute_result=None):
|
||||
trade_step = self.trade_calendar.get_trade_step()
|
||||
trade_start, trade_end = self.trade_calendar.get_step_time(trade_step)
|
||||
pred_start, pred_end = self.trade_calendar.get_step_time(trade_step, shift=1)
|
||||
pred_score = self.signal.get_signal(start_time=pred_start, end_time=pred_end)
|
||||
if isinstance(pred_score, pd.DataFrame):
|
||||
pred_score = pred_score.iloc[:, 0]
|
||||
if pred_score is None or len(pred_score) == 0:
|
||||
return TradeDecisionWO([], self)
|
||||
|
||||
pct = self._pct_rank(pred_score)
|
||||
time_per_step = self.trade_calendar.get_freq()
|
||||
current_temp = __import__("copy").deepcopy(self.trade_position)
|
||||
|
||||
holdings = {}
|
||||
for code in current_temp.get_stock_list():
|
||||
if abs(current_temp.get_stock_amount(code)) > 1e-6:
|
||||
holdings[code] = current_temp
|
||||
|
||||
# ---- optimal stopping: close held positions -----------------------
|
||||
sell_orders: List[Order] = []
|
||||
closed_today = set()
|
||||
kept = {}
|
||||
for code, pos in holdings.items():
|
||||
held = current_temp.get_stock_count(code, bar=time_per_step)
|
||||
mark = self.trade_exchange.get_deal_price(
|
||||
stock_id=code, start_time=trade_start, end_time=trade_end, direction=Order.SELL
|
||||
)
|
||||
if mark is None or mark != mark:
|
||||
continue
|
||||
rank = pct.get(code, 0.0)
|
||||
stop_pnl = held >= self.min_hold_days and self.sl < 0 and self._pnl_pct(pos, mark) <= self.sl
|
||||
if held >= self.max_hold_days or rank < self.exit_pct or stop_pnl:
|
||||
amt = abs(current_temp.get_stock_amount(code))
|
||||
o = Order(stock_id=code, amount=amt, start_time=trade_start,
|
||||
end_time=trade_end, direction=Order.SELL)
|
||||
if self.trade_exchange.check_order(o):
|
||||
sell_orders.append(o)
|
||||
self.trade_exchange.deal_order(o, position=current_temp)
|
||||
closed_today.add(code)
|
||||
else:
|
||||
kept[code] = mark
|
||||
|
||||
# ---- equal-weight control: target notional per name -----------------
|
||||
# candidate opens: top-ranked names whose signal pct >= entry_pct
|
||||
rank_desc = pred_score.sort_values(ascending=False)
|
||||
held_codes = set(kept)
|
||||
opens = []
|
||||
for sym in rank_desc.index:
|
||||
if len(opens) >= self.topk:
|
||||
break
|
||||
if sym in held_codes:
|
||||
continue
|
||||
if pct.get(sym, 0.0) < self.entry_pct:
|
||||
continue
|
||||
if not self._is_tradable(sym, trade_start, trade_end, OrderDir.BUY):
|
||||
continue
|
||||
opens.append(sym)
|
||||
|
||||
targets = held_codes | set(opens)
|
||||
if not targets:
|
||||
return TradeDecisionWO(sell_orders, self)
|
||||
|
||||
# total value (cash + marked positions) -> per-target notional
|
||||
total_value = current_temp.get_cash()
|
||||
for code, mark in kept.items():
|
||||
total_value += abs(current_temp.get_stock_amount(code)) * mark
|
||||
|
||||
target_notional = total_value * self.risk_degree / max(1, len(targets))
|
||||
|
||||
# ---- rebalance kept positions toward target weight ------------------
|
||||
buy_orders: List[Order] = []
|
||||
for code, mark in kept.items():
|
||||
cur = abs(current_temp.get_stock_amount(code)) * mark
|
||||
diff_notional = target_notional - cur
|
||||
if abs(diff_notional) / target_notional < 0.02:
|
||||
continue # skip tiny rebalances
|
||||
amount_delta = diff_notional / mark
|
||||
direction = Order.BUY if amount_delta > 0 else Order.SELL
|
||||
o = Order(stock_id=code, amount=abs(amount_delta), start_time=trade_start,
|
||||
end_time=trade_end, direction=direction)
|
||||
if self.trade_exchange.check_order(o):
|
||||
(buy_orders if direction == Order.BUY else sell_orders).append(o)
|
||||
self.trade_exchange.deal_order(o, position=current_temp)
|
||||
|
||||
# ---- open new positions at target weight ----------------------------
|
||||
for sym in opens:
|
||||
px = self.trade_exchange.get_deal_price(
|
||||
stock_id=sym, start_time=trade_start, end_time=trade_end, direction=OrderDir.BUY
|
||||
)
|
||||
if px is None or px != px or px <= 0:
|
||||
continue
|
||||
amount = target_notional / px
|
||||
factor = self.trade_exchange.get_factor(
|
||||
stock_id=sym, start_time=trade_start, end_time=trade_end
|
||||
)
|
||||
amount = self.trade_exchange.round_amount_by_trade_unit(amount, factor)
|
||||
o = Order(stock_id=sym, amount=amount, start_time=trade_start,
|
||||
end_time=trade_end, direction=Order.BUY)
|
||||
if self.trade_exchange.check_order(o):
|
||||
buy_orders.append(o)
|
||||
|
||||
return TradeDecisionWO(sell_orders + buy_orders, self)
|
||||
@@ -0,0 +1,25 @@
|
||||
from .config import (
|
||||
LakeConfig,
|
||||
BAR_FIELD_MAP,
|
||||
FREQ_TO_TIMEFRAME,
|
||||
UNKNOWN_FIELD_NAMES,
|
||||
timeframe_for_freq,
|
||||
resolve_lake_root,
|
||||
)
|
||||
from .providers import (
|
||||
LakeCalendarProvider,
|
||||
LakeInstrumentProvider,
|
||||
LakeFeatureProvider,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LakeConfig",
|
||||
"BAR_FIELD_MAP",
|
||||
"FREQ_TO_TIMEFRAME",
|
||||
"UNKNOWN_FIELD_NAMES",
|
||||
"timeframe_for_freq",
|
||||
"resolve_lake_root",
|
||||
"LakeCalendarProvider",
|
||||
"LakeInstrumentProvider",
|
||||
"LakeFeatureProvider",
|
||||
]
|
||||
@@ -0,0 +1,202 @@
|
||||
"""TradeAC lake configuration helpers.
|
||||
|
||||
The lake is a hive-partitioned parquet store (see ``tac-engine/skills/tradeac-lake``):
|
||||
|
||||
$TAC_LAKE_DIR/
|
||||
├── market=US/
|
||||
│ └── timeframe=1d/
|
||||
│ └── symbol=AAPL.parquet # OHLCV bars: t, date, o, h, l, c, v, n, vw
|
||||
├── features/ # indicators, wide format, family tier
|
||||
│ └── market=US/
|
||||
│ └── timeframe=1d/
|
||||
│ ├── family=ta/symbol=AAPL.parquet # t, sma_5, sma_20, rsi_14, ...
|
||||
│ └── family=sp/symbol=AAPL.parquet # t, sp_ou_*, sp_hmm_*, ...
|
||||
├── calendar.parquet # trading days per market
|
||||
├── coverage.parquet # per (market,timeframe,symbol) loaded windows
|
||||
└── symbols.parquet # asset master
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
#: qlib freq string (Freq.__str__) -> lake timeframe partition name
|
||||
FREQ_TO_TIMEFRAME: Dict[str, str] = {
|
||||
"day": "1d",
|
||||
"1d": "1d",
|
||||
"min": "1m",
|
||||
"1min": "1m",
|
||||
"5min": "5m",
|
||||
"10min": "10m",
|
||||
"15min": "15m",
|
||||
"30min": "30m",
|
||||
"hour": "1h",
|
||||
"1hour": "1h",
|
||||
"2hour": "2h",
|
||||
"4hour": "4h",
|
||||
"week": "1w",
|
||||
"1week": "1w",
|
||||
"month": "1M",
|
||||
"1month": "1M",
|
||||
}
|
||||
|
||||
#: bar-field map: qlib field name (without the leading ``$``) -> lake bar column
|
||||
BAR_FIELD_MAP: Dict[str, str] = {
|
||||
"open": "o",
|
||||
"high": "h",
|
||||
"low": "l",
|
||||
"close": "c",
|
||||
"volume": "v",
|
||||
"vwap": "vw",
|
||||
"avg_amount": "vw", # amount / volume
|
||||
}
|
||||
|
||||
#: fields that qlib core/backtest queries but the lake does not store -> all-NaN
|
||||
UNKNOWN_FIELD_NAMES = ("factor", "change", "trade_unit", "suspend_flag")
|
||||
|
||||
#: columns in the parquet files that are not features
|
||||
NON_FEATURE_COLUMNS = ("t", "date", "market", "timeframe", "symbol")
|
||||
|
||||
|
||||
def timeframe_for_freq(freq: str) -> str:
|
||||
"""Map a qlib frequency (e.g. ``day``, ``1min``) to a lake timeframe (e.g. ``1d``)."""
|
||||
f = str(freq).lower()
|
||||
if f not in FREQ_TO_TIMEFRAME:
|
||||
raise ValueError(
|
||||
f"unsupported qlib freq {freq!r}; supported freqs: {sorted(set(FREQ_TO_TIMEFRAME))}"
|
||||
)
|
||||
return FREQ_TO_TIMEFRAME[f]
|
||||
|
||||
|
||||
def resolve_lake_root(lake_root: Optional[str] = None) -> Path:
|
||||
"""Resolve the lake root: explicit arg > ``TAC_LAKE_DIR`` (no fallback).
|
||||
|
||||
``TAC_LAKE_DIR`` is **mandatory** — there is deliberately no default
|
||||
A missing/empty value raises so a
|
||||
misconfigured environment never silently points at a wrong directory.
|
||||
"""
|
||||
if lake_root is None:
|
||||
lake_root = os.environ.get("TAC_LAKE_DIR")
|
||||
if not lake_root:
|
||||
raise RuntimeError(
|
||||
"TAC_LAKE_DIR is not set. Point it at the TradeAC lake root, e.g. "
|
||||
"export TAC_LAKE_DIR=/home/data/lake (docker) or set an absolute "
|
||||
"path in your local .env."
|
||||
)
|
||||
return Path(str(lake_root)).expanduser().resolve()
|
||||
|
||||
|
||||
class LakeConfig:
|
||||
"""Path helpers + cached readers for a (lake_root, market) combination."""
|
||||
|
||||
def __init__(self, lake_root: Optional[str] = None, market: str = "US"):
|
||||
self.lake_root: Path = resolve_lake_root(lake_root)
|
||||
self.market: str = (market or "US").upper()
|
||||
|
||||
# ---- paths --------------------------------------------------------------
|
||||
def bar_dir(self, timeframe: str) -> Path:
|
||||
return self.lake_root / f"market={self.market}" / f"timeframe={timeframe}"
|
||||
|
||||
def bar_path(self, timeframe: str, symbol: str) -> Path:
|
||||
return self.bar_dir(timeframe) / f"symbol={str(symbol).upper()}.parquet"
|
||||
|
||||
def features_dir(self, timeframe: str) -> Path:
|
||||
return self.lake_root / "features" / f"market={self.market}" / f"timeframe={timeframe}"
|
||||
|
||||
def features_path(self, timeframe: str, symbol: str) -> Path:
|
||||
# Legacy flat path (no family tier). Prefer `load_features` which
|
||||
# resolves the family=ta|sp partition layout.
|
||||
return self.features_dir(timeframe) / f"symbol={str(symbol).upper()}.parquet"
|
||||
|
||||
def load_features(self, timeframe: str, symbol: str) -> pd.DataFrame:
|
||||
"""All feature columns for a symbol, merging the `family=ta` and
|
||||
`family=sp` partitions by timestamp. Returns an empty frame when no
|
||||
feature files exist (legacy flat layout falls back transparently)."""
|
||||
sym = str(symbol).upper()
|
||||
frames = []
|
||||
for family in ("ta", "sp"):
|
||||
p = self.features_dir(timeframe) / f"family={family}" / f"symbol={sym}.parquet"
|
||||
if p.exists():
|
||||
frames.append(pd.read_parquet(p))
|
||||
if not frames:
|
||||
flat = self.features_dir(timeframe) / f"symbol={sym}.parquet"
|
||||
if flat.exists():
|
||||
return pd.read_parquet(flat)
|
||||
return pd.DataFrame()
|
||||
if len(frames) == 1:
|
||||
return frames[0]
|
||||
merged = frames[0]
|
||||
for extra in frames[1:]:
|
||||
merged = merged.merge(extra, on="t", how="outer", suffixes=("", "_dup"))
|
||||
for c in [c for c in merged.columns if c.endswith("_dup")]:
|
||||
merged = merged.drop(columns=c)
|
||||
return merged
|
||||
|
||||
def calendar_path(self) -> Path:
|
||||
return self.lake_root / "calendar.parquet"
|
||||
|
||||
def symbols_path(self) -> Path:
|
||||
return self.lake_root / "symbols.parquet"
|
||||
|
||||
def coverage_path(self) -> Path:
|
||||
return self.lake_root / "coverage.parquet"
|
||||
|
||||
# ---- metadata readers ----------------------------------------------------
|
||||
def load_symbols(self) -> List[str]:
|
||||
"""All symbols known to the lake (from ``symbols.parquet``)."""
|
||||
p = self.symbols_path()
|
||||
if not p.exists():
|
||||
return []
|
||||
df = pd.read_parquet(p)
|
||||
if "symbol" not in df.columns:
|
||||
return []
|
||||
return sorted(df["symbol"].astype(str).str.upper().tolist())
|
||||
|
||||
def symbol_spans(self, symbol: str, timeframe: str) -> List[tuple]:
|
||||
"""Listing span(s) ``[(start_iso, end_iso)]`` for a symbol from coverage.parquet."""
|
||||
p = self.coverage_path()
|
||||
if p.exists():
|
||||
try:
|
||||
df = pd.read_parquet(p)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
df = pd.DataFrame()
|
||||
if len(df):
|
||||
df = df[
|
||||
(df.get("market") == self.market)
|
||||
& (df.get("timeframe") == timeframe)
|
||||
& (df.get("symbol") == str(symbol).upper())
|
||||
]
|
||||
if len(df):
|
||||
row = df.iloc[0]
|
||||
first = pd.Timestamp(row["first_t"]).date()
|
||||
last = pd.Timestamp(row["last_t"]).date()
|
||||
return [(first.isoformat(), last.isoformat())]
|
||||
# fallback: derive from the bar file itself
|
||||
p = self.bar_path(timeframe, symbol)
|
||||
if p.exists():
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
tbl = pq.read_table(p, columns=["t"])
|
||||
first = pd.Timestamp(tbl.column("t")[0].as_py()).date()
|
||||
last = pd.Timestamp(tbl.column("t")[-1].as_py()).date()
|
||||
return [(first.isoformat(), last.isoformat())]
|
||||
return [("1970-01-01", "2099-12-31")]
|
||||
|
||||
def load_calendar_dates(self) -> List[pd.Timestamp]:
|
||||
"""Trading days (midnight timestamps) for the market, from ``calendar.parquet``."""
|
||||
p = self.calendar_path()
|
||||
if p.exists():
|
||||
df = pd.read_parquet(p)
|
||||
if "date" in df.columns:
|
||||
if "market" in df.columns:
|
||||
df = df[df["market"] == self.market]
|
||||
dates = pd.to_datetime(df["date"]).dt.normalize().sort_values().unique()
|
||||
return [pd.Timestamp(x) for x in dates]
|
||||
return []
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"LakeConfig(lake_root={self.lake_root}, market={self.market})"
|
||||
@@ -0,0 +1,230 @@
|
||||
"""qlib data providers backed by the TradeAC parquet lake.
|
||||
|
||||
These providers plug into the standard qlib mechanism: ``qlib.init(calendar_provider=...,
|
||||
instrument_provider=..., feature_provider=...)`` instantiates them and binds them to the
|
||||
``Cal`` / ``Inst`` / ``FeatureD`` wrappers (see ``qlib.data.data.register_all_wrappers``).
|
||||
The rest of qlib (``LocalDatasetProvider`` expression engine, backtest ``Exchange``) keeps
|
||||
working unchanged because the interface contract is identical to the file-based providers:
|
||||
|
||||
- ``feature()`` returns a ``pd.Series`` indexed by the **calendar position** range
|
||||
``[start_index, end_index]`` (matching ``FileFeatureStorage.__getitem__`` semantics).
|
||||
- ``list_instruments()`` returns ``{symbol: [(start, end), ...]}``.
|
||||
- ``load_calendar()`` returns a list of ``pd.Timestamp`` trading days.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from qlib.data.data import CalendarProvider, FeatureProvider, InstrumentProvider
|
||||
from qlib.log import get_module_logger
|
||||
|
||||
from .config import (
|
||||
BAR_FIELD_MAP,
|
||||
LakeConfig,
|
||||
UNKNOWN_FIELD_NAMES,
|
||||
timeframe_for_freq,
|
||||
)
|
||||
|
||||
logger = get_module_logger("tac_qlib.data.providers")
|
||||
|
||||
|
||||
def _day_freq(freq: str) -> bool:
|
||||
return str(freq).lower() in ("day", "1d")
|
||||
|
||||
|
||||
def _calendar_keys(cal: List[pd.Timestamp], freq: str) -> pd.Index:
|
||||
"""Convert calendar timestamps into the same key space as the lake parquet."""
|
||||
if _day_freq(freq):
|
||||
return pd.Index([pd.Timestamp(x).date() for x in cal])
|
||||
return pd.Index([pd.Timestamp(x) for x in cal])
|
||||
|
||||
|
||||
class LakeCalendarProvider(CalendarProvider):
|
||||
"""Trading calendar read from ``<lake>/calendar.parquet`` (fallback: derived from bars)."""
|
||||
|
||||
def __init__(self, lake_root: Optional[str] = None, market: str = "US"):
|
||||
super().__init__()
|
||||
self.cfg = LakeConfig(lake_root, market)
|
||||
|
||||
def load_calendar(self, freq, future):
|
||||
timeframe = timeframe_for_freq(freq)
|
||||
if not _day_freq(freq):
|
||||
raise NotImplementedError(
|
||||
f"freq={freq!r} (timeframe={timeframe}) is not supported yet: the lake calendar "
|
||||
f"only covers daily sessions; add a minute-level calendar to `calendar.parquet`"
|
||||
)
|
||||
|
||||
dates = self.cfg.load_calendar_dates()
|
||||
if not dates:
|
||||
# Fallback: derive the trading-day set from the persisted bar files.
|
||||
bar_dir = self.cfg.bar_dir(timeframe)
|
||||
if bar_dir.exists():
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
cal: Dict[pd.Timestamp, None] = {}
|
||||
for p in sorted(bar_dir.glob("symbol=*.parquet")):
|
||||
tbl = pq.read_table(p, columns=["t"])
|
||||
for v in tbl.column("t"):
|
||||
cal[pd.Timestamp(v.as_py()).normalize()] = None
|
||||
dates = sorted(cal.keys())
|
||||
if not dates:
|
||||
return []
|
||||
|
||||
if future:
|
||||
# append the next calendar day so that "today" is a valid trade date
|
||||
last = dates[-1]
|
||||
dates = dates + [pd.Timestamp(last) + pd.Timedelta(days=1)]
|
||||
return dates
|
||||
|
||||
|
||||
class LakeInstrumentProvider(InstrumentProvider):
|
||||
"""Instruments from ``<lake>/symbols.parquet`` with listing spans from ``coverage.parquet``."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lake_root: Optional[str] = None,
|
||||
market: str = "US",
|
||||
markets: Optional[Dict[str, list]] = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.cfg = LakeConfig(lake_root, market)
|
||||
#: optional named pools, e.g. ``{"sp500": ["AAPL", "MSFT"], "etf": ["SPY"]}``.
|
||||
#: ``all`` / any unregistered name resolves to every symbol in the lake.
|
||||
self.markets: Dict[str, list] = markets or {}
|
||||
|
||||
def _resolve_symbols(self, market: Union[str, list]) -> List[str]:
|
||||
if isinstance(market, (list, tuple, pd.Index, np.ndarray)):
|
||||
return [str(s).upper() for s in market]
|
||||
if isinstance(market, str) and "," in market:
|
||||
return [s.strip().upper() for s in market.split(",") if s.strip()]
|
||||
if market in self.markets:
|
||||
return [str(s).upper() for s in self.markets[market]]
|
||||
return self.cfg.load_symbols()
|
||||
|
||||
def list_instruments(self, instruments, start_time=None, end_time=None, freq="day", as_list=False):
|
||||
market = instruments["market"]
|
||||
timeframe = timeframe_for_freq(freq)
|
||||
|
||||
symbols = self._resolve_symbols(market)
|
||||
if not symbols:
|
||||
if as_list:
|
||||
return []
|
||||
return {}
|
||||
|
||||
# clip listing spans to the queried window (mirror of LocalInstrumentProvider)
|
||||
from qlib.data.data import Cal # pylint: disable=C0415
|
||||
|
||||
cal = Cal.calendar(freq=freq)
|
||||
start_time = pd.Timestamp(start_time or cal[0])
|
||||
end_time = pd.Timestamp(end_time or cal[-1])
|
||||
|
||||
out: Dict[str, list] = {}
|
||||
for symbol in symbols:
|
||||
spans = []
|
||||
for begin, end in self.cfg.symbol_spans(symbol, timeframe):
|
||||
lo = max(start_time, pd.Timestamp(begin))
|
||||
hi = min(end_time, pd.Timestamp(end))
|
||||
if lo <= hi:
|
||||
spans.append((lo, hi))
|
||||
if spans:
|
||||
out[symbol] = spans
|
||||
|
||||
filter_pipe = instruments.get("filter_pipe") or []
|
||||
for filter_config in filter_pipe:
|
||||
from qlib.data import filter as F # pylint: disable=C0415
|
||||
|
||||
filter_t = getattr(F, filter_config["filter_type"]).from_config(filter_config)
|
||||
out = filter_t(out, start_time, end_time, freq)
|
||||
|
||||
if as_list:
|
||||
return list(out)
|
||||
return out
|
||||
|
||||
|
||||
class LakeFeatureProvider(FeatureProvider):
|
||||
"""Feature data from the lake parquet (OHLCV bars + pre-computed ta-lib features).
|
||||
|
||||
Field routing:
|
||||
- ``$open/$high/$low/$close/$volume/$vwap`` -> bar parquet columns
|
||||
- ``$amount`` (= v*vw), ``$avg_amount`` (= vw) -> derived from bar parquet
|
||||
- ``$factor/$change/...`` -> all-NaN (not stored)
|
||||
- anything else -> a ta-lib column in the features parquet
|
||||
"""
|
||||
|
||||
def __init__(self, lake_root: Optional[str] = None, market: str = "US"):
|
||||
super().__init__()
|
||||
self.cfg = LakeConfig(lake_root, market)
|
||||
self._bar_cache: Dict[tuple, pd.DataFrame] = {}
|
||||
self._feature_cache: Dict[tuple, pd.DataFrame] = {}
|
||||
|
||||
# ------------------------------------------------------------------ caches
|
||||
def _load_bar_df(self, instrument: str, timeframe: str) -> pd.DataFrame:
|
||||
key = (instrument, timeframe)
|
||||
if key not in self._bar_cache:
|
||||
p = self.cfg.bar_path(timeframe, instrument)
|
||||
self._bar_cache[key] = pd.read_parquet(p) if p.exists() else pd.DataFrame()
|
||||
return self._bar_cache[key]
|
||||
|
||||
def _load_feature_df(self, instrument: str, timeframe: str) -> pd.DataFrame:
|
||||
key = (instrument, timeframe)
|
||||
if key not in self._feature_cache:
|
||||
self._feature_cache[key] = self.cfg.load_features(timeframe, instrument)
|
||||
return self._feature_cache[key]
|
||||
|
||||
@staticmethod
|
||||
def _keys(df: pd.DataFrame, freq: str) -> pd.Index:
|
||||
ts = pd.to_datetime(df["t"])
|
||||
return ts.dt.date if _day_freq(freq) else ts
|
||||
|
||||
# ------------------------------------------------------------------ fields
|
||||
def _extract(self, instrument: str, field: str, timeframe: str, freq: str) -> Optional[pd.Series]:
|
||||
"""Return the field as a Series keyed by date/timestamp (None if not present in the lake)."""
|
||||
bar = self._load_bar_df(instrument, timeframe)
|
||||
|
||||
if field in BAR_FIELD_MAP:
|
||||
col = BAR_FIELD_MAP[field]
|
||||
if col in bar.columns:
|
||||
return bar[col].astype(float).set_axis(self._keys(bar, freq))
|
||||
return None
|
||||
if field == "amount":
|
||||
if "v" in bar.columns and "vw" in bar.columns:
|
||||
return (bar["v"] * bar["vw"]).astype(float).set_axis(self._keys(bar, freq))
|
||||
return None
|
||||
if field in UNKNOWN_FIELD_NAMES:
|
||||
return None
|
||||
|
||||
feat = self._load_feature_df(instrument, timeframe)
|
||||
if field in feat.columns:
|
||||
return feat[field].astype(float).set_axis(self._keys(feat, freq))
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------ api
|
||||
def _get_calendar(self, freq: str) -> List[pd.Timestamp]:
|
||||
from qlib.data.data import Cal # pylint: disable=C0415
|
||||
|
||||
cal = Cal.calendar(freq=freq)
|
||||
return list(cal)
|
||||
|
||||
def feature(self, instrument, field, start_index, end_index, freq):
|
||||
field = str(field)[1:]
|
||||
timeframe = timeframe_for_freq(freq)
|
||||
|
||||
cal = self._get_calendar(freq)
|
||||
n = len(cal)
|
||||
lo = max(0, int(start_index))
|
||||
hi = min(n - 1, int(end_index))
|
||||
if lo > hi:
|
||||
return pd.Series(dtype=np.float32)
|
||||
|
||||
keys = _calendar_keys(cal[lo : hi + 1], freq)
|
||||
ser = self._extract(str(instrument).upper(), field, timeframe, freq)
|
||||
if ser is None:
|
||||
vals = np.full(len(keys), np.nan, dtype=np.float64)
|
||||
else:
|
||||
vals = ser.reindex(keys).to_numpy(dtype=np.float64)
|
||||
return pd.Series(vals, index=pd.RangeIndex(lo, hi + 1))
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Drop-in replacement for ``qlib.init`` that configures qlib against the TradeAC lake.
|
||||
|
||||
Usage::
|
||||
|
||||
from tac_qlib.qlib_init import qlib_init
|
||||
|
||||
qlib_init(
|
||||
provider_uri="/path/to/lake", # same layout as tac-engine's TAC_LAKE_DIR
|
||||
market="US",
|
||||
freq="day",
|
||||
markets={"sp500": ["AAPL", "MSFT"]}, # optional named instrument pools
|
||||
**qlib_init_kwargs, # anything qlib.init accepts
|
||||
)
|
||||
|
||||
It sets ``provider_uri`` to the lake root and points the calendar / instrument / feature
|
||||
providers at the lake-backed implementations, then delegates to the upstream ``qlib.init``.
|
||||
The dataset provider (expression engine, backtest ``Exchange``) is left untouched, so the
|
||||
rest of the qlib workflow is byte-for-byte upstream code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
import qlib
|
||||
from qlib.config import C
|
||||
|
||||
from .data.config import LakeConfig, resolve_lake_root
|
||||
|
||||
PROVIDERS = "tac_qlib.data.providers"
|
||||
|
||||
|
||||
def provider_config(cls: str, **kwargs) -> dict:
|
||||
return {"class": f"{PROVIDERS}.{cls}", "kwargs": kwargs}
|
||||
|
||||
|
||||
def qlib_init(
|
||||
provider_uri: Optional[str] = None,
|
||||
market: str = "US",
|
||||
freq: str = "day",
|
||||
markets: Optional[Dict[str, list]] = None,
|
||||
**qlib_kwargs,
|
||||
) -> qlib.Initialized:
|
||||
"""Initialize qlib with the lake-backed data providers and re-export qlib.init results."""
|
||||
if qlib_kwargs.pop("calendar_provider", None) is not None or qlib_kwargs.pop("instrument_provider", None) is not None:
|
||||
raise ValueError("calendar_provider / instrument_provider are managed by tac_qlib; use `market` instead")
|
||||
|
||||
lake_root = resolve_lake_root(provider_uri)
|
||||
if freq != "day":
|
||||
raise ValueError("freq must be 'day' for now: the lake calendar only covers daily sessions")
|
||||
|
||||
qlib_kwargs.setdefault("provider_uri", lake_root)
|
||||
qlib_kwargs.setdefault("region", "us")
|
||||
qlib_kwargs.setdefault("expression_cache", None)
|
||||
qlib_kwargs.setdefault("dataset_cache", None)
|
||||
qlib_kwargs["calendar_provider"] = provider_config("LakeCalendarProvider", lake_root=lake_root, market=market)
|
||||
qlib_kwargs["instrument_provider"] = provider_config(
|
||||
"LakeInstrumentProvider", lake_root=lake_root, market=market, markets=markets or {}
|
||||
)
|
||||
qlib_kwargs["feature_provider"] = provider_config("LakeFeatureProvider", lake_root=lake_root, market=market)
|
||||
return qlib.init(**qlib_kwargs)
|
||||
|
||||
|
||||
__all__ = ["qlib_init", "provider_config"]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
"""Risk-limit spec shared by backtest and live executor.
|
||||
|
||||
One JSON spec is consulted by BOTH ``rd_backtest`` (as a strategy filter
|
||||
overlay) and ``rd_strategy_targets`` (as pre-gate + sizing caps), so a limit
|
||||
that holds in backtest holds in live — the round's ``strategy_snapshot``
|
||||
stores the exact spec used.
|
||||
|
||||
Supported keys (all optional, all pct are 0-100):
|
||||
liquidity_floor_adv : min avg daily dollar volume (USD) per symbol.
|
||||
Names below it are filtered out of the tradable set.
|
||||
size_cap_pct : max notional per name as % of account equity.
|
||||
concentration_cap_pct: max total deployed as % of account equity.
|
||||
drawdown_pause_pct : if equity drawdown from peak exceeds this, new buys
|
||||
are paused (executor gate; not expressible in a
|
||||
one-shot qlib backtest and therefore documented).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def parse_limits(spec: Optional[str]) -> Dict[str, float]:
|
||||
"""Parse a risk_limits JSON string into a flat float map (empty = no limits)."""
|
||||
if not spec or not str(spec).strip():
|
||||
return {}
|
||||
if isinstance(spec, dict):
|
||||
raw = spec
|
||||
else:
|
||||
raw = json.loads(str(spec))
|
||||
out: Dict[str, float] = {}
|
||||
for k in ("liquidity_floor_adv", "size_cap_pct", "concentration_cap_pct", "drawdown_pause_pct"):
|
||||
v = raw.get(k)
|
||||
if v is not None and str(v) != "":
|
||||
out[k] = float(v)
|
||||
return out
|
||||
|
||||
|
||||
def dollar_adv(
|
||||
symbols: List[str],
|
||||
lake_root: str = "",
|
||||
market: str = "US",
|
||||
asof: Optional[str] = None,
|
||||
lookback: int = 20,
|
||||
) -> Dict[str, float]:
|
||||
"""Average daily dollar volume per symbol over the ``lookback`` sessions
|
||||
ending at ``asof`` (inclusive), read straight from lake 1d bars. Symbols
|
||||
with no lake data map to 0.0 (treated as illiquid)."""
|
||||
from tac_qlib.data.config import LakeConfig, resolve_lake_root
|
||||
|
||||
cfg = LakeConfig(resolve_lake_root(lake_root or None), market)
|
||||
asof_ts = pd.Timestamp(asof) if asof else pd.Timestamp.utcnow()
|
||||
out: Dict[str, float] = {}
|
||||
for sym in sorted({str(s).upper() for s in symbols}):
|
||||
p = cfg.bar_path("1d", sym)
|
||||
if not p.exists():
|
||||
out[sym] = 0.0
|
||||
continue
|
||||
try:
|
||||
df = pd.read_parquet(p)
|
||||
except Exception:
|
||||
out[sym] = 0.0
|
||||
continue
|
||||
if not len(df):
|
||||
out[sym] = 0.0
|
||||
continue
|
||||
tcol = df["t"] if "t" in df.columns else df["date"]
|
||||
ts = pd.to_datetime(tcol)
|
||||
df = df.assign(_t=ts).sort_values("_t")
|
||||
df = df[df["_t"] <= asof_ts]
|
||||
if not len(df):
|
||||
out[sym] = 0.0
|
||||
continue
|
||||
df = df.tail(lookback)
|
||||
px = df["vw"] if "vw" in df.columns else df["c"]
|
||||
out[sym] = float((df["v"] * px).mean()) if len(df) else 0.0
|
||||
return out
|
||||
|
||||
|
||||
def apply_to_ranking(
|
||||
ranking: pd.Series,
|
||||
adv: Dict[str, float],
|
||||
limits: Dict[str, float],
|
||||
account: float,
|
||||
risk_degree: float,
|
||||
topk: int,
|
||||
) -> Tuple[pd.Series, Dict[str, Any]]:
|
||||
"""Executor-side overlay on the ranked signal (``pd.Series`` symbol -> score).
|
||||
|
||||
Returns (filtered_ranking, applied) where ``filtered_ranking`` has
|
||||
illiquid names removed and ``applied`` records what the limits did (audit
|
||||
trail). Per-name notional and total caps are reported but not folded into
|
||||
the ranking — the caller sizes targets and can read ``applied`` to cap.
|
||||
"""
|
||||
applied: Dict[str, Any] = {"notes": [], "dropped_liquidity": []}
|
||||
filtered = ranking
|
||||
floor = limits.get("liquidity_floor_adv")
|
||||
if floor:
|
||||
dropped = [s for s in filtered.index if adv.get(str(s).upper(), 0.0) < floor]
|
||||
if dropped:
|
||||
filtered = filtered.drop(index=[s for s in dropped if s in filtered.index])
|
||||
applied["dropped_liquidity"] = [str(s) for s in dropped]
|
||||
applied["notes"].append(f"liquidity floor ${floor:,.0f} ADV dropped {len(dropped)}")
|
||||
per_name = account * risk_degree / max(topk, 1)
|
||||
size_cap = limits.get("size_cap_pct")
|
||||
if size_cap:
|
||||
cap = account * size_cap / 100.0
|
||||
applied["size_cap_notional"] = round(cap, 2)
|
||||
if per_name > cap:
|
||||
applied["per_name_capped_from"] = round(per_name, 2)
|
||||
per_name = cap
|
||||
applied["notes"].append(f"size cap {size_cap:g}% cut per-name notional to ${cap:,.2f}")
|
||||
applied["per_name_notional"] = round(per_name, 2)
|
||||
n_buys = min(topk, max(len(filtered), 0))
|
||||
conc = limits.get("concentration_cap_pct")
|
||||
if conc:
|
||||
conc_cap = account * conc / 100.0
|
||||
applied["concentration_cap_notional"] = round(conc_cap, 2)
|
||||
total = per_name * max(n_buys, 1)
|
||||
if total > conc_cap:
|
||||
applied["total_capped_from"] = round(total, 2)
|
||||
applied["notes"].append(f"concentration cap {conc:g}% cut total to ${conc_cap:,.2f}")
|
||||
per_name = conc_cap / max(n_buys, 1)
|
||||
applied["per_name_capped_from"] = applied.get("per_name_capped_from") or round(total / max(n_buys, 1), 2)
|
||||
applied["per_name_notional"] = round(per_name, 2)
|
||||
applied["total_notional"] = round(min(total, conc_cap), 2)
|
||||
else:
|
||||
applied["total_notional"] = round(per_name * n_buys, 2)
|
||||
return filtered, applied
|
||||
|
||||
|
||||
def drawdown_pause(equity: float, peak_equity: float, limits: Dict[str, float]) -> Tuple[bool, Optional[str]]:
|
||||
"""Executor gate: True when drawdown from peak exceeds drawdown_pause_pct."""
|
||||
pct = limits.get("drawdown_pause_pct")
|
||||
if not pct or not peak_equity or not equity:
|
||||
return False, None
|
||||
dd = (peak_equity - equity) / peak_equity * 100.0
|
||||
if dd >= pct:
|
||||
return True, f"drawdown {dd:.1f}% >= pause {pct:g}% (peak ${peak_equity:,.2f}, equity ${equity:,.2f})"
|
||||
return False, None
|
||||
@@ -0,0 +1,635 @@
|
||||
"""Trace tools for the tac-qlib-rd MCP server — replace the `trace.sh`/`trace_db.py`/`git_exp.sh` scripts.
|
||||
|
||||
The R&D lineage (`/rd/lineage`) and the round book build on the `rd_experiments`
|
||||
Postgres table. This module exposes the full trace lifecycle as MCP tools so an
|
||||
agent can drive tracing through the long-lived tac-qlib-rd server instead of
|
||||
shelling out to bash scripts (which re-import psycopg + reconnect per call and
|
||||
force the agent to parse prose output).
|
||||
|
||||
Because the server is long-lived, `psycopg` is imported and the DB connection
|
||||
is opened once per call (not once per script invocation), and every tool returns
|
||||
a single JSON object — no output parsing, fully deterministic.
|
||||
|
||||
Git operations (fork / commit / push on the `experiments/` clone) are performed
|
||||
via `git` subprocess with the repo's mandated credential helper, exactly as the
|
||||
old `git_exp.sh` did.
|
||||
|
||||
Env (from the repo `.env`, already loaded by rd_server): DATABASE_URL,
|
||||
EMBEDDING_API_BASE_URL, EMBEDDING_API_KEY, GIT_USER, GIT_PASS, GIT_REPO_URL,
|
||||
TAC_LAKE_DIR.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from tac_qlib.trace_embed import embed
|
||||
|
||||
EMBEDDING_DIM = 384
|
||||
_MIN_SCORE = 0.5
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- db
|
||||
def _conn():
|
||||
url = (os.environ.get("DATABASE_URL") or "").strip()
|
||||
if not url:
|
||||
raise RuntimeError("DATABASE_URL is not set")
|
||||
return psycopg.connect(url, row_factory=dict_row)
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _vector_literal(vec: Optional[List[float]]) -> Optional[str]:
|
||||
if not vec:
|
||||
return None
|
||||
return "[" + ",".join(repr(float(v)) for v in vec) + "]"
|
||||
|
||||
|
||||
def _jsonable(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return {k: _jsonable(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple, set)):
|
||||
return [_jsonable(v) for v in obj]
|
||||
if hasattr(obj, "isoformat"):
|
||||
return obj.isoformat()
|
||||
return obj
|
||||
|
||||
|
||||
def _row_json(row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {}
|
||||
for k, v in row.items():
|
||||
if k in ("rational_embedding", "details_embedding"):
|
||||
out[k] = v.tolist() if hasattr(v, "tolist") else v
|
||||
elif k == "metrics" and isinstance(v, str):
|
||||
try:
|
||||
out[k] = json.loads(v)
|
||||
except Exception: # noqa: BLE001
|
||||
out[k] = v
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _get_row(exp_id: int) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM rd_experiments WHERE id = %s", (exp_id,))
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def _all_rows(limit: int) -> List[Dict[str, Any]]:
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT * FROM rd_experiments ORDER BY id DESC LIMIT %s", (limit,))
|
||||
return cur.fetchall()
|
||||
|
||||
|
||||
def _init_db() -> None:
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM pg_extension WHERE extname = 'vector'")
|
||||
if not cur.fetchone():
|
||||
raise RuntimeError("pgvector extension is not installed. Run: CREATE EXTENSION IF NOT EXISTS vector;")
|
||||
cur.execute("SELECT to_regclass('public.rd_experiments')")
|
||||
exists = bool(cur.fetchone())
|
||||
if not exists:
|
||||
DDL = """
|
||||
CREATE TABLE IF NOT EXISTS rd_experiments (
|
||||
id bigserial PRIMARY KEY NOT NULL,
|
||||
experiment_name text,
|
||||
rational text NOT NULL,
|
||||
rational_embedding vector(384),
|
||||
details text,
|
||||
details_embedding vector(384),
|
||||
evaluation text,
|
||||
metrics jsonb,
|
||||
evolved_from bigint,
|
||||
start_ts timestamptz DEFAULT now() NOT NULL,
|
||||
end_ts timestamptz,
|
||||
git_branch text NOT NULL,
|
||||
experiment_ref_id text,
|
||||
session_id text,
|
||||
mlruns_dir text,
|
||||
status text DEFAULT 'starting' NOT NULL,
|
||||
created_at timestamptz DEFAULT now() NOT NULL,
|
||||
updated_at timestamptz DEFAULT now() NOT NULL
|
||||
);
|
||||
"""
|
||||
for stmt in DDL.split(";"):
|
||||
stmt = stmt.strip()
|
||||
if stmt:
|
||||
cur.execute(stmt)
|
||||
# Idempotent backfills so pre-existing tables gain new columns.
|
||||
for stmt in ["ALTER TABLE rd_experiments ADD COLUMN IF NOT EXISTS session_id text;"]:
|
||||
stmt = stmt.strip()
|
||||
if stmt:
|
||||
cur.execute(stmt)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _search_evolved_from(text: str, limit: int = 5) -> Optional[int]:
|
||||
vec = embed(text)
|
||||
if not vec:
|
||||
return None
|
||||
lit = _vector_literal(vec)
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, 1 - LEAST(
|
||||
COALESCE(rational_embedding <=> %s::vector, 1),
|
||||
COALESCE(details_embedding <=> %s::vector, 1)
|
||||
) AS similarity
|
||||
FROM rd_experiments
|
||||
ORDER BY similarity DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(lit, lit, limit),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
for row in rows:
|
||||
if row["similarity"] is not None and float(row["similarity"]) >= _MIN_SCORE:
|
||||
return int(row["id"])
|
||||
return None
|
||||
|
||||
|
||||
def _search(query: str, limit: int = 10, min_score: float = _MIN_SCORE) -> List[Dict[str, Any]]:
|
||||
vec = embed(query)
|
||||
if not vec:
|
||||
needle = f"%{query.replace('%', ' ').strip()}%"
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, rational, details, git_branch, experiment_ref_id, status,
|
||||
start_ts, end_ts, evaluation, session_id
|
||||
FROM rd_experiments
|
||||
WHERE rational ILIKE %s OR details ILIKE %s
|
||||
ORDER BY id DESC LIMIT %s
|
||||
""",
|
||||
(needle, needle, limit),
|
||||
)
|
||||
return [_row_json(r) for r in cur.fetchall()]
|
||||
lit = _vector_literal(vec)
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, rational, details, git_branch, experiment_ref_id, status,
|
||||
start_ts, end_ts, evaluation, session_id,
|
||||
1 - LEAST(
|
||||
COALESCE(rational_embedding <=> %s::vector, 1),
|
||||
COALESCE(details_embedding <=> %s::vector, 1)
|
||||
) AS similarity
|
||||
FROM rd_experiments
|
||||
ORDER BY similarity DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(lit, lit, limit),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
sim = float(r.get("similarity") or 0)
|
||||
if sim < min_score:
|
||||
continue
|
||||
r = dict(r)
|
||||
r["similarity"] = sim
|
||||
out.append(_row_json(r))
|
||||
return out
|
||||
|
||||
|
||||
def _start(
|
||||
rational: str,
|
||||
details: str = "",
|
||||
evolved_from: str = "none",
|
||||
experiment_name: str = "",
|
||||
branch: str = "",
|
||||
session_id: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
rational = rational.strip()
|
||||
details = (details or "").strip()
|
||||
if not rational:
|
||||
raise ValueError("--rational is required")
|
||||
|
||||
rational_vec = _vector_literal(embed(rational))
|
||||
details_vec = _vector_literal(embed(details)) if details else None
|
||||
|
||||
evo: Optional[int] = None
|
||||
if evolved_from == "auto":
|
||||
evo = _search_evolved_from(f"{rational}\n{details}") if rational_vec or details_vec else None
|
||||
elif evolved_from and evolved_from.isdigit():
|
||||
evo = int(evolved_from)
|
||||
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO rd_experiments
|
||||
(experiment_name, rational, rational_embedding, details, details_embedding,
|
||||
evolved_from, start_ts, git_branch, status, session_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'starting', %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
experiment_name or None,
|
||||
rational,
|
||||
rational_vec,
|
||||
details,
|
||||
details_vec,
|
||||
evo,
|
||||
_now_iso(),
|
||||
branch or "",
|
||||
session_id.strip() or None,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
exp_id = int(row["id"])
|
||||
conn.commit()
|
||||
|
||||
branch = branch or f"exp/{exp_id}"
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("UPDATE rd_experiments SET git_branch = %s WHERE id = %s", (branch, exp_id))
|
||||
conn.commit()
|
||||
return _row_json(_get_row(exp_id) or {})
|
||||
|
||||
|
||||
def _finish(
|
||||
exp_id: int,
|
||||
ref_id: str = "",
|
||||
evaluation: Optional[str] = None,
|
||||
metrics: Optional[str] = None,
|
||||
mlruns_dir: str = "",
|
||||
experiment_name: str = "",
|
||||
rational: Optional[str] = None,
|
||||
details: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
row = _get_row(exp_id)
|
||||
if not row:
|
||||
raise ValueError(f"experiment {exp_id} not found")
|
||||
|
||||
fields: List[str] = []
|
||||
params: List[Any] = []
|
||||
status = status or "done"
|
||||
if status is None and row.get("status") in ("starting", "running"):
|
||||
status = "done"
|
||||
|
||||
rational = (rational or row.get("rational") or "").strip()
|
||||
details = (details if details is not None else row.get("details") or "").strip()
|
||||
|
||||
fields.append("rational = %s"); params.append(rational)
|
||||
fields.append("rational_embedding = %s"); params.append(_vector_literal(embed(rational)))
|
||||
fields.append("details = %s"); params.append(details)
|
||||
fields.append("details_embedding = %s"); params.append(_vector_literal(embed(details)) if details else None)
|
||||
if evaluation is not None:
|
||||
fields.append("evaluation = %s"); params.append(evaluation.strip())
|
||||
if metrics is not None:
|
||||
fields.append("metrics = %s"); params.append(json.dumps(json.loads(metrics)))
|
||||
if ref_id:
|
||||
fields.append("experiment_ref_id = %s"); params.append(ref_id.strip())
|
||||
if mlruns_dir:
|
||||
fields.append("mlruns_dir = %s"); params.append(mlruns_dir.strip())
|
||||
if experiment_name:
|
||||
fields.append("experiment_name = %s"); params.append(experiment_name.strip())
|
||||
fields.append("status = %s"); params.append(status)
|
||||
fields.append("end_ts = %s"); params.append(_now_iso())
|
||||
fields.append("updated_at = %s"); params.append(_now_iso())
|
||||
params.append(exp_id)
|
||||
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute(f"UPDATE rd_experiments SET {', '.join(fields)} WHERE id = %s", params)
|
||||
conn.commit()
|
||||
return _row_json(_get_row(exp_id) or {})
|
||||
|
||||
|
||||
def _mlruns_dir(exp_name: str) -> str:
|
||||
uri = (os.environ.get("DATABASE_URL") or "").strip()
|
||||
if uri.startswith("postgres://"):
|
||||
uri = "postgresql+psycopg://" + uri[len("postgres://") :]
|
||||
if uri.startswith("postgresql://") or uri.startswith("postgresql+psycopg://"):
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT artifact_location FROM experiments WHERE name = %s", (exp_name,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise RuntimeError(f"mlflow experiment {exp_name!r} not found")
|
||||
return row["artifact_location"]
|
||||
|
||||
lake = (os.environ.get("TAC_LAKE_DIR") or "").strip()
|
||||
if not lake:
|
||||
raise RuntimeError("TAC_LAKE_DIR not set")
|
||||
db_path = Path(lake) / "mlruns.db"
|
||||
if not db_path.exists():
|
||||
raise RuntimeError(f"mlruns.db not found at {db_path}")
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT artifact_location FROM experiments WHERE name = ?", (exp_name,)).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
raise RuntimeError(f"mlflow experiment {exp_name!r} not found in {db_path}")
|
||||
return row[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- git
|
||||
def _parent_root() -> Path:
|
||||
start = Path.cwd()
|
||||
dir = start
|
||||
while dir != Path(dir.anchor):
|
||||
if (dir / "pnpm-workspace.yaml").exists() or (dir / "Cargo.toml").exists() or (dir / "opencode.json").exists():
|
||||
return dir
|
||||
dir = dir.parent
|
||||
raise RuntimeError("not inside a tradeac workspace")
|
||||
|
||||
|
||||
def _gitc(*args: str) -> subprocess.CompletedProcess:
|
||||
root = _parent_root()
|
||||
exp = root / "experiments"
|
||||
user = (os.environ.get("GIT_USER") or "").strip()
|
||||
password = (os.environ.get("GIT_PASS") or "").strip()
|
||||
helper = f'!f() {{ echo "username={user}"; echo "password={password}"; }}; f'
|
||||
cmd = ["git", "-C", str(exp), "-c", f"credential.helper={helper}"] + list(args)
|
||||
return subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
|
||||
def _git_ok(proc: subprocess.CompletedProcess) -> bool:
|
||||
return proc.returncode == 0
|
||||
|
||||
|
||||
def _git_out(proc: subprocess.CompletedProcess) -> str:
|
||||
return (proc.stdout or "").strip() or (proc.stderr or "").strip()
|
||||
|
||||
|
||||
def _require_auth() -> None:
|
||||
if not (os.environ.get("GIT_REPO_URL") or "").strip() or not (os.environ.get("GIT_USER") or "").strip():
|
||||
raise RuntimeError("GIT_REPO_URL / GIT_USER not set")
|
||||
|
||||
|
||||
def _ensure_repo() -> None:
|
||||
root = _parent_root()
|
||||
exp = root / "experiments"
|
||||
if (exp / ".git").exists():
|
||||
_gitc("remote", "set-url", "origin", os.environ["GIT_REPO_URL"])
|
||||
else:
|
||||
_require_auth()
|
||||
(root / "experiments").mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(
|
||||
["git", "clone", "-q", os.environ["GIT_REPO_URL"], str(exp)],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
_gitc("config", "user.email", f"{os.environ.get('GIT_USER', '')}@tradeac.local")
|
||||
_gitc("config", "user.name", os.environ.get("GIT_USER", "tradeac-agent"))
|
||||
|
||||
|
||||
def _ensure_base(base: str = "main") -> str:
|
||||
_require_auth()
|
||||
_gitc("fetch", "origin", base)
|
||||
if _git_ok(_gitc("rev-parse", "--verify", f"origin/{base}")):
|
||||
return f"origin/{base}"
|
||||
if _git_ok(_gitc("rev-parse", "--verify", base)):
|
||||
return base
|
||||
return base
|
||||
|
||||
|
||||
def _fork_branch(base_ref: str, branch: str) -> str:
|
||||
if _git_ok(_gitc("rev-parse", "--verify", f"origin/{branch}")):
|
||||
_gitc("checkout", "-q", "-B", branch, f"origin/{branch}")
|
||||
_gitc("reset", "-q", "--hard", f"origin/{branch}")
|
||||
return "reused existing branch"
|
||||
_gitc("fetch", "-q", "origin")
|
||||
if _git_ok(_gitc("rev-parse", "--verify", f"origin/{branch}")):
|
||||
_gitc("checkout", "-q", "-B", branch, f"origin/{branch}")
|
||||
return "reused existing branch"
|
||||
base_commit = ""
|
||||
if _git_ok(_gitc("rev-parse", "--verify", f"{base_ref}^{{commit}}")):
|
||||
base_commit = base_ref
|
||||
elif not base_ref.startswith("origin/"):
|
||||
base_commit = f"origin/{base_ref}"
|
||||
if not base_commit:
|
||||
raise RuntimeError(f"base '{base_ref}' not found locally or on origin")
|
||||
_gitc("checkout", "-q", "-B", branch, base_commit)
|
||||
return f"forked from {base_ref}"
|
||||
|
||||
|
||||
def _snapshot_code(paths: Optional[List[str]] = None) -> str:
|
||||
root = _parent_root()
|
||||
exp = root / "experiments"
|
||||
paths = paths or ["tac-qlib/tac_qlib/contrib", "tac-qlib/tac_qlib/data"]
|
||||
parent_head = _git_out(_gitc("rev-parse", "HEAD")) or "unknown"
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(exp / "code", ignore_errors=True)
|
||||
(exp / "code").mkdir(parents=True, exist_ok=True)
|
||||
manifest = [f"# TradeAC custom-qlib-code snapshot (auto-generated)", f"# parent repo HEAD : {parent_head}"]
|
||||
for p in paths:
|
||||
manifest.append(f"# {p}")
|
||||
manifest.append("# per-file hashes (git hash-object):")
|
||||
for p in paths:
|
||||
src = root / p
|
||||
if not src.exists():
|
||||
continue
|
||||
dst = exp / "code" / p
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if src.is_dir():
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
for f in sorted(src.rglob("*")):
|
||||
if f.is_file():
|
||||
rel = str(f.relative_to(root))
|
||||
h = _git_out(_gitc("hash-object", str(f)))
|
||||
manifest.append(f" {h} {rel}")
|
||||
else:
|
||||
shutil.copy2(src, dst)
|
||||
h = _git_out(_gitc("hash-object", str(src)))
|
||||
manifest.append(f" {h} {p}")
|
||||
(exp / "code" / "MANIFEST.txt").write_text("\n".join(manifest) + "\n")
|
||||
return f"code snapshotted -> experiments/code (parent @ {parent_head[:12]})"
|
||||
|
||||
|
||||
def _commit(message: str) -> str:
|
||||
_gitc("add", "-A")
|
||||
if _git_ok(_gitc("diff", "--cached", "--quiet")):
|
||||
return "nothing to commit"
|
||||
_gitc("commit", "-q", "-m", message)
|
||||
return "committed"
|
||||
|
||||
|
||||
def _commit_push(message: str) -> str:
|
||||
result = _commit(message)
|
||||
if result == "nothing to commit":
|
||||
return result
|
||||
branch = _git_out(_gitc("branch", "--show-current"))
|
||||
_require_auth()
|
||||
proc = _gitc("push", "-u", "origin", branch)
|
||||
if not _git_ok(proc):
|
||||
raise RuntimeError(f"push failed: {_git_out(proc)}")
|
||||
return f"pushed {branch}"
|
||||
|
||||
|
||||
def _parent_changes() -> str:
|
||||
root = _parent_root()
|
||||
proc = subprocess.run(["git", "-C", str(root), "status", "--porcelain"], capture_output=True, text=True)
|
||||
out = (proc.stdout or "").strip()
|
||||
if not out:
|
||||
return "parent repo clean (no changes)"
|
||||
lines = out.splitlines()
|
||||
filtered = [
|
||||
l for l in lines
|
||||
if not l.startswith(".. experiments/")
|
||||
and not l.startswith("?? experiments/")
|
||||
and not l.startswith(".. tac-qlib/tac_qlib/contrib/")
|
||||
and not l.startswith(".. tac-qlib/tac_qlib/data/")
|
||||
and not l.startswith("?? tac-qlib/tac_qlib/contrib/")
|
||||
and not l.startswith("?? tac-qlib/tac_qlib/data/")
|
||||
]
|
||||
expected = [l for l in lines if l.startswith(".. tac-qlib/tac_qlib/contrib/") or l.startswith(".. tac-qlib/tac_qlib/data/")]
|
||||
note = ""
|
||||
if expected:
|
||||
note = "note: custom qlib code changed in the parent repo (contrib/data) — snapshotted to the experiment branch via trace snapshot:\n" + "\n".join(expected)
|
||||
if not filtered:
|
||||
return "parent repo changes limited to experiments/ and snapshotted custom qlib code (ok)" + (f"\n{note}" if note else "")
|
||||
return "WARNING: unexpected parent-repo changes outside the experiments/ clone:\n" + "\n".join(filtered) + "\n→ review and revert before finishing" + (f"\n{note}" if note else "")
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
s = "".join(c for c in text.lower() if c.isalnum() or c in " -").replace(" ", "-")
|
||||
return s[:40].strip("-")
|
||||
|
||||
|
||||
def get_experiment_branch(exp_id: int) -> str:
|
||||
row = _get_row(exp_id)
|
||||
if not row:
|
||||
raise ValueError(f"experiment {exp_id} not found")
|
||||
return row["git_branch"] or f"exp/{exp_id}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- MCP tools
|
||||
def rd_trace_init() -> dict:
|
||||
"""Ensure the traceability store + experiments git repo are ready (rd_experiments table, base main)."""
|
||||
_init_db()
|
||||
_ensure_repo()
|
||||
base = _ensure_base("main")
|
||||
return {"status": "ready", "base": base}
|
||||
|
||||
|
||||
def rd_trace_start(
|
||||
rational: str,
|
||||
details: str = "",
|
||||
experiment_name: str = "",
|
||||
evolved_from: str = "none",
|
||||
session_id: str = "",
|
||||
) -> dict:
|
||||
"""Open a traced experiment: insert the rd_experiments row, resolve evolved_from, fork + push the experiment branch. Pass `session_id` (the opencode chat id) so the lineage keeps a stable chat link. Returns experiment_id / branch / evolved_from / base_branch as one JSON object."""
|
||||
_init_db()
|
||||
_ensure_repo()
|
||||
|
||||
evo_id = evolved_from
|
||||
if evolved_from == "auto":
|
||||
evo_id = str(_search_evolved_from(rational) or "")
|
||||
|
||||
row = _start(rational, details, evolved_from=evo_id or "none", experiment_name=experiment_name, session_id=session_id)
|
||||
exp_id = int(row["id"])
|
||||
branch = f"exp/{exp_id}-{slugify(rational)}"
|
||||
_gitc("checkout", "-q", "-B", branch, "main") if False else None
|
||||
|
||||
# fork from the predecessor branch (or main)
|
||||
base_branch = "main"
|
||||
if evo_id and evo_id.isdigit():
|
||||
base_branch = get_experiment_branch(int(evo_id))
|
||||
base_ref = _ensure_base(base_branch)
|
||||
_fork_branch(base_ref, branch)
|
||||
_snapshot_code()
|
||||
_gitc("checkout", "-q", "-B", branch, branch) if False else None
|
||||
|
||||
# persist branch on the row
|
||||
with _conn() as conn, conn.cursor() as cur:
|
||||
cur.execute("UPDATE rd_experiments SET git_branch = %s WHERE id = %s", (branch, exp_id))
|
||||
conn.commit()
|
||||
|
||||
_commit_push(f"start experiment {exp_id} ({branch})")
|
||||
return {"experiment_id": exp_id, "branch": branch, "evolved_from": evo_id or "none", "base_branch": base_branch}
|
||||
|
||||
|
||||
def rd_trace_finish(
|
||||
experiment_id: int,
|
||||
ref_id: str = "",
|
||||
evaluation: str = "",
|
||||
metrics: str = "",
|
||||
mlruns_dir: str = "",
|
||||
experiment_name: str = "",
|
||||
) -> dict:
|
||||
"""Close a traced experiment: update the row (link the mlflow run, metrics/evaluation/end_ts), snapshot code, commit + push the branch. Returns the updated row."""
|
||||
_finish(experiment_id, ref_id=ref_id, evaluation=evaluation or None, metrics=metrics or None, mlruns_dir=mlruns_dir, experiment_name=experiment_name)
|
||||
branch = get_experiment_branch(experiment_id)
|
||||
_gitc("checkout", "-q", "-B", branch, branch)
|
||||
_snapshot_code()
|
||||
_commit_push(f"finish experiment {experiment_id} ({branch})")
|
||||
return {"experiment_id": experiment_id, "branch": branch, "row": _row_json(_get_row(experiment_id) or {})}
|
||||
|
||||
|
||||
def rd_trace_commit(experiment_id: int, message: str = "wip") -> dict:
|
||||
"""Commit the current experiment branch state (no push)."""
|
||||
branch = get_experiment_branch(experiment_id)
|
||||
_gitc("checkout", "-q", "-B", branch, branch)
|
||||
result = _commit(f"exp {experiment_id}: {message}")
|
||||
return {"experiment_id": experiment_id, "branch": branch, "result": result}
|
||||
|
||||
|
||||
def rd_trace_snapshot(experiment_id: int, paths: str = "") -> dict:
|
||||
"""Snapshot custom qlib contrib/data code onto the experiment branch (default contrib+data)."""
|
||||
branch = get_experiment_branch(experiment_id)
|
||||
_gitc("checkout", "-q", "-B", branch, branch)
|
||||
path_list = [p.strip() for p in paths.split(",") if p.strip()] if paths else None
|
||||
msg = _snapshot_code(path_list)
|
||||
_commit_push(f"exp {experiment_id}: snapshot custom qlib code")
|
||||
return {"experiment_id": experiment_id, "branch": branch, "result": msg}
|
||||
|
||||
|
||||
def rd_trace_guard() -> dict:
|
||||
"""Check the parent repo for unexpected changes outside the experiments clone."""
|
||||
return {"parent_changes": _parent_changes()}
|
||||
|
||||
|
||||
def rd_trace_search(query: str, limit: int = 10, min_score: float = _MIN_SCORE) -> dict:
|
||||
"""Semantic search over experiment rationals/details (pgvector, falls back to ILIKE)."""
|
||||
return {"results": _search(query, limit=limit, min_score=min_score)}
|
||||
|
||||
|
||||
def rd_trace_get(experiment_id: int) -> dict:
|
||||
"""Return one traced experiment row."""
|
||||
row = _get_row(experiment_id)
|
||||
if not row:
|
||||
raise ValueError(f"experiment {experiment_id} not found")
|
||||
return _row_json(row)
|
||||
|
||||
|
||||
def rd_trace_list(limit: int = 20) -> dict:
|
||||
"""List traced experiments (newest first)."""
|
||||
return {"experiments": [_row_json(r) for r in _all_rows(limit)]}
|
||||
|
||||
|
||||
def rd_trace_mlruns_dir(experiment_name: str) -> dict:
|
||||
"""Resolve the mlflow artifact location for an experiment name."""
|
||||
return {"mlruns_dir": _mlruns_dir(experiment_name)}
|
||||
|
||||
|
||||
def register_trace_tools(server) -> None:
|
||||
"""Attach all rd_trace_* tools to an MCPServer instance (called by rd_server.main())."""
|
||||
for fn in (
|
||||
rd_trace_init,
|
||||
rd_trace_start,
|
||||
rd_trace_finish,
|
||||
rd_trace_commit,
|
||||
rd_trace_snapshot,
|
||||
rd_trace_guard,
|
||||
rd_trace_search,
|
||||
rd_trace_get,
|
||||
rd_trace_list,
|
||||
rd_trace_mlruns_dir,
|
||||
):
|
||||
server.tool(structured_output=False)(fn)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Embed text via the self-hosted infinity embedding API (used by tac_qlib.trace).
|
||||
|
||||
Mirrors the standalone `embed.py` in the tac-qlib-custom skill lib so the trace
|
||||
MCP tools can embed rational/details without shelling out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
EMBEDDING_MODEL = "michaelfeil/bge-small-en-v1.5"
|
||||
MAX_TOKENS = 512
|
||||
CHARS_PER_TOKEN = 4
|
||||
|
||||
|
||||
def estimate_tokens(text: str) -> int:
|
||||
return max(1, -(-len(text) // CHARS_PER_TOKEN))
|
||||
|
||||
|
||||
def embed(text: str, timeout: int = 40) -> list[float] | None:
|
||||
base_url = (os.environ.get("EMBEDDING_API_BASE_URL") or "").strip()
|
||||
api_key = (os.environ.get("EMBEDDING_API_KEY") or "").strip()
|
||||
if not base_url or not api_key:
|
||||
return None
|
||||
|
||||
if estimate_tokens(text) > MAX_TOKENS:
|
||||
raise ValueError(
|
||||
f"text is ~{estimate_tokens(text)} tokens, exceeding the {MAX_TOKENS}-token embedding "
|
||||
"context. Write a <=512-token summary of the experiment and embed that instead."
|
||||
)
|
||||
|
||||
body = json.dumps({"model": EMBEDDING_MODEL, "input": text}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
base_url,
|
||||
data=body,
|
||||
headers={
|
||||
"accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
user, _, password = api_key.partition(":")
|
||||
cred = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii")
|
||||
req.add_header("Authorization", f"Basic {cred}")
|
||||
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
if isinstance(data, list) and data and isinstance(data[0], dict):
|
||||
emb = data[0].get("embedding")
|
||||
if isinstance(emb, list) and emb:
|
||||
return [float(v) for v in emb]
|
||||
embeddings = payload.get("embeddings") if isinstance(payload, dict) else None
|
||||
if isinstance(embeddings, list) and embeddings and isinstance(embeddings[0], list):
|
||||
return [float(v) for v in embeddings[0]]
|
||||
raise RuntimeError(f"unexpected embedding response shape: {str(payload)[:300]}")
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Smoke tests for the tac_qlib contrib package (model/strategy).
|
||||
|
||||
Covers the pieces a workflow YAML resolves via ``module_path``:
|
||||
|
||||
- ``tac_qlib.contrib.model.rank_gbdt`` -> RankICLGBModel (+ rank feval)
|
||||
- ``tac_qlib.contrib.strategy.optimal_stop`` -> OptimalStopControl
|
||||
|
||||
The strategy smoke test runs a real (tiny) daily backtest through qlib's
|
||||
executor against the TradeAC lake. The model smoke test checks data
|
||||
preparation (per-day query groups) + the rank feval without a full fit.
|
||||
|
||||
Run::
|
||||
|
||||
TAC_LAKE_DIR=/home/data/lake .venv/bin/python tests/test_contrib.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
LAKE_ROOT = os.environ["TAC_LAKE_DIR"]
|
||||
CODES = ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN", "META"]
|
||||
START, END = "2026-06-01", "2026-07-31"
|
||||
|
||||
|
||||
def _signal(close: pd.DataFrame) -> pd.Series:
|
||||
"""3-day momentum score indexed (datetime, instrument) covering [START, END]."""
|
||||
mom = close.pct_change(3).stack()
|
||||
mom.index = mom.index.set_names(["datetime", "instrument"])
|
||||
return mom.dropna()
|
||||
|
||||
|
||||
def main():
|
||||
from tac_qlib.qlib_init import qlib_init
|
||||
|
||||
qlib_init(provider_uri=LAKE_ROOT, market="US", freq="day", log_level="WARN")
|
||||
from qlib.data import D
|
||||
|
||||
close = D.features(CODES, ["$close"], START, END, freq="day")["$close"]
|
||||
close = close.unstack("instrument")
|
||||
sig = _signal(close)
|
||||
assert len(sig) > 0, "empty synthetic signal"
|
||||
print(f"[ok] synthetic signal: {len(sig)} rows, {sig.index.get_level_values(0).nunique()} days")
|
||||
|
||||
# ---- OptimalStopControl end-to-end ------------------------------------
|
||||
from tac_qlib.contrib.strategy.optimal_stop import OptimalStopControl
|
||||
from qlib.contrib.evaluate import backtest_daily
|
||||
|
||||
strat = OptimalStopControl(
|
||||
signal=sig, topk=2, entry_pct=0.8, exit_pct=0.5,
|
||||
max_hold_days=5, min_hold_days=1, sl=-0.05, notional=10_000.0,
|
||||
)
|
||||
report, positions = backtest_daily(
|
||||
start_time=START, end_time=END, strategy=strat, account=1_000_000,
|
||||
benchmark=None,
|
||||
exchange_kwargs={"codes": CODES, "deal_price": "$close", "freq": "day",
|
||||
"open_cost": 0.0005, "close_cost": 0.0015, "min_cost": 5.0},
|
||||
)
|
||||
assert isinstance(report, pd.DataFrame) and "return" in report and len(report) >= 5
|
||||
assert not report["return"].isna().all()
|
||||
print(f"[ok] OptimalStopControl backtest: {len(report)} days, "
|
||||
f"end equity {float(report['return'].add(1).cumprod().iloc[-1]):.4f}")
|
||||
|
||||
# ---- RankICLGBModel: instantiate + _prepare_data (per-day groups) ------
|
||||
from tac_qlib.contrib.data.handler import TACHandler
|
||||
from qlib.data.dataset import DatasetH
|
||||
|
||||
h = TACHandler(
|
||||
instruments=CODES, start_time=START, end_time=END,
|
||||
fit_start_time=START, fit_end_time="2026-06-30", freq="day",
|
||||
lake_root=LAKE_ROOT, market="US",
|
||||
label="Ref($close,-6)/Ref($close,-1)-1",
|
||||
)
|
||||
ds = DatasetH(
|
||||
handler=h,
|
||||
segments={"train": (START, "2026-06-30"), "valid": ("2026-07-01", END)},
|
||||
)
|
||||
from tac_qlib.contrib.model.rank_gbdt import RankICLGBModel, rankic_feval
|
||||
|
||||
model = RankICLGBModel(loss="mse", learning_rate=0.05, num_leaves=7, n_estimators=50)
|
||||
data = model._prepare_data(ds)
|
||||
lgb_ds, names = list(zip(*data))
|
||||
assert names == ("train", "valid")
|
||||
groups = lgb_ds[0].get_group()
|
||||
assert groups is not None and len(groups) >= 5, f"per-day query groups missing: {groups}"
|
||||
# every group size == number of instruments that day
|
||||
assert set(groups) <= {len(CODES), len(CODES) - 1}, f"unexpected group sizes {groups}"
|
||||
print(f"[ok] RankICLGBModel._prepare_data: groups={groups[:5]}... (n_days={len(groups)})")
|
||||
|
||||
# rank feval on a hand-built lgb.Dataset
|
||||
import lightgbm as lgb
|
||||
|
||||
y = np.array([1.0, 2.0, 3.0, 3.0, 2.0, 1.0])
|
||||
preds = np.array([1.0, 2.0, 3.0, 3.0, 2.0, 1.0])
|
||||
dv = lgb.Dataset(np.zeros((6, 2)), label=y, group=np.array([3, 3]))
|
||||
name, value, higher = rankic_feval(preds, dv)
|
||||
assert name == "rankic" and higher is True and abs(value - 1.0) < 1e-9
|
||||
print(f"[ok] rankic_feval: {name}={value:.4f} (higher_is_better={higher})")
|
||||
|
||||
print("\nALL CONTRIB CHECKS PASSED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Smoke tests: qlib against the TradeAC lake (plain asserts, no pytest needed).
|
||||
|
||||
Run::
|
||||
|
||||
.venv/bin/python tests/test_lake_providers.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
# TAC_LAKE_DIR is mandatory (no default fallback). Fail fast if it is missing.
|
||||
LAKE_ROOT = os.environ["TAC_LAKE_DIR"]
|
||||
|
||||
|
||||
def main():
|
||||
from tac_qlib.qlib_init import qlib_init
|
||||
|
||||
qlib_init(provider_uri=LAKE_ROOT, market="US", freq="day", log_level="WARN")
|
||||
|
||||
from qlib.data import D
|
||||
from qlib.data.data import Cal, ExpressionD, Inst, DatasetD
|
||||
|
||||
# ---- calendar ---------------------------------------------------------
|
||||
cal = Cal.calendar(freq="day")
|
||||
assert isinstance(cal, (list, np.ndarray)) and len(cal) >= 100, f"calendar too small: {len(cal)}"
|
||||
print(f"[ok] calendar: {len(cal)} trading days, {pd.Timestamp(cal[0]).date()} -> {pd.Timestamp(cal[-1]).date()}")
|
||||
|
||||
# ---- instruments ------------------------------------------------------
|
||||
inst = Inst.list_instruments({"market": "all"}, start_time=cal[0], end_time=cal[-1], freq="day")
|
||||
assert len(inst) >= 5, f"expected >=5 instruments, got {inst}"
|
||||
print(f"[ok] instruments: {sorted(inst)}")
|
||||
|
||||
# ---- raw features -----------------------------------------------------
|
||||
start, end = "2026-03-01", "2026-06-30"
|
||||
df = D.features(sorted(inst)[:4], ["$close", "$volume", "$vwap"], start, end, freq="day")
|
||||
assert not df.empty
|
||||
assert df.columns.tolist() == ["$close", "$volume", "$vwap"]
|
||||
assert not df["$close"].isna().all()
|
||||
# index must be the (datetime, instrument) MultiIndex, sorted
|
||||
assert isinstance(df.index, pd.MultiIndex)
|
||||
assert df.index.names == [df.index.names[0], df.index.names[1]]
|
||||
n_rows = len(df)
|
||||
print(f"[ok] D.features: {len(df)} rows x {len(df.columns)} cols; close sample:\n{df['$close'].head(3)}")
|
||||
|
||||
# NaN for fields the lake does not store
|
||||
df_unk = D.features(sorted(inst)[:2], ["$factor", "$change"], start, end, freq="day")
|
||||
assert df_unk["$factor"].isna().all() and df_unk["$change"].isna().all()
|
||||
print("[ok] unknown fields ($factor/$change) are all-NaN")
|
||||
|
||||
# ---- expression engine (Option A / qlib defaults) ---------------------
|
||||
exp = "Ref($close,-2)/$close-1" # same default label as Alpha158
|
||||
sym = sorted(inst)[0] # use a symbol that is actually in the lake
|
||||
s = ExpressionD.expression(sym, exp, start_time=start, end_time=end, freq="day")
|
||||
assert isinstance(s, pd.Series) and len(s) > 0
|
||||
assert s.notna().any()
|
||||
print(f"[ok] ExpressionD.expression: {len(s)} values, sample:\n{s.head(3)}")
|
||||
|
||||
# a full dataset can be materialised through the expression engine
|
||||
df_ds = DatasetD.dataset(sorted(inst), [exp], start, end, freq="day")
|
||||
assert isinstance(df_ds, pd.DataFrame) and len(df_ds) > 0
|
||||
print(f"[ok] DatasetD.dataset: {df_ds.shape}")
|
||||
|
||||
# ---- TACHandler: feature discovery + DropAllNaN ------------------------
|
||||
from tac_qlib.contrib.data.handler import TACHandler
|
||||
|
||||
h = TACHandler(
|
||||
instruments=sorted(inst)[:6],
|
||||
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_ROOT,
|
||||
market="US",
|
||||
)
|
||||
# the lake's stoch_* columns are fully NaN -> they must be dropped by DropAllNaN
|
||||
assert not any("stoch" in str(c) for c in h._infer.columns), h._infer.columns.tolist()
|
||||
# train/valid/test must expose identical feature columns
|
||||
from qlib.data.dataset import DatasetH
|
||||
from qlib.data.dataset.handler import DataHandlerLP
|
||||
|
||||
ds = DatasetH(
|
||||
handler=h,
|
||||
segments={
|
||||
"train": ("2026-03-01", "2026-05-31"),
|
||||
"valid": ("2026-06-01", "2026-06-30"),
|
||||
"test": ("2026-07-01", "2026-08-06"),
|
||||
},
|
||||
)
|
||||
cols = {
|
||||
seg: ds.prepare(segments=seg, col_set="feature", data_key=DataHandlerLP.DK_I).columns.tolist()
|
||||
for seg in ("train", "valid", "test")
|
||||
}
|
||||
assert cols["train"] == cols["valid"] == cols["test"], cols
|
||||
print(f"[ok] TACHandler: {len(cols['train'])} features, stoch dropped, segments aligned")
|
||||
|
||||
print("\nALL LAKE PROVIDER CHECKS PASSED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,109 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Basic LightGBM qrun workflow on the TradeAC lake -- short window sanity run.
|
||||
# Train on ~3 months, early-stop on 1 month valid, predict+backtest on ~1 month.
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- 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-basic-short"
|
||||
|
||||
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: all
|
||||
start_time: 2026-03-01
|
||||
end_time: 2026-08-14
|
||||
fit_start_time: 2026-03-01
|
||||
fit_end_time: 2026-05-31
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
segments:
|
||||
train: [2026-03-01, 2026-05-31]
|
||||
valid: [2026-06-01, 2026-06-30]
|
||||
test: [2026-07-01, 2026-08-14]
|
||||
|
||||
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-14
|
||||
account: 1000000
|
||||
benchmark: QQQ
|
||||
exchange_kwargs:
|
||||
codes: all
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -0,0 +1,129 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tune run 1: wider, longer-horizon, de-duplicated universe.
|
||||
#
|
||||
# Baseline (exp 1 / run f29f5446): IC 0.071 / ICIR 0.17, Rank IC ~0.014;
|
||||
# strategy +4.9% ann (raw) vs benchmark ~+89% ann; excess return w/ cost
|
||||
# -0.94 ann, IR -2.23, excess max drawdown -18.9%. topk=2 with 24 trades over
|
||||
# 27 days on a universe of correlated ETFs + leveraged hedges (VXX/USO/SLV)
|
||||
# produced high turnover and a portfolio that trailed AAPL badly.
|
||||
#
|
||||
# Changes:
|
||||
# - universe: drop leveraged/noisy names (VXX, USO, SLV, BIL) and near-
|
||||
# duplicate index baskets (GPIQ, QQQE, KTEC); keep 10 liquid core names.
|
||||
# - label: 5-day forward return (Ref($close,-6)/Ref($close,-1)-1) to cut
|
||||
# single-day noise and match the intended holding horizon.
|
||||
# - topk 2 -> 5, n_drop 1: more diversification, lower turnover per name.
|
||||
# - benchmark AAPL -> QQQ (a real index ETF the universe tracks).
|
||||
# - model: learning_rate 0.03, 300 estimators (slower, deeper fit).
|
||||
#
|
||||
# Trigger:
|
||||
# rd_run_workflow config_path=tac-qlib/workflows/tune_run1_wider_5d.yaml \
|
||||
# experiment_name=tac-rd-tune
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- 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-tune"
|
||||
|
||||
task:
|
||||
model:
|
||||
class: LGBModel
|
||||
module_path: qlib.contrib.model.gbdt
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.03
|
||||
num_leaves: 15
|
||||
n_estimators: 300
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.01
|
||||
reg_lambda: 0.01
|
||||
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,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ
|
||||
start_time: 2000-01-03
|
||||
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:
|
||||
- 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: 5
|
||||
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,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -0,0 +1,127 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tune run 2: same-day signal, strongly regularized model, 3x rotating book.
|
||||
#
|
||||
# Baseline (exp 1 / run f29f5446): IC 0.071 / ICIR 0.17, Rank IC ~0.014;
|
||||
# excess return w/ cost -0.94 ann, IR -2.23. The 1-day signal was noisy
|
||||
# (Rank IC ~ 0) and the topk=2 book turned over 24 times in 27 days, paying
|
||||
# ~1.1% of the $1M account in costs.
|
||||
#
|
||||
# Changes (isolates model/backtest effects; universe + label same as baseline):
|
||||
# - model: stronger regularization (reg_alpha 0.5, reg_lambda 5.0,
|
||||
# subsample 0.7, colsample 0.6) to combat the unstable Rank IC.
|
||||
# - topk 2 -> 3, n_drop 1 -> 2: rotate out losers faster (lower cost drag,
|
||||
# higher turnover on only the worst names).
|
||||
# - benchmark AAPL -> QQQ.
|
||||
# - universe: drop leveraged/duplicate names (VXX, USO, SLV, BIL, GPIQ,
|
||||
# QQQE, KTEC) for a cleaner cross-section; keeps baseline 1-day label.
|
||||
#
|
||||
# Trigger:
|
||||
# rd_run_workflow config_path=tac-qlib/workflows/tune_run2_regularized.yaml \
|
||||
# experiment_name=tac-rd-tune
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- 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-tune"
|
||||
|
||||
task:
|
||||
model:
|
||||
class: LGBModel
|
||||
module_path: qlib.contrib.model.gbdt
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.05
|
||||
num_leaves: 15
|
||||
n_estimators: 250
|
||||
colsample_bytree: 0.6
|
||||
subsample: 0.7
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.5
|
||||
reg_lambda: 5.0
|
||||
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,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ
|
||||
start_time: 2000-01-03
|
||||
end_time: 2026-08-06
|
||||
fit_start_time: 2026-03-01
|
||||
fit_end_time: 2026-05-31
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
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: 3
|
||||
n_drop: 2
|
||||
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,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -0,0 +1,146 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tune run 3 (NEXT run): 5-day label + clean 10-name universe.
|
||||
#
|
||||
# Baseline (exp 1 / run f29f5446):
|
||||
# IC 0.071, ICIR 0.17, Rank IC 0.014, Rank ICIR 0.03 -> ranking ~ coin flip
|
||||
# valid l2 best at round 0 and never improved (early-stopped ~50 rounds, overfit)
|
||||
# backtest: strategy +4.9% ann (raw) vs equal-weight universe +89.2% ann
|
||||
# (benchmark was unset -> qlib used equal-weight), excess w/ cost -94.0% ann,
|
||||
# IR -2.23, max DD -18.9%. topk=2, 24 trades/27 days, $11.1k cost (1.1% of $1M),
|
||||
# ending book ~97.5% in AAPL+IBIT (two names, both ~49%).
|
||||
#
|
||||
# PRIMARY LEVER (change one thing, everything else held at baseline):
|
||||
# label: 1-day next return -> 5-day forward return
|
||||
# "Ref($close,-6)/Ref($close,-1)-1".
|
||||
# Rationale: Rank ICIR 0.03 is the binding constraint - a topk book's return
|
||||
# is bounded by ranking quality, and no backtest tuning fixes a non-existent
|
||||
# ranking. The retained TA features (rsi_14, macd_hist, ema_20, volume,
|
||||
# stoch, aroon) are momentum/mean-reversion proxies that predict multi-day
|
||||
# drift, not overnight noise; and the avg holding in the baseline book was
|
||||
# several days, so a 1-day label mismatches the holding horizon.
|
||||
#
|
||||
# SUPPORTING (kept minimal, flagged for attribution):
|
||||
# - universe 17 -> 10: drop leveraged/vol/cash names (VXX, USO, SLV, BIL)
|
||||
# and near-duplicate index baskets (GPIQ, QQQE, KTEC). 17 names were really
|
||||
# ~8 independent betas (QQQ/QQQE/IVV/SMH/AIQ overlap heavily).
|
||||
# - topk 2 -> 5, n_drop 1 -> 2: stop the 2-name lottery, cut per-name turnover.
|
||||
# - benchmark: unset -> QQQ (a real index ETF the universe tracks; the
|
||||
# "excess return" vs equal-weight of a 17-name universe is misleading).
|
||||
# - model: explicitly num_boost_round 1000 + early_stopping_rounds 50 so the
|
||||
# round count is actually controlled (baseline's n_estimators: 200 was a
|
||||
# no-op, swallowed into lgb params; rounds were the 1000 default).
|
||||
# Hyperparameters otherwise identical to baseline (lr 0.05, num_leaves 15,
|
||||
# reg 0.01/0.01) for a clean label A/B.
|
||||
#
|
||||
# Trigger into a NEW experiment (do not pollute exp 1):
|
||||
# rd_run_workflow config_path=tac-qlib/workflows/tune_run3_label5d_clean_universe.yaml \
|
||||
# experiment_name=tac-rd-tune
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- 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:///{{ LAKE }}/mlruns.db"
|
||||
default_exp_name: "tac-rd-tune"
|
||||
|
||||
task:
|
||||
model:
|
||||
class: LGBModel
|
||||
module_path: qlib.contrib.model.gbdt
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.05
|
||||
num_leaves: 15
|
||||
num_boost_round: 1000
|
||||
early_stopping_rounds: 50
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.01
|
||||
reg_lambda: 0.01
|
||||
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,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ
|
||||
start_time: 2000-01-03
|
||||
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:
|
||||
- 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: 5
|
||||
n_drop: 2
|
||||
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,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -0,0 +1,121 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Run 94736d89 (exp-4 tac-rd-tune2) follow-up -- single lever: WIDER UNIVERSE.
|
||||
#
|
||||
# Baseline (run 94736d89): 10 correlated tech/growth names -> weak cross-section
|
||||
# (IC 0.038 / ICIR 0.10), topk=5 book all-correlated, 295 trades / 152d and
|
||||
# $58k cost drag (5.8% of $1M) -> excess ann -18.8% vs QQQ.
|
||||
#
|
||||
# This run holds EVERYTHING else fixed (windows, 5-day label, LGB hyperparams,
|
||||
# topk=5/n_drop=2, benchmark QQQ) and only widens the universe 10 -> 17 with the
|
||||
# full lake set, adding genuinely uncorrelated assets (BIL cash, USO oil, SLV
|
||||
# silver, VXX vol, KTEC/QQQE/GPIQ factor sleeves) to de-correlate the cross-section,
|
||||
# stabilize the top-5 ranking and cut the churn/cost drag.
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- 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-tune3"
|
||||
|
||||
task:
|
||||
model:
|
||||
class: LGBModel
|
||||
module_path: qlib.contrib.model.gbdt
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.05
|
||||
num_leaves: 15
|
||||
num_boost_round: 1000
|
||||
early_stopping_rounds: 50
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.01
|
||||
reg_lambda: 0.01
|
||||
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,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ,BIL,GPIQ,KTEC,QQQE,SLV,USO,VXX
|
||||
start_time: 2000-01-03
|
||||
end_time: 2026-08-01
|
||||
fit_start_time: 2024-06-03
|
||||
fit_end_time: 2025-11-28
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
label: "Ref($close,-6)/Ref($close,-1)-1"
|
||||
segments:
|
||||
train: [2024-06-03, 2025-11-28]
|
||||
valid: [2025-12-01, 2025-12-31]
|
||||
test: [2026-01-01, 2026-08-01]
|
||||
|
||||
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: 5
|
||||
n_drop: 2
|
||||
only_tradable: true
|
||||
risk_degree: 0.95
|
||||
backtest:
|
||||
start_time: 2026-01-01
|
||||
end_time: 2026-08-01
|
||||
account: 1000000
|
||||
benchmark: QQQ
|
||||
exchange_kwargs:
|
||||
codes: AAPL,MSFT,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ,BIL,GPIQ,KTEC,QQQE,SLV,USO,VXX
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -0,0 +1,144 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tune run 4 (NEXT run): fix the universe bug + extend the train window.
|
||||
#
|
||||
# Previous (exp 3 / run 1e170e7f): IC -0.025 / ICIR -0.071 / RankIC -0.022 /
|
||||
# RankICIR -0.073 (noise), Long-Short -27% ann; excess +15.8% ann w/ cost
|
||||
# (IR 1.35) vs QQQ; $1M -> $971.7k (-2.8%); 65 trades/27d, $12.1k cost.
|
||||
# l2.train 0.35 vs l2.valid 0.95 -> gross overfit (valid best at round 0,
|
||||
# early-stopped at 16 trees).
|
||||
#
|
||||
# CRITICAL BUG in that run: the 10-name universe was silently IGNORED.
|
||||
# TACHandler passes `instruments` as a comma-separated STRING; qlib wraps it
|
||||
# as {"market": "<comma string>", "filter_pipe": []}; LakeInstrumentProvider
|
||||
# ._resolve_symbols() only handles list/tuple/ndarray and falls through to
|
||||
# load_symbols() = the ENTIRE 17-symbol lake. So the model trained/traded on
|
||||
# VXX, USO, SLV, BIL, GPIQ, QQQE, KTEC too - exactly the leveraged/hedge
|
||||
# names the "clean 10-name universe" hypothesis meant to drop. The universe
|
||||
# A/B is UNTESTED.
|
||||
# Fix (providers.py:100 _resolve_symbols): split comma-separated strings.
|
||||
#
|
||||
# PRIMARY LEVER (this run, ONE hypothesis):
|
||||
# universe = the intended 10-name dedup pool (AAPL,MSFT,TSLA,QQQ,IVV,SMH,
|
||||
# TLT,IBIT,MCHI,AIQ), now actually enforced, + train window 3 months -> 2
|
||||
# years. The 3-month window (~1000 rows for a 21-feature GBDT) is the hard
|
||||
# ceiling on signal; features span 2000-2026 so more data is free.
|
||||
# Everything else held at run-1e170e7f for a clean A/B: 5-day label,
|
||||
# LGB baseline hyperparams, topk 5 / n_drop 2, benchmark QQQ.
|
||||
#
|
||||
# SUPPORTING (flagged, NOT changed this run to keep attribution clean):
|
||||
# - if valid loss still rises monotonically after 2y of data, next step is
|
||||
# regularization (reg_alpha/lambda 0.01 -> ~0.5, num_leaves 15 -> 10,
|
||||
# lr 0.05 -> 0.02) rather than label/topk changes.
|
||||
#
|
||||
# Trigger into a NEW experiment (do not pollute exp 1/3):
|
||||
# rd_run_workflow config_path=tac-qlib/workflows/tune_run4_fix_universe_longtrain.yaml \
|
||||
# experiment_name=tac-rd-tune2
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- 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:///{{ LAKE }}/mlruns.db"
|
||||
default_exp_name: "tac-rd-tune2"
|
||||
|
||||
task:
|
||||
model:
|
||||
class: LGBModel
|
||||
module_path: qlib.contrib.model.gbdt
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.05
|
||||
num_leaves: 15
|
||||
num_boost_round: 1000
|
||||
early_stopping_rounds: 50
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.01
|
||||
reg_lambda: 0.01
|
||||
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,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ
|
||||
start_time: 2000-01-03
|
||||
end_time: 2026-08-06
|
||||
fit_start_time: 2024-06-03
|
||||
fit_end_time: 2026-05-31
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
label: "Ref($close,-6)/Ref($close,-1)-1"
|
||||
segments:
|
||||
train: [2024-06-03, 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: 5
|
||||
n_drop: 2
|
||||
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,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -0,0 +1,133 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tune run 5: longer backtest window (2026-01-01 -> 2026-08-01).
|
||||
#
|
||||
# Purpose: test the fixed universe provider (_resolve_symbols now honors the
|
||||
# comma-separated 10-name instruments) and the fixed artifact pinning
|
||||
# (mlruns/<exp_id>/<run_id>/) over a 7-month out-of-sample window instead of
|
||||
# the single month (Jul) of run 47e9e369 / tune_run4.
|
||||
#
|
||||
# Changes vs tune_run4_fix_universe_longtrain.yaml:
|
||||
# - test/backtest window 2026-07-01..08-06 -> 2026-01-01..2026-08-01
|
||||
# - train/valid moved back so they stay strictly before test (no leakage):
|
||||
# train: 2024-06-03 .. 2025-11-28 (~18 months, ~4500 rows x 10 names)
|
||||
# valid: 2025-12-01 .. 2025-12-31 (1 month, right before test)
|
||||
# test : 2026-01-01 .. 2026-08-01 (7 months)
|
||||
# - everything else held fixed: 5-day label, LGB baseline hyperparams,
|
||||
# topk 5 / n_drop 2, benchmark QQQ, universe 10 names.
|
||||
#
|
||||
# NOTE: requires the providers.py fix so the universe is actually 10 names
|
||||
# (not silently expanded to all 17 lake symbols).
|
||||
#
|
||||
# Trigger (existing experiment, exp id 4 -> artifacts under
|
||||
# $TAC_LAKE_DIR/mlruns/4/<run_id>/ ):
|
||||
# rd_run_workflow config_path=tac-qlib/workflows/tune_run5_longtest.yaml \
|
||||
# experiment_name=tac-rd-tune2
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- 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:///{{ LAKE }}/mlruns.db"
|
||||
default_exp_name: "tac-rd-tune2"
|
||||
|
||||
task:
|
||||
model:
|
||||
class: LGBModel
|
||||
module_path: qlib.contrib.model.gbdt
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.05
|
||||
num_leaves: 15
|
||||
num_boost_round: 1000
|
||||
early_stopping_rounds: 50
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.01
|
||||
reg_lambda: 0.01
|
||||
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,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ
|
||||
start_time: 2000-01-03
|
||||
end_time: 2026-08-01
|
||||
fit_start_time: 2024-06-03
|
||||
fit_end_time: 2025-11-28
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
label: "Ref($close,-6)/Ref($close,-1)-1"
|
||||
segments:
|
||||
train: [2024-06-03, 2025-11-28]
|
||||
valid: [2025-12-01, 2025-12-31]
|
||||
test: [2026-01-01, 2026-08-01]
|
||||
|
||||
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: 5
|
||||
n_drop: 2
|
||||
only_tradable: true
|
||||
risk_degree: 0.95
|
||||
backtest:
|
||||
start_time: 2026-01-01
|
||||
end_time: 2026-08-01
|
||||
account: 1000000
|
||||
benchmark: QQQ
|
||||
exchange_kwargs:
|
||||
codes: AAPL,MSFT,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -0,0 +1,148 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tune run 6 (NEXT run): wider 10-name universe A/B vs run f744455056 (exp 1).
|
||||
#
|
||||
# Baseline (exp 1 / run f744455056 — this run):
|
||||
# Input : universe AAPL,MSFT,QQQ,IVV,SMH,TLT (6 names, 5 of them the same
|
||||
# tech beta); 21 features (OHLCV + TA); label 1-day next return;
|
||||
# LGB lr 0.05 / 15 leaves / 200 trees / reg 0.01,0.01;
|
||||
# train 03-01..05-31 / valid 06-01..06-30 / test 07-01..08-06.
|
||||
# Output: IC 0.048, ICIR 0.09, Rank IC 0.065, Rank ICIR 0.13 -> noise-level
|
||||
# (per-day n=6, IC swings -0.89..+0.74 with many null days).
|
||||
# Backtest had NO benchmark (benchmark null) -> the "+180% ann, IR 6.4"
|
||||
# headline is raw strategy return, not excess. Strategy +16.5% over 27
|
||||
# days, but ~half the P&L came from ONE day (2026-07-30 MSFT +14% sell,
|
||||
# +$72k realized). 30 trades/27 days, $15.3k cost (1.5% of $1M),
|
||||
# ending book 46.6% SMH + 50.8% TLT (2-name lottery).
|
||||
#
|
||||
# PRIMARY LEVER (change one thing, everything else held at baseline):
|
||||
# universe: 6 -> 10 names (AAPL,MSFT,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ).
|
||||
# Rationale: with 6 near-collinear names there is nothing to rank — ICIR 0.09
|
||||
# is cross-sectional noise and the topk book just re-buys tech momentum on
|
||||
# correlated bets. Widening to ~10 independent-ish betas (mega tech, semis,
|
||||
# S&P, Nasdaq, bonds, BTC, EM, robotics) gives the cross-section real breadth,
|
||||
# stabilizes IC, and makes a diversified topk book possible.
|
||||
#
|
||||
# SUPPORTING (kept minimal, flagged for attribution):
|
||||
# - topk 2 -> 4, n_drop 1 -> 2: kill the 2-name lottery, cut per-name churn.
|
||||
# - benchmark: unset -> QQQ: the baseline "excess return" was raw strategy
|
||||
# return because no benchmark was wired; QQQ is the index the tech-heavy
|
||||
# universe tracks.
|
||||
# - model: explicit num_boost_round 1000 + early_stopping_rounds 50 so round
|
||||
# count is controlled (baseline's n_estimators: 200 was swallowed into lgb
|
||||
# params and valid l2 rose monotonically -> overfit). Hyperparameters
|
||||
# otherwise identical to baseline for a clean universe A/B.
|
||||
# - label: KEPT at 1-day next return so this run isolates the universe lever;
|
||||
# a 5-day horizon is the natural NEXT experiment (see tune_run3).
|
||||
#
|
||||
# Trigger into a NEW experiment (do not pollute exp 1); evolved_from = f744455056:
|
||||
# rd_run_workflow config_path=tac-qlib/workflows/tune_run6_wider_universe_ab.yaml \
|
||||
# experiment_name=tac-rd-tune
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- 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:///{{ LAKE }}/mlruns.db"
|
||||
default_exp_name: "tac-rd-tune"
|
||||
|
||||
task:
|
||||
model:
|
||||
class: LGBModel
|
||||
module_path: qlib.contrib.model.gbdt
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.05
|
||||
num_leaves: 15
|
||||
num_boost_round: 1000
|
||||
early_stopping_rounds: 50
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.01
|
||||
reg_lambda: 0.01
|
||||
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,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ
|
||||
start_time: 2000-01-03
|
||||
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,-2)/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:
|
||||
- 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: 4
|
||||
n_drop: 2
|
||||
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,TSLA,QQQ,IVV,SMH,TLT,IBIT,MCHI,AIQ
|
||||
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 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Improved RankIC workflow: 300+ stock universe, proven RankICLGBModel params,
|
||||
# extended 12-month validation, full SP feature set (40 features).
|
||||
#
|
||||
# Changes from repro run:
|
||||
# 1. Single RankICLGBModel (not ensemble) — proven config from skill
|
||||
# 2. num_leaves=15 (not 31) — the verified value
|
||||
# 3. Universe expanded from 50 ETFs to 300+ single stocks + ETFs
|
||||
# 4. Validation extended to 12 months (2025-01 to 2026-01)
|
||||
# 5. Full 40 SP features (no leakage confirmed)
|
||||
# 6. Early stopping still at 200 (proven)
|
||||
#
|
||||
# Run:
|
||||
# rd_run_workflow config_path=tac-qlib/workflows/workflow_lgb_300sp_rankic.yaml \
|
||||
# experiment_name=tac-rd-300sp-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-300sp-rankic" }
|
||||
|
||||
task:
|
||||
model:
|
||||
# Single RankICLGBModel — proven config from tac-qlib-custom skill.
|
||||
# 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:
|
||||
# Expanded universe: all lake symbols (instruments: "all" = every symbol with bars in the lake)
|
||||
instruments: "all"
|
||||
start_time: "2015-01-03"
|
||||
end_time: "2026-08-14"
|
||||
fit_start_time: "2016-01-04"
|
||||
fit_end_time: "2025-01-01"
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
label: "Ref($close,-6)/Ref($close,-1)-1"
|
||||
# Full 40 SP features + 6 OHLCV = 46 features
|
||||
feature_fields: "$open,$high,$low,$close,$vwap,$volume,sp_ret,sp_logp,sp_hurst_exponent,sp_ou_half_life,sp_ou_revert,sp_ou_zscore,sp_hmm_state,sp_hmm_p_regime1,sp_jump_flag,sp_jump_ratio,sp_jump_tail,sp_max_move,sp_max_up,sp_max_down,sp_rv1,sp_rv5,sp_rv22,sp_rv_ac1,sp_rv_cv_22,sp_vol_ratio_1_22,sp_vol_ratio_5_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_rskew_5,sp_rskew_22,sp_rkurt_5,sp_rkurt_22,sp_dsv_1,sp_dsv_5,sp_dsv_22,sp_dsv_ratio_1,sp_dsv_ratio_5,sp_dsv_ratio_22,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead,sp_sig_level2_lead_lag_5,sp_sig_level2_lag_lead_5"
|
||||
infer_processors:
|
||||
- { class: DropAllNaN, kwargs: { fit_start_time: "2016-01-04", fit_end_time: "2025-01-01" } }
|
||||
- { class: ProcessInf, kwargs: {} }
|
||||
- { class: CSRankNorm, kwargs: {} }
|
||||
- { class: ZScoreNorm, kwargs: { fit_start_time: "2016-01-04", fit_end_time: "2025-01-01" } }
|
||||
- { class: Fillna, kwargs: {} }
|
||||
segments:
|
||||
train: ["2016-01-04", "2024-12-31"]
|
||||
valid: ["2025-01-02", "2026-01-02"]
|
||||
test: ["2026-01-04", "2026-08-14"]
|
||||
|
||||
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: 10, n_drop: 2, only_tradable: true, risk_degree: 0.95 }
|
||||
backtest:
|
||||
start_time: "2026-01-04"
|
||||
end_time: "2026-08-14"
|
||||
account: 1000000
|
||||
benchmark: SPY
|
||||
exchange_kwargs:
|
||||
codes: ""
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -0,0 +1,145 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# CANONICAL: SP-5d LightGBM with the stochastic-control OptimalStopControl
|
||||
# strategy (entry-gated by signal percentile, optimal-stopping exits by
|
||||
# percentile / time stop / stop-loss, equal-weight control sizing).
|
||||
#
|
||||
# This is the stochastic-optimal-stopping strategy ported from the experiments:
|
||||
# - entry: a symbol opens only when its cross-sectional signal percentile
|
||||
# >= entry_pct and fewer than `topk` positions are open
|
||||
# - exit: percentile < exit_pct (continuation value too low), or
|
||||
# max_hold_days (finite-horizon time stop), or P&L <= sl
|
||||
# (loss control) after min_hold_days
|
||||
# - sizing: equal-weight control (risk_degree fraction of total value split
|
||||
# across targets)
|
||||
#
|
||||
# Strategy class: tac_qlib.contrib.strategy.optimal_stop.OptimalStopControl
|
||||
# Calibrate entry_pct / exit_pct / max_hold_days on the VALID window only
|
||||
# (the experiments showed valid-window calibration overfits; prefer robust
|
||||
# defaults: entry 0.85 / exit 0.7 / hold 10 / sl -0.08).
|
||||
#
|
||||
# Run:
|
||||
# rd_run_workflow config_path=tac-qlib/workflows/workflow_lgb_sp5d_optstop.yaml \
|
||||
# experiment_name=tac-rd-optstop
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- set LAKE = TAC_LAKE_DIR %}
|
||||
{%- set UNIVERSE = "SPY,QQQ,DIA,IWM,MDY,VTI,VOO,VEA,VWO,VT,EFA,EEM,TLT,IEF,SHY,AGG,BND,LQD,HYG,JNK,EMB,GLD,SLV,USO,UNG,DBA,DBC,XLK,XLF,XLE,XLV,XLI,XLY,XLP,XLU,XLB,XLRE,ARKK,SMH,SOXX,IBB,XBI,ITA,XAR,ICLN,TAN,FDN,IGV,ESPO,REM" %}
|
||||
{%- set SP_FIELDS = "sp_ret,sp_ou_zscore,sp_ou_half_life,sp_ou_revert,sp_hmm_p_regime1,sp_hmm_state,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_move,sp_rv1,sp_rv5,sp_rv22,sp_vol_ratio_5_22,sp_vol_ratio_1_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead" %}
|
||||
|
||||
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:///{{ LAKE }}/mlruns.db"
|
||||
default_exp_name: "tac-rd-optstop"
|
||||
|
||||
task:
|
||||
model:
|
||||
class: LGBModel
|
||||
module_path: qlib.contrib.model.gbdt
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.03
|
||||
num_leaves: 31
|
||||
n_estimators: 500
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.1
|
||||
reg_lambda: 1.0
|
||||
seed: 42
|
||||
|
||||
dataset:
|
||||
class: DatasetH
|
||||
module_path: qlib.data.dataset
|
||||
kwargs:
|
||||
handler:
|
||||
class: TACHandler
|
||||
module_path: tac_qlib.contrib.data.handler
|
||||
kwargs:
|
||||
instruments: "{{ UNIVERSE }}"
|
||||
start_time: 2015-01-03
|
||||
end_time: 2026-08-10
|
||||
fit_start_time: 2015-01-03
|
||||
fit_end_time: 2025-09-01
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
label: "Ref($close,-6)/Ref($close,-1)-1"
|
||||
feature_fields: "$open,$high,$low,$close,$vwap,$volume,{{ SP_FIELDS }}"
|
||||
infer_processors:
|
||||
- class: DropAllNaN
|
||||
kwargs: {}
|
||||
- class: ProcessInf
|
||||
kwargs: {}
|
||||
- class: CSRankNorm
|
||||
kwargs: {}
|
||||
- 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:
|
||||
- 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: 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
|
||||
backtest:
|
||||
start_time: 2026-01-04
|
||||
end_time: 2026-08-10
|
||||
account: 1000000
|
||||
benchmark: SPY
|
||||
exchange_kwargs:
|
||||
codes: "{{ UNIVERSE }}"
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -0,0 +1,142 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# CANONICAL: LightGBM with RankIC early-stopping on the 50-ETF SP-5d panel.
|
||||
#
|
||||
# Uses the tac-qlib contrib stack so no reinvention is needed:
|
||||
# - model: RankICLGBModel (tac_qlib.contrib.model.rank_gbdt) — early-stops
|
||||
# on per-day cross-sectional RankIC, not l2. The measured lever:
|
||||
# RankIC 0.047 -> 0.075 on the SP-5d signal, and with the tuned
|
||||
# budget the first config that beat SPY net of costs.
|
||||
# - handler: TACHandler (tac_qlib.contrib.data.handler) — lake features
|
||||
# - records: SignalRecord + SigAnaRecord + PortAnaRecord (TopkDropout)
|
||||
#
|
||||
# Feature columns are the 24 sp_* columns computed by the Rust get_lake_sp tool
|
||||
# (7 stochastic-process families: ou,hmm,jump,har,trend,hurst,signature). Any
|
||||
# other column present in the lake features parquet can be listed instead.
|
||||
#
|
||||
# Run:
|
||||
# rd_run_workflow config_path=tac-qlib/workflows/workflow_lgb_sp5d_rankic.yaml \
|
||||
# experiment_name=tac-rd-rankic
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- set LAKE = TAC_LAKE_DIR %}
|
||||
{%- set UNIVERSE = "SPY,QQQ,DIA,IWM,MDY,VTI,VOO,VEA,VWO,VT,EFA,EEM,TLT,IEF,SHY,AGG,BND,LQD,HYG,JNK,EMB,GLD,SLV,USO,UNG,DBA,DBC,XLK,XLF,XLE,XLV,XLI,XLY,XLP,XLU,XLB,XLRE,ARKK,SMH,SOXX,IBB,XBI,ITA,XAR,ICLN,TAN,FDN,IGV,ESPO,REM" %}
|
||||
{%- set SP_FIELDS = "sp_ret,sp_ou_zscore,sp_ou_half_life,sp_ou_revert,sp_hmm_p_regime1,sp_hmm_state,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_move,sp_rv1,sp_rv5,sp_rv22,sp_vol_ratio_5_22,sp_vol_ratio_1_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead" %}
|
||||
|
||||
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:///{{ LAKE }}/mlruns.db"
|
||||
default_exp_name: "tac-rd-rankic"
|
||||
|
||||
task:
|
||||
model:
|
||||
class: RankICLGBModel
|
||||
module_path: tac_qlib.contrib.model.rank_gbdt
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.02
|
||||
num_leaves: 31
|
||||
n_estimators: 3000
|
||||
num_boost_round: 3000
|
||||
early_stopping_rounds: 200
|
||||
min_data_in_leaf: 20
|
||||
lambda_l2: 0.5
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.1
|
||||
reg_lambda: 1.0
|
||||
seed: 42
|
||||
|
||||
dataset:
|
||||
class: DatasetH
|
||||
module_path: qlib.data.dataset
|
||||
kwargs:
|
||||
handler:
|
||||
class: TACHandler
|
||||
module_path: tac_qlib.contrib.data.handler
|
||||
kwargs:
|
||||
instruments: "{{ UNIVERSE }}"
|
||||
start_time: 2015-01-03
|
||||
end_time: 2026-08-10
|
||||
fit_start_time: 2015-01-03
|
||||
fit_end_time: 2025-09-01
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
label: "Ref($close,-6)/Ref($close,-1)-1"
|
||||
feature_fields: "$open,$high,$low,$close,$vwap,$volume,{{ SP_FIELDS }}"
|
||||
infer_processors:
|
||||
- class: DropAllNaN
|
||||
kwargs: {}
|
||||
- class: ProcessInf
|
||||
kwargs: {}
|
||||
- class: CSRankNorm
|
||||
kwargs: {}
|
||||
- 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:
|
||||
- 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: 10
|
||||
n_drop: 2
|
||||
only_tradable: true
|
||||
risk_degree: 0.95
|
||||
backtest:
|
||||
start_time: 2026-01-04
|
||||
end_time: 2026-08-10
|
||||
account: 1000000
|
||||
benchmark: SPY
|
||||
exchange_kwargs:
|
||||
codes: "{{ UNIVERSE }}"
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -0,0 +1,137 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Seed ensemble of the RankIC-early-stopping LightGBM on the 50-ETF SP-5d panel.
|
||||
#
|
||||
# Same canonical setup as workflow_lgb_sp5d_rankic.yaml but with
|
||||
# RankICEnsembleLGBModel (tac_qlib.contrib.model.rank_ensemble): 5 sub-models,
|
||||
# one per seed, identical hyper-parameters; predictions are the seed average.
|
||||
# The seeds train in a thread pool (parallel: 5), so this is ~2x faster than
|
||||
# the same 5 models serially on a 6-physical-core host.
|
||||
#
|
||||
# Run:
|
||||
# rd_run_workflow config_path=tac-qlib/workflows/workflow_lgb_sp5d_rankic_ensemble.yaml \
|
||||
# experiment_name=tac-rd-rankic-ensemble
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- set LAKE = TAC_LAKE_DIR %}
|
||||
{%- set UNIVERSE = "SPY,QQQ,DIA,IWM,MDY,VTI,VOO,VEA,VWO,VT,EFA,EEM,TLT,IEF,SHY,AGG,BND,LQD,HYG,JNK,EMB,GLD,SLV,USO,UNG,DBA,DBC,XLK,XLF,XLE,XLV,XLI,XLY,XLP,XLU,XLB,XLRE,ARKK,SMH,SOXX,IBB,XBI,ITA,XAR,ICLN,TAN,FDN,IGV,ESPO,REM" %}
|
||||
{%- set SP_FIELDS = "sp_ret,sp_ou_zscore,sp_ou_half_life,sp_ou_revert,sp_hmm_p_regime1,sp_hmm_state,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_move,sp_rv1,sp_rv5,sp_rv22,sp_vol_ratio_5_22,sp_vol_ratio_1_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead" %}
|
||||
|
||||
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:///{{ LAKE }}/mlruns.db"
|
||||
default_exp_name: "tac-rd-rankic-ensemble"
|
||||
|
||||
task:
|
||||
model:
|
||||
class: RankICEnsembleLGBModel
|
||||
module_path: tac_qlib.contrib.model.rank_ensemble
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.02
|
||||
num_leaves: 31
|
||||
n_estimators: 3000
|
||||
num_boost_round: 3000
|
||||
early_stopping_rounds: 200
|
||||
min_data_in_leaf: 20
|
||||
lambda_l2: 0.5
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.1
|
||||
reg_lambda: 1.0
|
||||
seeds: "42,7,2026,99,123"
|
||||
parallel: 5
|
||||
|
||||
dataset:
|
||||
class: DatasetH
|
||||
module_path: qlib.data.dataset
|
||||
kwargs:
|
||||
handler:
|
||||
class: TACHandler
|
||||
module_path: tac_qlib.contrib.data.handler
|
||||
kwargs:
|
||||
instruments: "{{ UNIVERSE }}"
|
||||
start_time: 2015-01-03
|
||||
end_time: 2026-08-10
|
||||
fit_start_time: 2015-01-03
|
||||
fit_end_time: 2025-09-01
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
label: "Ref($close,-6)/Ref($close,-1)-1"
|
||||
feature_fields: "$open,$high,$low,$close,$vwap,$volume,{{ SP_FIELDS }}"
|
||||
infer_processors:
|
||||
- class: DropAllNaN
|
||||
kwargs: {}
|
||||
- class: ProcessInf
|
||||
kwargs: {}
|
||||
- class: CSRankNorm
|
||||
kwargs: {}
|
||||
- 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:
|
||||
- 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: 10
|
||||
n_drop: 2
|
||||
only_tradable: true
|
||||
risk_degree: 0.95
|
||||
backtest:
|
||||
start_time: 2026-01-04
|
||||
end_time: 2026-08-10
|
||||
account: 1000000
|
||||
benchmark: SPY
|
||||
exchange_kwargs:
|
||||
codes: "{{ UNIVERSE }}"
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -0,0 +1,103 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Reproduction run of the RankIC-early-stopping LightGBM ensemble on 50-ETF SP-5d.
|
||||
# Matches the canonical ensemble but with trimmed SP features (no OU/HMM) and
|
||||
# fit_start_time shifted to 2016-01-04 to avoid warm-up NaN rows.
|
||||
#
|
||||
# Run:
|
||||
# rd_run_workflow config_path=tac-qlib/workflows/workflow_lgb_sp5d_rankic_ensemble_repro.yaml \
|
||||
# experiment_name=tac-rd-rank-ensemble-repro
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- 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-rank-ensemble-repro" }
|
||||
|
||||
task:
|
||||
model:
|
||||
class: RankICEnsembleLGBModel
|
||||
module_path: tac_qlib.contrib.model.rank_ensemble
|
||||
kwargs:
|
||||
loss: mse
|
||||
learning_rate: 0.02
|
||||
num_leaves: 31
|
||||
n_estimators: 3000
|
||||
num_boost_round: 3000
|
||||
early_stopping_rounds: 200
|
||||
min_data_in_leaf: 20
|
||||
lambda_l2: 0.5
|
||||
colsample_bytree: 0.8
|
||||
subsample: 0.8
|
||||
subsample_freq: 1
|
||||
reg_alpha: 0.1
|
||||
reg_lambda: 1.0
|
||||
seeds: "42,7,2026,99,123"
|
||||
|
||||
dataset:
|
||||
class: DatasetH
|
||||
module_path: qlib.data.dataset
|
||||
kwargs:
|
||||
handler:
|
||||
class: TACHandler
|
||||
module_path: tac_qlib.contrib.data.handler
|
||||
kwargs:
|
||||
instruments: "SPY,QQQ,DIA,IWM,MDY,VTI,VOO,VEA,VWO,VT,EFA,EEM,TLT,IEF,SHY,AGG,BND,LQD,HYG,JNK,EMB,GLD,SLV,USO,UNG,DBA,DBC,XLK,XLF,XLE,XLV,XLI,XLY,XLP,XLU,XLB,XLRE,ARKK,SMH,SOXX,IBB,XBI,ITA,XAR,ICLN,TAN,FDN,IGV,ESPO,REM"
|
||||
start_time: "2015-01-03"
|
||||
end_time: "2026-08-14"
|
||||
fit_start_time: "2016-01-04"
|
||||
fit_end_time: "2025-09-01"
|
||||
freq: day
|
||||
lake_root: "{{ LAKE }}"
|
||||
market: US
|
||||
label: "Ref($close,-6)/Ref($close,-1)-1"
|
||||
feature_fields: "$open,$high,$low,$close,$vwap,$volume,sp_ret,sp_jump_ratio,sp_jump_flag,sp_jump_tail,sp_max_move,sp_rv1,sp_rv5,sp_rv22,sp_vol_ratio_5_22,sp_vol_ratio_1_22,sp_trend_slope_5,sp_trend_slope_20,sp_trend_slope_60,sp_logp,sp_hurst_exponent,sp_sig_level1_lead,sp_sig_level1_lag,sp_sig_level2_lead_lag,sp_sig_level2_lag_lead"
|
||||
infer_processors:
|
||||
- { class: DropAllNaN, kwargs: { fit_start_time: "2016-01-04", fit_end_time: "2025-09-01" } }
|
||||
- { class: ProcessInf, kwargs: {} }
|
||||
- { class: CSRankNorm, kwargs: {} }
|
||||
- { class: ZScoreNorm, kwargs: { fit_start_time: "2016-01-04", fit_end_time: "2025-09-01" } }
|
||||
- { class: Fillna, kwargs: {} }
|
||||
segments:
|
||||
train: ["2016-01-04", "2025-09-01"]
|
||||
valid: ["2025-09-03", "2026-01-03"]
|
||||
test: ["2026-01-04", "2026-08-10"]
|
||||
|
||||
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: 10, n_drop: 2, only_tradable: true, risk_degree: 0.95 }
|
||||
backtest:
|
||||
start_time: "2026-01-04"
|
||||
end_time: "2026-08-10"
|
||||
account: 1000000
|
||||
benchmark: SPY
|
||||
exchange_kwargs:
|
||||
codes: "SPY,QQQ,DIA,IWM,MDY,VTI,VOO,VEA,VWO,VT,EFA,EEM,TLT,IEF,SHY,AGG,BND,LQD,HYG,JNK,EMB,GLD,SLV,USO,UNG,DBA,DBC,XLK,XLF,XLE,XLV,XLI,XLY,XLP,XLU,XLB,XLRE,ARKK,SMH,SOXX,IBB,XBI,ITA,XAR,ICLN,TAN,FDN,IGV,ESPO,REM"
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
@@ -0,0 +1,129 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# LightGBM on the TradeAC lake -- qrun workflow (train -> signal -> backtest).
|
||||
#
|
||||
# Run it like a stock qlib project:
|
||||
#
|
||||
# cd tac-qlib
|
||||
# qrun workflows/workflow_lgb_taclake.yaml \
|
||||
# --experiment_name tac-lake-lgb --uri_folder mlruns
|
||||
#
|
||||
# Or with a custom lake root:
|
||||
#
|
||||
# TAC_LAKE_DIR=/path/to/lake qrun workflows/workflow_lgb_taclake.yaml \
|
||||
# --experiment_name tac-lake-lgb
|
||||
#
|
||||
# The lake providers (calendar/instrument/feature) are wired in `qlib_init`; the
|
||||
# expression engine and backtest Exchange stay upstream qlib. The TACHandler reads
|
||||
# OHLCV + ta-lib features straight from the parquet lake.
|
||||
#
|
||||
# Segment split (the lake holds 1d bars since 2026-02-09):
|
||||
# train 2026-03-01..2026-05-31 / valid 2026-06-01..2026-06-30 / test 2026-07-01..2026-08-06
|
||||
# -----------------------------------------------------------------------------
|
||||
{%- set LAKE = TAC_LAKE_DIR %}
|
||||
|
||||
qlib_init:
|
||||
provider_uri: "{{ LAKE }}"
|
||||
region: us
|
||||
expression_cache: null
|
||||
dataset_cache: null
|
||||
|
||||
# --- lake-backed providers (see tac_qlib.data.providers) -----------------
|
||||
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
|
||||
|
||||
# sqlite backend avoids mlflow's filesystem-backend maintenance-mode opt-out
|
||||
exp_manager:
|
||||
class: MLflowExpManager
|
||||
module_path: qlib.workflow.expm
|
||||
kwargs:
|
||||
uri: "sqlite:///mlruns.db"
|
||||
default_exp_name: "tac-lake-demo"
|
||||
|
||||
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: all
|
||||
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
|
||||
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
|
||||
# any symbol the lake holds works; the lake has no index quotes yet
|
||||
benchmark: AAPL
|
||||
exchange_kwargs:
|
||||
codes: all
|
||||
deal_price: $close
|
||||
freq: day
|
||||
open_cost: 0.0005
|
||||
close_cost: 0.0015
|
||||
min_cost: 5.0
|
||||
risk_analysis_freq: 1d
|
||||
Reference in New Issue
Block a user