s-ttp commited on
Commit
8378e52
·
verified ·
1 Parent(s): df7e6bc

Updraft: Quality-Trend 3 automated paper strategy + live page

Browse files
.github/workflows/updraft-keepalive.yml ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Updraft keep-alive and check
2
+
3
+ # Daily (incl. weekends) so the free Space never reaches HF's 48-hour sleep, and so the ranking is rebuilt each
4
+ # morning. Fails the run (= an email from GitHub) if the Space reports a problem: missing keys, a ranking older
5
+ # than 3 days, or a data-build error.
6
+ on:
7
+ schedule:
8
+ - cron: "30 12 * * *"
9
+ workflow_dispatch:
10
+
11
+ jobs:
12
+ check:
13
+ runs-on: ubuntu-24.04
14
+ steps:
15
+ - name: Wake and check
16
+ env:
17
+ URL: ${{ secrets.UPDRAFT_URL || 'https://s-ttp-updraft.hf.space' }}
18
+ run: |
19
+ for i in $(seq 1 40); do
20
+ CODE=$(curl -s -o /tmp/h.json -w '%{http_code}' --max-time 30 "$URL/api/health" || echo 000)
21
+ [ "$CODE" = "200" ] && break; echo "attempt $i: $CODE"; sleep 15
22
+ done
23
+ [ "$CODE" = "200" ] || { echo "::error::Space not reachable"; exit 1; }
24
+ cat /tmp/h.json; echo
25
+ curl -s --max-time 120 "$URL/api/ranking" -o /tmp/r.json
26
+ python3 - <<'PY'
27
+ import json, sys
28
+ h, r = json.load(open("/tmp/h.json")), json.load(open("/tmp/r.json"))
29
+ problems = [k for k in ("data_key", "alpaca_keys", "rebalance_enabled") if not h.get(k)]
30
+ err = (h.get("ranking_state") or {}).get("error")
31
+ if err: problems.append("ranking build error: " + err)
32
+ if r.get("age_hours", 0) > 72 and not r.get("building"): problems.append(f"ranking {r.get('age_hours')} h old")
33
+ print("prices to", r.get("prices_as_of"), "· pool", r.get("pool"), "· top 3", [x["ticker"] for x in (r.get("ranked") or [])[:3]])
34
+ if problems:
35
+ print("::error::" + "; ".join(problems)); sys.exit(1)
36
+ PY
.github/workflows/updraft-rebalance.yml ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Updraft rebalance
2
+
3
+ # Fires every weekday at 15:00 UTC (mid-morning New York, always market hours). The Space guards it: it trades
4
+ # only while the market is open, on one of the first 5 trading days of a month, once per month, on a ranking
5
+ # built from data at most 5 days old — so in practice it trades on the first trading day of each month and
6
+ # returns "executed: false" every other day. Also runnable by hand from the Actions tab ("force" overrides the
7
+ # once-a-month / window guards, never the market-open or data-freshness guards).
8
+ #
9
+ # Free Spaces sleep after 48 h idle and a cold start can take minutes ("Internal server error" from HF's
10
+ # gateway meanwhile), so the job wakes the Space first and retries the POST. The POST is idempotent.
11
+ on:
12
+ schedule:
13
+ - cron: "0 15 * * 1-5"
14
+ workflow_dispatch:
15
+ inputs:
16
+ force:
17
+ description: "Rebalance now even if already done this month / outside the window"
18
+ type: boolean
19
+ default: false
20
+
21
+ concurrency: updraft-rebalance
22
+
23
+ jobs:
24
+ rebalance:
25
+ runs-on: ubuntu-24.04
26
+ steps:
27
+ - name: Wake the Space
28
+ env:
29
+ URL: ${{ secrets.UPDRAFT_URL || 'https://s-ttp-updraft.hf.space' }}
30
+ run: |
31
+ for i in $(seq 1 40); do
32
+ CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "$URL/api/health" || echo 000)
33
+ echo "attempt $i: /api/health -> $CODE"
34
+ [ "$CODE" = "200" ] && { echo "Space is awake."; exit 0; }
35
+ sleep 15
36
+ done
37
+ echo "::error::Space did not wake within ~10 minutes"; exit 1
38
+
39
+ - name: Trigger rebalance (retries on gateway errors)
40
+ env:
41
+ URL: ${{ secrets.UPDRAFT_URL || 'https://s-ttp-updraft.hf.space' }}
42
+ REBALANCE_TOKEN: ${{ secrets.UPDRAFT_REBALANCE_TOKEN }}
43
+ FORCE: ${{ inputs.force && '1' || '0' }}
44
+ run: |
45
+ for i in $(seq 1 5); do
46
+ RESP=$(curl -s -w $'\n%{http_code}' --max-time 300 -X POST "$URL/api/rebalance?dry=0&force=$FORCE" \
47
+ -H "X-Rebalance-Token: $REBALANCE_TOKEN")
48
+ CODE=$(printf '%s' "$RESP" | tail -1); BODY=$(printf '%s' "$RESP" | sed '$d')
49
+ echo "attempt $i: HTTP $CODE"; echo "$BODY" | python3 -m json.tool 2>/dev/null || echo "$BODY"
50
+ [ "$CODE" = "200" ] && exit 0
51
+ [ "$CODE" = "403" ] && { echo "::error::token rejected — check UPDRAFT_REBALANCE_TOKEN"; exit 1; }
52
+ echo "transient HTTP $CODE — retrying in 30s"; sleep 30
53
+ done
54
+ echo "::error::rebalance did not return 200 after retries"; exit 1
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ .pytest_cache/
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+ ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 QT3_CACHE=/tmp/qt3
5
+
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # HF recommends a non-root user (uid 1000). The ranking cache lives in /tmp (rebuilt after a restart).
10
+ RUN useradd -m -u 1000 app
11
+ COPY --chown=app:app . .
12
+ USER app
13
+
14
+ EXPOSE 7860
15
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,61 @@
1
  ---
2
  title: Updraft
3
- emoji: 📈
4
- colorFrom: gray
5
- colorTo: indigo
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Updraft
3
+ emoji: 🪁
4
+ colorFrom: red
5
+ colorTo: yellow
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ short_description: Rides the strongest quality stocks; 3 names, monthly
10
  ---
11
 
12
+ # 🪁 Updraft
13
+
14
+ *The Quality-Trend 3 strategy, running on its own.*
15
+
16
+ Three large US stocks with strong **quality and capital discipline** and the strongest **price trends**, held on an
17
+ Alpaca **paper** account and rebalanced automatically once a month. The page shows the live portfolio, growth vs the
18
+ S&P 500 since the first trade, the trade log and this month's ranking.
19
+
20
+ **Rule** (backtested in the RS-VCP / Quant Model research, `scripts/finalize_qt3_managed.py`):
21
+ 1. Universe — the 1,000 largest US companies by market cap; price ≥ $5; ≥ $5M a day traded; US-dollar statements.
22
+ 2. Quality pool — top 30% of a capital-discipline composite: basic-diluted gap, quality score (ROE, ROA, gross margin,
23
+ FCF conversion), −equity financing, FCF yield, ROE, buyback yield, net share reduction.
24
+ 3. Trend — the pool ranked by 0.5 · pct(MA50/MA200) + 0.5 · pct(6-1-month return).
25
+ 4. Monthly — keep holdings still ranked in the top 6, sell the rest, buy from the top until 3 are held, re-split
26
+ equally (1% kept in cash). Sells first, waited for, then buys sized to the cash actually available (no margin).
27
+
28
+ Backtest 1999-08 → 2026-09, after 10 bps per side, point-in-time lists incl. delisted names: **+30.9%/yr** (S&P +8.5%),
29
+ worst fall −34%, any 12 months median +24% (in profit 87% of the time). Backtests flatter; expect less.
30
+
31
+ ## How it runs without a PC
32
+ - **Data** — each day the Space builds the ranking itself from the Sharadar API (Nasdaq Data Link, ~40 requests,
33
+ ~70 s). It reproduces the research snapshot exactly (verified 2026-10-13: same top 10, same 250-name pool).
34
+ - **Trading** — `POST /api/rebalance?dry=0` with `X-Rebalance-Token`. It trades only when **all** hold: market open;
35
+ one of the first 5 trading days of the month (or the very first run); not already rebalanced this month; ranking
36
+ data ≤ 5 days old. Every other call is a harmless no-op. Orders carry `client_order_id = qt3-YYYYMMDD-SYM-side`,
37
+ so a retried call cannot duplicate them.
38
+ - **Schedule** — GitHub Actions (free): `.github/workflows/updraft-rebalance.yml` every weekday 15:00 UTC (wakes the
39
+ Space, then POSTs the rebalance with retries) and `updraft-keepalive.yml` daily (keeps the free Space from sleeping,
40
+ rebuilds the ranking, fails the run — an email — on missing keys or stale data).
41
+ - **State** — all in Alpaca (positions, orders, portfolio history). A restart only rebuilds the ranking cache.
42
+
43
+ ## Settings (Space → Settings → Variables and secrets)
44
+ | Secret | Purpose |
45
+ |---|---|
46
+ | `NASDAQ_DATA_LINK_API_KEY` | Sharadar data for the ranking |
47
+ | `ALPACA_API_KEY_ID`, `ALPACA_API_SECRET_KEY` | the **paper** account this Space manages (use a dedicated account) |
48
+ | `REBALANCE_TOKEN` | a long random string; the same value goes into the GitHub secret `UPDRAFT_REBALANCE_TOKEN` |
49
+
50
+ Optional variables: `ALPACA_DATA_FEED` (default `iex`), `CASH_BUFFER` (0.01), `QT3_INCEPTION` (YYYY-MM-DD for the
51
+ public stats). **Live trading** is off unless both `ALPACA_PAPER=false` and `ALPACA_ALLOW_LIVE=yes` are set.
52
+
53
+ ## API
54
+ Public: `GET /` page · `/api/health` · `/api/status` · `/api/portfolio` · `/api/performance` · `/api/orders` ·
55
+ `/api/ranking` · `/api/plan` (dry-run of what a rebalance would do now). Private (token): `POST /api/rebalance?dry=0`
56
+ (`force=1` overrides only the window / once-a-month guards) · `POST /api/refresh` (rebuild the ranking now).
57
+
58
+ ## Notes
59
+ Educational paper-trading demonstration — not investment advice. The page shows derived rankings only, not raw
60
+ Sharadar data. Each stock is a third of the money: one stock halving costs ~17%. Taxes and slippage beyond 10 bps
61
+ are not modelled.
app.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Updraft (the Quality-Trend 3 strategy) — automated monthly strategy on an Alpaca paper account, with a public live page.
2
+
3
+ Public (read-only): GET /, /api/health, /api/status, /api/portfolio, /api/performance, /api/orders,
4
+ /api/ranking, /api/plan (dry-run of what a rebalance would do now).
5
+ Private: POST /api/rebalance?dry=0 and POST /api/refresh with header X-Rebalance-Token: <REBALANCE_TOKEN>.
6
+ A daily GitHub Action calls /api/rebalance; the Space trades only on the first trading days of a month (once),
7
+ while the market is open, on a ranking built from data at most 5 days old.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from contextlib import asynccontextmanager
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+
16
+ from fastapi import FastAPI, Header, HTTPException
17
+ from fastapi.responses import FileResponse
18
+ from starlette.concurrency import run_in_threadpool
19
+
20
+ from qt3 import config, performance, ranking, rebalance
21
+ from qt3.broker import Alpaca, BrokerError
22
+
23
+ @asynccontextmanager
24
+ async def lifespan(_app):
25
+ if config.NDL_KEY:
26
+ ranking.refresh_async() # build today's ranking in the background at start-up
27
+ yield
28
+
29
+
30
+ app = FastAPI(title="Updraft", lifespan=lifespan)
31
+ STATIC = Path(__file__).parent / "static"
32
+ _cache: dict = {}
33
+
34
+
35
+ def cached(key: str, ttl: float, fn):
36
+ hit = _cache.get(key)
37
+ if hit and time.time() - hit[0] < ttl:
38
+ return hit[1]
39
+ val = fn()
40
+ _cache[key] = (time.time(), val)
41
+ return val
42
+
43
+
44
+ def broker() -> Alpaca:
45
+ if not (config.ALPACA_KEY and config.ALPACA_SECRET):
46
+ raise HTTPException(503, "Alpaca keys are not configured on this Space")
47
+ return Alpaca()
48
+
49
+
50
+ def _authorised(token: str) -> None:
51
+ if not config.REBALANCE_TOKEN or token != config.REBALANCE_TOKEN:
52
+ raise HTTPException(403, "rebalance disabled or bad token (set REBALANCE_TOKEN, send X-Rebalance-Token)")
53
+
54
+
55
+ @app.get("/")
56
+ def index():
57
+ return FileResponse(STATIC / "index.html")
58
+
59
+
60
+ @app.get("/api/health")
61
+ def health():
62
+ return {"ok": True, **config.status(), "ranking_state": ranking.state()}
63
+
64
+
65
+ @app.get("/api/ranking")
66
+ def get_ranking():
67
+ if config.NDL_KEY:
68
+ ranking.refresh_async()
69
+ r = ranking.cached() or {}
70
+ return {**r, "rule": {"names": config.N, "keep_within": config.KEEP, "pool": f"top {round((1 - config.POOL) * 100)}%"},
71
+ "building": ranking.state()["building"], "age_hours": round(ranking.age_hours(r or None), 1)}
72
+
73
+
74
+ @app.get("/api/portfolio")
75
+ async def portfolio():
76
+ def load():
77
+ a = broker()
78
+ acct, pos = a.account(), a.positions()
79
+ rk = {r["ticker"]: r for r in (ranking.cached() or {}).get("ranked", [])}
80
+ rows = []
81
+ for s, p in sorted(pos.items(), key=lambda x: -x[1]["market_value"]):
82
+ r = rk.get(s, {})
83
+ rows.append({"symbol": s, "name": r.get("name"), "sector": r.get("sector"), "qty": p["qty"],
84
+ "price": p["price"], "value": p["market_value"],
85
+ "weight": p["market_value"] / acct["equity"] if acct["equity"] else None,
86
+ "pl": p["unrealized_pl"], "pl_pct": p["unrealized_plpc"], "today": p["change_today"],
87
+ "rank_now": r.get("rank")})
88
+ return {"paper": a.paper, "equity": acct["equity"], "cash": acct["cash"],
89
+ "today_change": acct["equity"] - acct["last_equity"] if acct["last_equity"] else None,
90
+ "today_pct": acct["equity"] / acct["last_equity"] - 1 if acct["last_equity"] else None,
91
+ "positions": rows, "as_of": datetime.now(timezone.utc).isoformat(timespec="seconds")}
92
+ try:
93
+ return await run_in_threadpool(cached, "portfolio", 30, load)
94
+ except BrokerError as e:
95
+ raise HTTPException(502, str(e))
96
+
97
+
98
+ @app.get("/api/performance")
99
+ async def perf():
100
+ try:
101
+ return await run_in_threadpool(cached, "performance", 600, lambda: performance.series(broker()))
102
+ except BrokerError as e:
103
+ raise HTTPException(502, str(e))
104
+
105
+
106
+ @app.get("/api/orders")
107
+ async def orders():
108
+ def load():
109
+ a = broker()
110
+ out = []
111
+ for o in a.orders(status="all"):
112
+ if not str(o.get("client_order_id", "")).startswith(config.TAG + "-"):
113
+ continue
114
+ out.append({"date": (o.get("filled_at") or o.get("submitted_at") or "")[:10], "symbol": o.get("symbol"),
115
+ "side": o.get("side"), "qty": o.get("filled_qty") or o.get("qty"),
116
+ "notional": o.get("notional"), "price": o.get("filled_avg_price"), "status": o.get("status")})
117
+ return out[:200]
118
+ try:
119
+ return await run_in_threadpool(cached, "orders", 120, load)
120
+ except BrokerError as e:
121
+ raise HTTPException(502, str(e))
122
+
123
+
124
+ @app.get("/api/status")
125
+ async def status():
126
+ def load():
127
+ a = broker()
128
+ chk = rebalance.check(a, ranking.cached())
129
+ clk = a.clock()
130
+ nxt = str(datetime.now(rebalance.NY).date()) if (chk["in_window"] or chk["first_run"]) and not chk["done_this_month"] \
131
+ else chk["next_rebalance"]
132
+ return {"market_open": bool(clk.get("is_open")), "next_open": clk.get("next_open"),
133
+ "next_rebalance": nxt, "done_this_month": chk["done_this_month"], "first_run": chk["first_run"],
134
+ "would_trade_now": chk["ok"], "blocked_by": chk["reasons"], **config.status()}
135
+ try:
136
+ return await run_in_threadpool(cached, "status", 60, load)
137
+ except BrokerError as e:
138
+ raise HTTPException(502, str(e))
139
+
140
+
141
+ @app.get("/api/plan")
142
+ async def dry_plan():
143
+ r = ranking.cached()
144
+ if not r:
145
+ raise HTTPException(503, "ranking not built yet")
146
+ try:
147
+ return await run_in_threadpool(cached, "plan", 120, lambda: rebalance.build_plan(broker(), r))
148
+ except (BrokerError, ValueError) as e:
149
+ raise HTTPException(502, str(e))
150
+
151
+
152
+ @app.post("/api/refresh")
153
+ async def force_refresh(x_rebalance_token: str = Header(default="")):
154
+ _authorised(x_rebalance_token)
155
+ r = await run_in_threadpool(ranking.refresh, True)
156
+ return {"prices_as_of": (r or {}).get("prices_as_of"), "error": ranking.state()["error"]}
157
+
158
+
159
+ @app.post("/api/rebalance")
160
+ async def run_rebalance(dry: int = 1, force: int = 0, x_rebalance_token: str = Header(default="")):
161
+ """Dry-run by default. A real run needs dry=0 and the token; it then refreshes the ranking if needed,
162
+ checks the guards (market open, rebalance window, once a month, fresh data) and executes."""
163
+ r = await run_in_threadpool(ranking.refresh)
164
+ if dry:
165
+ return {"dry_run": True, **(await dry_plan())}
166
+ _authorised(x_rebalance_token)
167
+ a = broker()
168
+ chk = await run_in_threadpool(rebalance.check, a, r, None, bool(force))
169
+ if not chk["ok"]:
170
+ return {"dry_run": False, "executed": False, "reasons": chk["reasons"]}
171
+ try:
172
+ p = await run_in_threadpool(rebalance.build_plan, a, r)
173
+ res = await run_in_threadpool(rebalance.execute, a, p)
174
+ except (BrokerError, ValueError) as e:
175
+ raise HTTPException(502, f"rebalance failed: {e}")
176
+ _cache.clear()
177
+ return {"dry_run": False, "executed": True, "plan": p, "results": res}
qt3/__init__.py ADDED
File without changes
qt3/broker.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Alpaca REST v2 (trading + market data) via requests. Keys come from config; never logged."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from datetime import date, datetime, timezone
6
+ from typing import Optional
7
+
8
+ import requests
9
+
10
+ from . import config
11
+
12
+ PAPER_URL, LIVE_URL, DATA_URL = "https://paper-api.alpaca.markets", "https://api.alpaca.markets", \
13
+ "https://data.alpaca.markets"
14
+
15
+
16
+ class BrokerError(RuntimeError):
17
+ pass
18
+
19
+
20
+ class Alpaca:
21
+ def __init__(self, key: str = "", secret: str = "", paper: Optional[bool] = None,
22
+ session: Optional[requests.Session] = None, feed: Optional[str] = None):
23
+ self.paper = config.ALPACA_PAPER if paper is None else paper
24
+ self.base = PAPER_URL if self.paper else LIVE_URL
25
+ self.feed = feed or config.DATA_FEED
26
+ self.s = session or requests.Session()
27
+ self.s.headers.update({"APCA-API-KEY-ID": key or config.ALPACA_KEY,
28
+ "APCA-API-SECRET-KEY": secret or config.ALPACA_SECRET})
29
+
30
+ def _req(self, method: str, path: str, base: Optional[str] = None, **kw):
31
+ r = self.s.request(method, (base or self.base) + path, timeout=30, **kw)
32
+ if r.status_code >= 400:
33
+ try:
34
+ msg = r.json().get("message", r.text)
35
+ except ValueError:
36
+ msg = r.text
37
+ raise BrokerError(f"{method} {path} → {r.status_code}: {str(msg)[:200]}")
38
+ return r.json() if r.content else {}
39
+
40
+ # ---------------------------------------------------------------- reads
41
+ def account(self) -> dict:
42
+ a = self._req("GET", "/v2/account")
43
+ f = lambda k: float(a.get(k) or 0)
44
+ return {"number": a.get("account_number", ""), "status": a.get("status", ""), "equity": f("equity"),
45
+ "last_equity": f("last_equity"), "cash": f("cash"), "buying_power": f("buying_power"),
46
+ "non_marginable_buying_power": f("non_marginable_buying_power"),
47
+ "blocked": bool(a.get("trading_blocked") or a.get("account_blocked"))}
48
+
49
+ def positions(self) -> dict[str, dict]:
50
+ out = {}
51
+ for p in self._req("GET", "/v2/positions"):
52
+ out[p["symbol"]] = {"qty": float(p.get("qty") or 0), "price": float(p.get("current_price") or 0),
53
+ "market_value": float(p.get("market_value") or 0),
54
+ "cost_basis": float(p.get("cost_basis") or 0),
55
+ "unrealized_pl": float(p.get("unrealized_pl") or 0),
56
+ "unrealized_plpc": float(p.get("unrealized_plpc") or 0),
57
+ "change_today": float(p.get("change_today") or 0)}
58
+ return out
59
+
60
+ def clock(self) -> dict:
61
+ return self._req("GET", "/v2/clock")
62
+
63
+ def trading_days(self, start: date, end: date) -> list[str]:
64
+ return [d["date"] for d in self._req("GET", "/v2/calendar", params={"start": str(start), "end": str(end)})]
65
+
66
+ def orders(self, after: Optional[datetime] = None, status: str = "all", limit: int = 500) -> list[dict]:
67
+ p = {"status": status, "limit": limit, "direction": "desc"}
68
+ if after:
69
+ p["after"] = after.astimezone(timezone.utc).isoformat()
70
+ return self._req("GET", "/v2/orders", params=p)
71
+
72
+ def order(self, oid: str) -> dict:
73
+ return self._req("GET", f"/v2/orders/{oid}")
74
+
75
+ def assets(self, symbols: list[str]) -> dict[str, dict]:
76
+ out = {}
77
+ for s in sorted(set(symbols)):
78
+ a = self._req("GET", f"/v2/assets/{s}")
79
+ out[s] = {"tradable": bool(a.get("tradable")), "fractionable": bool(a.get("fractionable"))}
80
+ return out
81
+
82
+ def latest_prices(self, symbols: list[str]) -> dict[str, float]:
83
+ if not symbols:
84
+ return {}
85
+ j = self._req("GET", "/v2/stocks/trades/latest", base=DATA_URL,
86
+ params={"symbols": ",".join(sorted(set(symbols))), "feed": self.feed})
87
+ return {s: float(t["p"]) for s, t in (j.get("trades") or {}).items() if t.get("p")}
88
+
89
+ def daily_bars(self, symbol: str, start: date) -> list[dict]:
90
+ out, token = [], None
91
+ while True:
92
+ p = {"timeframe": "1Day", "start": str(start), "limit": 10000, "adjustment": "all", "feed": self.feed}
93
+ if token:
94
+ p["page_token"] = token
95
+ j = self._req("GET", f"/v2/stocks/{symbol}/bars", base=DATA_URL, params=p)
96
+ out += [{"t": b["t"][:10], "c": float(b["c"])} for b in (j.get("bars") or [])]
97
+ token = j.get("next_page_token")
98
+ if not token:
99
+ return out
100
+
101
+ def portfolio_history(self, period: str = "all", timeframe: str = "1D") -> dict:
102
+ return self._req("GET", "/v2/account/portfolio/history",
103
+ params={"period": period, "timeframe": timeframe, "extended_hours": "false"})
104
+
105
+ def cash_flows(self) -> list[dict]:
106
+ """Deposits / withdrawals (for time-weighted returns)."""
107
+ try:
108
+ acts = self._req("GET", "/v2/account/activities", params={"activity_types": "CSD,CSW", "page_size": 100})
109
+ except BrokerError:
110
+ return []
111
+ return [{"date": a.get("date", "")[:10], "amount": float(a.get("net_amount") or 0)} for a in acts]
112
+
113
+ # ---------------------------------------------------------------- writes
114
+ def submit(self, symbol: str, side: str, qty: Optional[float] = None, notional: Optional[float] = None,
115
+ client_order_id: Optional[str] = None) -> dict:
116
+ body = {"symbol": symbol, "side": side, "type": "market", "time_in_force": "day"}
117
+ if notional is not None:
118
+ body["notional"] = f"{notional:.2f}"
119
+ else:
120
+ body["qty"] = f"{qty:.9f}".rstrip("0").rstrip(".")
121
+ if client_order_id:
122
+ body["client_order_id"] = client_order_id
123
+ return self._req("POST", "/v2/orders", json=body)
124
+
125
+ def wait(self, ids: list[str], seconds: float = 60.0, poll: float = 2.0) -> dict[str, dict]:
126
+ end, last = time.time() + seconds, {}
127
+ while ids and time.time() < end:
128
+ last = {i: self.order(i) for i in ids}
129
+ if all(o.get("status") in ("filled", "canceled", "rejected", "expired") for o in last.values()):
130
+ break
131
+ time.sleep(poll)
132
+ return last
qt3/config.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Settings for the Updraft Space (Quality-Trend 3 strategy). Secrets come from the Space's "Variables and secrets" settings
2
+ (never from the repo): NASDAQ_DATA_LINK_API_KEY, ALPACA_API_KEY_ID, ALPACA_API_SECRET_KEY, REBALANCE_TOKEN."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ NDL_KEY = os.getenv("NASDAQ_DATA_LINK_API_KEY", "")
8
+ ALPACA_KEY = os.getenv("ALPACA_API_KEY_ID", "")
9
+ ALPACA_SECRET = os.getenv("ALPACA_API_SECRET_KEY", "")
10
+ # Paper unless BOTH ALPACA_PAPER=false and ALPACA_ALLOW_LIVE=yes are set.
11
+ ALPACA_PAPER = os.getenv("ALPACA_PAPER", "true").strip().lower() != "false" or \
12
+ os.getenv("ALPACA_ALLOW_LIVE", "").strip().lower() != "yes"
13
+ DATA_FEED = os.getenv("ALPACA_DATA_FEED", "iex")
14
+ REBALANCE_TOKEN = os.getenv("REBALANCE_TOKEN", "")
15
+
16
+ # The backtested rule (Quant Model scripts/finalize_qt3_managed.py)
17
+ N, KEEP = 3, 6 # hold 3; keep a holding while it ranks in the top 6
18
+ TOPN, MIN_PRICE, MIN_DV = 1000, 5.0, 5e6
19
+ POOL = 0.70 # quality pool = top 30% of the capital-discipline composite
20
+ QCD = ["basic_diluted_gap", "quality_score", "equity_financing_neg", "fcf_yield", "roe", "buyback_yield",
21
+ "net_share_reduction_yoy"]
22
+
23
+ # Execution
24
+ CASH_BUFFER = float(os.getenv("CASH_BUFFER", "0.01")) # keep 1% cash for fills / drift
25
+ MIN_TRADE = float(os.getenv("MIN_TRADE", "25")) # skip adjustments smaller than this ($) …
26
+ BAND = float(os.getenv("REBALANCE_BAND", "0.02")) # … or smaller than 2% of a position's target
27
+ TAG = "qt3" # client_order_id prefix of this strategy's orders
28
+
29
+ CACHE_DIR = os.getenv("QT3_CACHE", "/tmp/qt3")
30
+ RANKING_MAX_AGE_H = float(os.getenv("RANKING_MAX_AGE_H", "20"))
31
+ INCEPTION = os.getenv("QT3_INCEPTION", "") # YYYY-MM-DD for public stats; default = first order
32
+
33
+
34
+ def status() -> dict:
35
+ return {"data_key": bool(NDL_KEY), "alpaca_keys": bool(ALPACA_KEY and ALPACA_SECRET),
36
+ "paper": ALPACA_PAPER, "rebalance_enabled": bool(REBALANCE_TOKEN)}
qt3/fundamental.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The quality / capital-discipline inputs, ported unchanged from the research code
2
+ (Quant Model qmodel/features/fundamental.py, only the fields the rule uses).
3
+
4
+ Input: one ticker's SF1 ARQ rows, sorted by the date each quarter became public (`reported_date`).
5
+ Flows are summed over the trailing four quarters; balance-sheet items are as of the quarter."""
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ _FLOWS = ["revenue", "cor", "gp", "opinc", "ebitda", "netinc", "rnd", "sbcomp", "ncfo", "fcf", "ncfcommon",
12
+ "ncfdiv", "depamor", "capex", "sgna"]
13
+ SF1_COLS = ["ticker", "dimension", "calendardate", "datekey", "reportperiod", "marketcap", "equity", "assets",
14
+ "netinc", "fcf", "ncfcommon", "ncfdiv", "shareswa", "shareswadil", "gp", "revenue", "cor", "opinc",
15
+ "ebitda", "rnd", "sbcomp", "ncfo", "depamor", "capex", "sgna", "ev", "epsdil", "debt", "receivables",
16
+ "inventory", "payables", "cashneq", "roa"]
17
+
18
+
19
+ def _safe(numer, denom):
20
+ d = denom.replace(0, np.nan) if hasattr(denom, "replace") else denom
21
+ return numer / d
22
+
23
+
24
+ def compute_fundamental(sf1: pd.DataFrame) -> pd.DataFrame:
25
+ if sf1.empty:
26
+ return pd.DataFrame()
27
+ df = sf1.sort_values("reported_date").set_index("reported_date").copy()
28
+ for c in df.columns:
29
+ if c not in {"ticker", "dimension", "fiscal_date_ending", "calendardate"}:
30
+ df[c] = pd.to_numeric(df[c], errors="coerce")
31
+ for c in _FLOWS:
32
+ if c not in df:
33
+ df[c] = np.nan
34
+ ttm = {c: df[c].rolling(4, min_periods=4).sum() for c in _FLOWS}
35
+ mc = df["marketcap"].replace(0, np.nan)
36
+ out = pd.DataFrame(index=df.index)
37
+ out["earnings_yield"] = _safe(ttm["netinc"], mc)
38
+ out["fcf_yield"] = _safe(ttm["fcf"], mc)
39
+ out["gross_margin"] = _safe(ttm["gp"], ttm["revenue"])
40
+ out["roe"] = _safe(ttm["netinc"], df["equity"])
41
+ out["roa"] = _safe(ttm["netinc"], df["assets"])
42
+ out["buyback_yield"] = _safe(-ttm["ncfcommon"], mc)
43
+ # research code ran pandas 2.x pct_change (forward-fill first); made explicit so pandas 3 gives the same
44
+ out["net_share_reduction_yoy"] = -df["shareswadil"].ffill().pct_change(4, fill_method=None)
45
+ out["basic_diluted_gap"] = _safe(df["shareswadil"] - df["shareswa"], df["shareswa"])
46
+ out["rd_intensity"] = _safe(ttm["rnd"], ttm["revenue"])
47
+ out["fcf_conversion"] = _safe(ttm["fcf"], ttm["netinc"])
48
+ out["equity_financing"] = _safe(ttm["ncfcommon"], df["assets"])
49
+ return out
50
+
51
+
52
+ def latest_inputs(sf1: pd.DataFrame) -> pd.DataFrame:
53
+ """Latest quality inputs per ticker from SF1 ARQ rows (as known today: the latest version of each
54
+ quarter, in calendar order) — the live snapshot's `fundamentals()` logic."""
55
+ if sf1.empty:
56
+ return pd.DataFrame(columns=["ticker"])
57
+ s = sf1.copy()
58
+ for c in SF1_COLS:
59
+ if c in s and c not in ("ticker", "dimension", "calendardate", "datekey", "reportperiod"):
60
+ s[c] = pd.to_numeric(s[c], errors="coerce")
61
+ s["datekey"], s["calendardate"] = pd.to_datetime(s["datekey"]), pd.to_datetime(s["calendardate"])
62
+ rows = []
63
+ for t, g in s[s["dimension"] == "ARQ"].groupby("ticker"):
64
+ g = g.sort_values("datekey").drop_duplicates("calendardate", keep="last").sort_values("calendardate")
65
+ g = g.assign(reported_date=g["datekey"].cummax(), fiscal_date_ending=g["reportperiod"])
66
+ f = compute_fundamental(g.drop(columns=["datekey", "reportperiod"]))
67
+ if f.empty:
68
+ continue
69
+ last = f.iloc[-1]
70
+ rows.append({"ticker": t, "filed": str(f.index[-1].date()), **{c: last.get(c) for c in (
71
+ "roe", "roa", "gross_margin", "fcf_conversion", "fcf_yield", "buyback_yield", "net_share_reduction_yoy",
72
+ "basic_diluted_gap", "equity_financing", "earnings_yield", "rd_intensity")}})
73
+ return pd.DataFrame(rows) if rows else pd.DataFrame(columns=["ticker"])
qt3/performance.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Live performance from the Alpaca account: time-weighted return (deposits / withdrawals removed), drawdown,
2
+ and the S&P 500 (SPY) over the same days."""
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, datetime, timedelta, timezone
6
+ from typing import Optional
7
+ from zoneinfo import ZoneInfo
8
+
9
+ from . import config
10
+ from .broker import Alpaca
11
+
12
+
13
+ def twr(dates: list[str], equity: list[float], flows: dict[str, float]) -> list[float]:
14
+ """Growth of $1 (time-weighted): each day's return excludes that day's net deposit."""
15
+ out, g = [], 1.0
16
+ for i, (d, e) in enumerate(zip(dates, equity)):
17
+ if i > 0 and equity[i - 1] > 0:
18
+ g *= (e - flows.get(d, 0.0)) / equity[i - 1]
19
+ out.append(g)
20
+ return out
21
+
22
+
23
+ def max_drawdown(g: list[float]) -> float:
24
+ peak, worst = 0.0, 0.0
25
+ for x in g:
26
+ peak = max(peak, x)
27
+ worst = min(worst, x / peak - 1 if peak else 0.0)
28
+ return worst
29
+
30
+
31
+ def series(a: Alpaca, inception: Optional[str] = None) -> dict:
32
+ h = a.portfolio_history()
33
+ ny = ZoneInfo("America/New_York")
34
+ pts = [(datetime.fromtimestamp(t, ny).date().isoformat(), float(e))
35
+ for t, e in zip(h.get("timestamp") or [], h.get("equity") or []) if e is not None]
36
+ start = inception or config.INCEPTION
37
+ if not start:
38
+ first = [o for o in a.orders(status="all") if str(o.get("client_order_id", "")).startswith(config.TAG + "-")]
39
+ start = min((o["created_at"][:10] for o in first), default=None)
40
+ pts = [p for p in pts if p[1] > 0]
41
+ if start: # base = the last close before the first trade (all cash)
42
+ before = [p for p in pts if p[0] < start]
43
+ pts = before[-1:] + [p for p in pts if p[0] >= start]
44
+ today = datetime.now(ZoneInfo("America/New_York")).date().isoformat()
45
+ live_eq = a.account()["equity"]
46
+ if live_eq > 0 and (not pts or pts[-1][0] < today):
47
+ pts.append((today, live_eq)) # Alpaca's daily history ends at the previous close
48
+ if len(pts) < 1:
49
+ return {"start": start, "dates": [], "strategy": [], "spy": [], "stats": {}}
50
+ dates, eq = [p[0] for p in pts], [p[1] for p in pts]
51
+ flows = {}
52
+ for f in a.cash_flows():
53
+ flows[f["date"]] = flows.get(f["date"], 0.0) + f["amount"]
54
+ g = twr(dates, eq, flows)
55
+ bars = {b["t"]: b["c"] for b in a.daily_bars("SPY", date.fromisoformat(dates[0]) - timedelta(days=7))}
56
+ spy_now = a.latest_prices(["SPY"]).get("SPY")
57
+ if spy_now and dates[-1] == today:
58
+ bars[today] = spy_now
59
+ closes = sorted(bars.items()) # latest S&P close on or before each date
60
+ spy, j, last = [], 0, None
61
+ for d in dates:
62
+ while j < len(closes) and closes[j][0] <= d:
63
+ last = closes[j][1]
64
+ j += 1
65
+ spy.append(last)
66
+ base = next((x for x in spy if x), None)
67
+ spy = [round(x / base, 6) if (x and base) else None for x in spy]
68
+ days = (date.fromisoformat(dates[-1]) - date.fromisoformat(dates[0])).days
69
+ tot = g[-1] - 1
70
+ spy_tot = (spy[-1] - 1) if spy and spy[-1] else None
71
+ ann = (g[-1] ** (365.25 / days) - 1) if days >= 365 else None
72
+ return {"start": start or dates[0], "base": dates[0], "dates": dates, "strategy": [round(x, 6) for x in g], "spy": spy,
73
+ "stats": {"return": tot, "spy_return": spy_tot, "annualised": ann, "max_drawdown": max_drawdown(g),
74
+ "days": days, "equity": eq[-1]}}
qt3/ranking.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Today's Quality-Trend ranking, built from the Sharadar API (no local data needed).
2
+
3
+ Same steps as the live RS-VCP snapshot (Quant Model scripts/build_screens_snapshot.py) and the backtest:
4
+ 1. universe top 1,000 US-listed companies by market cap (Sharadar DAILY, latest date)
5
+ 2. trend ~300 trading days of SEP prices → MA50/MA200 − 1 and the 6-1-month return; 20-day $ volume
6
+ 3. quality SF1 quarterly statements (as filed) → basic-diluted gap, quality score (ROE, ROA, gross
7
+ margin, FCF conversion), −equity financing, FCF yield, ROE, buyback yield, net share reduction
8
+ 4. slice price ≥ $5, ≥ $5M/day, US-dollar reporters; quality pool = top 30% of the composite
9
+ 5. rank pool ranked by 0.5·pct(MA50/MA200) + 0.5·pct(6-1-month return)
10
+ About 40 API requests; cached for the day.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import threading
17
+ import time
18
+ from datetime import datetime, timedelta, timezone
19
+ from typing import Callable, Optional
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+
24
+ from . import config
25
+ from .fundamental import SF1_COLS, latest_inputs
26
+ from .sharadar import query, query_tickers
27
+
28
+ _lock = threading.Lock()
29
+ _state = {"building": False, "error": None, "started": None}
30
+
31
+
32
+ def trend(p: pd.DataFrame) -> pd.DataFrame:
33
+ out = []
34
+ for t, g in p.sort_values(["ticker", "date"]).groupby("ticker", sort=False):
35
+ c = g["closeadj"].to_numpy(float)
36
+ if len(c) < 200 or not np.isfinite(c[-1]):
37
+ continue
38
+ dv = (g["close"].astype(float) * g["volume"].astype(float)).tail(20).mean()
39
+ out.append({"ticker": t, "last_bar": str(pd.Timestamp(g["date"].iloc[-1]).date()),
40
+ "price": float(g["close"].iloc[-1]), "dv20": float(dv),
41
+ "ma50_200": c[-50:].mean() / c[-200:].mean() - 1,
42
+ "mom_6_1": c[-21] / c[-126] - 1 if len(c) >= 126 else np.nan,
43
+ "ret_1m": c[-1] / c[-22] - 1, "ret_6m": c[-1] / c[-127] - 1 if len(c) >= 127 else np.nan})
44
+ return pd.DataFrame(out)
45
+
46
+
47
+ def rank_table(u: pd.DataFrame) -> pd.DataFrame:
48
+ """Steps 4-5 on a merged universe table (pure; unit-tested)."""
49
+ pct = lambda d, c: d[c].rank(pct=True)
50
+ u = u.copy()
51
+ for c in ("roe", "roa", "gross_margin", "fcf_conversion", "fcf_yield", "buyback_yield", "net_share_reduction_yoy",
52
+ "basic_diluted_gap", "equity_financing", "currency"):
53
+ if c not in u:
54
+ u[c] = np.nan # missing statements → the name simply can't enter the pool
55
+ u["equity_financing_neg"] = -u["equity_financing"]
56
+ liq = (u["price"] >= config.MIN_PRICE) & (u["dv20"] >= config.MIN_DV)
57
+ q = u[liq & (u["currency"].fillna("USD") == "USD")].copy()
58
+ q["quality_score"] = pd.concat([pct(q, c) for c in ("roe", "roa", "gross_margin", "fcf_conversion")],
59
+ axis=1).mean(axis=1)
60
+ P = pd.concat([pct(q, c) for c in config.QCD], axis=1)
61
+ q["qcd"] = P.mean(axis=1).where(P.notna().mean(axis=1) >= 0.5)
62
+ q["qcd_pct"] = q["qcd"].rank(pct=True)
63
+ q["trend"] = 0.5 * pct(q, "ma50_200") + 0.5 * pct(q, "mom_6_1")
64
+ pool = q[(q["qcd_pct"] >= config.POOL) & q["trend"].notna()]
65
+ pool = pool.sort_values("trend", ascending=False, kind="stable").reset_index(drop=True)
66
+ pool["rank"] = np.arange(1, len(pool) + 1)
67
+ pool.attrs.update(universe=int(len(u)), liquid_usd=int(len(q)))
68
+ return pool
69
+
70
+
71
+ def build(key: str, today: Optional[datetime] = None, fetch: Callable = query,
72
+ fetch_tickers: Callable = query_tickers) -> dict:
73
+ day = (today or datetime.now(timezone.utc)).date()
74
+ d = fetch("DAILY", key, date__gte=str(day - timedelta(days=14)), columns=["ticker", "date", "marketcap"])
75
+ d["date"] = pd.to_datetime(d["date"])
76
+ d = d[d["date"] == d["date"].max()].dropna(subset=["marketcap"])
77
+ uni = d.assign(market_cap=pd.to_numeric(d["marketcap"]) * 1e6).nlargest(config.TOPN, "market_cap")
78
+ tickers = uni["ticker"].tolist()
79
+ tk = fetch_tickers("TICKERS", key, tickers, columns=["table", "ticker", "name", "sector", "industry", "currency"])
80
+ meta = tk[tk["table"] == "SEP"].drop_duplicates("ticker").set_index("ticker")[["name", "sector", "industry"]]
81
+ cur = tk[tk["table"] == "SF1"].drop_duplicates("ticker").set_index("ticker")["currency"]
82
+ px = fetch_tickers("SEP", key, tickers, date__gte=str(day - timedelta(days=430)),
83
+ columns=["ticker", "date", "close", "volume", "closeadj"])
84
+ px["date"] = pd.to_datetime(px["date"])
85
+ for c in ("close", "volume", "closeadj"):
86
+ px[c] = pd.to_numeric(px[c], errors="coerce")
87
+ tr = trend(px)
88
+ sf1 = fetch_tickers("SF1", key, tickers, dimension="ARQ", datekey__gte=str(day - timedelta(days=4 * 365)),
89
+ columns=SF1_COLS)
90
+ f = latest_inputs(sf1)
91
+ u = uni[["ticker", "market_cap"]].merge(tr, on="ticker", how="inner").merge(f, on="ticker", how="left")
92
+ u = u.join(meta, on="ticker").assign(currency=u["ticker"].map(cur))
93
+ pool = rank_table(u)
94
+ keep = ["rank", "ticker", "name", "sector", "industry", "price", "market_cap", "trend", "qcd_pct", "ma50_200",
95
+ "mom_6_1", "ret_1m", "ret_6m", "roe", "fcf_yield", "buyback_yield", "filed"]
96
+ rows = pool[[c for c in keep if c in pool]].head(40)
97
+ return {"built": datetime.now(timezone.utc).isoformat(timespec="seconds"),
98
+ "prices_as_of": str(tr["last_bar"].max()) if not tr.empty else None,
99
+ "universe": pool.attrs["universe"], "liquid_usd": pool.attrs["liquid_usd"], "pool": int(len(pool)),
100
+ "ranked": json.loads(rows.to_json(orient="records"))}
101
+
102
+
103
+ # ------------------------------------------------------------------ daily cache
104
+ def _path() -> str:
105
+ os.makedirs(config.CACHE_DIR, exist_ok=True)
106
+ return os.path.join(config.CACHE_DIR, "ranking.json")
107
+
108
+
109
+ def cached() -> Optional[dict]:
110
+ try:
111
+ with open(_path()) as fh:
112
+ return json.load(fh)
113
+ except (OSError, ValueError):
114
+ return None
115
+
116
+
117
+ def age_hours(r: Optional[dict]) -> float:
118
+ if not r or not r.get("built"):
119
+ return float("inf")
120
+ return (datetime.now(timezone.utc) - datetime.fromisoformat(r["built"])).total_seconds() / 3600
121
+
122
+
123
+ def refresh(force: bool = False) -> Optional[dict]:
124
+ """Rebuild if older than RANKING_MAX_AGE_H (blocking; one build at a time)."""
125
+ r = cached()
126
+ if not force and age_hours(r) < config.RANKING_MAX_AGE_H:
127
+ return r
128
+ if not _lock.acquire(blocking=False):
129
+ return r
130
+ try:
131
+ _state.update(building=True, error=None, started=time.time())
132
+ r = build(config.NDL_KEY)
133
+ tmp = _path() + ".part"
134
+ with open(tmp, "w") as fh:
135
+ json.dump(r, fh)
136
+ os.replace(tmp, _path())
137
+ return r
138
+ except Exception as e: # noqa: BLE001 — keep serving the last good ranking
139
+ _state["error"] = f"{type(e).__name__}: {e}"[:300]
140
+ return cached()
141
+ finally:
142
+ _state["building"] = False
143
+ _lock.release()
144
+
145
+
146
+ def refresh_async() -> None:
147
+ if age_hours(cached()) >= config.RANKING_MAX_AGE_H and not _state["building"]:
148
+ threading.Thread(target=refresh, daemon=True).start()
149
+
150
+
151
+ def state() -> dict:
152
+ return dict(_state)
qt3/rebalance.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Monthly rebalance: target list (keep while in the top 6, refill to 3), order plan, guards and execution.
2
+
3
+ The backtest checked once a month and re-split the money equally; here that runs on the first trading days of
4
+ each month (an external daily cron calls it; everything else is a no-op). State lives in Alpaca: the orders this
5
+ strategy placed carry a client_order_id starting with "qt3-".
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ from dataclasses import asdict, dataclass
11
+ from datetime import date, datetime, timedelta, timezone
12
+ from typing import Optional
13
+ from zoneinfo import ZoneInfo
14
+
15
+ from . import config
16
+ from .broker import Alpaca, BrokerError
17
+
18
+ NY = ZoneInfo("America/New_York")
19
+ WINDOW_DAYS = 5 # a month's rebalance may run on its first 5 trading days (retries after outages)
20
+
21
+
22
+ @dataclass
23
+ class Order:
24
+ symbol: str
25
+ side: str
26
+ shares: float
27
+ price: float
28
+ value: float
29
+ reason: str
30
+ close_all: bool = False
31
+
32
+
33
+ def target_list(ranked: list[str], held: list[str], n: int = config.N, keep: int = config.KEEP):
34
+ """Keep holdings ranked within `keep`, refill from the top to `n`. Returns (final, sell reasons)."""
35
+ rank = {t: i + 1 for i, t in enumerate(ranked)}
36
+ kept = sorted([t for t in held if rank.get(t, 10 ** 9) <= keep], key=lambda t: rank[t])[:n]
37
+ why = {}
38
+ for t in held:
39
+ if t in kept:
40
+ continue
41
+ r = rank.get(t)
42
+ why[t] = (f"now ranked #{r} — outside the top {keep}" if r and r > keep else
43
+ f"ranked #{r} — below the {n} names kept" if r else "left the quality pool / universe")
44
+ final = kept + [t for t in ranked if t not in kept][: n - len(kept)]
45
+ return sorted(final, key=lambda t: rank[t]), why
46
+
47
+
48
+ def _floor(x: float, fractional: bool) -> float:
49
+ return math.floor(x * 1e6) / 1e6 if fractional else float(math.floor(x + 1e-9))
50
+
51
+
52
+ def plan(final: list[str], positions: dict[str, float], prices: dict[str, float], cash: float,
53
+ reasons: Optional[dict] = None, fractional: bool = True, buffer: float = config.CASH_BUFFER,
54
+ min_trade: float = config.MIN_TRADE, band: float = config.BAND) -> dict:
55
+ """Orders that turn `positions` (symbol → shares) + `cash` into equal thirds of `final`, keeping `buffer`
56
+ of the total in cash. Exits and trims first; adjustments under max(min_trade, band × target) skipped."""
57
+ reasons = reasons or {}
58
+ held = {s: float(q) for s, q in positions.items() if float(q) > 0}
59
+ missing = sorted(s for s in set(held) | set(final) if not prices.get(s))
60
+ if missing:
61
+ raise ValueError(f"no price for {', '.join(missing)}")
62
+ total = sum(q * prices[s] for s, q in held.items()) + cash
63
+ target = total * (1 - buffer) / len(final) if final else 0.0
64
+ skip = max(min_trade, band * target)
65
+ sells, buys, after = [], [], {}
66
+ for s, q in held.items():
67
+ if s not in final:
68
+ sells.append(Order(s, "sell", q, prices[s], q * prices[s], reasons.get(s, "not in the plan"), True))
69
+ else:
70
+ after[s] = q
71
+ for s in final:
72
+ over = after.get(s, 0.0) * prices[s] - target
73
+ if over >= skip:
74
+ q = min(_floor(over / prices[s], fractional), after[s])
75
+ if q > 0:
76
+ sells.append(Order(s, "sell", q, prices[s], q * prices[s], "trim back to a third"))
77
+ after[s] -= q
78
+ spendable = cash + sum(o.value for o in sells) - total * buffer
79
+ wants = sorted(((s, target - after.get(s, 0.0) * prices[s]) for s in final), key=lambda x: -x[1])
80
+ for s, need in wants:
81
+ if need < skip and s in after:
82
+ continue
83
+ amt = min(need, max(spendable, 0.0))
84
+ q = _floor(amt / prices[s], fractional)
85
+ if q <= 0 or amt < 1:
86
+ continue
87
+ buys.append(Order(s, "buy", q, prices[s], q * prices[s], "new — top 3" if s not in held else "top up to a third"))
88
+ spendable -= q * prices[s]
89
+ after[s] = after.get(s, 0.0) + q
90
+ return {"final": final, "orders": [asdict(o) for o in sells + buys], "total": round(total, 2),
91
+ "target_each": round(target, 2), "cash_after": round(spendable + total * buffer, 2),
92
+ "skipped_below": round(skip, 2)}
93
+
94
+
95
+ # ------------------------------------------------------------------ guards
96
+ def _ts(s: str) -> datetime:
97
+ """Alpaca timestamps carry nanoseconds; seconds are enough here."""
98
+ return datetime.fromisoformat(s[:19]).replace(tzinfo=timezone.utc)
99
+
100
+
101
+ def strategy_orders(a: Alpaca, since: datetime) -> list[dict]:
102
+ return [o for o in a.orders(after=since) if str(o.get("client_order_id", "")).startswith(config.TAG + "-")]
103
+
104
+
105
+ def window(a: Alpaca, today: date) -> dict:
106
+ """Trading days of this month so far and whether today is within the rebalance window."""
107
+ days = a.trading_days(today.replace(day=1), today + timedelta(days=40))
108
+ month = [d for d in days if d.startswith(today.strftime("%Y-%m"))]
109
+ nxt = next((d for d in days if d > today.strftime("%Y-%m-31")), None)
110
+ pos = month.index(str(today)) + 1 if str(today) in month else None
111
+ return {"trading_day_of_month": pos, "in_window": pos is not None and pos <= WINDOW_DAYS,
112
+ "next_rebalance": month[0] if month and month[0] > str(today) else nxt}
113
+
114
+
115
+ def check(a: Alpaca, ranking: Optional[dict], now: Optional[datetime] = None, force: bool = False) -> dict:
116
+ now = (now or datetime.now(NY)).astimezone(NY)
117
+ today = now.date()
118
+ reasons = []
119
+ clk = a.clock()
120
+ if not clk.get("is_open"):
121
+ reasons.append(f"market closed (next open {clk.get('next_open', '?')})")
122
+ w = window(a, today)
123
+ month_start = datetime(today.year, today.month, 1, tzinfo=NY)
124
+ ever = strategy_orders(a, now - timedelta(days=400))
125
+ this_month = [o for o in ever if _ts(o["created_at"]) >= month_start]
126
+ done = any(o.get("side") == "buy" and o.get("status") not in ("canceled", "rejected", "expired") for o in this_month)
127
+ first_run = not ever
128
+ if not force:
129
+ if done:
130
+ reasons.append("already rebalanced this month")
131
+ if not first_run and not w["in_window"]:
132
+ reasons.append(f"outside the rebalance window (first {WINDOW_DAYS} trading days of the month)")
133
+ if not ranking or not ranking.get("ranked"):
134
+ reasons.append("no ranking available")
135
+ else:
136
+ age = (today - date.fromisoformat(ranking["prices_as_of"])).days
137
+ if age > 5:
138
+ reasons.append(f"ranking data is {age} days old")
139
+ return {"ok": not reasons, "reasons": reasons, "first_run": first_run, "done_this_month": done, **w}
140
+
141
+
142
+ # ------------------------------------------------------------------ execution
143
+ def build_plan(a: Alpaca, ranking: dict) -> dict:
144
+ pos = a.positions()
145
+ held = sorted(pos)
146
+ ranked = [r["ticker"] for r in ranking["ranked"]]
147
+ final, why = target_list(ranked, held)
148
+ px = a.latest_prices(sorted(set(final) | set(held)))
149
+ for s, p in pos.items():
150
+ px.setdefault(s, p["price"])
151
+ acct = a.account()
152
+ cash = max(0.0, min(acct["cash"], acct["non_marginable_buying_power"] or acct["cash"]))
153
+ assets = a.assets(final)
154
+ frac = all(assets[s]["fractionable"] for s in final)
155
+ p = plan(final, {s: v["qty"] for s, v in pos.items()}, px, cash, why, fractional=frac)
156
+ p.update(fractional=frac, untradable=[s for s in final if not assets[s]["tradable"]], equity=acct["equity"])
157
+ return p
158
+
159
+
160
+ def execute(a: Alpaca, p: dict, today: Optional[date] = None, wait_s: float = 90.0) -> list[dict]:
161
+ if p.get("untradable"):
162
+ raise BrokerError("not tradable on Alpaca: " + ", ".join(p["untradable"]))
163
+ day = (today or datetime.now(NY).date()).strftime("%Y%m%d")
164
+ res, sell_ids = [], []
165
+ for o in [o for o in p["orders"] if o["side"] == "sell"]:
166
+ r = a.submit(o["symbol"], "sell", qty=o["shares"], client_order_id=f"{config.TAG}-{day}-{o['symbol']}-sell")
167
+ sell_ids.append(r.get("id"))
168
+ res.append({"symbol": o["symbol"], "side": "sell", "id": r.get("id"), "status": r.get("status")})
169
+ if sell_ids:
170
+ done = a.wait([i for i in sell_ids if i], wait_s)
171
+ for x in res:
172
+ x["status"] = (done.get(x["id"]) or {}).get("status", x["status"])
173
+ if any(x["status"] != "filled" for x in res):
174
+ return res + [{"symbol": "-", "side": "buy", "status": "deferred — sells not filled; retried next run"}]
175
+ buys = [o for o in p["orders"] if o["side"] == "buy"]
176
+ acct = a.account()
177
+ cash = max(0.0, min(acct["cash"], acct["non_marginable_buying_power"] or acct["cash"]) - acct["equity"] * config.CASH_BUFFER)
178
+ want = sum(o["value"] for o in buys)
179
+ scale = min(1.0, cash / want) if want > 0 else 1.0
180
+ for o in buys:
181
+ cid = f"{config.TAG}-{day}-{o['symbol']}-buy"
182
+ if p.get("fractional"):
183
+ amt = round(o["value"] * scale, 2)
184
+ if amt < 1:
185
+ continue
186
+ r = a.submit(o["symbol"], "buy", notional=amt, client_order_id=cid)
187
+ else:
188
+ q = float(math.floor(o["shares"] * scale + 1e-9))
189
+ if q < 1:
190
+ continue
191
+ r = a.submit(o["symbol"], "buy", qty=q, client_order_id=cid)
192
+ res.append({"symbol": o["symbol"], "side": "buy", "id": r.get("id"), "status": r.get("status"),
193
+ "scaled": round(scale, 4)})
194
+ return res
qt3/sharadar.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal Sharadar (Nasdaq Data Link tables API) client: cursor pagination, ticker chunking, retries.
2
+ The API key is sent as a query parameter and never logged or returned in errors."""
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Optional
7
+
8
+ import pandas as pd
9
+ import requests
10
+
11
+ BASE = "https://data.nasdaq.com/api/v3/datatables/SHARADAR/"
12
+
13
+
14
+ class SharadarError(RuntimeError):
15
+ pass
16
+
17
+
18
+ def query(table: str, key: str, session: Optional[requests.Session] = None, retries: int = 5,
19
+ **filters) -> pd.DataFrame:
20
+ """All rows of SHARADAR/<table> matching `filters` (e.g. ticker="A,B", date__gte="2026-01-01",
21
+ columns=["ticker", "date"]). Double underscores become dots (date__gte → date.gte)."""
22
+ if not key:
23
+ raise SharadarError("NASDAQ_DATA_LINK_API_KEY is not set")
24
+ s = session or requests.Session()
25
+ params = {k.replace("__", "."): v for k, v in filters.items() if k != "columns"}
26
+ if "columns" in filters:
27
+ params["qopts.columns"] = ",".join(filters["columns"])
28
+ params["qopts.per_page"] = 10000
29
+ frames, cursor = [], None
30
+ while True:
31
+ p = {**params, "api_key": key, **({"qopts.cursor_id": cursor} if cursor else {})}
32
+ for attempt in range(retries):
33
+ r = s.get(BASE + table + ".json", params=p, timeout=90)
34
+ if r.status_code in (429, 500, 502, 503, 504):
35
+ time.sleep(min(60, 2 ** attempt * 2))
36
+ continue
37
+ break
38
+ if r.status_code >= 400:
39
+ try:
40
+ msg = r.json().get("quandl_error", {}).get("message", "")
41
+ except ValueError:
42
+ msg = ""
43
+ raise SharadarError(f"SHARADAR/{table} → HTTP {r.status_code} {msg}".strip())
44
+ j = r.json()
45
+ dt = j.get("datatable", {})
46
+ cols = [c["name"] for c in dt.get("columns", [])]
47
+ if dt.get("data"):
48
+ frames.append(pd.DataFrame(dt["data"], columns=cols))
49
+ cursor = (j.get("meta") or {}).get("next_cursor_id")
50
+ if not cursor:
51
+ break
52
+ return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
53
+
54
+
55
+ def query_tickers(table: str, key: str, tickers: list[str], chunk: int = 100,
56
+ session: Optional[requests.Session] = None, **filters) -> pd.DataFrame:
57
+ """`query` over a long ticker list, `chunk` tickers per request."""
58
+ out = []
59
+ tickers = sorted(set(tickers))
60
+ for i in range(0, len(tickers), chunk):
61
+ part = query(table, key, session=session, ticker=",".join(tickers[i:i + chunk]), **filters)
62
+ if not part.empty:
63
+ out.append(part)
64
+ return pd.concat(out, ignore_index=True) if out else pd.DataFrame()
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi>=0.115,<1
2
+ uvicorn[standard]>=0.30,<1
3
+ requests>=2.31,<3
4
+ pandas>=2.2,<4
5
+ numpy>=1.26,<3
6
+ tzdata>=2024.1
static/index.html ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Updraft — live</title>
7
+ <meta name="description" content="Updraft: a three-stock quality-and-trend strategy, rebalanced automatically each month on an Alpaca paper account.">
8
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
9
+ <style>
10
+ :root{--bg:#f7f7f5;--card:#ffffff;--ink:#1d1d1f;--muted:#6b6b72;--line:#e6e4df;--accent:#d9480f;--spy:#6b7280;
11
+ --pos:#1a7f37;--neg:#c92a2a;--chip:#fff4e6;--chipink:#9a3412;font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
12
+ @media (prefers-color-scheme: dark){:root:not([data-theme="light"]){--bg:#121214;--card:#1b1b1f;--ink:#ececf1;--muted:#a0a0aa;
13
+ --line:#2c2c33;--accent:#ff7a3d;--spy:#9ca3af;--pos:#4ade80;--neg:#f87171;--chip:#2a1d14;--chipink:#fdba74}}
14
+ :root[data-theme="dark"]{--bg:#121214;--card:#1b1b1f;--ink:#ececf1;--muted:#a0a0aa;--line:#2c2c33;--accent:#ff7a3d;--spy:#9ca3af;
15
+ --pos:#4ade80;--neg:#f87171;--chip:#2a1d14;--chipink:#fdba74}
16
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);line-height:1.45}
17
+ .wrap{max-width:1080px;margin:0 auto;padding:28px 16px 48px}
18
+ header{display:flex;flex-wrap:wrap;gap:10px 16px;align-items:baseline;justify-content:space-between}
19
+ h1{font-size:28px;margin:0;letter-spacing:-.01em}h2{font-size:17px;margin:0 0 12px}
20
+ .lead{color:var(--muted);margin:6px 0 0;max-width:760px}
21
+ .chip{display:inline-block;font-size:12px;font-weight:600;padding:3px 9px;border-radius:999px;background:var(--chip);color:var(--chipink)}
22
+ .live{font-size:12px;color:var(--muted)}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--pos);margin-right:6px}
23
+ .tiles{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:22px 0}
24
+ .tile{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px}
25
+ .tile .l{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
26
+ .tile .v{font-size:24px;font-weight:700;margin-top:4px;font-variant-numeric:tabular-nums}.tile .s{font-size:12px;color:var(--muted);margin-top:2px}
27
+ .pos{color:var(--pos)}.neg{color:var(--neg)}
28
+ .card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:18px;margin-top:14px}
29
+ .grid2{display:grid;grid-template-columns:1.4fr 1fr;gap:14px}
30
+ table{width:100%;border-collapse:collapse;font-size:14px;font-variant-numeric:tabular-nums}
31
+ th{text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);font-weight:600;padding:6px 8px;border-bottom:1px solid var(--line)}
32
+ td{padding:8px;border-bottom:1px solid var(--line)}td.r,th.r{text-align:right}
33
+ .sym{font-weight:700}.sub{color:var(--muted);font-size:12px}
34
+ .note{font-size:13px;color:var(--muted)}.empty{color:var(--muted);font-size:14px;padding:8px 0}
35
+ .chartbox{position:relative;height:300px}
36
+ ul.rules{margin:0;padding-left:18px}ul.rules li{margin:4px 0}
37
+ .kv{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:10px}
38
+ .kv div{border:1px solid var(--line);border-radius:10px;padding:10px}.kv b{display:block;font-size:18px}
39
+ footer{margin-top:22px;font-size:12px;color:var(--muted)}
40
+ button.theme{background:none;border:1px solid var(--line);color:var(--muted);border-radius:8px;padding:4px 8px;font-size:12px;cursor:pointer}
41
+ @media (max-width:860px){.tiles{grid-template-columns:repeat(2,1fr)}.grid2{grid-template-columns:1fr}.kv{grid-template-columns:repeat(2,1fr)}}
42
+ .scroll{overflow-x:auto}
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <div class="wrap">
47
+ <header>
48
+ <div>
49
+ <h1>🪁 Updraft <span class="chip" id="mode">Paper trading</span></h1>
50
+ <div class="sub" style="margin-top:2px">The Quality-Trend 3 strategy — rides the strongest risers among quality companies</div>
51
+ <p class="lead">Three large US stocks with strong <b>quality and capital discipline</b> and the strongest
52
+ <b>price trends</b>. Checked once a month; a stock is kept while it ranks in the top 6, the rest is replaced
53
+ and the money re-split into thirds. Runs on its own — no human in the loop.</p>
54
+ </div>
55
+ <div class="live"><span class="dot" id="dot"></span><span id="updated">loading…</span>
56
+ <button class="theme" id="themeBtn" title="Toggle light / dark">◐</button></div>
57
+ </header>
58
+
59
+ <section class="tiles" id="tiles">
60
+ <div class="tile"><div class="l">Portfolio value</div><div class="v" id="t-eq">—</div><div class="s" id="t-eq-s"></div></div>
61
+ <div class="tile"><div class="l">Since start</div><div class="v" id="t-ret">—</div><div class="s" id="t-ret-s"></div></div>
62
+ <div class="tile"><div class="l">S&amp;P 500, same days</div><div class="v" id="t-spy">—</div><div class="s" id="t-spy-s"></div></div>
63
+ <div class="tile"><div class="l">Worst fall</div><div class="v" id="t-dd">—</div><div class="s">peak to trough, since start</div></div>
64
+ <div class="tile"><div class="l">Next rebalance</div><div class="v" id="t-next">—</div><div class="s" id="t-next-s"></div></div>
65
+ </section>
66
+
67
+ <section class="card">
68
+ <h2>Growth of $1 — strategy vs S&amp;P 500</h2>
69
+ <div class="chartbox"><canvas id="chart"></canvas></div>
70
+ <p class="note" id="chartnote">Time-weighted (deposits and withdrawals don't count as gains). Updated every 10 minutes.</p>
71
+ </section>
72
+
73
+ <div class="grid2">
74
+ <section class="card">
75
+ <h2>Holdings now</h2>
76
+ <div class="scroll"><table id="holdings"><thead><tr><th>Stock</th><th class="r">Weight</th><th class="r">Value</th>
77
+ <th class="r">Gain</th><th class="r">Today</th><th class="r">Rank now</th></tr></thead><tbody></tbody></table></div>
78
+ <p class="note" id="cashnote"></p>
79
+ </section>
80
+ <section class="card">
81
+ <h2>This month's ranking</h2>
82
+ <div class="scroll"><table id="ranking"><thead><tr><th>#</th><th>Stock</th><th>Sector</th><th class="r">6-month</th></tr></thead><tbody></tbody></table></div>
83
+ <p class="note" id="ranknote"></p>
84
+ </section>
85
+ </div>
86
+
87
+ <section class="card">
88
+ <h2>Trade log</h2>
89
+ <div class="scroll"><table id="orders"><thead><tr><th>Date</th><th>Action</th><th>Stock</th><th class="r">Shares</th><th class="r">Price</th><th>Status</th></tr></thead><tbody></tbody></table></div>
90
+ </section>
91
+
92
+ <section class="card">
93
+ <h2>How it works — and what the backtest says</h2>
94
+ <ul class="rules">
95
+ <li><b>Universe:</b> the 1,000 largest US companies, price ≥ $5, ≥ $5M traded a day, US-dollar financial statements.</li>
96
+ <li><b>Quality pool:</b> the top 30% on capital discipline — low dilution, no new share issuance, buybacks, free-cash-flow yield, ROE, gross margin, cash conversion.</li>
97
+ <li><b>Trend:</b> the pool ranked by the 50- vs 200-day average and the 6-month return (skipping the last month).</li>
98
+ <li><b>Monthly:</b> on the first trading day of each month, keep holdings still ranked in the top 6; sell the rest; buy from the top until there are 3; re-split equally (1% kept in cash). Sells go first.</li>
99
+ </ul>
100
+ <div class="kv">
101
+ <div><span class="sub">Return per year, 1999–2026</span><b>+30.9%</b><span class="sub">S&amp;P 500 +8.5%</span></div>
102
+ <div><span class="sub">Worst fall</span><b>−34%</b><span class="sub">worst month −24%</span></div>
103
+ <div><span class="sub">Any 12 months: median</span><b>+24%</b><span class="sub">in profit 87% of the time</span></div>
104
+ <div><span class="sub">Beat the S&amp;P over 12 months</span><b>77%</b><span class="sub">of all start months</span></div>
105
+ </div>
106
+ <p class="note">Backtest after 10 bps per trade, point-in-time stock lists, delisted companies included. Backtests flatter — the
107
+ 2024-26 AI rally dominates recent years; expect less, with falls of 30–35% or worse. Each stock is a third of the
108
+ money: one stock halving costs about 17%.</p>
109
+ </section>
110
+
111
+ <footer>Educational paper-trading demonstration — not investment advice. Prices and fills from Alpaca (IEX feed); rankings from
112
+ Sharadar fundamentals and prices. <span id="ver"></span></footer>
113
+ </div>
114
+
115
+ <script>
116
+ const $ = id => document.getElementById(id);
117
+ const pct = (x, d=1) => x == null || isNaN(x) ? "—" : (x >= 0 ? "+" : "−") + Math.abs(x * 100).toFixed(d) + "%";
118
+ const usd = x => x == null ? "—" : "$" + Number(x).toLocaleString(undefined, {maximumFractionDigits: 0});
119
+ const cls = (el, x) => { el.classList.remove("pos", "neg"); if (x != null) el.classList.add(x >= 0 ? "pos" : "neg"); };
120
+ const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
121
+ async function get(u){ const r = await fetch(u); if(!r.ok) throw new Error((await r.json().catch(()=>({}))).detail || r.status); return r.json(); }
122
+ let chart;
123
+
124
+ try { const t = localStorage.getItem("updraft-theme"); if (t) document.documentElement.dataset.theme = t; } catch (e) {}
125
+ $("themeBtn").onclick = () => { const cur = document.documentElement.dataset.theme ||
126
+ (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
127
+ const nxt = cur === "dark" ? "light" : "dark"; document.documentElement.dataset.theme = nxt;
128
+ try { localStorage.setItem("updraft-theme", nxt); } catch (e) {} drawChart(window._perf); };
129
+
130
+ async function loadPortfolio(){
131
+ try {
132
+ const p = await get("/api/portfolio");
133
+ $("mode").textContent = p.paper ? "Paper trading" : "LIVE trading";
134
+ $("t-eq").textContent = usd(p.equity);
135
+ $("t-eq-s").textContent = p.today_pct == null ? "" : `today ${pct(p.today_pct, 2)} (${p.today_change >= 0 ? "+" : "−"}${usd(Math.abs(p.today_change))})`;
136
+ const tb = $("holdings").querySelector("tbody");
137
+ tb.innerHTML = p.positions.length ? p.positions.map(r => `<tr><td><span class="sym">${esc(r.symbol)}</span><br><span class="sub">${esc(r.name || "")}</span></td>
138
+ <td class="r">${pct(r.weight, 0).replace("+","")}</td><td class="r">${usd(r.value)}</td>
139
+ <td class="r ${r.pl_pct >= 0 ? "pos" : "neg"}">${pct(r.pl_pct)}</td><td class="r ${r.today >= 0 ? "pos" : "neg"}">${pct(r.today)}</td>
140
+ <td class="r">${r.rank_now ? "#" + r.rank_now : "—"}</td></tr>`).join("")
141
+ : `<tr><td colspan="6" class="empty">No positions yet — the first rebalance buys the top 3.</td></tr>`;
142
+ $("cashnote").textContent = `Cash ${usd(p.cash)}. Prices update every 30 seconds while the page is open.`;
143
+ $("updated").textContent = "Updated " + new Date().toLocaleTimeString();
144
+ $("dot").style.background = "var(--pos)";
145
+ } catch (e) { $("updated").textContent = "Live data unavailable: " + e.message; $("dot").style.background = "var(--neg)"; }
146
+ }
147
+
148
+ async function loadStatus(){
149
+ try {
150
+ const s = await get("/api/status");
151
+ $("t-next").textContent = s.next_rebalance ? new Date(s.next_rebalance + "T12:00:00").toLocaleDateString(undefined, {day:"numeric", month:"short"}) : "—";
152
+ $("t-next-s").textContent = s.done_this_month ? "this month's rebalance is done" : (s.market_open ? "market open" : "market closed");
153
+ } catch (e) { $("t-next-s").textContent = ""; }
154
+ }
155
+
156
+ async function loadRanking(){
157
+ try {
158
+ const r = await get("/api/ranking");
159
+ const tb = $("ranking").querySelector("tbody");
160
+ tb.innerHTML = (r.ranked || []).slice(0, 6).map(x => `<tr><td>${x.rank}</td><td><span class="sym">${esc(x.ticker)}</span><br><span class="sub">${esc(x.name || "")}</span></td>
161
+ <td class="sub">${esc(x.sector || "")}</td><td class="r ${x.ret_6m >= 0 ? "pos" : "neg"}">${pct(x.ret_6m, 0)}</td></tr>`).join("")
162
+ || `<tr><td colspan="4" class="empty">${r.building ? "Building today's ranking…" : "Ranking not available yet."}</td></tr>`;
163
+ $("ranknote").textContent = r.prices_as_of ? `Prices to ${r.prices_as_of} · ${r.pool} stocks in the quality pool. Holdings are kept while they stay in the top 6.` : "";
164
+ } catch (e) { $("ranknote").textContent = "Ranking unavailable: " + e.message; }
165
+ }
166
+
167
+ async function loadOrders(){
168
+ try {
169
+ const o = await get("/api/orders");
170
+ const tb = $("orders").querySelector("tbody");
171
+ tb.innerHTML = o.length ? o.slice(0, 30).map(x => `<tr><td>${esc(x.date)}</td><td class="${x.side === "buy" ? "pos" : "neg"}">${x.side === "buy" ? "Buy" : "Sell"}</td>
172
+ <td class="sym">${esc(x.symbol)}</td><td class="r">${x.qty ? Number(x.qty).toFixed(3) : "—"}</td>
173
+ <td class="r">${x.price ? "$" + Number(x.price).toFixed(2) : "—"}</td><td class="sub">${esc(x.status)}</td></tr>`).join("")
174
+ : `<tr><td colspan="6" class="empty">No trades yet.</td></tr>`;
175
+ } catch (e) {}
176
+ }
177
+
178
+ function drawChart(p){
179
+ if (!p || !p.dates || !p.dates.length) return;
180
+ const css = getComputedStyle(document.documentElement);
181
+ const accent = css.getPropertyValue("--accent").trim(), spyc = css.getPropertyValue("--spy").trim(), grid = css.getPropertyValue("--line").trim(),
182
+ ink = css.getPropertyValue("--muted").trim();
183
+ if (chart) chart.destroy();
184
+ chart = new Chart($("chart"), {type: "line", data: {labels: p.dates, datasets: [
185
+ {label: "Updraft", data: p.strategy, borderColor: accent, borderWidth: 2.2, pointRadius: 0, tension: .15},
186
+ {label: "S&P 500", data: p.spy, borderColor: spyc, borderWidth: 1.5, pointRadius: 0, borderDash: [4, 3], tension: .15}]},
187
+ options: {responsive: true, maintainAspectRatio: false, interaction: {mode: "index", intersect: false},
188
+ plugins: {legend: {labels: {color: ink, boxWidth: 14}}, tooltip: {callbacks: {label: c => `${c.dataset.label}: ${pct(c.parsed.y - 1)}`}}},
189
+ scales: {x: {ticks: {color: ink, maxTicksLimit: 8}, grid: {display: false}},
190
+ y: {ticks: {color: ink, callback: v => pct(v - 1, Math.abs(v - 1) < 0.05 ? 1 : 0)}, grid: {color: grid}}}}});
191
+ }
192
+
193
+ async function loadPerformance(){
194
+ try {
195
+ const p = await get("/api/performance"); window._perf = p; const s = p.stats || {};
196
+ $("t-ret").textContent = pct(s.return); cls($("t-ret"), s.return);
197
+ $("t-ret-s").textContent = p.start ? `since the first trade, ${p.start}` + (s.annualised != null ? ` · ${pct(s.annualised)} a year` : "") : "starts with the first trade";
198
+ $("t-spy").textContent = pct(s.spy_return); cls($("t-spy"), s.spy_return);
199
+ $("t-spy-s").textContent = s.return != null && s.spy_return != null ? `strategy ${s.return >= s.spy_return ? "ahead by" : "behind by"} ${pct(Math.abs(s.return - s.spy_return)).replace("+","")}` : "";
200
+ $("t-dd").textContent = s.max_drawdown == null ? "—" : pct(s.max_drawdown); cls($("t-dd"), s.max_drawdown < 0 ? -1 : null);
201
+ drawChart(p);
202
+ if (!p.dates.length) $("chartnote").textContent = "The chart starts after the first rebalance.";
203
+ } catch (e) { $("chartnote").textContent = "Performance unavailable: " + e.message; }
204
+ }
205
+
206
+ loadPortfolio(); loadStatus(); loadRanking(); loadOrders(); loadPerformance();
207
+ setInterval(loadPortfolio, 30000); setInterval(loadStatus, 60000); setInterval(loadOrders, 120000);
208
+ setInterval(loadRanking, 600000); setInterval(loadPerformance, 600000);
209
+ </script>
210
+ </body>
211
+ </html>
tests/test_qt3_space.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quality-Trend 3 Space: data client, ranking, rebalance rules and guards, execution, performance, API auth.
2
+ No network: Sharadar and Alpaca are faked."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from datetime import date, datetime
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ import pytest
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
15
+ from qt3 import config, performance, ranking, rebalance, sharadar # noqa: E402
16
+ from qt3.broker import BrokerError # noqa: E402
17
+
18
+
19
+ # --------------------------------------------------------------------------- Sharadar client
20
+ class _R:
21
+ def __init__(self, code, body):
22
+ self.status_code, self._b = code, body
23
+
24
+ def json(self):
25
+ return self._b
26
+
27
+
28
+ class FakeHTTP:
29
+ def __init__(self, pages):
30
+ self.pages, self.calls = pages, []
31
+
32
+ def get(self, url, params=None, timeout=None):
33
+ self.calls.append((url, dict(params)))
34
+ return _R(200, self.pages[len(self.calls) - 1])
35
+
36
+
37
+ def test_sharadar_paginates_and_maps_filters():
38
+ cols = [{"name": "ticker"}, {"name": "date"}]
39
+ s = FakeHTTP([{"datatable": {"data": [["A", "2026-10-01"]], "columns": cols}, "meta": {"next_cursor_id": "c2"}},
40
+ {"datatable": {"data": [["B", "2026-10-01"]], "columns": cols}, "meta": {"next_cursor_id": None}}])
41
+ df = sharadar.query("SEP", "KEY", session=s, ticker="A,B", date__gte="2026-01-01", columns=["ticker", "date"])
42
+ assert list(df["ticker"]) == ["A", "B"]
43
+ p0, p1 = s.calls[0][1], s.calls[1][1]
44
+ assert p0["date.gte"] == "2026-01-01" and p0["qopts.columns"] == "ticker,date" and "qopts.cursor_id" not in p0
45
+ assert p1["qopts.cursor_id"] == "c2"
46
+ with pytest.raises(sharadar.SharadarError):
47
+ sharadar.query("SEP", "", session=s)
48
+
49
+
50
+ # --------------------------------------------------------------------------- ranking (steps 4-5)
51
+ def _universe(n=40, seed=0):
52
+ rng = np.random.default_rng(seed)
53
+ u = pd.DataFrame({"ticker": [f"T{i:02d}" for i in range(n)], "price": 50.0, "dv20": 2e7,
54
+ "currency": "USD", "ma50_200": rng.normal(0, .1, n), "mom_6_1": rng.normal(0, .2, n)})
55
+ for c in ("roe", "roa", "gross_margin", "fcf_conversion", "fcf_yield", "buyback_yield",
56
+ "net_share_reduction_yoy", "basic_diluted_gap", "equity_financing"):
57
+ u[c] = rng.normal(0, 1, n)
58
+ return u
59
+
60
+
61
+ def test_rank_table_pool_and_order():
62
+ u = _universe()
63
+ u.loc[0, "currency"] = "JPY" # foreign reporter excluded
64
+ u.loc[1, "price"] = 3.0 # below $5 excluded
65
+ pool = ranking.rank_table(u)
66
+ assert "T00" not in set(pool["ticker"]) and "T01" not in set(pool["ticker"])
67
+ assert pool.attrs["liquid_usd"] == 38
68
+ assert len(pool) == pytest.approx(38 * 0.3, abs=1)
69
+ assert pool["trend"].is_monotonic_decreasing and list(pool["rank"]) == list(range(1, len(pool) + 1))
70
+
71
+
72
+ def test_build_uses_api_tables_and_returns_ranked(monkeypatch):
73
+ u = _universe(60)
74
+ days = pd.bdate_range(end="2026-10-09", periods=260)
75
+ def fetch(table, key, **f):
76
+ assert table == "DAILY"
77
+ return pd.DataFrame({"ticker": u["ticker"], "date": "2026-10-09", "marketcap": np.arange(60, 0, -1) * 1e3})
78
+ def fetch_t(table, key, tickers, **f):
79
+ if table == "TICKERS":
80
+ return pd.concat([pd.DataFrame({"table": t, "ticker": tickers, "name": "Co", "sector": "Tech",
81
+ "industry": "X", "currency": "USD"}) for t in ("SEP", "SF1")])
82
+ if table == "SEP":
83
+ rows = []
84
+ for i, t in enumerate(tickers):
85
+ c = 100 * np.exp(np.cumsum(np.full(len(days), (i - 30) / 6000)))
86
+ rows.append(pd.DataFrame({"ticker": t, "date": days, "close": c, "volume": 1e6, "closeadj": c}))
87
+ return pd.concat(rows)
88
+ return pd.DataFrame() # SF1 empty → no quality inputs → empty pool is fine
89
+ r = ranking.build("KEY", today=datetime(2026, 10, 10), fetch=fetch, fetch_tickers=fetch_t)
90
+ assert r["prices_as_of"] == "2026-10-09" and r["universe"] == 60
91
+
92
+
93
+ # --------------------------------------------------------------------------- rebalance rules
94
+ RANKED = ["A", "B", "C", "D", "E", "F", "G", "H"]
95
+
96
+
97
+ def test_target_list_keeps_top6_and_refills():
98
+ final, why = rebalance.target_list(RANKED, ["F", "H", "ZZ"])
99
+ assert final == ["A", "B", "F"] # F (#6) kept; H (#8) and ZZ sold; refill A, B
100
+ assert "#8" in why["H"] and "left the quality pool" in why["ZZ"]
101
+ assert rebalance.target_list(RANKED, [])[0] == ["A", "B", "C"]
102
+
103
+
104
+ def test_plan_initial_equal_thirds_with_buffer():
105
+ px = {s: 100.0 for s in "ABC"}
106
+ p = rebalance.plan(["A", "B", "C"], {}, px, cash=10_000.0, fractional=True)
107
+ buys = {o["symbol"]: o["value"] for o in p["orders"]}
108
+ assert set(buys) == {"A", "B", "C"} and all(v == pytest.approx(3300, abs=1) for v in buys.values())
109
+ assert p["cash_after"] == pytest.approx(100, abs=1)
110
+
111
+
112
+ def test_plan_sells_first_exit_and_trim():
113
+ px = {"A": 200.0, "B": 100.0, "C": 100.0, "OLD": 10.0}
114
+ p = rebalance.plan(["A", "B", "C"], {"A": 30, "B": 10, "OLD": 100}, px, cash=0.0,
115
+ reasons={"OLD": "now ranked #9"}, fractional=True)
116
+ sides = [(o["symbol"], o["side"]) for o in p["orders"]]
117
+ assert sides[0] == ("OLD", "sell") and p["orders"][0]["close_all"]
118
+ assert ("A", "sell") in sides and sides.index(("A", "sell")) < sides.index(("C", "buy"))
119
+
120
+
121
+ def test_plan_whole_shares_and_missing_price():
122
+ p = rebalance.plan(["A", "B", "C"], {}, {s: 333.0 for s in "ABC"}, cash=3000.0, fractional=False)
123
+ assert all(float(o["shares"]).is_integer() for o in p["orders"])
124
+ with pytest.raises(ValueError, match="C"):
125
+ rebalance.plan(["A", "B", "C"], {}, {"A": 1, "B": 1}, cash=10.0)
126
+
127
+
128
+ # --------------------------------------------------------------------------- guards + execution (fake Alpaca)
129
+ class FakeAlpaca:
130
+ def __init__(self, open_=True, orders=(), days=None, fill="filled"):
131
+ self.open_, self._orders, self.fill = open_, list(orders), fill
132
+ self._days = days or ["2026-11-02", "2026-11-03", "2026-11-04", "2026-11-05", "2026-11-06", "2026-11-09",
133
+ "2026-12-01"]
134
+ self.submitted = []
135
+ self.cash = 0.0
136
+
137
+ def clock(self):
138
+ return {"is_open": self.open_, "next_open": "2026-11-03T09:30:00-05:00"}
139
+
140
+ def trading_days(self, s, e):
141
+ return self._days
142
+
143
+ def orders(self, after=None, status="all", limit=500):
144
+ return self._orders
145
+
146
+ def submit(self, symbol, side, qty=None, notional=None, client_order_id=None):
147
+ self.submitted.append({"symbol": symbol, "side": side, "qty": qty, "notional": notional, "cid": client_order_id})
148
+ if side == "sell":
149
+ self.cash += 1000.0
150
+ return {"id": f"o{len(self.submitted)}", "status": "accepted"}
151
+
152
+ def wait(self, ids, seconds=0, poll=0):
153
+ return {i: {"status": self.fill} for i in ids}
154
+
155
+ def account(self):
156
+ return {"cash": self.cash, "non_marginable_buying_power": self.cash, "equity": 10_000.0}
157
+
158
+
159
+ NOV2 = datetime(2026, 11, 2, 10, 30, tzinfo=rebalance.NY)
160
+ FRESH = {"ranked": [{"ticker": t} for t in RANKED], "prices_as_of": "2026-10-30"}
161
+
162
+
163
+ def test_check_first_run_and_guards():
164
+ assert rebalance.check(FakeAlpaca(), FRESH, NOV2)["ok"] # first run, day 1, open
165
+ assert "market closed" in rebalance.check(FakeAlpaca(open_=False), FRESH, NOV2)["reasons"][0]
166
+ stale = {**FRESH, "prices_as_of": "2026-10-20"}
167
+ assert any("days old" in r for r in rebalance.check(FakeAlpaca(), stale, NOV2)["reasons"])
168
+ done = [{"client_order_id": "qt3-20261102-A-buy", "side": "buy", "status": "filled",
169
+ "created_at": "2026-11-02T15:00:01.123456789Z"}]
170
+ c = rebalance.check(FakeAlpaca(orders=done), FRESH, NOV2)
171
+ assert not c["ok"] and "already rebalanced this month" in c["reasons"] and c["done_this_month"]
172
+ old = [{"client_order_id": "qt3-20261001-A-buy", "side": "buy", "status": "filled",
173
+ "created_at": "2026-10-01T15:00:00Z"}]
174
+ late = datetime(2026, 11, 9, 10, 30, tzinfo=rebalance.NY) # 6th trading day
175
+ assert any("window" in r for r in rebalance.check(FakeAlpaca(orders=old), FRESH, late)["reasons"])
176
+ fresh_late = {**FRESH, "prices_as_of": "2026-11-06"}
177
+ assert rebalance.check(FakeAlpaca(orders=old), fresh_late, late, force=True)["ok"] # force skips window
178
+ assert not rebalance.check(FakeAlpaca(orders=old), FRESH, late, force=True)["ok"] # never stale data
179
+
180
+
181
+ def test_execute_sells_then_scaled_buys_with_ids():
182
+ a = FakeAlpaca()
183
+ p = {"fractional": True, "orders": [
184
+ {"symbol": "OLD", "side": "sell", "shares": 5.0, "value": 1000.0},
185
+ {"symbol": "A", "side": "buy", "shares": 1.0, "value": 600.0},
186
+ {"symbol": "B", "side": "buy", "shares": 1.0, "value": 600.0}]}
187
+ res = rebalance.execute(a, p, today=date(2026, 11, 2))
188
+ assert [r["side"] for r in res] == ["sell", "buy", "buy"]
189
+ assert a.submitted[0]["cid"] == "qt3-20261102-OLD-sell"
190
+ # cash after sells 1000 − 1% of equity (100) = 900 → buys scaled 900/1200
191
+ assert a.submitted[1]["notional"] == pytest.approx(450.0) and a.submitted[1]["cid"] == "qt3-20261102-A-buy"
192
+
193
+
194
+ def test_execute_defers_buys_if_sells_not_filled():
195
+ a = FakeAlpaca(fill="new")
196
+ res = rebalance.execute(a, {"fractional": True, "orders": [
197
+ {"symbol": "OLD", "side": "sell", "shares": 1.0, "value": 10.0},
198
+ {"symbol": "A", "side": "buy", "shares": 1.0, "value": 10.0}]}, today=date(2026, 11, 2), wait_s=0)
199
+ assert res[-1]["status"].startswith("deferred") and all(s["side"] == "sell" for s in a.submitted)
200
+
201
+
202
+ # --------------------------------------------------------------------------- performance
203
+ def test_twr_excludes_deposits_and_drawdown():
204
+ g = performance.twr(["d1", "d2", "d3"], [1000.0, 2100.0, 1890.0], {"d2": 1000.0})
205
+ assert g[1] == pytest.approx(1.10) and g[2] == pytest.approx(0.99)
206
+ assert performance.max_drawdown([1.0, 1.2, 0.9, 1.3]) == pytest.approx(-0.25)
207
+
208
+
209
+ # --------------------------------------------------------------------------- API auth
210
+ def test_rebalance_endpoint_requires_token(monkeypatch, tmp_path):
211
+ from fastapi.testclient import TestClient
212
+ monkeypatch.setattr(config, "REBALANCE_TOKEN", "secret-token")
213
+ monkeypatch.setattr(config, "NDL_KEY", "")
214
+ monkeypatch.setattr(config, "CACHE_DIR", str(tmp_path))
215
+ (tmp_path / "ranking.json").write_text(json.dumps({**FRESH, "built": datetime.now().astimezone().isoformat()}))
216
+ import app as space
217
+ c = TestClient(space.app)
218
+ assert c.get("/api/health").json()["rebalance_enabled"]
219
+ r = c.post("/api/rebalance?dry=0", headers={"X-Rebalance-Token": "wrong"})
220
+ assert r.status_code == 403