File size: 13,569 Bytes
0f038be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
"""Review fixes in the feature engine and the train pipeline (compaction, window highs, exit-cost size, mature
graduation refresh, causal replay band, backtest exits and baseline, corpus part commits, selector folds)."""
import math
import random
from datetime import date, datetime, timedelta, timezone

import duckdb
import numpy as np
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
import pytest

from fly_trader import config
from fly_trader.market.exit_cost import exit_cost_fraction
from fly_trader.market.features import FEATURE_VERSION, FEATURES, FIDX, TokenMeta, TokenState
from fly_trader.train import corpus_backtest as cb
from fly_trader.train import corpus_features as cf
from fly_trader.train import mature, selector
from fly_trader.train.decisions import DecisionSet
from fly_trader.train.replay_assemble import band_amm


# ---------------------------------------------------------------- market/features.py
def test_compaction_keeps_window_start_price():
    st = TokenState("M"); t = 0.0
    for i in range(20500):
        st.append(t, 1.0, 0.1, True, f"s{i % 50}", 100.0); t += 1.0
    t += 3 * 3600 + 300
    st.append(t, 2.0, 0.1, True, "new", 100.0)              # triggers compaction
    assert len(st.ts) == 2                                   # the last pre-window trade survives
    f, mask = st.features(t, TokenMeta("M"))
    assert f[FIDX["ret_3h"]] == pytest.approx(math.log(2.0)) and mask & (1 << FIDX["ret_3h"])
    assert f[FIDX["logsigners_1h"]] == pytest.approx(math.log1p(1))
    assert st.cum_vol[-1] == pytest.approx(0.2)


def test_window_highs_exact_on_long_windows_and_after_compaction():
    random.seed(1); st = TokenState("M"); t = 0.0; lp = 0.0; compacted = False
    for i in range(50000):
        lp += random.gauss(0, 0.01); st.append(t, math.exp(lp), 0.1, i % 2 == 0, None, 100.0); t += 0.5
        compacted |= st.n_since_compact == 0
        if i % 4999 == 0 and i:
            f, _ = st.features(t, TokenMeta("M"))
            for k, w in (("1h", 3600.0), ("3h", 10800.0)):
                j = next(n for n, x in enumerate(st.ts) if x >= t - w)
                assert f[FIDX[f"dd_{k}"]] == pytest.approx(st.logp[-1] - max(st.logp[j:]), abs=1e-12)
    assert compacted


def test_exit_cost_uses_fixed_size(monkeypatch):
    monkeypatch.setattr(config, "MAX_POSITION_SOL", 7.0)
    st = TokenState("M"); st.append(0.0, 1.0, 1.0, True, None, 30.0)
    f, _ = st.features(60.0, TokenMeta("M", graduated_at=-3600.0, program_label="Pump.fun Amm"))
    assert f[FIDX["exit_cost_0p1"]] == pytest.approx(exit_cost_fraction(0.1, 30.0, 1.0 * 1e9, "Pump.fun Amm"))   # market cap = price × supply


# ---------------------------------------------------------------- train/mature.py
def _candles(path, day: date, mints):
    t0 = datetime(day.year, day.month, day.day, 12, tzinfo=timezone.utc); rows = []
    for m in mints:
        for k in range(30):
            rows.append({"mint": m, "ts": t0 + timedelta(minutes=k), "open": 1.0, "high": 1.0, "low": 1.0, "close": 1.0 + k / 100, "buy_sol": 1.0, "sell_sol": 0.5,
                         "n_buys": 2, "n_sells": 1, "n_traders": 2, "resq_sol": 50.0})
    pq.write_table(pa.Table.from_pylist(rows), path)


def test_mature_part_records_known_graduations_and_refreshes(tmp_path, monkeypatch):
    monkeypatch.setattr(mature, "MATURE_DIR", tmp_path / "mature"); monkeypatch.setattr(mature, "MATURE_FEAT_DIR", tmp_path / "feat")
    (tmp_path / "mature").mkdir(); d = date(2026, 9, 2)
    _candles(tmp_path / "mature" / "2026-09-01.parquet", date(2026, 9, 1), ["A", "B"]); _candles(tmp_path / "mature" / "2026-09-02.parquet", d, ["A", "B"])
    grads = {"A": datetime(2026, 8, 30, tzinfo=timezone.utc)}
    assert mature.build_day(d, grads=grads) > 0
    part = tmp_path / "feat" / "2026-09-02" / "part.parquet"
    assert mature.part_version(part) == FEATURE_VERSION and mature.part_known(part) == 1
    t = pq.read_table(part, columns=["mint", "age_h"]).to_pandas()
    assert t.loc[t["mint"] == "A", "age_h"].notna().all() and t.loc[t["mint"] == "B", "age_h"].isna().all()
    more = {**grads, "B": datetime(2026, 8, 31, tzinfo=timezone.utc)}
    assert not mature._knows_more(part, grads)
    assert not mature._knows_more(part, more)                   # one more dated mint is below the rebuild bar (no rebuild storm)
    monkeypatch.setattr(mature, "KNOWN_REBUILD_MIN", 1)
    assert mature._knows_more(part, more)
    # a replaced part stays readable until its successor lands; the old one is kept aside
    monkeypatch.setattr(mature, "STALE_DIR", tmp_path / "stale")
    mature._archive(part, "feat_2026-09-02")
    assert part.exists() and len(list((tmp_path / "stale").glob("feat_2026-09-02_v*.parquet"))) == 1
    # a part from before the metadata key: the known count comes from its age_h column
    legacy = tmp_path / "legacy.parquet"; tab = pq.read_table(part)
    pq.write_table(tab.replace_schema_metadata({b"fly_version": str(FEATURE_VERSION).encode()}), legacy)
    assert mature.part_known(legacy) == 1


# ---------------------------------------------------------------- train/replay_assemble.py
def test_replay_band_is_causal():
    con = duckdb.connect()
    con.execute("""CREATE TEMP TABLE t0 AS SELECT 'M' AS mint, TIMESTAMP '2026-09-01' + to_seconds(i) AS ts, i AS slot, 'pump-amm' AS pool,
                   CASE WHEN i < 10 THEN 1.0 ELSE exp(ln(10000.0) * (i - 9) / 291.0) END AS price FROM range(300) r(i)""")
    con.execute("INSERT INTO t0 VALUES ('M', TIMESTAMP '2026-09-01' + to_seconds(150), 150, 'pump-amm', 1e9), "
                "('M', TIMESTAMP '2026-08-31 23:50', 0, 'pump', 1e-9)")
    band_amm(con, "t0", "t")
    assert con.execute("SELECT count(*) FROM t WHERE price = 1.0").fetchone()[0] == 10     # a later 50x+ run no longer deletes graduation-time legs
    assert con.execute("SELECT count(*) FROM t WHERE price >= 1e9").fetchone()[0] == 0      # the off-scale leg is dropped
    assert con.execute("SELECT count(*) FROM t WHERE pool = 'pump'").fetchone()[0] == 1     # curve legs are not banded
    assert con.execute("SELECT count(*) FROM t").fetchone()[0] == 301


# ---------------------------------------------------------------- train/corpus_backtest.py
def _bt_frame(minutes, closes, t0, resq=1e12, broken=None):
    ts = [t0 + timedelta(minutes=m) for m in minutes]
    df = pd.DataFrame({"mint": "A", "ts": pd.to_datetime(ts, utc=True), "close": closes, "resq": resq, "has_trades": False})
    df["grad_day"] = df["ts"].dt.date.astype(str); df["broken"] = False if broken is None else broken
    return df


def test_time_stop_between_traded_minutes_and_entry_day_fold():
    t0 = datetime(2026, 9, 1, 23, 58, tzinfo=timezone.utc)
    df = _bt_frame([0, 1, 2, 200], [1.0, 1.1, 1.2, 5.0], t0)
    cand = np.array([True, False, False, False])
    tr = cb.simulate(df, cand, 0.0, 1.0, 1.0, 30)
    assert tr.shape == (1, 2)
    assert tr[0, 0] == date(2026, 9, 1).toordinal()          # entry day, although the exit is on the 2nd
    assert tr[0, 1] == pytest.approx(0.2, abs=1e-6)          # last close before the 30-minute stop, not minute 200's 5.0
    df = _bt_frame([0, 10, 30, 31], [1.0, 1.1, 1.3, 2.0], t0)
    assert cb.simulate(df, cand, 0.0, 1.0, 1.0, 30)[0, 1] == pytest.approx(0.3, abs=1e-6)   # a traded minute right at the stop exits there


def test_random_baseline_respects_gates(monkeypatch):
    t0 = datetime(2026, 9, 1, 0, 0, tzinfo=timezone.utc); frames = []
    for j in range(12):
        f = _bt_frame(list(range(200)), list(1.0 + 0.001 * np.arange(200)), t0 + timedelta(days=j), resq=np.where(np.arange(200) % 2, 50.0, 5.0),
                      broken=np.arange(200) >= 150)
        f["mint"] = f"M{j}"; frames.append(f)
    df = pd.concat(frames, ignore_index=True)
    gate = (df["resq"].to_numpy() >= cb.MIN_RESQ_SOL) & ~df["broken"].to_numpy(); seen = []
    real = cb.simulate
    monkeypatch.setattr(cb, "simulate", lambda d, cand, *a, **k: (seen.append(cand.copy()), real(d, cand, *a, **k))[1])
    monkeypatch.setattr(cb, "STRATEGIES", {"S9 all": lambda d: [("all", np.ones(len(d), bool))]})
    cb.run(df, 0.003, min_trades=1)
    assert len(seen) == len(cb.EXITS) + 5
    assert all(not (c & ~gate).any() for c in seen)          # strategy and random entries only where the gate allows
    assert seen[-1].any()


# ---------------------------------------------------------------- train/corpus_features.py
class _Tx:
    def __init__(self, fail=False, pending=()):
        self.fail, self.pending, self.rows = fail, list(pending), None

    def __call__(self):
        return self

    def __enter__(self):
        return self

    def __exit__(self, *a):
        return False

    def cursor(self):
        return self

    def execute(self, *a):
        return self

    def fetchall(self):
        return self.pending

    def executemany(self, sql, rows):
        if self.fail:
            raise RuntimeError("db down")
        self.rows = rows


def _fake_rows(mint):
    g = datetime(2026, 9, 1, tzinfo=timezone.utc).timestamp()
    return [cf._row(mint, g + 60 * k, g, 1.0, 1.0, 1.0, 1.0, 1.0, 50.0, False, [0.0] * len(FEATURES), 0, {}) for k in range(3)], 0


def test_corpus_features_failures_and_atomic_commit(tmp_path, monkeypatch):
    g = datetime(2026, 9, 1, tzinfo=timezone.utc)
    todo = [{"mint": m, "graduated_at": g, "candle_path": None, "trade_path": None} for m in ("GOOD", "BAD")]
    monkeypatch.setattr(config, "CORPUS_FEATURES_DIR", tmp_path); monkeypatch.setattr(cf, "_failed", set())
    monkeypatch.setattr(cf, "build_token", lambda m, *a: (_ for _ in ()).throw(ValueError("corrupt")) if m == "BAD" else _fake_rows(m))
    monkeypatch.setattr(cf, "_pending", lambda limit: todo)
    monkeypatch.setattr(cf, "transaction", _Tx(fail=True))
    with pytest.raises(RuntimeError):
        cf.build_batch()
    assert not [p for p in tmp_path.rglob("*") if p.is_file()]      # failed insert: no part left behind, so no duplicate later
    tx = _Tx(); monkeypatch.setattr(cf, "transaction", tx)
    assert cf.build_batch() == 2
    assert [r[0] for r in tx.rows] == ["GOOD"] and "BAD" in cf._failed  # the failure is not registered
    files = [p for p in tmp_path.rglob("*") if p.is_file()]
    assert len(files) == 1 and files[0].name.startswith("part-") and files[0].suffix == ".parquet"
    assert pq.read_table(files[0]).num_rows == 3 and tx.rows[0][4] == str(files[0])
    monkeypatch.undo(); monkeypatch.setattr(cf, "_failed", {"BAD"})
    monkeypatch.setattr(cf, "transaction", _Tx(pending=[{"mint": "BAD"}, {"mint": "X"}, {"mint": "Y"}]))
    assert [r["mint"] for r in cf._pending(1)] == ["X"]              # skipped in this process


# ---------------------------------------------------------------- train/selector.py
def _ds(n_days=5, per_day=400, seed=0):
    rng = np.random.default_rng(seed); n = n_days * per_day
    X = np.c_[rng.normal(size=(n, 3)), np.zeros((n, 2))].astype(np.float32); y = (X[:, 0] + 0.3 * rng.normal(size=n) > 0).astype(np.int8)
    day = np.array([date(2026, 9, 1) + timedelta(days=k) for k in range(n_days) for _ in range(per_day)], dtype=object)
    ts = np.array([datetime(d.year, d.month, d.day, tzinfo=timezone.utc).timestamp() + 60 * (i % per_day) for i, d in enumerate(day)])
    fwd = np.where(y == 1, 0.05, -0.02).astype(np.float32)
    return DecisionSet(X=X, y=y, fwd=fwd, fwd_pess=fwd, day=day, ts=ts, mint=np.array([f"M{i % 7}" for i in range(n)]),
                       cols=["a", "b", "c", "age_known", "log_age_h"], horizon_s=1800.0)      # age unknown: graduated before the archive, tradable


def test_selector_fold_guards():
    ds = _ds(); days = ds.days
    fo = selector.fold(ds, days[-1], min_train=100, min_test=10)
    pick = fo.test & (fo.scores >= selector.MIN_EV)
    assert fo is not None and fo.ic is not None and fo.ic > 0.5 and pick.any() and np.isnan(fo.scores[~fo.test]).all()
    assert selector.fold(ds, days[0], min_train=100, min_test=10) is None                         # empty train
    assert selector.fold(ds, days[-1]) is None                                                      # below the default sizes
    from fly_trader.train import selector_eval                                                      # importable: runs only as a script
    assert selector_eval.fold is selector.fold


def test_selector_trades_only_aged_tokens():
    ds = _ds(); days = ds.days; young = (ds.mint == "M0")
    ds.X[young, 3] = 1.0; ds.X[young, 4] = np.log1p(2.0)                                           # M0: known graduation, 2 h old
    fo = selector.fold(ds, days[-1], min_train=100, min_test=10); pick = fo.test & (fo.scores >= selector.MIN_EV)
    assert pick.any() and not (pick & young).any() and not (fo.test & young).any()                 # never a test row or a pick
    assert list(selector.in_universe(np.array([[0, 0, 0, 1, np.log1p(30.0)], [0, 0, 0, 1, np.log1p(5.0)], [0, 0, 0, 0, 0]]), ds.cols)) == [True, False, True]
    assert fo.model.threshold == selector.MIN_EV and fo.model.scaler is not None


def test_buy_line_is_chosen_by_total_profit():
    ds = _ds(); rows = np.ones(len(ds.y), bool)
    scores = ds.fwd_pess.astype(np.float64) - 0.01                                                 # good rows +4 %, bad rows -3 %
    line, table = selector.choose_line(ds, scores, rows)
    assert line in selector.LINE_CANDIDATES and [t["line"] for t in table] == list(selector.LINE_CANDIDATES)
    best = max((t for t in table if t["trades"] >= selector.MIN_LINE_TRADES), key=lambda t: t["total"])
    assert line == best["line"] and best["mean"] > 0