TA feature importance
Ranks about 2,500 technical-indicator columns by how well they predict 15-minute direction, without fooling itself, and uses a local LLM to port TradingView indicators behind deterministic causality checks.
- Python
- scikit-learn
- TA-Lib
- pandas
- Ollama
- indicator columns per split
- ~2,500
- indicator columns per split
- LLM-translator self-test checks pass
- 14 / 14
- LLM-translator self-test checks pass
- top features redundant (VIF > 10) before deduplication
- 19 / 20
- top features redundant (VIF > 10) before deduplication
Problem
Technical-analysis libraries offer thousands of indicators, and many of them say the same thing. Ranking them naively finds "important" features that are duplicates, price levels in disguise, or leaks from the future. I wanted a pipeline that cuts about 2,500 indicator columns down to a small, non-redundant set that carries real signal for the direction of 15-minute US-market bars.
A second problem came with it. Many useful indicators exist only as TradingView Pine Script, a different language. An LLM can write the port, but LLM-written code that silently reads the future would poison every result downstream.
Constraints
- Indicators must be computed after the split. Indicators computed on the full series before splitting can carry test-period information into training features.
- Overlapping labels. A label over the next 520 bars shares bars with its neighbours: 100,000 bars carry only about 192 non-overlapping labels at that horizon.
- Thousands of candidates. With this many features, many will look significant by chance alone.
- Generated code is untrusted. A ported indicator has to pass a causality test before it reaches the engine.
- Size. The indicator tables for the reduced universe come to 7.1 GB.
Design
Code Gates
Deterministic checks decide PASS or FAIL. A static scan allows only eight libraries and rejects look-ahead idioms and dangerous calls. Then the function runs on 4,000 synthetic bars: removing the last 37 rows must not change any earlier value.
# causality: recompute on truncated data; earlier rows must be identical
trunc = df.iloc[:-cut]
out_trunc = fn(trunc)
for c in new_cols:
a = out_full[c].astype(float).iloc[:-cut].to_numpy()
b = out_trunc[c].astype(float).to_numpy()
if len(b) != len(a) or not np.allclose(a, b, equal_nan=True, rtol=1e-9, atol=1e-12):
causal_bad.append(c)The data path runs across the top and back along the middle row. Tickers are clustered and one liquid representative is kept per cluster. Bars are split by date, then about 2,500 indicators are computed on each side separately. A screen removes price levels, close-price lookalikes and duplicates, and adds random shadow features. Labels are cut at training quantiles and weighted for overlap. A random forest is scored by clustered permutation importance, and the survivors are meant to go through false-discovery control.
The bottom row is the translator. A local model writes the Python port in one call, deterministic gates decide whether it passes, and a person merges what passes into the indicator engine.
Key decisions
- Split first, compute second. The date split happens on raw bars, before any indicator exists. The package version also purges the last h training rows of each fold.
- Clustered permutation importance instead of impurity importance. Impurity importance favours features with many distinct values and splits credit between correlated copies. Permuting a whole cluster of correlated features at once, and requiring it to beat four noise and four shuffled shadow features, avoids both.
- Weight labels by uniqueness. Samples are strided (at most 26 bars apart) and weighted by their average uniqueness, so long horizons don't look like more evidence than they are.
- Control false discoveries. Benjamini-Hochberg across all candidates, a check that features hold up across horizons, and the Deflated Sharpe for any strategy built on the result. This is built as a module and called from the notebook; the walk-forward pipeline doesn't apply it yet.
- The LLM writes, code decides. The translator makes one call to a local model, with no tools and no retries. A static scan allows only eight libraries and rejects look-ahead idioms such as negative shifts and centred windows. Then the function runs on 4,000 synthetic bars: removing the last 37 rows must leave every earlier value unchanged. The self-test includes a leaky function that contains no forbidden syntax; only the causality test catches it.
Evidence
- Translator self-test: 14 of 14 checks pass, including the leak no syntax check can see.
- Two TradingView indicators ported: Squeeze Momentum (written by gpt-oss:120b) passed with 6 columns, and Trendlines with Breaks (gemma4:31b) passed with 4. The second was edited by hand afterwards and now fails, so it needs another pass.
- Redundancy, measured. Among the 20 features that ranked as important most often for SPY, 69 pairs correlate above 0.7 and 19 of the 20 have a variance inflation factor above 10. Two Keltner channel widths are exact duplicates, and six variants of one volume indicator (VPT and its moving averages) are near-copies of one signal. The duplicate screen and clustered importance came next.
- No importance results saved yet. The walk-forward pipeline is built but hasn't produced its first importance table.
What I'd change
- Bring the notebook and the package together. The notebook uses a 70/30 split, 3 clusters and a 2.5-year slice. The package uses four walk-forward folds, 14 clusters and the full history.
- Harden the translator. Re-run the gate after any hand edit, and run generated code in a sandbox rather than in-process.
- Show the effect of deduplication. Regenerate the redundancy chart after the duplicate screen, for a before-and-after comparison.