tosi-n commited on
Commit
ce6b50a
·
verified ·
1 Parent(s): 65053bf

Upload folder using huggingface_hub

Browse files
LOOP.md ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The Recursive Self-Improving Loop
2
+
3
+ How tradewatch's soft reflection (events → MEMORY.md prompt text) becomes a real
4
+ gradient loop on Laguna XS.2, where improvement compounds across iterations through
5
+ **both adapter weights and curriculum**.
6
+
7
+ ## The two improvement channels
8
+
9
+ 1. **Weights (parametric continuation):** each hosted RL run warm-starts from the prior
10
+ iteration's adapter via `checkpoint_id`. The model is never reset — discipline learned
11
+ in iter N carries into iter N+1. This is the thing tradewatch never had.
12
+ 2. **Curriculum (reflection-driven):** between runs, `recursive_loop.py reflect` reads the
13
+ prior adapter's OOS eval and shifts the next run's **objective** (sharpe → min_drawdown
14
+ → balanced) and **focus symbols** (the weakest performers). This is tradewatch's
15
+ `summarize_session_events` reflection — repurposed to steer RL instead of prompt notes.
16
+
17
+ ## One iteration
18
+
19
+ ```
20
+ ┌──────────────────────────────────────────────┐
21
+ │ configs/rl/iter_N.toml │
22
+ │ model=poolside/Laguna-XS.2 │
23
+ │ checkpoint_id=<iter N-1 adapter> ← weights │
24
+ │ [[env]] objective=..., symbols=[weak...] │
25
+ └───────────────────┬──────────────────────────┘
26
+ prime train run iter_N.toml │ (FREE hosted RL, GRPO, 128 rollouts/step)
27
+
28
+ LoRA adapter ──► prime deployments create
29
+ │ base:adapter_id, OpenAI-compatible
30
+
31
+ python scripts/laguna_eval.py --model base:adapter_id --split oos_symbols
32
+ (writes strategy per HELD-OUT symbol, scores via rubric)
33
+ │ logs/eval_*.json
34
+
35
+ python scripts/recursive_loop.py reflect <eval.json> --checkpoint-id <adapter>
36
+ (curriculum policy → objective + weak-symbol focus)
37
+
38
+
39
+ configs/rl/iter_{N+1}.toml ──► loop repeats
40
+ ```
41
+
42
+ Note Prime enforces **max 1 concurrent run/user**, so iterations are sequential — which
43
+ is exactly what warm-starting requires anyway (iter N+1 needs iter N's adapter to exist).
44
+
45
+ ## Curriculum policy (`_choose_objective`, inspectable & deterministic)
46
+ - valid≥0.8 but mean_total<0.5 → `min_drawdown` (strategies run but lose → control risk)
47
+ - pct_wrote_code<0.7 → `sharpe` + more steps (model still learning to code)
48
+ - otherwise → `balanced` (competent → broaden)
49
+ - always: next run focuses the 3 weakest OOS symbols, rotates `seed` for fresh task mixes,
50
+ lengthens to 75 steps if learning stalled (<0.5).
51
+
52
+ ## Closing to tradewatch (the demo)
53
+ The deployed adapter is OpenAI-compatible, so tradewatch's existing `HybrieClient` runs it
54
+ live with one config change:
55
+ ```
56
+ base_url = https://api.pinference.ai/api/v1
57
+ model = poolside/Laguna-XS.2:<adapter_id>
58
+ ```
59
+ **Ablation money-shot:** run the adapter with MEMORY.md stripped from the prompt. If the
60
+ discipline holds, it's provably in the weights — the memory became the adapter.
61
+
62
+ ## Run it
63
+ ```bash
64
+ # bootstrap iteration 1
65
+ python scripts/recursive_loop.py init --env-id <you>/stock-strategy-env --model poolside/Laguna-XS.2
66
+ prime train run configs/rl/iter_1.toml
67
+ prime deployments create <adapter_id>
68
+ export PRIME_API_KEY=...
69
+ python scripts/laguna_eval.py --model poolside/Laguna-XS.2:<adapter_id> --split oos_symbols
70
+ python scripts/recursive_loop.py reflect logs/eval_*.json --checkpoint-id <adapter_id>
71
+ # -> configs/rl/iter_2.toml ready; repeat
72
+ ```
SPEC.md ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # hack-x — Environment & Training Spec
2
+
3
+ A self-improving crypto-trading **coding agent** trained with RL on Prime Lab.
4
+ tradewatch's soft reflection loop (events → MEMORY.md prompt rules) becomes a real
5
+ gradient loop: rollout → backtest verifier → GRPO → **LoRA adapter** → deploy → live demo.
6
+
7
+ ---
8
+
9
+ ## 1. The one-line thesis
10
+ > Today tradewatch's trading discipline lives as *prompt text* (MEMORY.md). We make it
11
+ > live as *adapter weights* by rewarding disciplined, profitable behavior on
12
+ > out-of-sample replayed market history. The proof: the trained adapter stays
13
+ > disciplined with MEMORY.md **removed from the prompt**.
14
+
15
+ ## 1b. tradewatch as seed (pull all of it — but know its limits)
16
+ Mined both DB (4,875 events) and journal (708 rows). What's there:
17
+ - **618 `analysis_decision` rows** with 18 market features each (price_usd, liquidity_usd,
18
+ volume_24h/1h, buys/sells 1h+24h, buy_sell_24h, vol/liq, price_change 5m→24h, age_hours,
19
+ fdv, market_cap) — this IS the agent's feature schema + seed/format examples.
20
+ - Real verdicts + entry/target/stop + reasoning over 9 tokens (action-surface-B examples).
21
+ - **9 confirmed pool addresses** → guaranteed-good fetch universe seed.
22
+ - Trade outcomes (exits/PnL/skips) → rubric calibration.
23
+ - **NOT present:** any stored OHLCV time series. `ohlcv_1h` was fetched live, never persisted.
24
+ Every market row is a point-in-time snapshot, not a replayable tape.
25
+ - **Therefore:** tradewatch = seed of *decisions/features/pools* (the brains + universe);
26
+ GeckoTerminal fetch = the *replay price tape*. Both required; neither substitutes.
27
+
28
+ ## 1c. The three loops are independent (demo ≠ batch_size)
29
+ - **Training loop:** batch_size=128 parallel replay-rollouts/step — a GRPO requirement only.
30
+ - **Demo loop:** 1 (or few) LIVE paper session in tradewatch, trained adapter via
31
+ base:adapter_id. As small as we want. 128 was never the demo.
32
+ - **Recursive loop (stretch):** reflect → checkpoint_id warm-start → retrain.
33
+
34
+ ## 2. Data (replay substrate) — crypto
35
+ - **Source:** GeckoTerminal free OHLCV — `/networks/base/pools/{pool}/ohlcv/{tf}?aggregate=N&limit=L`
36
+ (already used in `scanner/base_scanner.py:843`). No key.
37
+ - **Universe:** start from the 9 journal tokens (NOCK, VIRTUAL, DEUS, CTR, Surplus,
38
+ GITLAWB, VVV, DEGEN, PITCH) + expand via trending-pool discovery to ~30–50 Base pools.
39
+ - **Bars & DEPTH CEILING (probed live 2026-05-30):** free OHLCV caps at **~1000 bars/pool,
40
+ `before_timestamp` pagination does NOT walk back further.** So:
41
+ - hourly (`tf=hour`): ~1000 bars ≈ **41 days**
42
+ - daily (`tf=day`): ~181 bars ≈ **6 months**
43
+ - **Implication:** shallow history → depth can't supply many decorrelated time windows.
44
+ **Breadth carries GRPO variance: tasks = symbol × window, universe = 30–50 pools.**
45
+ - **Storage:** `data/ohlcv/<symbol>.parquet` (ts, o, h, l, c, v). Reproducible, no rate limits at train time.
46
+ - **Splits (anti-leakage, structural):**
47
+ - per-symbol time split: train window | OOS window (chronological, no overlap)
48
+ - held-out OOS **symbols** (never seen in training) for generalization check
49
+ - with shallow per-symbol history, the symbol-holdout split does the heavy lifting
50
+ - **Crypto-hardening (because reward is gameable here):**
51
+ - slippage + fee model on every fill (fixed bps + size-vs-liquidity impact)
52
+ - min-liquidity / min-volume gate per bar (illiquid bars can't be traded)
53
+ - survivorship caveat noted; mitigate by including tokens that later died
54
+
55
+ ## 3. Action surface — support BOTH (decide after baseline rollouts)
56
+ The backtester accepts a uniform `target_positions` dict; two agent framings feed it:
57
+ - **A. Strategy-code:** agent writes `strategy(features_window) -> target_positions`,
58
+ executed causally over the OOS window. Strongest "coding agent" framing.
59
+ - **B. Structured decisions:** agent emits per-bar JSON (verdict/entry/target/stop),
60
+ reusing tradewatch's schema. Closer to existing code.
61
+ Both reduce to the same backtest call → same rubric. We prototype both, pick via eval.
62
+
63
+ ## 4. Backtester (port `agents/paper_ledger.py`)
64
+ - `ReplayFeed`: serves bars **only up to t** — lookahead impossible by construction
65
+ (the agent/strategy literally never receives `bars[t+1:]`).
66
+ - Engine: step bar-by-bar, apply target_positions via slippage/fee model, mark-to-market,
67
+ track equity, cash, exposure, per-trade R:R, drawdown.
68
+ - Metrics: Sharpe (primary), CAGR/return, max drawdown, equity/cash ratio, turnover, win rate.
69
+ - Benchmarks computed on same window: **buy-and-hold**, MA-crossover, z-score mean-reversion.
70
+ - Invariants (unit-tested): causality (no future leak), determinism (seed → identical run).
71
+
72
+ ## 5. verifiers environment (v0 stable API)
73
+ Class: **`StatefulToolEnv`** (per-rollout state + stateful tools).
74
+
75
+ ```python
76
+ def load_environment(
77
+ symbols: list[str] | str = "train", # universe or split name
78
+ split: str = "train", # train | oos | oos_symbols
79
+ objective: str = "sharpe", # sharpe | return | min_drawdown
80
+ max_turns: int = 8,
81
+ n_windows: int = 4, # OOS windows averaged per reward
82
+ seed: int = 0,
83
+ ) -> vf.Environment: ...
84
+ ```
85
+
86
+ - `setup_state(state)`: pick task (symbol(s) × window × objective), build fresh ReplayFeed + ledger.
87
+ - **Tools** (stateful, NOT per-rollout sandbox — global/in-proc exec to save credits):
88
+ - `get_features(lookback)` → indicators up to current bar (RSI, MACD, MAs, z-score,
89
+ BB, vol, buy/sell ratio, liquidity) — reuses scanner feature logic
90
+ - `run_backtest(strategy_or_decisions)` → in-sample metrics (the agent's feedback loop)
91
+ - `read_metrics()` → current equity/DD/Sharpe
92
+ - `@vf.stop`: agent submits final strategy, or max_turns.
93
+ - **Reward (Rubric, weighted) — computed on OOS, aggregated over n_windows × basket:**
94
+
95
+ | reward fn | weight | source |
96
+ |---|---|---|
97
+ | `r_sharpe` (normalized OOS Sharpe) | 0.40 | spine |
98
+ | `r_beats_benchmark` (vs buy-and-hold) | 0.20 | anti-overfit |
99
+ | `r_drawdown` (penalty for deep DD) | 0.15 | MEMORY: protect capital |
100
+ | `r_rr_discipline` (R:R≥2 compliance) | 0.10 | trading_agent._validate |
101
+ | `r_exposure` (sane equity/cash, no all-in) | 0.10 | MEMORY: sizing |
102
+ | `r_cost` (turnover/fee penalty) | 0.05 | realism |
103
+ | **HARD GATES → reward 0** | — | invalid code, lookahead, NaN equity, illiquid trades |
104
+
105
+ Outcome terms (sharpe + beats_benchmark) must dominate so the model can't fake
106
+ discipline while losing money.
107
+
108
+ ## 6. Training (Prime Hosted, FREE for Laguna)
109
+ ```toml
110
+ model = "poolside/Laguna-XS.2" # validate first on Qwen/Qwen3-4B-Instruct-2507
111
+ max_steps = 50 # small validation; scale after curve moves
112
+ batch_size = 128 # = parallel rollouts (the "128 sessions")
113
+ rollouts_per_example = 8 # GRPO group — needs decorrelated windows
114
+ learning_rate = 1e-4
115
+ lora_alpha = 16
116
+ [sampling]
117
+ max_tokens = 512
118
+ enable_thinking = false # docs: start non-reasoning for agentic tasks
119
+ [[env]]
120
+ id = "<user>/stock-strategy-env"
121
+ [eval]
122
+ eval_base_model = true # base-vs-trained comparison built in
123
+ ```
124
+ Pipeline: `prime eval run` (sanity, baseline 10–80%) → small Qwen RL → Laguna RL →
125
+ download LoRA → `prime deployments create` → `base:adapter_id`.
126
+
127
+ ## 7. Closing the loop
128
+ - Deploy adapter → point tradewatch `HybrieClient` at `api.pinference.ai` → **live paper demo**.
129
+ - **Ablation (money shot):** trained adapter, MEMORY.md stripped from prompt → discipline holds.
130
+ - **Recursive (stretch):** `checkpoint_id` warm-start → reflect on failures →
131
+ adjust rubric weights / add tasks / `[buffer]` difficulty filtering → retrain.
132
+
133
+ ## 8. Non-negotiable risks
134
+ 1. Leakage → structural causal feed (not a detector).
135
+ 2. Reward variance → aggregate over basket × windows (or GRPO has no signal).
136
+ 3. Reward hacking → OOS + must-beat-benchmark + hard lookahead gate.
137
+ 4. Crypto noise → slippage/fee/liquidity model on every fill.
baseline_eval.txt ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Resolved source: stimulir/trade-pool
2
+ Eval job_id: trade_pool_poolside_laguna_xs.2_20260530_112447_9949ef5b
3
+ eval_dataset is not set, falling back to train dataset
4
+
5
+ --- Evaluation ---
6
+ Environment: trade-pool
7
+ Model: poolside/laguna-xs.2
8
+ Provider: https://api.pinference.ai/api/v1
9
+ Examples: 4
10
+ Rollouts per example: 1
11
+ --- Example ---
12
+ ╭─────────────────────────────────── Step 0 ───────────────────────────────────╮
13
+ │ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┓ │
14
+ │ ┃ Prompt ┃ Completion ┃ Reward ┃ │
15
+ │ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━┩ │
16
+ │ │ system: You are a quantitative │ assistant: │ 0.91 │ │
17
+ │ │ trading strategy engineer. You │ [reasoning] │ │ │
18
+ │ │ write a Python function that │ │ │ │
19
+ │ │ decides a target position each │ Let me analyze the price data │ │ │
20
+ │ │ bar, using ONLY causal │ and develop a strategy that │ │ │
21
+ │ │ technical features. │ follows the given rules. │ │ │
22
+ │ │ │ │ │ │
23
+ │ │ You must output a single │ Looking at the recent 30 │ │ │
24
+ │ │ Python code block defining │ closes, I can see the price │ │ │
25
+ │ │ exactly: │ has been declining from around │ │ │
26
+ │ │ │ 0.0245 to 0.0177, which is a │ │ │
27
+ │ │ ```python │ significant drop. This could │ │ │
28
+ │ │ def strategy(features, │ be a "post-dip curl" setup if │ │ │
29
+ │ │ position): │ we see a recovery. │ │ │
30
+ │ │ # features: dict of the │ │ │ │
31
+ │ │ current bar's indicators (see │ Let me design a strategy that: │ │ │
32
+ │ │ list below), all causal │ 1. Uses RSI to identify │ │ │
33
+ │ │ # position: your current │ oversold conditions │ │ │
34
+ │ │ target position, a float in │ 2. Uses MACD for momentum │ │ │
35
+ │ │ [-1, 1] │ confirmation │ │ │
36
+ │ │ # return: new target │ 3. Uses moving averages for │ │ │
37
+ │ │ position, float in [-1, 1] │ trend direction │ │ │
38
+ │ │ (1=full long, -1=full short, │ 4. Uses Bollinger Bands for │ │ │
39
+ │ │ 0=flat) │ position sizing │ │ │
40
+ │ │ ... │ 5. Incorporates volatility for │ │ │
41
+ │ │ return new_position │ risk management │ │ │
42
+ │ │ ``` │ 6. Follows the capital │ │ │
43
+ │ │ │ protection rules │ │ │
44
+ │ │ Available features (floats, │ │ │ │
45
+ │ │ computed on data up to the │ Key patterns to look for: │ │ │
46
+ │ │ current bar only): │ - Post-dip curl: RSI rising │ │ │
47
+ │ │ close, sma_10, sma_20, sma_50, │ from oversold (<30), MACD hist │ │ │
48
+ │ │ ema_12, ema_26, rsi_14, macd, │ turning positive after a drop │ │ │
49
+ │ │ macd_signal, macd_hist, │ - Healthy momentum: SMA_10 > │ │ │
50
+ │ │ zscore_20, bb_lo, bb_mid, │ SMA_20, MACD > MACD_signal │ │ │
51
+ │ │ bb_hi, ret_1, vol_20 │ - Avoid: Price too far from │ │ │
52
+ │ │ │ Bollinger Mid, high │ │ │
53
+ │ │ Hard rules: │ volatility, thin liquidity │ │ │
54
+ │ │ - No imports, no IO, no future │ │ │ │
55
+ │ │ data. You only get the current │ │ │ │
56
+ │ │ bar's features. │ │ │ │
57
+ │ │ - Favor capital protection: │ ```python │ │ │
58
+ │ │ control drawdown, avoid being │ def strategy(features, │ │ │
59
+ │ │ all-in blindly. │ position): │ │ │
60
+ │ │ - Your strategy is scored on a │ # Extract features │ │ │
61
+ │ │ HELD-OUT window and must beat │ close = features['close'] │ │ │
62
+ │ │ buy-and-hold. │ sma_10 = │ │ │
63
+ │ │ │ features['sma_10'] │ │ │
64
+ │ │ Trading discipline distilled │ sma_20 = │ │ │
65
+ │ │ from 618 real prior trading │ features['sma_20'] │ │ │
66
+ │ │ decisions (94 BUY, 489 WATCH, │ sma_50 = │ │ │
67
+ │ │ 35 AVOID): │ features['sma_50'] │ │ │
68
+ │ │ │ ema_12 = │ │ │
69
+ │ │ Patterns that earned a BUY │ features['ema_12'] │ │ │
70
+ │ │ (translate these into your │ ema_26 = │ │ │
71
+ │ │ strategy's entry logic): │ features['ema_26'] │ │ │
72
+ │ │ - "Post-dip curl": a 24h drop │ rsi_14 = │ │ │
73
+ │ │ followed by a 1h recovery with │ features['rsi_14'] │ │ │
74
+ │ │ a strong short-term buy/sell │ macd = features['macd'] │ │ │
75
+ │ │ ratio (>1.5). Enter on the │ macd_signal = │ │ │
76
+ │ │ curl, not the dip. → buy when │ features['macd_signal'] │ │ │
77
+ │ │ recent return turns up after a │ macd_hist = │ │ │
78
+ │ │ drawdown and momentum (rsi │ features['macd_hist'] │ │ │
79
+ │ │ rising from oversold, │ zscore_20 = │ │ │
80
+ │ │ macd_hist turning positive) │ features['zscore_20'] │ │ │
81
+ │ │ confirms. │ bb_lo = features['bb_lo'] │ │ │
82
+ │ │ - "Healthy momentum │ bb_mid = │ │ │
83
+ │ │ continuation": positive 1h AND │ features['bb_mid'] │ │ │
84
+ │ │ 24h change with buy pressure │ bb_hi = features['bb_hi'] │ │ │
85
+ │ │ and │ ret_1 = features['ret_1'] │ │ │
86
+ │ │ volume/liquidity > 1. → │ vol_20 = │ │ │
87
+ │ │ trend-follow when sma_10 > │ features['vol_20'] │ │ │
88
+ │ │ sma_20 and macd > macd_signal. │ │ │ │
89
+ │ │ - Size up only with liquidity │ # Initialize new position │ │ │
90
+ │ │ + volume backing the move; │ new_position = position │ │ │
91
+ │ │ size down when volatility is │ │ │ │
92
+ │ │ high. │ # Capital protection: │ │ │
93
+ │ │ │ avoid overexposure when │ │ │
94
+ │ │ Conditions that earned │ already up significantly │ │ │
95
+ │ │ WATCH/AVOID (translate into │ if ret_1 > 0.05: # │ │ │
96
+ │ │ your exits and risk limits): │ Already up 5% on the hour │ │ │
97
+ │ │ - Already +30% on the day → │ return min(position, │ │ │
98
+ │ │ entry timing is poor; do not │ 0.3) # Reduce exposure │ │ │
99
+ │ │ chase. → avoid full long when │ │ │ │
100
+ │ │ ret_1 is extremely high or │ # Avoid full long when │ │ │
101
+ │ │ price is far above bb_mid. │ price is far above middle │ │ │
102
+ │ │ - Volume/liquidity < 1.0 or │ Bollinger Band │ │ │
103
+ │ │ thin liquidity → lower │ if close > bb_mid * 1.03: │ │ │
104
+ │ │ conviction; reduce exposure. │ # 3% above BB mid │ │ │
105
+ │ │ - Sell-heavy short-term flow │ return max(position, │ │ │
106
+ │ │ after entry → cut quickly; │ -0.2) # Cap long exposure │ │ │
107
+ │ │ don't wait for a vague │ │ │ │
108
+ │ │ recovery. │ # Base position sizing │ │ │
109
+ │ │ │ inversely related to │ │ │
110
+ │ │ Capital-protection rules │ volatility │ │ │
111
+ │ │ (every BUY required these): │ vol_factor = max(0.5, 1.0 │ │ │
112
+ │ │ - Always define a stop below │ - vol_20) # Reduce size when │ │ │
113
+ │ │ entry and a target above; │ volatility high │ │ │
114
+ │ │ favor reward:risk >= 2:1. │ │ │ │
115
+ │ │ - Protect capital first: │ # Post-dip curl pattern: │ │ │
116
+ │ │ control drawdown over chasing │ RSI coming back from oversold │ │ │
117
+ │ │ upside. │ with MACD improvement │ │ │
118
+ │ │ - Don't average down losers; │ dip_recovery = False │ │ │
119
+ │ │ don't let a winner round-trip │ if rsi_14 < 30 and │ │ │
120
+ │ │ — tighten when flow weakens. │ macd_hist > 0: # Oversold │ │ │
121
+ │ │ │ with positive momentum │ │ │
122
+ │ │ │ dip_recovery = True │ │ │
123
+ │ │ │ │ │ │
124
+ │ │ user: Token: NOCK │ # Healthy momentum: short │ │ │
125
+ │ │ In-sample bars available: 600 │ and medium term trend │ │ │
126
+ │ │ Recent closes (last 30): │ confirmation │ │ │
127
+ │ │ [0.024502, 0.02227, 0.022973, │ momentum_ok = sma_10 > │ │ │
128
+ │ │ 0.022138, 0.022407, 0.02138, │ sma_20 and macd > macd_signal │ │ │
129
+ │ │ 0.02204, 0.025153, 0.023984, │ │ │ │
130
+ │ │ 0.025721, 0.024264, 0.023896, │ # Trend following with │ │ │
131
+ │ │ 0.023152, 0.022478, 0.023024, │ moving averages │ │ │
132
+ │ │ 0.022491, 0.022522, 0.02344, │ trend_up = ema_12 > ema_26 │ │ │
133
+ │ │ 0.023711, 0.022644, 0.022412, │ and sma_20 > sma_50 │ │ │
134
+ │ │ 0.022769, 0.02276, 0.023428, │ │ │ │
135
+ │ │ 0.022117, 0.021182, 0.020521, │ # Entry signals │ │ │
136
+ │ │ 0.019539, 0.018417, 0.017665] │ if dip_recovery and │ │ │
137
+ │ │ │ momentum_ok: │ │ │
138
+ │ │ Write a `strategy(features, │ # Post-dip entry with │ │ │
139
+ │ │ position)` function. Use │ strong confirmation │ │ │
140
+ │ │ indicators like RSI (rsi_14), │ new_position = min(0.6 │ │ │
141
+ │ │ MACD │ * vol_factor, 0.6) │ │ │
142
+ │ │ (macd/macd_signal/macd_hist), │ elif trend_up and │ │ │
143
+ │ │ moving averages (sma_*/ema_*), │ momentum_ok and zscore_20 < │ │ │
144
+ │ │ z-score (zscore_20), Bollinger │ 1.5: │ │ │
145
+ │ │ bands (bb_*), and volatility │ # Trend continuation │ │ │
146
+ │ │ (vol_20). Return a target │ with reasonable valuation │ │ │
147
+ │ │ position in [-1, 1]. │ new_position = min(0.4 │ │ │
148
+ │ │ │ * vol_factor, 0.4) │ │ │
149
+ │ │ │ elif zscore_20 < -1.0 and │ │ │
150
+ │ │ │ macd_hist > -0.001: │ │ │
151
+ │ │ │ # Mean reversion setup │ │ │
152
+ │ │ │ near lower Bollinger Band │ │ │
153
+ │ │ │ new_position = min(0.3 │ │ │
154
+ │ │ │ * vol_factor, 0.3) │ │ │
155
+ │ │ │ else: │ │ │
156
+ │ │ │ # Reduce exposure if │ │ │
157
+ │ │ │ no clear signal │ │ │
158
+ │ │ │ new_position = │ │ │
159
+ │ │ │ position * 0.7 │ │ │
160
+ │ │ │ │ │ │
161
+ │ │ │ # Exit management: reduce │ │ │
162
+ │ │ │ position if momentum weakens │ │ │
163
+ │ │ │ if macd_hist < 0 and │ │ │
164
+ │ │ │ position > 0.2: │ │ │
165
+ │ │ │ new_position = │ │ │
166
+ │ │ │ max(new_position, -0.1) # │ │ │
167
+ │ │ │ Quick exit │ │ │
168
+ │ │ │ elif rsi_14 > 70 and │ │ │
169
+ │ │ │ position > 0.3: │ │ │
170
+ │ │ │ new_position = │ │ │
171
+ │ │ │ max(new_position, 0.0) # Take │ │ │
172
+ │ │ │ profits on overbought │ │ │
173
+ │ │ │ │ │ │
174
+ │ │ │ # Ensure position bounds │ │ │
175
+ │ │ │ new_position = max(-1.0, │ │ │
176
+ │ │ │ min(1.0, new_position)) │ │ │
177
+ │ │ │ │ │ │
178
+ │ │ │ return new_position │ │ │
179
+ │ │ │ ``` │ │ │
180
+ │ │ │ </assistant> │ │ │
181
+ │ └────────────────────────────────┴────────────────────────────────┴────────┘ │
182
+ ╰──────────────────────────────────────────────────────────────────────────────╯
183
+ --- All ---
184
+ Rewards:
185
+ reward: avg - 0.495, std - 0.328
186
+ r1: [0.915, 0.587, 0.0, 0.478]
187
+ reward_sharpe: avg - 0.448, std - 0.320
188
+ r1: [0.873, 0.577, 0.0, 0.344]
189
+ reward_beats_benchmark: avg - 0.250, std - 0.433
190
+ r1: [1.0, 0.0, 0.0, 0.0]
191
+ reward_drawdown: avg - 0.652, std - 0.381
192
+ r1: [0.943, 0.878, 0.0, 0.788]
193
+ reward_exposure: avg - 0.750, std - 0.433
194
+ r1: [1.0, 1.0, 0.0, 1.0]
195
+ reward_cost: avg - 0.355, std - 0.206
196
+ r1: [0.482, 0.5, 0.0, 0.438]
197
+ reward_valid: avg - 0.750, std - 0.433
198
+ r1: [1.0, 1.0, 0.0, 1.0]
199
+ num_turns: avg - 1.000, std - 0.000
200
+ r1: [1.0, 1.0, 1.0, 1.0]
201
+ Info:
202
+ is_truncated: avg - 0.000, std - 0.000
203
+ stop_conditions: max_turns_reached: 1.000
204
+ Timing:
205
+ total min - 6s, mean - 15s, max - 29s
206
+ setup min - 0ms, mean - 0ms, max - 0ms
207
+ generation min - 6s, mean - 15s, max - 29s
208
+ model min - 6s, mean - 15s, max - 29s
209
+ env min - 0ms, mean - 0ms, max - 0ms
210
+ scoring min - 1ms, mean - 41ms, max - 55ms
211
+ overhead min - 0ms, mean - 0ms, max - 1ms
212
+ Usage:
213
+ input_tokens (avg): 1061.500
214
+ output_tokens (avg): 2066.000
215
+ final_input_tokens (avg): 1061.500
216
+ final_output_tokens (avg): 2066.000
217
+
218
+ Performance:
219
+ event_loop_lag: mean=7ms, p99=3ms, max=2s (n=335)
configs/iter_1.toml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AUTO-GENERATED iteration 1 — recursive self-improving loop
2
+ # objective=sharpe focus_symbols=all warm_start=cold
3
+ model = "poolside/Laguna-XS.2"
4
+ max_steps = 50
5
+ batch_size = 128
6
+ rollouts_per_example = 8
7
+ learning_rate = 0.0001
8
+ lora_alpha = 16
9
+ # checkpoint_id = (none — cold start)
10
+ [sampling]
11
+ max_tokens = 768
12
+ enable_thinking = false
13
+
14
+ [[env]]
15
+ id = "stimulir/trade-pool"
16
+ args = { split = "train", seed = 0, objective = "sharpe" }
17
+
18
+ [eval]
19
+ interval = 25
20
+ eval_base_model = true
21
+ [[eval.env]]
22
+ id = "stimulir/trade-pool"
23
+ args = { split = "oos_symbols", seed = 0, objective = "sharpe" }
24
+
25
+ [adapters]
26
+ interval = 0
configs/laguna-stock-strategy.toml ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # RL training config — coding agent writes causal crypto strategies, scored by OOS backtest.
2
+ # Validate on Qwen first (cheap), then swap model to poolside/Laguna-XS.2 (FREE hosted).
3
+
4
+ model = "Qwen/Qwen3-4B-Instruct-2507" # → "poolside/Laguna-XS.2" once env is validated
5
+ max_steps = 50 # small validation run; scale after reward curve moves
6
+ batch_size = 128 # parallel rollouts/step (the "128 sessions") — GRPO group source
7
+ rollouts_per_example = 8 # GRPO group size; decorrelated tasks supply variance
8
+ learning_rate = 1e-4
9
+ lora_alpha = 16
10
+
11
+ [sampling]
12
+ max_tokens = 768 # room to write a strategy function
13
+ enable_thinking = false # docs: start non-reasoning for agentic tasks
14
+
15
+ [[env]]
16
+ id = "REPLACE_WITH/stock-strategy-env" # set after `prime env push`
17
+ args = { split = "train", seed = 0 }
18
+
19
+ [eval]
20
+ interval = 25
21
+ eval_base_model = true # base-vs-trained comparison built in
22
+ [[eval.env]]
23
+ id = "REPLACE_WITH/stock-strategy-env"
24
+ args = { split = "oos_symbols", seed = 0 } # generalization: HELD-OUT symbols
25
+
26
+ [adapters]
27
+ interval = 0 # upload LoRA adapter at run end
reward_curve.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ step 49: 0.3657
trade_pool/__init__.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """trade_pool — a verifiers RL environment where a coding agent writes a
2
+ causal crypto trading strategy, scored by an out-of-sample backtest.
3
+
4
+ The agent receives a task (a token's in-sample price history + the feature surface),
5
+ writes a Python `strategy(features, position) -> target_position` function, and is
6
+ rewarded by how that strategy performs on a HELD-OUT window — must beat buy-and-hold,
7
+ control drawdown, and keep sane exposure. tradewatch's MEMORY.md discipline becomes
8
+ the rubric; RL turns it into adapter weights.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+
15
+ import numpy as np
16
+ import verifiers as vf
17
+ from datasets import Dataset
18
+
19
+ from .backtester import benchmarks, run_backtest
20
+ from .data import build_tasks
21
+ from .executor import compile_strategy, extract_code
22
+ from .seed_principles import principles_block
23
+
24
+ FEATURE_NAMES = [
25
+ "close", "sma_10", "sma_20", "sma_50", "ema_12", "ema_26", "rsi_14",
26
+ "macd", "macd_signal", "macd_hist", "zscore_20", "bb_lo", "bb_mid", "bb_hi",
27
+ "ret_1", "vol_20",
28
+ ]
29
+
30
+ SYSTEM_PROMPT = """You are a quantitative trading strategy engineer. You write a Python \
31
+ function that decides a target position each bar, using ONLY causal technical features.
32
+
33
+ You must output a single Python code block defining exactly:
34
+
35
+ ```python
36
+ def strategy(features, position):
37
+ # features: dict of the current bar's indicators (see list below), all causal
38
+ # position: your current target position, a float in [-1, 1]
39
+ # return: new target position, float in [-1, 1] (1=full long, -1=full short, 0=flat)
40
+ ...
41
+ return new_position
42
+ ```
43
+
44
+ Available features (floats, computed on data up to the current bar only):
45
+ {features}
46
+
47
+ Hard rules:
48
+ - No imports, no IO, no future data. You only get the current bar's features.
49
+ - Favor capital protection: control drawdown, avoid being all-in blindly.
50
+ - Your strategy is scored on a HELD-OUT window and must beat buy-and-hold.
51
+
52
+ {seed_principles}
53
+ """
54
+
55
+
56
+ def _task_to_prompt(task: dict) -> str:
57
+ close = task["train_close"]
58
+ tail = close[-30:] if len(close) > 30 else close
59
+ return (
60
+ f"Token: {task['symbol']}\n"
61
+ f"In-sample bars available: {len(close)}\n"
62
+ f"Recent closes (last {len(tail)}): {[round(c, 6) for c in tail]}\n\n"
63
+ "Write a `strategy(features, position)` function. Use indicators like RSI "
64
+ "(rsi_14), MACD (macd/macd_signal/macd_hist), moving averages (sma_*/ema_*), "
65
+ "z-score (zscore_20), Bollinger bands (bb_*), and volatility (vol_20). "
66
+ "Return a target position in [-1, 1]."
67
+ )
68
+
69
+
70
+ # ── Rubric reward functions (each receives completion + the task info via state) ──
71
+
72
+ # Memoize per (symbol, code) so the 6 reward fns trigger ONE backtest set per rollout,
73
+ # not 6×(1 strategy + 3 benchmarks). Bounded so it can't grow unboundedly across a run.
74
+ _SCORE_CACHE: dict[tuple, dict] = {}
75
+ _PARTICIPATION_FLOOR = 0.03 # avg |exposure| below this = "did nothing", risk rewards void
76
+
77
+
78
+ def _score_strategy(completion, info) -> dict:
79
+ """Compile + backtest the agent's strategy on the OOS window. Memoized."""
80
+ text = completion if isinstance(completion, str) else completion[-1]["content"]
81
+ code = extract_code(text)
82
+ key = (info["symbol"], hash(code))
83
+ if key in _SCORE_CACHE:
84
+ return _SCORE_CACHE[key]
85
+
86
+ fn, err = compile_strategy(code)
87
+ oos = np.asarray(info["oos_close"], dtype=float)
88
+ warmup = min(50, len(oos) // 3)
89
+ if fn is None:
90
+ out = {"error": err, "res": None, "bench": None}
91
+ else:
92
+ res = run_backtest(oos, fn, warmup=warmup)
93
+ bench = benchmarks(oos, warmup=warmup)
94
+ out = {"error": res.error, "res": res, "bench": bench}
95
+ if len(_SCORE_CACHE) > 4096:
96
+ _SCORE_CACHE.clear()
97
+ _SCORE_CACHE[key] = out
98
+ return out
99
+
100
+
101
+ def _participates(res) -> bool:
102
+ return res is not None and res.avg_exposure >= _PARTICIPATION_FLOOR
103
+
104
+
105
+ def reward_sharpe(completion, info, **kwargs) -> float:
106
+ s = _score_strategy(completion, info)
107
+ if s["res"] is None or s["error"]:
108
+ return 0.0
109
+ # squash Sharpe to [0,1] via logistic; Sharpe 0 -> 0.5, 2 -> ~0.88.
110
+ # A flat strategy gets sharpe 0 -> 0.5; gate it so do-nothing can't bank 0.5.
111
+ if not _participates(s["res"]):
112
+ return 0.0
113
+ return float(1.0 / (1.0 + np.exp(-s["res"].sharpe)))
114
+
115
+
116
+ def reward_beats_benchmark(completion, info, **kwargs) -> float:
117
+ s = _score_strategy(completion, info)
118
+ if s["res"] is None or s["error"] or s["bench"] is None or not _participates(s["res"]):
119
+ return 0.0
120
+ bh = s["bench"]["buy_and_hold"].sharpe
121
+ return 1.0 if s["res"].sharpe > bh else 0.0
122
+
123
+
124
+ def reward_drawdown(completion, info, **kwargs) -> float:
125
+ # Drawdown reward only counts if the strategy actually traded — otherwise
126
+ # "do nothing" banks a perfect 1.0 for taking zero risk (the inactivity exploit).
127
+ s = _score_strategy(completion, info)
128
+ if s["res"] is None or s["error"] or not _participates(s["res"]):
129
+ return 0.0
130
+ return float(max(0.0, 1.0 - s["res"].max_drawdown))
131
+
132
+
133
+ def reward_exposure(completion, info, **kwargs) -> float:
134
+ s = _score_strategy(completion, info)
135
+ if s["res"] is None or s["error"] or not _participates(s["res"]):
136
+ return 0.0
137
+ e = s["res"].avg_exposure
138
+ return 1.0 if 0.1 <= e <= 0.85 else max(0.0, 1.0 - abs(e - 0.5))
139
+
140
+
141
+ def reward_cost(completion, info, **kwargs) -> float:
142
+ # Likewise gated: zero turnover (no trades) must not earn the low-cost reward.
143
+ s = _score_strategy(completion, info)
144
+ if s["res"] is None or s["error"] or not _participates(s["res"]):
145
+ return 0.0
146
+ return float(max(0.0, 1.0 - s["res"].turnover / 20.0))
147
+
148
+
149
+ def reward_valid(completion, info, **kwargs) -> float:
150
+ # Valid = compiles AND actually trades. A syntactically-valid do-nothing is not
151
+ # a valid trading strategy for our purposes.
152
+ s = _score_strategy(completion, info)
153
+ if s["res"] is None or s["error"]:
154
+ return 0.0
155
+ return 1.0 if _participates(s["res"]) else 0.0
156
+
157
+
158
+ # Objective presets = rubric weight vectors. The recursive self-improving loop selects
159
+ # an objective (or passes explicit reward_weights) per iteration, so curriculum can shift
160
+ # emphasis (e.g. toward drawdown control if the trained model is taking too much risk)
161
+ # WITHOUT rebuilding the wheel — these arrive via the TOML [[env]] args block.
162
+ # [sharpe, beats_bh, drawdown, exposure, cost, valid]
163
+ OBJECTIVE_WEIGHTS = {
164
+ "sharpe": [0.40, 0.20, 0.15, 0.10, 0.05, 0.10],
165
+ "min_drawdown": [0.20, 0.15, 0.35, 0.15, 0.05, 0.10],
166
+ "balanced": [0.30, 0.25, 0.20, 0.10, 0.05, 0.10],
167
+ }
168
+
169
+
170
+ def load_environment(
171
+ split: str = "train",
172
+ seed: int = 0,
173
+ objective: str = "sharpe",
174
+ reward_weights: list[float] | None = None,
175
+ symbols: list[str] | None = None,
176
+ use_seed_principles: bool = True,
177
+ max_turns: int = 1,
178
+ ) -> vf.Environment:
179
+ """Entry point Prime calls (args come from the TOML [[env]] args block).
180
+
181
+ Curriculum knobs for the recursive loop:
182
+ split train | oos | oos_symbols (which symbol pool)
183
+ seed rotates the symbol shuffle (exposes new task mixes per iteration)
184
+ objective sharpe | min_drawdown | balanced (rubric weight preset)
185
+ reward_weights explicit 6-vector, overrides objective
186
+ symbols restrict to these symbols (curriculum: focus on weak performers)
187
+ use_seed_principles inject tradewatch's 618-decision discipline block (default on);
188
+ set False for the "memory is the adapter" ablation (strip the prompt
189
+ discipline and test whether trained weights retain it)
190
+ """
191
+ tasks = build_tasks(split=split, seed=seed)
192
+ if symbols:
193
+ wanted = {s.upper() for s in symbols}
194
+ tasks = [t for t in tasks if t["symbol"].upper() in wanted] or tasks
195
+ seed_block = principles_block() if use_seed_principles else ""
196
+ system = SYSTEM_PROMPT.format(features=", ".join(FEATURE_NAMES), seed_principles=seed_block)
197
+ rows = []
198
+ for t in tasks:
199
+ rows.append({
200
+ "prompt": [
201
+ {"role": "system", "content": system},
202
+ {"role": "user", "content": _task_to_prompt(t)},
203
+ ],
204
+ "info": {"symbol": t["symbol"], "oos_close": t["oos_close"]},
205
+ })
206
+ dataset = Dataset.from_list(rows)
207
+
208
+ weights = reward_weights or OBJECTIVE_WEIGHTS.get(objective, OBJECTIVE_WEIGHTS["sharpe"])
209
+ rubric = vf.Rubric(
210
+ funcs=[reward_sharpe, reward_beats_benchmark, reward_drawdown,
211
+ reward_exposure, reward_cost, reward_valid],
212
+ weights=weights,
213
+ )
214
+ return vf.SingleTurnEnv(dataset=dataset, rubric=rubric)
trade_pool/backtester.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Causal replay backtester — the RL verifier's beating heart.
2
+
3
+ Ports tradewatch's paper_ledger.py accounting into a vectorised, deterministic,
4
+ LEAK-PROOF backtest. A strategy is a pure function:
5
+
6
+ def strategy(features_up_to_t: dict[str, float], position: float) -> float
7
+ # returns target position in [-1, 1] (fraction of equity; <0 = short)
8
+
9
+ The engine calls it bar-by-bar, passing ONLY features at indices <= t. The strategy
10
+ can never see the future because future bars are not in the dict it receives. Fills
11
+ happen at the NEXT bar's open-equivalent (close[t] used as decision price, close[t+1]
12
+ as fill realisation) with slippage + fees, so a decision at t cannot be filled at t's
13
+ hindsight-perfect price.
14
+
15
+ Metrics: Sharpe (primary), CAGR, max drawdown, equity/cash exposure, turnover, win rate.
16
+ Benchmarks: buy-and-hold, MA-crossover, z-score mean-reversion.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+
23
+ import numpy as np
24
+
25
+ from .features import feature_frame, sma, zscore
26
+
27
+
28
+ @dataclass
29
+ class BacktestResult:
30
+ equity_curve: np.ndarray
31
+ returns: np.ndarray
32
+ sharpe: float
33
+ cagr: float
34
+ max_drawdown: float
35
+ turnover: float
36
+ win_rate: float
37
+ final_equity: float
38
+ avg_exposure: float
39
+ n_trades: int
40
+ error: str | None = None
41
+ meta: dict = field(default_factory=dict)
42
+
43
+ def to_dict(self) -> dict:
44
+ return {
45
+ "sharpe": round(self.sharpe, 4),
46
+ "cagr": round(self.cagr, 4),
47
+ "max_drawdown": round(self.max_drawdown, 4),
48
+ "turnover": round(self.turnover, 4),
49
+ "win_rate": round(self.win_rate, 4),
50
+ "final_equity": round(self.final_equity, 4),
51
+ "avg_exposure": round(self.avg_exposure, 4),
52
+ "n_trades": self.n_trades,
53
+ "error": self.error,
54
+ }
55
+
56
+
57
+ # Periods-per-year for annualisation (daily bars by default; 24*365 for hourly).
58
+ def _ann_factor(bars_per_year: float) -> float:
59
+ return float(np.sqrt(bars_per_year))
60
+
61
+
62
+ def _metrics(equity: np.ndarray, exposures: np.ndarray, bars_per_year: float,
63
+ error: str | None = None) -> BacktestResult:
64
+ equity = np.asarray(equity, dtype=float)
65
+ if len(equity) < 2 or np.any(~np.isfinite(equity)) or np.any(equity <= 0):
66
+ return BacktestResult(equity, np.zeros(1), 0.0, 0.0, 1.0, 0.0, 0.0,
67
+ float(equity[-1]) if len(equity) else 0.0, 0.0, 0,
68
+ error=error or "degenerate equity")
69
+ rets = np.diff(equity) / equity[:-1]
70
+ sd = rets.std()
71
+ sharpe = float(rets.mean() / sd * _ann_factor(bars_per_year)) if sd > 1e-12 else 0.0
72
+ n = len(equity)
73
+ years = n / bars_per_year
74
+ cagr = float((equity[-1] / equity[0]) ** (1 / years) - 1) if years > 0 and equity[0] > 0 else 0.0
75
+ peak = np.maximum.accumulate(equity)
76
+ max_dd = float(np.max((peak - equity) / peak)) if len(peak) else 0.0
77
+ exp = np.asarray(exposures, dtype=float)
78
+ turnover = float(np.sum(np.abs(np.diff(np.insert(exp, 0, 0.0)))))
79
+ n_trades = int(np.sum(np.abs(np.diff(np.insert(exp, 0, 0.0))) > 1e-9))
80
+ win_rate = float(np.mean(rets > 0)) if len(rets) else 0.0
81
+ return BacktestResult(equity, rets, sharpe, cagr, max_dd, turnover, win_rate,
82
+ float(equity[-1]), float(np.mean(np.abs(exp))), n_trades, error=error)
83
+
84
+
85
+ def run_backtest(
86
+ close: np.ndarray,
87
+ strategy_fn,
88
+ *,
89
+ fee_bps: float = 30.0, # 0.30% per unit turnover (DEX-realistic)
90
+ slippage_bps: float = 20.0, # 0.20% adverse slippage on fills
91
+ bars_per_year: float = 365.0,
92
+ allow_short: bool = True,
93
+ warmup: int = 50, # bars reserved for feature warmup (no trading)
94
+ ) -> BacktestResult:
95
+ """Step the strategy causally over `close`, return performance metrics.
96
+
97
+ Fill model: decision made on info up to t, position realised against return
98
+ from t -> t+1, minus fees/slippage proportional to position change.
99
+ """
100
+ close = np.asarray(close, dtype=float)
101
+ if len(close) < warmup + 5:
102
+ return _metrics(np.array([1.0, 1.0]), np.zeros(1), bars_per_year, error="too few bars")
103
+
104
+ feats = feature_frame(close)
105
+ rets = np.zeros(len(close))
106
+ rets[1:] = np.diff(close) / np.where(close[:-1] == 0, np.nan, close[:-1])
107
+ rets = np.nan_to_num(rets)
108
+
109
+ equity = [1.0]
110
+ exposures = []
111
+ pos = 0.0
112
+ cost_rate = (fee_bps + slippage_bps) / 1e4
113
+
114
+ for t in range(warmup, len(close) - 1):
115
+ # features visible at t (scalar snapshot, causal by construction)
116
+ view = {k: (float(v[t]) if np.isfinite(v[t]) else 0.0) for k, v in feats.items()}
117
+ try:
118
+ target = float(strategy_fn(view, pos))
119
+ except Exception as e: # noqa: BLE001 - strategy bugs => penalised, not crash
120
+ return _metrics(np.array(equity + [equity[-1]]), np.array(exposures + [pos]),
121
+ bars_per_year, error=f"strategy raised: {type(e).__name__}: {e}")
122
+ if not np.isfinite(target):
123
+ target = 0.0
124
+ lo = -1.0 if allow_short else 0.0
125
+ target = max(lo, min(1.0, target))
126
+
127
+ trade_cost = abs(target - pos) * cost_rate
128
+ # equity evolves with NEXT bar's realised return at the chosen exposure
129
+ pnl = target * rets[t + 1]
130
+ new_equity = equity[-1] * (1.0 + pnl - trade_cost)
131
+ equity.append(new_equity)
132
+ exposures.append(target)
133
+ pos = target
134
+
135
+ return _metrics(np.array(equity), np.array(exposures), bars_per_year)
136
+
137
+
138
+ # ── Benchmarks (the anti-overfit baselines a strategy must beat) ──────────────
139
+
140
+ def _buy_and_hold(view, pos):
141
+ return 1.0
142
+
143
+
144
+ def _ma_crossover(view, pos):
145
+ return 1.0 if view.get("sma_10", 0) > view.get("sma_20", 0) else 0.0
146
+
147
+
148
+ def _zscore_meanrev(view, pos):
149
+ z = view.get("zscore_20", 0.0)
150
+ if z < -1.5:
151
+ return 1.0
152
+ if z > 1.5:
153
+ return -1.0
154
+ return pos # hold
155
+
156
+
157
+ def benchmarks(close: np.ndarray, **kw) -> dict[str, BacktestResult]:
158
+ return {
159
+ "buy_and_hold": run_backtest(close, _buy_and_hold, **kw),
160
+ "ma_crossover": run_backtest(close, _ma_crossover, **kw),
161
+ "zscore_meanrev": run_backtest(close, _zscore_meanrev, **kw),
162
+ }
trade_pool/data.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load the replay tape and build the train / OOS / OOS-symbol task splits.
2
+
3
+ Shallow per-symbol history (free GeckoTerminal cap ~1000 bars) means breadth carries
4
+ the GRPO variance: a task = (symbol, window). The symbol-holdout split is the real
5
+ generalization test.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import glob
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ # Resolve the tape relative to THIS file so it works after the env is built into a
19
+ # wheel and installed into site-packages on Prime's infra (where the repo's top-level
20
+ # data/ dir does not exist). Packaged copy is authoritative; repo dir is a dev fallback.
21
+ _PKG_DATA = Path(__file__).resolve().parent / "data" / "ohlcv"
22
+ _REPO_DATA = Path(__file__).resolve().parents[3] / "data" / "ohlcv"
23
+ DATA = _PKG_DATA if _PKG_DATA.exists() and any(_PKG_DATA.glob("*.parquet")) else _REPO_DATA
24
+
25
+ # Quote-side / blue-chip pairs that aren't tradeable "strategy" targets — excluded.
26
+ _EXCLUDE = {"USDC", "WETH", "CBBTC", "USDT", "DAI"}
27
+
28
+
29
+ def _load_series() -> dict[str, np.ndarray]:
30
+ series: dict[str, np.ndarray] = {}
31
+ for f in sorted(glob.glob(str(DATA / "*.parquet"))):
32
+ sym = os.path.basename(f).split("__")[0].upper()
33
+ if sym in _EXCLUDE:
34
+ continue
35
+ close = pd.read_parquet(f)["c"].to_numpy(dtype=float)
36
+ if len(close) >= 120 and np.all(np.isfinite(close)) and np.all(close > 0):
37
+ # keep the most liquid pool per symbol (first sorted = arbitrary but stable)
38
+ if sym not in series or len(close) > len(series[sym]):
39
+ series[sym] = close
40
+ return series
41
+
42
+
43
+ def build_tasks(split: str = "train", oos_frac: float = 0.4, seed: int = 0) -> list[dict]:
44
+ """Return a list of task dicts: {symbol, train_close, oos_close}.
45
+
46
+ - train: symbols sorted, first 70% of symbols; in-sample = early bars, oos = late bars
47
+ - oos: same symbols, evaluated on held-out late window
48
+ - oos_symbols: the held-out 30% of symbols (never seen in training)
49
+ """
50
+ series = _load_series()
51
+ symbols = sorted(series.keys())
52
+ rng = np.random.default_rng(seed)
53
+ rng.shuffle(symbols)
54
+ cut = max(1, int(len(symbols) * 0.7))
55
+ train_syms, holdout_syms = symbols[:cut], symbols[cut:]
56
+ use = holdout_syms if split == "oos_symbols" else train_syms
57
+
58
+ tasks = []
59
+ for sym in use:
60
+ close = series[sym]
61
+ split_at = int(len(close) * (1 - oos_frac))
62
+ tasks.append({
63
+ "symbol": sym,
64
+ "train_close": close[:split_at].tolist(),
65
+ "oos_close": close[split_at:].tolist(),
66
+ "n_bars": len(close),
67
+ })
68
+ return tasks
69
+
70
+
71
+ def universe_summary() -> dict:
72
+ series = _load_series()
73
+ return {"n_symbols": len(series), "symbols": sorted(series.keys()),
74
+ "bars": {k: len(v) for k, v in series.items()}}
75
+
76
+
77
+ if __name__ == "__main__":
78
+ print(json.dumps(universe_summary(), indent=2))
79
+ for split in ("train", "oos_symbols"):
80
+ t = build_tasks(split)
81
+ print(f"{split}: {len(t)} tasks -> {[x['symbol'] for x in t]}")
trade_pool/executor.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Safely turn agent-written Python into a callable strategy(view, pos) -> float.
2
+
3
+ In-process restricted exec (NOT a per-rollout sandbox — the hackathon docs warn that
4
+ per-rollout sandboxes burn credits fast). The strategy surface is deliberately tiny:
5
+ a pure function over a feature dict + current position. No imports, no IO, no builtins
6
+ beyond a safe math allowlist. A strategy that tries to escape returns an error string,
7
+ which the rubric maps to reward 0.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from typing import Callable
14
+
15
+ _SAFE_BUILTINS = {
16
+ "abs": abs, "min": min, "max": max, "round": round, "float": float,
17
+ "int": int, "bool": bool, "len": len, "sum": sum, "pow": pow,
18
+ "True": True, "False": False, "None": None,
19
+ }
20
+ _SAFE_MATH = {k: getattr(math, k) for k in (
21
+ "sqrt", "log", "log10", "exp", "fabs", "floor", "ceil", "tanh", "isnan", "isfinite", "pi", "e"
22
+ )}
23
+
24
+ _FORBIDDEN = ("import", "__", "open(", "eval(", "exec(", "globals(", "locals(",
25
+ "compile(", "getattr(", "setattr(", "input(", "lambda os")
26
+
27
+
28
+ def compile_strategy(code: str) -> tuple[Callable | None, str | None]:
29
+ """Compile agent code that must define `def strategy(features, position):`.
30
+
31
+ Returns (callable, None) on success or (None, error_message) on failure.
32
+ """
33
+ if not code or "def strategy" not in code:
34
+ return None, "code must define `def strategy(features, position):`"
35
+ lowered = code.lower()
36
+ for tok in _FORBIDDEN:
37
+ if tok in lowered:
38
+ return None, f"forbidden token in strategy: {tok!r}"
39
+
40
+ sandbox_globals = {"__builtins__": _SAFE_BUILTINS, "math": _SAFE_MATH, **_SAFE_MATH}
41
+ try:
42
+ compiled = compile(code, "<strategy>", "exec")
43
+ exec(compiled, sandbox_globals) # noqa: S102 - restricted globals, no builtins
44
+ except Exception as e: # noqa: BLE001
45
+ return None, f"compile/exec error: {type(e).__name__}: {e}"
46
+
47
+ fn = sandbox_globals.get("strategy")
48
+ if not callable(fn):
49
+ return None, "`strategy` is not callable"
50
+
51
+ def wrapped(view: dict, pos: float) -> float:
52
+ return fn(view, pos)
53
+
54
+ return wrapped, None
55
+
56
+
57
+ def extract_code(completion: str) -> str:
58
+ """Pull a python code block out of a model completion (```python ... ``` or raw)."""
59
+ if "```" in completion:
60
+ parts = completion.split("```")
61
+ for i in range(1, len(parts), 2):
62
+ block = parts[i]
63
+ if block.startswith("python"):
64
+ block = block[len("python"):]
65
+ if "def strategy" in block:
66
+ return block.strip()
67
+ return completion.strip()
trade_pool/features.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Causal technical indicators over an OHLCV frame.
2
+
3
+ Every function takes a 1-D array/series of closes (or OHLCV) and returns a series
4
+ aligned to the SAME index, where value[i] uses ONLY data at indices <= i. This is
5
+ the structural anti-lookahead guarantee: a strategy can request features at bar t
6
+ and physically cannot see t+1.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+
14
+ def sma(close: np.ndarray, window: int) -> np.ndarray:
15
+ out = np.full_like(close, np.nan, dtype=float)
16
+ if window <= 0:
17
+ return out
18
+ csum = np.cumsum(np.insert(close, 0, 0.0))
19
+ out[window - 1:] = (csum[window:] - csum[:-window]) / window
20
+ return out
21
+
22
+
23
+ def ema(close: np.ndarray, window: int) -> np.ndarray:
24
+ out = np.full_like(close, np.nan, dtype=float)
25
+ if len(close) == 0 or window <= 0:
26
+ return out
27
+ alpha = 2.0 / (window + 1.0)
28
+ out[0] = close[0]
29
+ for i in range(1, len(close)):
30
+ out[i] = alpha * close[i] + (1 - alpha) * out[i - 1]
31
+ return out
32
+
33
+
34
+ def rsi(close: np.ndarray, window: int = 14) -> np.ndarray:
35
+ out = np.full_like(close, np.nan, dtype=float)
36
+ if len(close) < window + 1:
37
+ return out
38
+ delta = np.diff(close)
39
+ gain = np.where(delta > 0, delta, 0.0)
40
+ loss = np.where(delta < 0, -delta, 0.0)
41
+ avg_gain = np.mean(gain[:window])
42
+ avg_loss = np.mean(loss[:window])
43
+ for i in range(window, len(close)):
44
+ if i > window:
45
+ avg_gain = (avg_gain * (window - 1) + gain[i - 1]) / window
46
+ avg_loss = (avg_loss * (window - 1) + loss[i - 1]) / window
47
+ rs = avg_gain / avg_loss if avg_loss > 1e-12 else np.inf
48
+ out[i] = 100.0 - (100.0 / (1.0 + rs))
49
+ return out
50
+
51
+
52
+ def macd(close: np.ndarray, fast: int = 12, slow: int = 26, signal: int = 9):
53
+ macd_line = ema(close, fast) - ema(close, slow)
54
+ signal_line = ema(np.nan_to_num(macd_line), signal)
55
+ return macd_line, signal_line, macd_line - signal_line
56
+
57
+
58
+ def zscore(close: np.ndarray, window: int = 20) -> np.ndarray:
59
+ out = np.full_like(close, np.nan, dtype=float)
60
+ for i in range(window - 1, len(close)):
61
+ w = close[i - window + 1: i + 1]
62
+ mu, sd = w.mean(), w.std()
63
+ out[i] = (close[i] - mu) / sd if sd > 1e-12 else 0.0
64
+ return out
65
+
66
+
67
+ def bollinger(close: np.ndarray, window: int = 20, k: float = 2.0):
68
+ mid = sma(close, window)
69
+ out_w = np.full_like(close, np.nan, dtype=float)
70
+ for i in range(window - 1, len(close)):
71
+ out_w[i] = close[i - window + 1: i + 1].std()
72
+ return mid - k * out_w, mid, mid + k * out_w
73
+
74
+
75
+ def returns(close: np.ndarray) -> np.ndarray:
76
+ out = np.zeros_like(close, dtype=float)
77
+ out[1:] = np.diff(close) / np.where(close[:-1] == 0, np.nan, close[:-1])
78
+ return np.nan_to_num(out)
79
+
80
+
81
+ def volatility(close: np.ndarray, window: int = 20) -> np.ndarray:
82
+ r = returns(close)
83
+ out = np.full_like(close, np.nan, dtype=float)
84
+ for i in range(window - 1, len(close)):
85
+ out[i] = r[i - window + 1: i + 1].std()
86
+ return out
87
+
88
+
89
+ # The full feature surface exposed to a strategy at bar t (all causal).
90
+ def feature_frame(close: np.ndarray) -> dict[str, np.ndarray]:
91
+ macd_line, macd_signal, macd_hist = macd(close)
92
+ bb_lo, bb_mid, bb_hi = bollinger(close)
93
+ return {
94
+ "close": close,
95
+ "sma_10": sma(close, 10),
96
+ "sma_20": sma(close, 20),
97
+ "sma_50": sma(close, 50),
98
+ "ema_12": ema(close, 12),
99
+ "ema_26": ema(close, 26),
100
+ "rsi_14": rsi(close, 14),
101
+ "macd": macd_line,
102
+ "macd_signal": macd_signal,
103
+ "macd_hist": macd_hist,
104
+ "zscore_20": zscore(close, 20),
105
+ "bb_lo": bb_lo,
106
+ "bb_mid": bb_mid,
107
+ "bb_hi": bb_hi,
108
+ "ret_1": returns(close),
109
+ "vol_20": volatility(close, 20),
110
+ }
trade_pool/seed_principles.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Distil tradewatch's 618 real trading decisions into grounded principles + exemplars
2
+ that ground the strategy-writing prompt.
3
+
4
+ These are NOT invented discipline rules — they are mined from actual BUY/AVOID decisions
5
+ the tradewatch agent made on live Base-chain tokens, so the discipline the coding agent
6
+ encodes into strategy logic is traceable to real prior trading, not a paraphrase. The
7
+ distilled block is baked into the wheel (committed below as a constant) so it ships with
8
+ the env and needs no runtime access to the tradewatch repo.
9
+
10
+ Regenerate with: python -m trade_pool.seed_principles --rebuild (dev only)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ # Baked-in distillation (committed). Mined from data/seed/decisions.jsonl:
19
+ # 94 BUY / 489 WATCH / 35 AVOID across 9 tokens, all with reasoning.
20
+ SEED_PRINCIPLES = """\
21
+ Trading discipline distilled from 618 real prior trading decisions (94 BUY, 489 WATCH, 35 AVOID):
22
+
23
+ Patterns that earned a BUY (translate these into your strategy's entry logic):
24
+ - "Post-dip curl": a 24h drop followed by a 1h recovery with a strong short-term buy/sell
25
+ ratio (>1.5). Enter on the curl, not the dip. → buy when recent return turns up after a
26
+ drawdown and momentum (rsi rising from oversold, macd_hist turning positive) confirms.
27
+ - "Healthy momentum continuation": positive 1h AND 24h change with buy pressure and
28
+ volume/liquidity > 1. → trend-follow when sma_10 > sma_20 and macd > macd_signal.
29
+ - Size up only with liquidity + volume backing the move; size down when volatility is high.
30
+
31
+ Conditions that earned WATCH/AVOID (translate into your exits and risk limits):
32
+ - Already +30% on the day → entry timing is poor; do not chase. → avoid full long when
33
+ ret_1 is extremely high or price is far above bb_mid.
34
+ - Volume/liquidity < 1.0 or thin liquidity → lower conviction; reduce exposure.
35
+ - Sell-heavy short-term flow after entry → cut quickly; don't wait for a vague recovery.
36
+
37
+ Capital-protection rules (every BUY required these):
38
+ - Always define a stop below entry and a target above; favor reward:risk >= 2:1.
39
+ - Protect capital first: control drawdown over chasing upside.
40
+ - Don't average down losers; don't let a winner round-trip — tighten when flow weakens.
41
+ """
42
+
43
+
44
+ def principles_block() -> str:
45
+ return SEED_PRINCIPLES
46
+
47
+
48
+ # ── Dev-only: regenerate the constant from the raw journal extraction ──
49
+ def _rebuild() -> str:
50
+ seed = Path(__file__).resolve().parents[3] / "data" / "seed" / "decisions.jsonl"
51
+ rows = [json.loads(l) for l in seed.read_text().splitlines() if l.strip()]
52
+ buys = [r for r in rows if r.get("verdict") == "BUY"]
53
+ patterns = {}
54
+ for r in buys:
55
+ p = (r.get("pattern") or "unlabeled").strip()
56
+ patterns[p] = patterns.get(p, 0) + 1
57
+ print(f"{len(rows)} decisions | BUY patterns: {patterns}")
58
+ print("Edit SEED_PRINCIPLES by hand from these — keep it compact and strategy-actionable.")
59
+ return ""
60
+
61
+
62
+ if __name__ == "__main__":
63
+ import sys
64
+ if "--rebuild" in sys.argv:
65
+ _rebuild()
66
+ else:
67
+ print(principles_block())