Updraft: Quality-Trend 3 automated paper strategy + live page
Browse files- .github/workflows/updraft-keepalive.yml +36 -0
- .github/workflows/updraft-rebalance.yml +54 -0
- .gitignore +4 -0
- Dockerfile +15 -0
- README.md +55 -4
- app.py +177 -0
- qt3/__init__.py +0 -0
- qt3/broker.py +132 -0
- qt3/config.py +36 -0
- qt3/fundamental.py +73 -0
- qt3/performance.py +74 -0
- qt3/ranking.py +152 -0
- qt3/rebalance.py +194 -0
- qt3/sharadar.py +64 -0
- requirements.txt +6 -0
- static/index.html +211 -0
- tests/test_qt3_space.py +220 -0
.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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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&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&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&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&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 => ({"&":"&","<":"<",">":">",'"':"""}[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
|