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
|