Research artifacts for a bear-specialist trading strategy and a Karpathy-style autoresearch loop that tunes its parameters against the 2022 bear-market window.
trader-research/
├── program.md # autoresearch loop spec (operator-facing)
├── harness.py # untouchable: data loader, backtest, scoring
├── sweep.py # modifiable: GemParams, model body, sweep driver
├── Makefile # make publish → copy feed.json to the blog repo
├── data/
│ ├── bear_portfolio_candles.csv # PAXG, EUR, USDC, TUSD daily candles, May–Dec 2022
│ ├── btcusdt_daily.csv # BTCUSDT daily 2017-08→present (regime notebook)
│ └── fetch_btcusdt_daily.py # paginated Binance klines fetcher
├── notebooks/
│ ├── regime_student_t_drawdown_2022.org # drawdown-HMM regime model → feed.json
│ └── gem_bear_models.org # exponential vs linear GEM prototype
├── docs/
│ ├── autoresearch-reports/ # post-mortems from prior loop runs
│ └── plans/ # design docs
├── results/ # sweep outputs (gitignored)
├── feed.json # GENERATED by the regime notebook (gitignored)
└── LICENSE
The split between harness.py and sweep.py mirrors the
autoresearch prepare.py /
train.py setup: the harness is the contract surface the loop cannot
touch (data loader, backtest skeleton, scoring, fee constants); the sweep
is the agent's playground (parameters, model body, driver). See
program.md for the operating contract the autoresearch loop runs under.
harness.py:
load_candles(path)-- read the candles CSV, return adict[token, DataFrame].PortfolioState,HeldPosition,DaySnapshot-- portfolio bookkeeping.gem_backtest(params, candles_by_token, fit_fn, portfolio_fn)-- day-by-day causal walk-forward.fit_fnandportfolio_fnare injected fromsweep.py, so the backtest skeleton stays fixed while the model body stays modifiable.ensemble_score(base, stress)-- the single scalar metric the loop optimizes.score = annualized_return × drawdown_dampener × diversification_bonus, with hard rejection on annualized return below -50% or negative stress-test calmar. The name is inherited from the full ensemble's scoring contract; only the bear specialist is under test in this repo.evaluate(params, candles_by_token, fit_fn, portfolio_fn)-- runs base + 1.5× fee-stress backtests, returns(score, base_metrics, stress_metrics). PinsFEE_RATEandINITIAL_CAPITALregardless of caller params.FEE_RATE = 0.003,FEE_STRESS_MULTIPLIER = 1.5,INITIAL_CAPITAL = 10_000.0.
sweep.py (modifiable interior):
GemParams-- bear-specialist parameter struct:top_n,r2_threshold,rebalance_cooldown,atr_window,fit_window,momentum_cap,r2_exponent, plususe_*ablation flags.Position-- output of the per-token fit (token, momentum, r2, a1, atr, weight).fit_token_exponential(candles, atr_window, r2_exponent)-- fitsy = a0 · a1ˣto closes, computesmomentum = r²ⁿ · (a1 - 1) · 100and ATR-normalized volatility.build_portfolio(candidates, params)-- applies the r²/growth/momentum filters, pickstop_n, weights by inverse volatility (or equal).sweep_one_parameter(name, values, candles, baseline)-- runs the one-at-a-time sweep, scores each candidate viaharness.evaluate, prints per-candidate diagnostics.
The data + control flow:
flowchart TD
csv[("data/bear_portfolio_candles.csv")]
load["harness.load_candles"]
eval["harness.evaluate(params, candles, fit_fn, portfolio_fn)"]
fit["sweep.fit_token_exponential"]
port["sweep.build_portfolio"]
bt["harness.gem_backtest<br/>(base + 1.5x fee stress)"]
metrics["base + stress metrics"]
score["harness.ensemble_score"]
sweep["sweep_one_parameter"]
tsv[("results/bear_sweep_results.tsv")]
csv --> load
load -->|candles_by_token| eval
fit -. injected as fit_fn .-> eval
port -. injected as portfolio_fn .-> eval
eval --> bt
bt --> metrics
metrics --> score
score -->|scalar| sweep
sweep -->|append row| tsv
Heavy-tail HMM regime detection on BTCUSDT, using drawdown from the rolling max as the single observation:
d_t = log(p_t) − max_{s≤t} log(p_s)
The feature is ≤ 0 by construction, mean-reverts to 0 at every new all-time
high, and sits deeply negative through sustained drawdowns. It is causal
(the running max uses only history up to t), so there is no look-ahead —
drawdown is the trader definition of a bear.
Data: data/btcusdt_daily.csv — BTCUSDT daily candles 2017-08 → present
(~3,200 rows), fetched by data/fetch_btcusdt_daily.py (paginated Binance
klines; --start / --end to rescope).
Models: self-contained Gaussian and Student's-t HMMs at K ∈ {2, 3}, fit
by log-space Baum–Welch with 5 random restarts and decoded with Viterbi.
States are named by ascending μ (K=3 → bear, ranging, bull). Student's-t
emissions collapse to Gaussian at this feature scale (ν saturates at its upper
bound), so the shipped model is K=3 Gaussian — three near-evenly-spaced
states (μ ≈ −1.09 / −0.57 / −0.13) over the full window.
Notebook sections: Description, Setup, Data Loading, the HMM core (forward–backward, emissions, M-steps, Baum–Welch, Viterbi), fitting the four models, regime labelling, full-window + 2022 plots, the 2022 pass-criterion, Export dashboard feed, and the positive-result write-up with references.
The Export dashboard feed cell emits feed.json — the artifact consumed by
the interactive dashboard at blog.nodrama.io/regimes.
flowchart LR
csv[("data/btcusdt_daily.csv")]
nb["notebook: fit K=3 Gaussian<br/>+ Export dashboard feed cell"]
feed[("feed.json<br/>gitignored here")]
pub["make publish"]
blog["blog repo:<br/>regimes/feed.json (committed)<br/>+ page / css / js"]
site["blog.nodrama.io/regimes"]
csv --> nb --> feed --> pub --> blog --> site
Clean separation of concerns:
- This repo = research + output. The notebook fits the model and writes
feed.jsonto the repo root;feed.jsonis gitignored here. - Blog repo = presentation.
fbielejec.github.ioowns the dashboard (layout, page,regimes.css,app.js) and commitsfeed.jsonunderregimes/. The page is model-agnostic — it introspectsmodel.states[], so a newK/ family / observation ships by re-running the cell and copying the file. make publishcopies the notebook-producedfeed.jsoninto the blog repo (<blog>/regimes/feed.json); commit it there to deploy.
The original (pre-split) design is recorded in
docs/plans/2026-06-03-drawdown-hmm-dashboard-design.md.
Comparing exponential (y = a0 · a1ˣ) vs. linear (y = b0 + b1 · x)
regression-based GEM models for capital preservation during an
established bear market. The notebook is the prototype the
fit_token_exponential / build_portfolio primitives in sweep.py were
extracted from. Originally published alongside the
Winning with the Bear
blog post.
Data: data/bear_portfolio_candles.csv -- 788 daily candles across 4
tokens, May 1 – Dec 31, 2022 (the established 2022 bear market).
Universe is intentionally a stablecoin / safe-haven basket:
| Token | Type |
|---|---|
| PAXGUSDT | Gold-backed |
| EURUSDT | Euro-pegged |
| USDCUSDT | USD stable |
| TUSDUSDT | USD stable |
Columns: token, timestamp, open, high, low, close, volume.
GEM signal pipeline (per token, per day):
flowchart TD
candles["candles[-fit_window:]"]
fit["fit y = a0 · a1ˣ"]
r2["r²"]
mom["momentum = r²ⁿ · (a1 − 1) · 100"]
atr["ATR over last atr_window candles<br/>÷ mean_close"]
pos["Position(token, momentum, r2, a1, atr)"]
filter["filter: growth (a1 > 1), r² ≥ threshold, momentum cap"]
sort["sort by momentum, take top_n"]
weight["weight by 1 / atr<br/>(inverse-volatility)"]
candles --> fit
fit --> r2
r2 --> mom
candles --> atr
mom --> pos
atr --> pos
pos --> filter
filter --> sort
sort --> weight
Sections in the notebook: Description, Setup, Data Loading, Core Functions, GEM Backtest (Exponential), Metrics Computation, Buy & Hold Benchmark, plus narrative sections on rolling-window analysis, the R² "dead cat bounce" filter, and the bear-w30 winning configuration.
The current bear specialist is a long-only stablecoin / safe-haven rotator
-- it preserves capital in a bear market but cannot profit from the
downtrend itself. A natural extension is a more aggressive bear model
built on dYdX perpetual futures, which would
let the strategy take short positions on the tokens it currently filters
out. The same r² · (a1 - 1) momentum signal becomes a short-entry
signal when negated, and the inverse-volatility weighting carries over.
Open questions:
- funding-rate cost vs. the current 30 bps round-trip fee budget
- sizing under leverage
- whether the hard-rejection gate on stress-test calmar still makes sense once shorting is allowed.
A second direction is a reinforcement-learning search policy as a
replacement for the autoresearch loop itself. The current loop is a
hand-coded one-parameter-at-a-time scan; an RL agent would learn the
search heuristics from the score signal directly -- for example,
"after finding a good r2_threshold, explore top_n" emerges from
training rather than being hard-wired in program.md.
Sketch:
- State: the current
GemParamstensor plus a summary of past evaluations ("where am I in the search space?") -- e.g. a fixed-size embedding of the last K (params, score) pairs, or per-axis quantile positions of already-tried values. - Action: a parameter edit -- pick an axis, pick a direction or a new value (discrete or continuous head per parameter).
- Reward:
ensemble_scorefromharness.py, possibly shaped by the delta against the current best. - Environment: a thin wrapper around the same walk-forward causal
backtest used in the bear-GEM notebook and
harness.evaluate. The scoring contract stays fixed; only the search policy changes.
This is also the natural setting in which to compare phased single-block sweeping against a true joint-space search and check how much of the historical "deletion wins" finding survives once interactions are modeled explicitly.
Apache-2.0. See LICENSE.