File size: 6,413 Bytes
0f038be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411abf3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0b4bb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The live fly learns from exactly the label it was trained on: its resolver equals ``decisions.build``'s ``fwd_pess``
row for row (next-open entry within two minutes else the close, last close at or before t + hold, exit cost at both
ends), drops an exit after a 50× jump or without the row's own bar; tags can be filtered."""
from datetime import datetime, timedelta, timezone

import numpy as np
import pandas as pd
import pyarrow as pa
import pytest
import torch

from fly_trader.agent.fly_session import resolve_label
from fly_trader.brain import plastic
from fly_trader.market.features import FEATURES
from fly_trader.train import decisions, mature


def _part(tmp_path):
    rng = np.random.default_rng(0)
    base = datetime(2026, 9, 1, tzinfo=timezone.utc)
    rows = []
    for mint in ("Apump", "Bpump"):
        offs = sorted(rng.choice(400, 220, replace=False).tolist())      # traded minutes with irregular gaps (some > 2 minutes)
        price = 1.0
        for o in offs:
            price *= float(np.exp(rng.normal(0, 0.02)))
            rows.append({**{f: 0.0 for f in FEATURES}, "mint": mint, "ts": base + timedelta(minutes=int(o)), "open": price * float(np.exp(rng.normal(0, 0.005))),
                         "close": price, "resq": 50.0, "age_h": 30.0, "traders_15m": 10.0, "traders_1h": 40.0, "n_trades_1m": 3.0, "logvol_15m": 3.0,
                         "exit_cost_0p1": float(rng.uniform(0.005, 0.02))})
    df = pd.DataFrame(rows)
    root = tmp_path / "feat"
    mature.write_part(pa.Table.from_pandas(df, preserve_index=False), root / "2026-09-01" / "part.parquet", mature.FEATURE_VERSION, {"fly_agg": mature.AGG_VERSION})
    return df, root


def test_resolver_equals_the_training_label(tmp_path):
    df, root = _part(tmp_path)
    ds = decisions.build(days=None, horizon_min=10, feature_dir=root)
    assert len(ds.y) > 100
    from fly_trader.market.exit_cost import cost_at_size          # the engine reprices before it hands a bar to the fly
    bars = {m: [(t.timestamp(), o, c, cost_at_size(e, rq)) for t, o, c, e, rq in
                zip(g["ts"], g["open"], g["close"], g["exit_cost_0p1"], g["resq"])]
            for m, g in df.sort_values("ts").groupby("mint")}
    got = np.array([resolve_label(bars[m], t, ds.horizon_s) for m, t in zip(ds.mint, ds.ts)], dtype=np.float64)
    assert np.allclose(got, ds.fwd_pess, rtol=1e-5, atol=1e-6)
    gaps = np.array([next((b[0] for b in bars[m] if b[0] > t), np.inf) - t for m, t in zip(ds.mint, ds.ts)])
    assert (gaps > 120).any() and (gaps <= 120).any()                               # both entry rules were exercised


def test_resolver_refuses_jumps_and_missing_rows():
    bars = [(0.0, 1.0, 1.0, 0.01), (60.0, 1.0, 1.0, 0.01), (120.0, 1.0, 60.0, 0.01)]
    assert resolve_label(bars, 0.0, 120.0) is None                                  # a 60× jump into the exit minute
    assert resolve_label(bars, 30.0, 120.0) is None                                 # the row's own minute is not in the bars
    ok = [(0.0, 1.0, 1.0, 0.01), (300.0, 2.0, 1.1, 0.02)]
    assert resolve_label(ok, 0.0, 600.0) == pytest.approx(1.1 / 1.0 * 0.99 * 0.98 - 1)   # next trade 5 min later: entry at the close


def test_tags_subset_keeps_the_chosen_rows():
    k = torch.rand(3, 8); t = plastic.Tags(keys=["a", "b", "c"], ts=np.array([0.0, 1.0, 2.0]), y_dn=torch.tensor([1.0, 2.0, 3.0]), u0=torch.rand(3, 4), k=k,
                                           w=torch.tensor([[1.0, 10.0, 1.0]]))
    s = t.subset(np.array([True, False, True]))
    assert s.keys == ["a", "c"] and list(s.ts) == [0.0, 2.0] and s.y_dn.tolist() == [1.0, 3.0] and torch.equal(s.k, k[[0, 2]]) and s.w.tolist() == [[1.0, 1.0]]


def test_outcomes_join_both_books_by_the_rows_decision(db_conn):
    """A scored row (minute start t, mint, strategy) finds its book's and the wallet's positions through the entry
    decision stamped at the minute's end, with each position's status and, once closed, realized / cost."""
    from types import SimpleNamespace

    from fly_trader.agent.fly_session import FlyBook
    t = datetime(2026, 9, 3, 12, 0, tzinfo=timezone.utc).timestamp(); mint = "OUTCOME_TEST"
    d = db_conn.execute("INSERT INTO decisions (ts, mint, kind, detail) VALUES (%s, %s, 'fly_enter', %s) RETURNING id",
                        (datetime.fromtimestamp(t + 60, timezone.utc), mint, '{"strategy": "capitulation", "book": "paper_fly"}')).fetchone()["id"]
    for book, status, realized in (("paper_fly", "closed", 0.02), ("live", "open", None)):
        db_conn.execute("INSERT INTO positions (book, mint, entry_decision_id, qty, cost_sol, realized_sol, status) VALUES (%s, %s, %s, 1, 0.1, %s, %s)",
                        (book, mint, d, realized, status))
    me = SimpleNamespace(BOOK="paper_fly", KIND="fly", market=SimpleNamespace(live_book="live"))
    book, live = FlyBook._outcomes(me, db_conn, [(t, mint, "capitulation"), (t + 60, mint, "capitulation")])
    assert book == {(t, mint, "capitulation"): ("closed", pytest.approx(0.2))} and live == {(t, mint, "capitulation"): ("open", None)}


def test_outcomes_leave_out_the_wallets_position_the_operator_sold(db_conn):
    """An operator's Sell Now is no fill of the fly's: the wallet's position it closed is left out (as an aborted RH buy),
    so the row resolves on its book's outcome alone and teaches the fill pathway nothing."""
    from types import SimpleNamespace

    from fly_trader.agent.fly_session import FlyBook
    t = datetime(2026, 9, 4, 12, 0, tzinfo=timezone.utc).timestamp(); mint = "OUTCOME_OP_TEST"
    d = db_conn.execute("INSERT INTO decisions (ts, mint, kind, detail) VALUES (%s, %s, 'fly_enter', %s) RETURNING id",
                        (datetime.fromtimestamp(t + 60, timezone.utc), mint, '{"strategy": "capitulation", "book": "paper_fly"}')).fetchone()["id"]
    for book, realized, kind in (("paper_fly", 0.02, None), ("live", -0.03, "operator")):
        db_conn.execute("INSERT INTO positions (book, mint, entry_decision_id, qty, cost_sol, realized_sol, status, forced_exit_kind) VALUES (%s, %s, %s, 1, 0.1, %s, 'closed', %s)",
                        (book, mint, d, realized, kind))
    me = SimpleNamespace(BOOK="paper_fly", KIND="fly", market=SimpleNamespace(live_book="live"))
    book, live = FlyBook._outcomes(me, db_conn, [(t, mint, "capitulation")])
    assert book == {(t, mint, "capitulation"): ("closed", pytest.approx(0.2))} and live == {}