Download app.py from s-ttp/updraft: direct link, hf CLI and curl.
- Browser
- Download file 9.11 kB
-
https://huggingface.co/spaces/s-ttp/updraft/resolve/main/app.py
- Command line
-
hf download hf://spaces/s-ttp/updraft/app.py
-
curl -L -o app.py https://huggingface.co/spaces/s-ttp/updraft/resolve/main/app.py
9.11 kB
| """Updraft (the Quality-Trend 3 strategy) — automated monthly strategy on an Alpaca paper account, with a public live page. | |
| Public (read-only): GET /, /api/health, /api/status, /api/portfolio, /api/performance, /api/orders, | |
| /api/history (rebalance by rebalance), /api/ranking, /api/plan (dry-run of what a rebalance would do now). | |
| Private: POST /api/rebalance?dry=0 and POST /api/refresh with header X-Rebalance-Token: <REBALANCE_TOKEN>. | |
| The Space schedules itself (qt3/scheduler.py: a 15-minute tick that rebuilds the ranking daily and attempts the | |
| guarded rebalance on weekdays 10:00-15:30 New York); an outside cron calling /api/rebalance is optional. Either | |
| way it trades only on the first trading days of a month (once), while the market is open, on fresh data. | |
| """ | |
| from __future__ import annotations | |
| import time | |
| from contextlib import asynccontextmanager | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from fastapi import FastAPI, Header, HTTPException | |
| from fastapi.responses import FileResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from starlette.concurrency import run_in_threadpool | |
| from qt3 import config, history, performance, ranking, rebalance, scheduler | |
| from qt3.broker import Alpaca, BrokerError | |
| async def lifespan(_app): | |
| if config.NDL_KEY: | |
| ranking.refresh_async() # build today's ranking in the background at start-up | |
| scheduler.start() # the Space's own monthly rebalance timer | |
| yield | |
| app = FastAPI(title="Updraft", lifespan=lifespan) | |
| STATIC = Path(__file__).parent / "static" | |
| app.mount("/static", StaticFiles(directory=STATIC), name="static") | |
| _cache: dict = {} | |
| def cached(key: str, ttl: float, fn): | |
| hit = _cache.get(key) | |
| if hit and time.time() - hit[0] < ttl: | |
| return hit[1] | |
| val = fn() | |
| _cache[key] = (time.time(), val) | |
| return val | |
| def broker() -> Alpaca: | |
| if not (config.ALPACA_KEY and config.ALPACA_SECRET): | |
| raise HTTPException(503, "Alpaca keys are not configured on this Space") | |
| return Alpaca() | |
| def _authorised(token: str) -> None: | |
| if not config.REBALANCE_TOKEN or token != config.REBALANCE_TOKEN: | |
| raise HTTPException(403, "rebalance disabled or bad token (set REBALANCE_TOKEN, send X-Rebalance-Token)") | |
| def index(): | |
| return FileResponse(STATIC / "index.html") | |
| def health(): | |
| return {"ok": True, **config.status(), "ranking_state": ranking.state(), "scheduler": scheduler.STATE} | |
| def get_ranking(): | |
| if config.NDL_KEY: | |
| ranking.refresh_async() | |
| r = ranking.cached() or {} | |
| age = ranking.age_hours(r or None) # inf until the first build finishes (not valid JSON) | |
| return {**r, "rule": {"names": config.N, "keep_within": config.KEEP, "pool": f"top {round((1 - config.POOL) * 100)}%"}, | |
| "building": ranking.state()["building"], "age_hours": round(age, 1) if age != float("inf") else None} | |
| async def portfolio(): | |
| def load(): | |
| a = broker() | |
| acct, pos = a.account(), a.positions() | |
| rk = {r["ticker"]: r for r in (ranking.cached() or {}).get("ranked", [])} | |
| rows = [] | |
| for s, p in sorted(pos.items(), key=lambda x: -x[1]["market_value"]): | |
| r = rk.get(s, {}) | |
| rows.append({"symbol": s, "name": r.get("name"), "sector": r.get("sector"), "qty": p["qty"], | |
| "price": p["price"], "value": p["market_value"], | |
| "weight": p["market_value"] / acct["equity"] if acct["equity"] else None, | |
| "pl": p["unrealized_pl"], "pl_pct": p["unrealized_plpc"], "today": p["change_today"], | |
| "rank_now": r.get("rank")}) | |
| return {"paper": a.paper, "equity": acct["equity"], "cash": acct["cash"], | |
| "today_change": acct["equity"] - acct["last_equity"] if acct["last_equity"] else None, | |
| "today_pct": acct["equity"] / acct["last_equity"] - 1 if acct["last_equity"] else None, | |
| "positions": rows, "as_of": datetime.now(timezone.utc).isoformat(timespec="seconds")} | |
| try: | |
| return await run_in_threadpool(cached, "portfolio", 30, load) | |
| except BrokerError as e: | |
| raise HTTPException(502, str(e)) | |
| async def perf(): | |
| try: | |
| return await run_in_threadpool(cached, "performance", 60, lambda: performance.series(broker())) | |
| except BrokerError as e: | |
| raise HTTPException(502, str(e)) | |
| async def orders(): | |
| def load(): | |
| a = broker() | |
| out = [] | |
| for o in a.orders(status="all"): | |
| if not str(o.get("client_order_id", "")).startswith(config.TAG + "-"): | |
| continue | |
| out.append({"date": (o.get("filled_at") or o.get("submitted_at") or "")[:10], "symbol": o.get("symbol"), | |
| "side": o.get("side"), "qty": o.get("filled_qty") or o.get("qty"), | |
| "notional": o.get("notional"), "price": o.get("filled_avg_price"), "status": o.get("status")}) | |
| return out[:200] | |
| try: | |
| return await run_in_threadpool(cached, "orders", 120, load) | |
| except BrokerError as e: | |
| raise HTTPException(502, str(e)) | |
| _names: dict = {} | |
| def _short_name(n: str | None) -> str | None: | |
| """Alpaca's listing name without the share-class tail ("Micron Technology, Inc. Common Stock" → "Micron Technology, Inc.").""" | |
| if not n: | |
| return n | |
| for tail in (" Common Stock", " Class A", " Class B", " Class C", " Ordinary Shares", " American Depositary", " Common Shares"): | |
| i = n.find(tail) | |
| if i > 0: | |
| n = n[:i] | |
| return n.strip() | |
| async def get_history(): | |
| def load(): | |
| a = broker() | |
| orders, pos = a.all_orders(), a.positions() | |
| flows = {} | |
| for f in a.cash_flows(): | |
| flows[f["date"]] = flows.get(f["date"], 0.0) + f["amount"] | |
| syms = {o["symbol"] for o in orders if str(o.get("client_order_id", "")).startswith(config.TAG + "-")} | |
| rk = {r["ticker"]: r.get("name") for r in (ranking.cached() or {}).get("ranked", [])} | |
| missing = sorted(s for s in syms if s not in _names and not rk.get(s)) | |
| if missing: # company names from Alpaca, once per symbol | |
| try: | |
| _names.update({s: _short_name(v.get("name")) for s, v in a.assets(missing).items()}) | |
| except BrokerError: | |
| pass | |
| return history.build(orders, pos, flows, {s: rk.get(s) or _names.get(s) for s in syms}) | |
| try: | |
| return await run_in_threadpool(cached, "history", 300, load) | |
| except BrokerError as e: | |
| raise HTTPException(502, str(e)) | |
| async def status(): | |
| def load(): | |
| a = broker() | |
| chk = rebalance.check(a, ranking.cached()) | |
| clk = a.clock() | |
| nxt = str(datetime.now(rebalance.NY).date()) if (chk["in_window"] or chk["first_run"]) and not chk["done_this_month"] \ | |
| else chk["next_rebalance"] | |
| return {"market_open": bool(clk.get("is_open")), "next_open": clk.get("next_open"), | |
| "next_rebalance": nxt, "done_this_month": chk["done_this_month"], "first_run": chk["first_run"], | |
| "would_trade_now": chk["ok"], "blocked_by": chk["reasons"], "scheduler": scheduler.STATE, | |
| **config.status()} | |
| try: | |
| return await run_in_threadpool(cached, "status", 60, load) | |
| except BrokerError as e: | |
| raise HTTPException(502, str(e)) | |
| async def dry_plan(): | |
| r = ranking.cached() | |
| if not r: | |
| raise HTTPException(503, "ranking not built yet") | |
| try: | |
| return await run_in_threadpool(cached, "plan", 120, lambda: rebalance.build_plan(broker(), r)) | |
| except (BrokerError, ValueError) as e: | |
| raise HTTPException(502, str(e)) | |
| async def force_refresh(x_rebalance_token: str = Header(default="")): | |
| _authorised(x_rebalance_token) | |
| r = await run_in_threadpool(ranking.refresh, True) | |
| return {"prices_as_of": (r or {}).get("prices_as_of"), "error": ranking.state()["error"]} | |
| async def run_rebalance(dry: int = 1, force: int = 0, x_rebalance_token: str = Header(default="")): | |
| """Dry-run by default. A real run needs dry=0 and the token; it then refreshes the ranking if needed, | |
| checks the guards (market open, rebalance window, once a month, fresh data) and executes.""" | |
| r = await run_in_threadpool(ranking.refresh) | |
| if dry: | |
| return {"dry_run": True, **(await dry_plan())} | |
| _authorised(x_rebalance_token) | |
| a = broker() | |
| try: | |
| out = await run_in_threadpool(scheduler.run_once, bool(force), a) | |
| except (BrokerError, ValueError) as e: | |
| raise HTTPException(502, f"rebalance failed: {e}") | |
| if out.get("executed"): | |
| _cache.clear() # portfolio, performance and history show the new trades at once | |
| return {"dry_run": False, **out} | |