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

Built-in monthly scheduler (no GitHub needed); README keep-awake notes

Browse files
Files changed (5) hide show
  1. README.md +7 -3
  2. app.py +13 -12
  3. qt3/config.py +2 -1
  4. qt3/scheduler.py +78 -0
  5. 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** — 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)
 
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
- 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
 
@@ -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"], **config.status()}
 
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
- 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}
 
 
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