Spaces:
Runtime error
Runtime error
File size: 14,686 Bytes
c341e23 c4d05bb c341e23 434d9ad c341e23 05546bf c341e23 c4d05bb c341e23 9b99d45 c341e23 05546bf e57b966 c341e23 9b99d45 c341e23 9b99d45 c341e23 c4d05bb 6d42820 c4d05bb 6d42820 c4d05bb 6d42820 c4d05bb c341e23 a946183 434d9ad a946183 c341e23 a946183 c4d05bb 5c8eff4 a946183 c4d05bb c341e23 a946183 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 | """
server.py - custom Spectrum 2 frontend for Gradio Server mode.
This is the Off-Brand entry point: instead of the default Gradio component
render, a gradio.Server instance serves the hand-built React UI
(ui_kits/chan-compass/) and exposes the unchanged Python backend as JSON/SSE
endpoints. The HF Space uses sdk: gradio and runs app.py, which calls
app.launch().
Architecture:
gradio.Server app
/api/... -> JSON + Server-Sent-Events endpoints calling the backend
/ -> the React frontend (static), Spectrum 2 design system
Backend modules (signal_runner, rotation, news_watch, research_agent,
automation, emailer, finetune_data, llm_local) are imported and called with no
business-logic changes.
"""
from __future__ import annotations
import json
import os
from fastapi import Request
from fastapi.responses import StreamingResponse, JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from gradio import Server
import paths # sets up /data, sys.path
import automation
import signal_runner
import rotation
import news_watch
import research_agent
import emailer
import finetune_data
import llm_local
HERE = os.path.dirname(os.path.abspath(__file__))
UI_DIR = os.path.join(HERE, "ui_kits", "chan-compass")
app = Server(title="Chan Compass · US")
# ───────────────────────── helpers ─────────────────────────
def _sse(gen):
"""Wrap a text generator as Server-Sent Events (one 'data:' per chunk)."""
def stream():
for chunk in gen:
yield f"data: {json.dumps({'text': chunk})}\n\n"
yield "event: done\ndata: {}\n\n"
return StreamingResponse(stream(), media_type="text/event-stream")
def _df_records(df):
if df is None or not hasattr(df, "to_dict"):
return []
return df.to_dict(orient="records")
# ───────────────────────── Signals ─────────────────────────
@app.get("/api/last-results")
async def last_results():
return JSONResponse(automation.load_results())
@app.post("/api/signals/run")
async def signals_run(req: Request):
body = await req.json()
tickers = [t.strip().upper() for t in
(body.get("pool") or "").replace("\n", ",").split(",") if t.strip()]
force = bool(body.get("force"))
df, details, summary, errors = signal_runner.run_signals(tickers or None, force=force)
automation.STATE["signals_df"] = df
automation.STATE["signals_details"] = details
automation.STATE["signals_summary"] = summary
return JSONResponse({"rows": _df_records(df), "summary": summary,
"tickers": sorted(details.keys())})
@app.get("/api/signals/raw")
async def signals_raw(ticker: str):
return JSONResponse({"raw": signal_runner.stock_raw_read(ticker)})
@app.get("/api/signals/summary")
async def signals_summary(ticker: str):
raw = signal_runner.stock_raw_read(ticker or "")
if not raw:
return _sse(iter(["Run the analysis and select a ticker first."]))
chain = automation.STATE.get("signals_details", {}).get(ticker or "", "")
chain_core = chain.split("日线买卖点逐项诊断")[0].strip()[:2000] if chain else ""
prompt = ("You are an equity analyst. Write a SHORT plain-English summary "
"(≤100 words) for a long-term holder of a US stock: the situation "
"today, whether to act or wait, and the key price levels.\n"
"Use the FACT LINE for the numbers, and the RULING CHAIN (a Chinese "
"multi-timeframe Chan-theory decision log) for the reasoning — "
"translate and synthesize it; output ENGLISH ONLY, no Chinese "
"characters, do not quote the log, no disclaimers.\n\n"
f"FACT LINE:\n{raw}")
if chain_core:
prompt += f"\n\nRULING CHAIN (translate & synthesize, don't quote):\n{chain_core}"
def gen():
final = ""
for acc in llm_local.chat_stream(prompt, max_tokens=240, temperature=0.2,
worker="interpreter"):
final = acc
yield acc
try:
finetune_data.record(raw, final)
except Exception:
pass
return _sse(gen())
# ───────────────────────── Sector Rotation ─────────────────────────
@app.get("/api/rotation")
async def rotation_tables():
d1, d5, d20, asof = rotation.build_rotation(force=True)
automation.STATE["rotation"] = (d1, d5, d20, asof)
return JSONResponse({
"asof": asof,
"d1": _df_records(rotation.fmt_table(d1)),
"d5": _df_records(rotation.fmt_table(d5)),
"d20": _df_records(rotation.fmt_table(d20)),
})
@app.get("/api/rotation/narrative")
async def rotation_narrative():
rot = automation.STATE.get("rotation")
if not rot or rot[0] is None:
return _sse(iter(["Refresh the rotation tables first."]))
d1, d5, d20, _ = rot
brief = rotation.rotation_brief(d1, d5, d20)
prompt = ("You are a US equity market strategist. Based only on the sector flow "
"data below (SPDR ETF proxy: change% × dollar volume, plus RS vs SPY), "
"write a crisp brief (<150 words): 1) where capital is rotating "
"INTO/OUT OF; 2) do 1-day moves agree with the 5/20-day trend; 3) one "
"watch item. No disclaimers.\n\nDATA:\n" + brief[:2200])
return _sse(llm_local.chat_stream(prompt, max_tokens=340, worker="narrator"))
# ───────────────────────── Watchlist News ─────────────────────────
@app.get("/api/news/holdings")
async def news_holdings():
return JSONResponse({"holdings": news_watch.load_holdings()})
@app.post("/api/news/save")
async def news_save(req: Request):
body = await req.json()
tickers = [t.strip().upper() for t in
(body.get("holdings") or "").replace("\n", ",").split(",") if t.strip()]
news_watch.save_holdings(tickers)
return JSONResponse({"saved": tickers})
@app.get("/api/news/check")
async def news_check():
return _sse(news_watch.check_holdings_news_stream())
# ───────────────────────── Auto Research ─────────────────────────
@app.get("/api/research/run")
async def research_run(ticker: str):
def gen():
last_report = ""
for progress, report in research_agent.run_research_stream(ticker):
last_report = report
yield json.dumps({"progress": progress, "report": report})
yield json.dumps({"progress": "__done__", "report": last_report,
"reports": research_agent.list_reports()})
def stream():
for chunk in gen():
yield f"data: {chunk}\n\n"
yield "event: done\ndata: {}\n\n"
return StreamingResponse(stream(), media_type="text/event-stream")
@app.get("/api/research/reports")
async def research_reports():
return JSONResponse({"reports": research_agent.list_reports()})
@app.get("/api/research/report")
async def research_report(name: str):
return JSONResponse({"markdown": research_agent.read_report(name)})
# ───────────────────────── Automation ─────────────────────────
@app.post("/api/automation/run")
async def automation_run():
import threading
if automation.STATE.get("running"):
return JSONResponse({"message": "Pipeline already running — watch the log."})
threading.Thread(target=lambda: automation.run_pipeline(force=True), daemon=True).start()
return JSONResponse({"message": "Pipeline started — the log updates live below."})
@app.get("/api/automation/state")
async def automation_state():
return JSONResponse({
"log": automation.STATE.get("log", [])[-40:],
"schedule": automation.schedule_info(),
"traces": research_agent.list_traces(),
"running": bool(automation.STATE.get("running")),
})
@app.post("/api/automation/publish-traces")
async def automation_publish(req: Request):
body = await req.json()
import trace_publish
return JSONResponse({"status": trace_publish.publish_traces(body.get("repo", ""))})
# ───────────────────────── Model ─────────────────────────
@app.get("/api/market/status")
async def market_status():
import datetime as dt
try:
from zoneinfo import ZoneInfo
now = dt.datetime.now(ZoneInfo("America/New_York"))
except Exception:
now = dt.datetime.utcnow()
wd = now.weekday() # 0=Mon … 6=Sun
minutes = now.hour * 60 + now.minute
is_weekday = wd < 5
# regular session 9:30–16:00 ET
is_open = is_weekday and (9*60+30) <= minutes < (16*60)
if is_open:
label, variant = "Market open", "positive"
elif is_weekday and minutes < (9*60+30):
label, variant = "Pre-market", "notice"
elif is_weekday and minutes >= (16*60):
label, variant = "After hours", "notice"
else:
label, variant = "Market closed · weekend", "neutral"
return JSONResponse({"open": is_open, "label": label, "variant": variant})
@app.get("/api/model/list")
async def model_list():
return JSONResponse({
"models": list(llm_local.MODEL_ZOO.keys()),
"analyst": llm_local.WORKERS["analyst"]["model"],
"analyst_ready": llm_local.WORKERS["analyst"]["llm"] is not None,
})
@app.post("/api/model/load")
async def model_load(req: Request):
body = await req.json()
name = body.get("model", "")
if name not in llm_local.MODEL_ZOO:
return JSONResponse({"status": "⚠️ Unknown model."})
import threading
threading.Thread(target=lambda: llm_local.load_model(name, worker="analyst"),
daemon=True).start()
return JSONResponse({"status": f"⏳ Loading {name} onto the Analyst sub-agent…"})
@app.get("/api/model/status")
async def model_status():
return JSONResponse({
"status": llm_local.status(),
"workers": {k: {"model": w["model"], "ready": w["llm"] is not None,
"stage": w["stage"]}
for k, w in llm_local.WORKERS.items()},
})
_SELFTEST = {"running": False, "result": ""}
@app.post("/api/model/test")
async def model_test():
import threading
if _SELFTEST["running"]:
return JSONResponse({"started": True, "running": True})
def _run():
_SELFTEST["running"] = True
_SELFTEST["result"] = ""
try:
_SELFTEST["result"] = llm_local.quick_test()
except Exception as e:
_SELFTEST["result"] = f"❌ {e}"
_SELFTEST["running"] = False
threading.Thread(target=_run, daemon=True).start()
return JSONResponse({"started": True, "running": True})
@app.get("/api/model/test-status")
async def model_test_status():
return JSONResponse({"running": _SELFTEST["running"], "result": _SELFTEST["result"]})
@app.get("/api/model/finetune-status")
async def finetune_status():
return JSONResponse({"status": finetune_data.status_line(),
"count": finetune_data.count()})
@app.post("/api/model/export-dataset")
async def export_dataset():
path = finetune_data.export()
if not path:
return JSONResponse({"path": "", "count": 0, "download": ""})
# Copy into a WRITABLE served dir. In Docker, /app is read-only for the
# non-root user, so use /data (persistent) when available, else the temp dir.
import shutil
import tempfile
base = paths.DATA_ROOT if paths.PERSISTENT else tempfile.gettempdir()
served_dir = os.path.join(base, "exports")
try:
os.makedirs(served_dir, exist_ok=True)
except OSError:
served_dir = tempfile.gettempdir()
fname = os.path.basename(path)
try:
shutil.copy(path, os.path.join(served_dir, fname))
except OSError:
pass
return JSONResponse({"path": path, "count": finetune_data.count(),
"download": "/download/" + fname})
@app.get("/download/{fname}")
async def download_file(fname: str):
import tempfile
base = paths.DATA_ROOT if paths.PERSISTENT else tempfile.gettempdir()
fname = os.path.basename(fname)
for d in (os.path.join(base, "exports"), os.path.join(tempfile.gettempdir(), "exports"),
tempfile.gettempdir()):
served = os.path.join(d, fname)
if os.path.exists(served):
return FileResponse(served, filename=fname, media_type="application/jsonl")
return JSONResponse({"error": "not found"}, status_code=404)
# ───────────────────────── Email (all tabs) ─────────────────────────
@app.post("/api/email")
async def send_email(req: Request):
body = await req.json()
status = emailer.send_result(body.get("content", ""), body.get("to", ""),
body.get("tag", "Chan Compass"))
return JSONResponse({"status": status})
# ───────────────────────── startup ─────────────────────────
def _boot():
try:
automation._SCHED = automation.start_scheduler()
except Exception:
pass
if os.environ.get("AUTO_LOAD_MODEL", "1") == "1":
import threading
threading.Thread(target=llm_local.auto_load_all, daemon=True).start()
# Static frontend mounted LAST so /api/* and /download/* win.
app.mount("/", StaticFiles(directory=UI_DIR, html=True), name="ui")
@app.middleware("http")
async def _no_cache_assets(request, call_next):
resp = await call_next(request)
p = request.url.path
if p.endswith((".jsx", ".js", ".css", ".html")) or p == "/":
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
return resp
# Boot background services at import time (uvicorn serves `app` directly).
_boot()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0",
port=int(os.environ.get("PORT", "7860")))
|