Built-in monthly scheduler (no GitHub needed); README keep-awake notes
Browse files- README.md +7 -3
- app.py +13 -12
- qt3/config.py +2 -1
- qt3/scheduler.py +78 -0
- tests/test_qt3_space.py +23 -0
README.md
CHANGED
|
@@ -35,9 +35,13 @@ worst fall −34%, any 12 months median +24% (in profit 87% of the time). Backte
|
|
| 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** —
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
- **State** — all in Alpaca (positions, orders, portfolio history). A restart only rebuilds the ranking cache.
|
| 42 |
|
| 43 |
## Settings (Space → Settings → Variables and secrets)
|
|
|
|
| 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 (built in)** — the Space runs its own timer (`qt3/scheduler.py`): every 15 minutes it rebuilds the
|
| 39 |
+
ranking if it is older than 20 hours, and on weekdays 10:00–15:30 New York time it attempts the guarded rebalance
|
| 40 |
+
above — so it trades on the first trading day of each month and does nothing otherwise. No GitHub needed.
|
| 41 |
+
Set `AUTO_REBALANCE=0` to switch it off. The GitHub workflows in `.github/workflows/` remain an optional backup.
|
| 42 |
+
- **Keep-awake** — a free Space sleeps after 48 h without visitors, and a sleeping Space's timer stops. Either point a
|
| 43 |
+
free uptime monitor (e.g. UptimeRobot or cron-job.org) at `https://s-ttp-updraft.hf.space/api/health` every 30–60
|
| 44 |
+
minutes (no secrets involved), or move the Space to paid hardware with sleep turned off.
|
| 45 |
- **State** — all in Alpaca (positions, orders, portfolio history). A restart only rebuilds the ranking cache.
|
| 46 |
|
| 47 |
## Settings (Space → Settings → Variables and secrets)
|
app.py
CHANGED
|
@@ -3,8 +3,9 @@
|
|
| 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 |
-
|
| 7 |
-
|
|
|
|
| 8 |
"""
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
@@ -17,13 +18,15 @@ 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 |
|
|
@@ -59,7 +62,7 @@ def index():
|
|
| 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")
|
|
@@ -131,7 +134,8 @@ async def status():
|
|
| 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"],
|
|
|
|
| 135 |
try:
|
| 136 |
return await run_in_threadpool(cached, "status", 60, load)
|
| 137 |
except BrokerError as e:
|
|
@@ -165,13 +169,10 @@ async def run_rebalance(dry: int = 1, force: int = 0, x_rebalance_token: str = H
|
|
| 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 |
-
|
| 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 |
-
|
| 177 |
-
|
|
|
|
|
|
| 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 |
+
The Space schedules itself (qt3/scheduler.py: a 15-minute tick that rebuilds the ranking daily and attempts the
|
| 7 |
+
guarded rebalance on weekdays 10:00-15:30 New York); an outside cron calling /api/rebalance is optional. Either
|
| 8 |
+
way it trades only on the first trading days of a month (once), while the market is open, on fresh data.
|
| 9 |
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
|
|
|
| 18 |
from fastapi.responses import FileResponse
|
| 19 |
from starlette.concurrency import run_in_threadpool
|
| 20 |
|
| 21 |
+
from qt3 import config, performance, ranking, rebalance, scheduler
|
| 22 |
from qt3.broker import Alpaca, BrokerError
|
| 23 |
|
| 24 |
+
|
| 25 |
@asynccontextmanager
|
| 26 |
async def lifespan(_app):
|
| 27 |
if config.NDL_KEY:
|
| 28 |
ranking.refresh_async() # build today's ranking in the background at start-up
|
| 29 |
+
scheduler.start() # the Space's own monthly rebalance timer
|
| 30 |
yield
|
| 31 |
|
| 32 |
|
|
|
|
| 62 |
|
| 63 |
@app.get("/api/health")
|
| 64 |
def health():
|
| 65 |
+
return {"ok": True, **config.status(), "ranking_state": ranking.state(), "scheduler": scheduler.STATE}
|
| 66 |
|
| 67 |
|
| 68 |
@app.get("/api/ranking")
|
|
|
|
| 134 |
else chk["next_rebalance"]
|
| 135 |
return {"market_open": bool(clk.get("is_open")), "next_open": clk.get("next_open"),
|
| 136 |
"next_rebalance": nxt, "done_this_month": chk["done_this_month"], "first_run": chk["first_run"],
|
| 137 |
+
"would_trade_now": chk["ok"], "blocked_by": chk["reasons"], "scheduler": scheduler.STATE,
|
| 138 |
+
**config.status()}
|
| 139 |
try:
|
| 140 |
return await run_in_threadpool(cached, "status", 60, load)
|
| 141 |
except BrokerError as e:
|
|
|
|
| 169 |
return {"dry_run": True, **(await dry_plan())}
|
| 170 |
_authorised(x_rebalance_token)
|
| 171 |
a = broker()
|
|
|
|
|
|
|
|
|
|
| 172 |
try:
|
| 173 |
+
out = await run_in_threadpool(scheduler.run_once, bool(force), a)
|
|
|
|
| 174 |
except (BrokerError, ValueError) as e:
|
| 175 |
raise HTTPException(502, f"rebalance failed: {e}")
|
| 176 |
+
if out.get("executed"):
|
| 177 |
+
_cache.clear()
|
| 178 |
+
return {"dry_run": False, **out}
|
qt3/config.py
CHANGED
|
@@ -12,6 +12,7 @@ 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
|
|
@@ -33,4 +34,4 @@ INCEPTION = os.getenv("QT3_INCEPTION", "") # YYYY-MM-DD for public
|
|
| 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)}
|
|
|
|
| 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 |
+
AUTO_REBALANCE = os.getenv("AUTO_REBALANCE", "1").strip() == "1" # the Space's own monthly scheduler
|
| 16 |
|
| 17 |
# The backtested rule (Quant Model scripts/finalize_qt3_managed.py)
|
| 18 |
N, KEEP = 3, 6 # hold 3; keep a holding while it ranks in the top 6
|
|
|
|
| 34 |
|
| 35 |
def status() -> dict:
|
| 36 |
return {"data_key": bool(NDL_KEY), "alpaca_keys": bool(ALPACA_KEY and ALPACA_SECRET),
|
| 37 |
+
"paper": ALPACA_PAPER, "rebalance_enabled": bool(REBALANCE_TOKEN), "auto_rebalance": AUTO_REBALANCE}
|
qt3/scheduler.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""In-Space scheduler — the Space triggers its own monthly rebalance (no GitHub or outside cron needed).
|
| 2 |
+
|
| 3 |
+
A background thread wakes every 15 minutes. Each tick it rebuilds the ranking if it is older than 20 hours, and on
|
| 4 |
+
weekdays between 10:00 and 15:30 New York time it calls the same guarded rebalance as POST /api/rebalance: that
|
| 5 |
+
trades only while the market is open, on one of the first 5 trading days of a month (or the very first run), once
|
| 6 |
+
a month, on data at most 5 days old — every other tick is a cheap no-op. A lock makes the scheduler and the API
|
| 7 |
+
never run a rebalance at the same time; Alpaca's order history (orders tagged "qt3-") is the only state, so a
|
| 8 |
+
restart cannot cause a second trade. The thread needs the Space to be awake (see README: keep-awake).
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import threading
|
| 14 |
+
import time
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
from datetime import time as dtime
|
| 17 |
+
|
| 18 |
+
from . import config, ranking, rebalance
|
| 19 |
+
from .broker import Alpaca
|
| 20 |
+
|
| 21 |
+
FROM, TO = dtime(10, 0), dtime(15, 30) # New York time; trades after the opening auction settles
|
| 22 |
+
STATE = {"enabled": config.AUTO_REBALANCE, "running": False, "interval_min": None, "last_tick": None,
|
| 23 |
+
"last_attempt": None, "last_result": None, "last_error": None}
|
| 24 |
+
_lock = threading.Lock()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def due(now: datetime) -> bool:
|
| 28 |
+
now = now.astimezone(rebalance.NY)
|
| 29 |
+
return now.weekday() < 5 and FROM <= now.time() <= TO
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def run_once(force: bool = False, alpaca: Alpaca | None = None) -> dict:
|
| 33 |
+
"""One guarded rebalance attempt (shared by the scheduler and the API)."""
|
| 34 |
+
if not _lock.acquire(blocking=False):
|
| 35 |
+
return {"executed": False, "reasons": ["a rebalance is already running"]}
|
| 36 |
+
try:
|
| 37 |
+
r = ranking.refresh()
|
| 38 |
+
a = alpaca or Alpaca()
|
| 39 |
+
chk = rebalance.check(a, r, None, force)
|
| 40 |
+
if not chk["ok"]:
|
| 41 |
+
return {"executed": False, "reasons": chk["reasons"]}
|
| 42 |
+
p = rebalance.build_plan(a, r)
|
| 43 |
+
res = rebalance.execute(a, p)
|
| 44 |
+
return {"executed": True, "plan": p, "results": res}
|
| 45 |
+
finally:
|
| 46 |
+
_lock.release()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _tick() -> None:
|
| 50 |
+
now = datetime.now(rebalance.NY)
|
| 51 |
+
STATE["last_tick"] = now.isoformat(timespec="seconds")
|
| 52 |
+
if config.NDL_KEY:
|
| 53 |
+
ranking.refresh() # no-op unless older than RANKING_MAX_AGE_H
|
| 54 |
+
if config.AUTO_REBALANCE and due(now):
|
| 55 |
+
out = run_once()
|
| 56 |
+
STATE["last_attempt"] = now.isoformat(timespec="seconds")
|
| 57 |
+
STATE["last_result"] = ({"executed": True, "orders": [f"{x['side']} {x['symbol']} {x.get('status')}"
|
| 58 |
+
for x in out.get("results", [])]}
|
| 59 |
+
if out.get("executed") else {"executed": False, "reasons": out.get("reasons")})
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _loop(interval_s: float) -> None:
|
| 63 |
+
while True:
|
| 64 |
+
try:
|
| 65 |
+
_tick()
|
| 66 |
+
STATE["last_error"] = None
|
| 67 |
+
except Exception as e: # noqa: BLE001 — keep the thread alive; the next tick retries
|
| 68 |
+
STATE["last_error"] = f"{type(e).__name__}: {e}"[:300]
|
| 69 |
+
time.sleep(interval_s)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def start(interval_s: float = 900) -> bool:
|
| 73 |
+
configured = config.NDL_KEY and config.ALPACA_KEY and config.ALPACA_SECRET
|
| 74 |
+
if STATE["running"] or not configured or os.getenv("QT3_DISABLE_SCHEDULER"):
|
| 75 |
+
return False
|
| 76 |
+
threading.Thread(target=_loop, args=(interval_s,), daemon=True, name="updraft-scheduler").start()
|
| 77 |
+
STATE.update(running=True, interval_min=interval_s / 60)
|
| 78 |
+
return True
|
tests/test_qt3_space.py
CHANGED
|
@@ -218,3 +218,26 @@ def test_rebalance_endpoint_requires_token(monkeypatch, tmp_path):
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# --------------------------------------------------------------------------- in-Space scheduler
|
| 224 |
+
def test_scheduler_window_and_guarded_run(monkeypatch):
|
| 225 |
+
from qt3 import scheduler
|
| 226 |
+
ny = rebalance.NY
|
| 227 |
+
assert scheduler.due(datetime(2026, 11, 2, 10, 15, tzinfo=ny)) # Monday 10:15 New York
|
| 228 |
+
assert not scheduler.due(datetime(2026, 11, 2, 9, 45, tzinfo=ny)) # before 10:00
|
| 229 |
+
assert not scheduler.due(datetime(2026, 11, 2, 15, 45, tzinfo=ny)) # after 15:30
|
| 230 |
+
assert not scheduler.due(datetime(2026, 11, 7, 11, 0, tzinfo=ny)) # Saturday
|
| 231 |
+
monkeypatch.setattr(ranking, "refresh", lambda force=False: FRESH)
|
| 232 |
+
a = FakeAlpaca(open_=False)
|
| 233 |
+
out = scheduler.run_once(alpaca=a)
|
| 234 |
+
assert not out["executed"] and "market closed" in out["reasons"][0] and a.submitted == []
|
| 235 |
+
assert scheduler._lock.acquire(blocking=False) # lock released afterwards
|
| 236 |
+
scheduler._lock.release()
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def test_scheduler_not_started_without_keys(monkeypatch):
|
| 240 |
+
from qt3 import scheduler
|
| 241 |
+
monkeypatch.setattr(config, "ALPACA_KEY", "")
|
| 242 |
+
monkeypatch.setattr(scheduler, "STATE", {**scheduler.STATE, "running": False})
|
| 243 |
+
assert scheduler.start() is False
|