File size: 17,503 Bytes
8378e52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad84bd6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
074b8e3
 
 
 
 
 
37f39a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ae54610
 
 
 
 
 
 
 
 
b71e2fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc6419d
 
 
 
 
 
 
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
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
"""Quality-Trend 3 Space: data client, ranking, rebalance rules and guards, execution, performance, API auth.
No network: Sharadar and Alpaca are faked."""
from __future__ import annotations

import json
import sys
from datetime import date, datetime
from pathlib import Path

import numpy as np
import pandas as pd
import pytest

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from qt3 import config, performance, ranking, rebalance, sharadar  # noqa: E402
from qt3.broker import BrokerError  # noqa: E402


# --------------------------------------------------------------------------- Sharadar client
class _R:
    def __init__(self, code, body):
        self.status_code, self._b = code, body

    def json(self):
        return self._b


class FakeHTTP:
    def __init__(self, pages):
        self.pages, self.calls = pages, []

    def get(self, url, params=None, timeout=None):
        self.calls.append((url, dict(params)))
        return _R(200, self.pages[len(self.calls) - 1])


def test_sharadar_paginates_and_maps_filters():
    cols = [{"name": "ticker"}, {"name": "date"}]
    s = FakeHTTP([{"datatable": {"data": [["A", "2026-10-01"]], "columns": cols}, "meta": {"next_cursor_id": "c2"}},
                  {"datatable": {"data": [["B", "2026-10-01"]], "columns": cols}, "meta": {"next_cursor_id": None}}])
    df = sharadar.query("SEP", "KEY", session=s, ticker="A,B", date__gte="2026-01-01", columns=["ticker", "date"])
    assert list(df["ticker"]) == ["A", "B"]
    p0, p1 = s.calls[0][1], s.calls[1][1]
    assert p0["date.gte"] == "2026-01-01" and p0["qopts.columns"] == "ticker,date" and "qopts.cursor_id" not in p0
    assert p1["qopts.cursor_id"] == "c2"
    with pytest.raises(sharadar.SharadarError):
        sharadar.query("SEP", "", session=s)


# --------------------------------------------------------------------------- ranking (steps 4-5)
def _universe(n=40, seed=0):
    rng = np.random.default_rng(seed)
    u = pd.DataFrame({"ticker": [f"T{i:02d}" for i in range(n)], "price": 50.0, "dv20": 2e7,
                      "currency": "USD", "ma50_200": rng.normal(0, .1, n), "mom_6_1": rng.normal(0, .2, n)})
    for c in ("roe", "roa", "gross_margin", "fcf_conversion", "fcf_yield", "buyback_yield",
              "net_share_reduction_yoy", "basic_diluted_gap", "equity_financing"):
        u[c] = rng.normal(0, 1, n)
    return u


def test_rank_table_pool_and_order():
    u = _universe()
    u.loc[0, "currency"] = "JPY"          # foreign reporter excluded
    u.loc[1, "price"] = 3.0               # below $5 excluded
    pool = ranking.rank_table(u)
    assert "T00" not in set(pool["ticker"]) and "T01" not in set(pool["ticker"])
    assert pool.attrs["liquid_usd"] == 38
    assert len(pool) == pytest.approx(38 * 0.3, abs=1)
    assert pool["trend"].is_monotonic_decreasing and list(pool["rank"]) == list(range(1, len(pool) + 1))


def test_build_uses_api_tables_and_returns_ranked(monkeypatch):
    u = _universe(60)
    days = pd.bdate_range(end="2026-10-09", periods=260)
    def fetch(table, key, **f):
        assert table == "DAILY"
        return pd.DataFrame({"ticker": u["ticker"], "date": "2026-10-09", "marketcap": np.arange(60, 0, -1) * 1e3})
    def fetch_t(table, key, tickers, **f):
        if table == "TICKERS":
            return pd.concat([pd.DataFrame({"table": t, "ticker": tickers, "name": "Co", "sector": "Tech",
                                            "industry": "X", "currency": "USD"}) for t in ("SEP", "SF1")])
        if table == "SEP":
            rows = []
            for i, t in enumerate(tickers):
                c = 100 * np.exp(np.cumsum(np.full(len(days), (i - 30) / 6000)))
                rows.append(pd.DataFrame({"ticker": t, "date": days, "close": c, "volume": 1e6, "closeadj": c}))
            return pd.concat(rows)
        return pd.DataFrame()                         # SF1 empty β†’ no quality inputs β†’ empty pool is fine
    r = ranking.build("KEY", today=datetime(2026, 10, 10), fetch=fetch, fetch_tickers=fetch_t)
    assert r["prices_as_of"] == "2026-10-09" and r["universe"] == 60


# --------------------------------------------------------------------------- rebalance rules
RANKED = ["A", "B", "C", "D", "E", "F", "G", "H"]


def test_target_list_keeps_top6_and_refills():
    final, why = rebalance.target_list(RANKED, ["F", "H", "ZZ"])
    assert final == ["A", "B", "F"]                       # F (#6) kept; H (#8) and ZZ sold; refill A, B
    assert "#8" in why["H"] and "left the quality pool" in why["ZZ"]
    assert rebalance.target_list(RANKED, [])[0] == ["A", "B", "C"]


def test_plan_initial_equal_thirds_with_buffer():
    px = {s: 100.0 for s in "ABC"}
    p = rebalance.plan(["A", "B", "C"], {}, px, cash=10_000.0, fractional=True)
    buys = {o["symbol"]: o["value"] for o in p["orders"]}
    assert set(buys) == {"A", "B", "C"} and all(v == pytest.approx(3300, abs=1) for v in buys.values())
    assert p["cash_after"] == pytest.approx(100, abs=1)


def test_plan_sells_first_exit_and_trim():
    px = {"A": 200.0, "B": 100.0, "C": 100.0, "OLD": 10.0}
    p = rebalance.plan(["A", "B", "C"], {"A": 30, "B": 10, "OLD": 100}, px, cash=0.0,
                       reasons={"OLD": "now ranked #9"}, fractional=True)
    sides = [(o["symbol"], o["side"]) for o in p["orders"]]
    assert sides[0] == ("OLD", "sell") and p["orders"][0]["close_all"]
    assert ("A", "sell") in sides and sides.index(("A", "sell")) < sides.index(("C", "buy"))


def test_plan_whole_shares_and_missing_price():
    p = rebalance.plan(["A", "B", "C"], {}, {s: 333.0 for s in "ABC"}, cash=3000.0, fractional=False)
    assert all(float(o["shares"]).is_integer() for o in p["orders"])
    with pytest.raises(ValueError, match="C"):
        rebalance.plan(["A", "B", "C"], {}, {"A": 1, "B": 1}, cash=10.0)


# --------------------------------------------------------------------------- guards + execution (fake Alpaca)
class FakeAlpaca:
    def __init__(self, open_=True, orders=(), days=None, fill="filled"):
        self.open_, self._orders, self.fill = open_, list(orders), fill
        self._days = days or ["2026-11-02", "2026-11-03", "2026-11-04", "2026-11-05", "2026-11-06", "2026-11-09",
                              "2026-12-01"]
        self.submitted = []
        self.cash = 0.0

    def clock(self):
        return {"is_open": self.open_, "next_open": "2026-11-03T09:30:00-05:00"}

    def trading_days(self, s, e):
        return self._days

    def orders(self, after=None, status="all", limit=500):
        return self._orders

    def submit(self, symbol, side, qty=None, notional=None, client_order_id=None):
        self.submitted.append({"symbol": symbol, "side": side, "qty": qty, "notional": notional, "cid": client_order_id})
        if side == "sell":
            self.cash += 1000.0
        return {"id": f"o{len(self.submitted)}", "status": "accepted"}

    def wait(self, ids, seconds=0, poll=0):
        return {i: {"status": self.fill} for i in ids}

    def account(self):
        return {"cash": self.cash, "non_marginable_buying_power": self.cash, "equity": 10_000.0}


NOV2 = datetime(2026, 11, 2, 10, 30, tzinfo=rebalance.NY)
FRESH = {"ranked": [{"ticker": t} for t in RANKED], "prices_as_of": "2026-10-30"}


def test_check_first_run_and_guards():
    assert rebalance.check(FakeAlpaca(), FRESH, NOV2)["ok"]                          # first run, day 1, open
    assert "market closed" in rebalance.check(FakeAlpaca(open_=False), FRESH, NOV2)["reasons"][0]
    stale = {**FRESH, "prices_as_of": "2026-10-20"}
    assert any("days old" in r for r in rebalance.check(FakeAlpaca(), stale, NOV2)["reasons"])
    done = [{"client_order_id": "qt3-20261102-A-buy", "side": "buy", "status": "filled",
             "created_at": "2026-11-02T15:00:01.123456789Z"}]
    c = rebalance.check(FakeAlpaca(orders=done), FRESH, NOV2)
    assert not c["ok"] and "already rebalanced this month" in c["reasons"] and c["done_this_month"]
    old = [{"client_order_id": "qt3-20261001-A-buy", "side": "buy", "status": "filled",
            "created_at": "2026-10-01T15:00:00Z"}]
    late = datetime(2026, 11, 9, 10, 30, tzinfo=rebalance.NY)                          # 6th trading day
    assert any("window" in r for r in rebalance.check(FakeAlpaca(orders=old), FRESH, late)["reasons"])
    fresh_late = {**FRESH, "prices_as_of": "2026-11-06"}
    assert rebalance.check(FakeAlpaca(orders=old), fresh_late, late, force=True)["ok"]      # force skips window
    assert not rebalance.check(FakeAlpaca(orders=old), FRESH, late, force=True)["ok"]      # never stale data


def test_execute_sells_then_scaled_buys_with_ids():
    a = FakeAlpaca()
    p = {"fractional": True, "orders": [
        {"symbol": "OLD", "side": "sell", "shares": 5.0, "value": 1000.0},
        {"symbol": "A", "side": "buy", "shares": 1.0, "value": 600.0},
        {"symbol": "B", "side": "buy", "shares": 1.0, "value": 600.0}]}
    res = rebalance.execute(a, p, today=date(2026, 11, 2))
    assert [r["side"] for r in res] == ["sell", "buy", "buy"]
    assert a.submitted[0]["cid"] == "qt3-20261102-OLD-sell"
    # cash after sells 1000 βˆ’ 1% of equity (100) = 900 β†’ buys scaled 900/1200
    assert a.submitted[1]["notional"] == pytest.approx(450.0) and a.submitted[1]["cid"] == "qt3-20261102-A-buy"


def test_execute_defers_buys_if_sells_not_filled():
    a = FakeAlpaca(fill="new")
    res = rebalance.execute(a, {"fractional": True, "orders": [
        {"symbol": "OLD", "side": "sell", "shares": 1.0, "value": 10.0},
        {"symbol": "A", "side": "buy", "shares": 1.0, "value": 10.0}]}, today=date(2026, 11, 2), wait_s=0)
    assert res[-1]["status"].startswith("deferred") and all(s["side"] == "sell" for s in a.submitted)


# --------------------------------------------------------------------------- performance
def test_twr_excludes_deposits_and_drawdown():
    g = performance.twr(["d1", "d2", "d3"], [1000.0, 2100.0, 1890.0], {"d2": 1000.0})
    assert g[1] == pytest.approx(1.10) and g[2] == pytest.approx(0.99)
    assert performance.max_drawdown([1.0, 1.2, 0.9, 1.3]) == pytest.approx(-0.25)


# --------------------------------------------------------------------------- API auth
def test_rebalance_endpoint_requires_token(monkeypatch, tmp_path):
    from fastapi.testclient import TestClient
    monkeypatch.setattr(config, "REBALANCE_TOKEN", "secret-token")
    monkeypatch.setattr(config, "NDL_KEY", "")
    monkeypatch.setattr(config, "CACHE_DIR", str(tmp_path))
    (tmp_path / "ranking.json").write_text(json.dumps({**FRESH, "built": datetime.now().astimezone().isoformat()}))
    import app as space
    c = TestClient(space.app)
    assert c.get("/api/health").json()["rebalance_enabled"]
    r = c.post("/api/rebalance?dry=0", headers={"X-Rebalance-Token": "wrong"})
    assert r.status_code == 403


# --------------------------------------------------------------------------- in-Space scheduler
def test_scheduler_window_and_guarded_run(monkeypatch):
    from qt3 import scheduler
    ny = rebalance.NY
    assert scheduler.due(datetime(2026, 11, 2, 10, 15, tzinfo=ny))          # Monday 10:15 New York
    assert not scheduler.due(datetime(2026, 11, 2, 9, 45, tzinfo=ny))      # before 10:00
    assert not scheduler.due(datetime(2026, 11, 2, 15, 45, tzinfo=ny))     # after 15:30
    assert not scheduler.due(datetime(2026, 11, 7, 11, 0, tzinfo=ny))      # Saturday
    monkeypatch.setattr(ranking, "refresh", lambda force=False: FRESH)
    a = FakeAlpaca(open_=False)
    out = scheduler.run_once(alpaca=a)
    assert not out["executed"] and "market closed" in out["reasons"][0] and a.submitted == []
    assert scheduler._lock.acquire(blocking=False)                        # lock released afterwards
    scheduler._lock.release()


def test_scheduler_not_started_without_keys(monkeypatch):
    from qt3 import scheduler
    monkeypatch.setattr(config, "ALPACA_KEY", "")
    monkeypatch.setattr(scheduler, "STATE", {**scheduler.STATE, "running": False})
    assert scheduler.start() is False


def test_bench_value_same_money_and_deposits():
    # S&P +10% on day 2; a $500 deposit on day 3 with S&P flat β†’ benchmark gets the same deposit
    b = performance.bench_value([1000.0, 1050.0, 1600.0], [100.0, 110.0, 110.0], ["d1", "d2", "d3"], {"d3": 500.0})
    assert b == [1000.0, 1100.0, 1600.0]


def test_rebuild_rolls_back_fills_and_deposits():
    # now: 10 A + $100 cash. A was bought (10 @ 50) on d3 and B sold (5 @ 20) on d3; $200 deposited on d3.
    fills = [{"date": "d3", "symbol": "A", "side": "buy", "qty": 10.0, "price": 50.0},
             {"date": "d3", "symbol": "B", "side": "sell", "qty": 5.0, "price": 20.0}]
    closes = {"A": {"d2": 48.0, "d3": 51.0}, "B": {"d2": 21.0, "d3": 20.0}}
    out = performance.rebuild(["d2", "d3"], 100.0, {"A": 10.0}, fills, {"d3": 200.0}, closes)
    # d2: cash 100 - 200 + 500 - 100 = 300, holding 5 B @ 21 β†’ 405; d3: today's holdings at d3 closes β†’ 610
    assert out == {"d2": 405.0, "d3": 610.0}
    assert performance.rebuild(["d2"], 100.0, {"A": 10.0}, [], {}, {"A": {}}) == {}   # missing close β†’ skipped


def test_series_fills_alpaca_history_gap_and_uses_live_equity(monkeypatch):
    from datetime import datetime as real_dt
    from zoneinfo import ZoneInfo
    ny = ZoneInfo("America/New_York")

    class FixedNow(real_dt):
        @classmethod
        def now(cls, tz=None):
            return real_dt(2026, 10, 13, 11, 0, tzinfo=ny)

    class Fake:
        def portfolio_history(self):          # Alpaca has posted 10-09 (all cash) but not 10-12 yet
            return {"timestamp": [int(real_dt(2026, 10, 9, 20, 0, tzinfo=ny).timestamp())], "equity": [1000.0]}
        def orders(self, after=None, status="all"):
            return [{"client_order_id": "qt3-20261012-A-buy", "created_at": "2026-10-12T14:30:00Z", "symbol": "A",
                     "side": "buy", "filled_qty": "10", "filled_avg_price": "99", "filled_at": "2026-10-12T14:31:00Z"}]
        def account(self):
            return {"equity": 1015.0, "cash": 10.0}
        def cash_flows(self):
            return []
        def positions(self):
            return {"A": {"qty": 10.0}}
        def daily_bars(self, sym, start, adjustment="all"):
            px = {"SPY": {"2026-10-09": 500.0, "2026-10-12": 505.0}, "A": {"2026-10-12": 100.0}}[sym]
            return [{"t": d, "c": c} for d, c in px.items()]
        def latest_prices(self, syms):
            return {"SPY": 510.0}

    monkeypatch.setattr(performance, "datetime", FixedNow)
    monkeypatch.setattr(config, "INCEPTION", "")
    s = performance.series(Fake())
    assert s["dates"] == ["2026-10-09", "2026-10-12", "2026-10-13"] and s["src"] == ["alpaca", "rebuilt", "live"]
    assert s["value"] == [1000.0, 1010.0, 1015.0]                 # 10-12: $10 cash + 10 A @ 100
    assert s["bench_value"] == [1000.0, 1010.0, 1020.0] and s["start"] == "2026-10-12"


def test_ranking_endpoint_while_first_build_runs(monkeypatch, tmp_path):
    from fastapi.testclient import TestClient
    monkeypatch.setattr(config, "NDL_KEY", "")
    monkeypatch.setattr(config, "CACHE_DIR", str(tmp_path))        # no cached ranking yet
    import app as space
    r = TestClient(space.app).get("/api/ranking")
    assert r.status_code == 200 and r.json()["age_hours"] is None and "ranked" not in r.json()


def test_history_replays_rebalances_from_orders():
    from qt3 import history

    def o(day, sym, side, qty, px, at="T14:00:00Z"):
        return {"client_order_id": f"qt3-{day.replace('-', '')}-{sym}-{side}", "symbol": sym, "side": side,
                "filled_qty": str(qty), "filled_avg_price": str(px), "filled_at": day + at}
    orders = [o("2026-10-12", s, "buy", 1, 100) for s in "ABC"] + [
        o("2026-11-02", "C", "sell", 1, 120, "T14:00:00Z"), o("2026-11-02", "D", "buy", 1, 110, "T14:05:00Z"),
        o("2026-11-02", "A", "buy", 0.05, 100, "T14:05:00Z"), o("2026-11-02", "B", "sell", 0.03, 100, "T14:00:00Z"),
        {"client_order_id": "manual-1", "symbol": "Z", "side": "buy", "filled_qty": "5", "filled_avg_price": "10"}]
    pos = {"A": {"market_value": 120.0}, "B": {"market_value": 90.0}, "D": {"market_value": 100.0}}
    h = history.build(orders, pos, {"2026-10-01": 10000.0, "2026-10-20": 1000.0}, {"C": "Cee Corp"})
    new, first = h["events"]
    assert first["first"] and first["bought"] == ["A", "B", "C"] and first["added"] == 0
    assert new["bought"] == ["D"] and new["kept"] == ["A", "B"] and new["added"] == 1000.0
    assert [s["symbol"] for s in new["sold"]] == ["C"] and new["sold"][0]["return"] == pytest.approx(0.2)
    assert new["sold"][0]["days"] == 21 and new["sold"][0]["name"] == "Cee Corp"
    assert {r["symbol"]: r["value"] for r in new["resplit"]} == {"A": 5.0, "B": -3.0}
    op = {r["symbol"]: r for r in h["open"]}
    assert set(op) == {"A", "B", "D"}                                   # the manual order is ignored
    assert op["A"]["return"] == pytest.approx(120 / 105 - 1) and op["B"]["return"] == pytest.approx(93 / 100 - 1)
    s = h["summary"]
    assert s["rebalances"] == 2 and s["swaps_per_rebalance"] == 1 and s["closed"] == 1 and s["win_rate"] == 1


def test_short_company_names():
    import app as space
    assert space._short_name("Micron Technology, Inc. Common Stock") == "Micron Technology, Inc."
    assert space._short_name("Alphabet Inc. Class A Common Stock") == "Alphabet Inc."
    assert space._short_name("HF Sinclair Corporation") == "HF Sinclair Corporation" and space._short_name(None) is None