Spaces:
Runtime error
Runtime error
Upload 33 files
Browse files- automation.py +52 -5
- server.py +6 -1
- ui_kits/chan-compass/api.js +1 -0
- ui_kits/chan-compass/app.jsx +26 -1
- ui_kits/chan-compass/views.jsx +11 -2
automation.py
CHANGED
|
@@ -43,8 +43,53 @@ STATE = {
|
|
| 43 |
"log": [],
|
| 44 |
}
|
| 45 |
_lock = threading.Lock()
|
|
|
|
| 46 |
|
| 47 |
_LAST_RUN_FILE = os.path.join(paths.OUTPUT_DIR, "last_run.txt")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
def _save_last_run(when: dt.datetime):
|
|
@@ -134,11 +179,12 @@ def run_pipeline(tickers=None, force: bool = True) -> str:
|
|
| 134 |
|
| 135 |
STATE["last_run"] = dt.datetime.now(NY)
|
| 136 |
_save_last_run(STATE["last_run"])
|
| 137 |
-
|
|
|
|
| 138 |
return f"Done. {summary}"
|
| 139 |
except Exception as e:
|
| 140 |
traceback.print_exc()
|
| 141 |
-
_log(f"Pipeline error: {e}")
|
| 142 |
return f"Pipeline error: {e}"
|
| 143 |
finally:
|
| 144 |
STATE["running"] = False
|
|
@@ -164,8 +210,9 @@ def start_scheduler():
|
|
| 164 |
def schedule_info() -> str:
|
| 165 |
now = dt.datetime.now(NY)
|
| 166 |
last = STATE["last_run"].strftime("%Y-%m-%d %H:%M ET") if STATE["last_run"] else "never"
|
|
|
|
| 167 |
return (f"**Schedule:** Mon–Fri **{RUN_HOUR:02d}:{RUN_MINUTE:02d} America/New_York** "
|
| 168 |
-
f"(
|
| 169 |
f"**Now (ET):** {now.strftime('%Y-%m-%d %H:%M')} · **Last run:** {last}\n\n"
|
| 170 |
-
f"
|
| 171 |
-
f"
|
|
|
|
| 43 |
"log": [],
|
| 44 |
}
|
| 45 |
_lock = threading.Lock()
|
| 46 |
+
_SCHED = None # holds the BackgroundScheduler so it isn't garbage-collected
|
| 47 |
|
| 48 |
_LAST_RUN_FILE = os.path.join(paths.OUTPUT_DIR, "last_run.txt")
|
| 49 |
+
_RESULTS_FILE = os.path.join(paths.OUTPUT_DIR, "last_results.json")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _df_to_records(df):
|
| 53 |
+
try:
|
| 54 |
+
return df.to_dict(orient="records") if df is not None and hasattr(df, "to_dict") else []
|
| 55 |
+
except Exception:
|
| 56 |
+
return []
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def save_results():
|
| 60 |
+
"""Persist the latest pipeline output (signals + rotation + news) to /data so
|
| 61 |
+
the app shows the last run's results after a restart, with no recompute."""
|
| 62 |
+
import json
|
| 63 |
+
try:
|
| 64 |
+
import rotation
|
| 65 |
+
d1, d5, d20, asof = STATE.get("rotation", (None, None, None, "—"))
|
| 66 |
+
payload = {
|
| 67 |
+
"signals_rows": _df_to_records(STATE.get("signals_df")),
|
| 68 |
+
"signals_summary": STATE.get("signals_summary", ""),
|
| 69 |
+
"rotation": {
|
| 70 |
+
"asof": asof,
|
| 71 |
+
"d1": _df_to_records(rotation.fmt_table(d1)) if d1 is not None else [],
|
| 72 |
+
"d5": _df_to_records(rotation.fmt_table(d5)) if d5 is not None else [],
|
| 73 |
+
"d20": _df_to_records(rotation.fmt_table(d20)) if d20 is not None else [],
|
| 74 |
+
},
|
| 75 |
+
"rotation_narrative": STATE.get("rotation_narrative", ""),
|
| 76 |
+
"news_md": STATE.get("news_md", ""),
|
| 77 |
+
"saved_at": dt.datetime.now(NY).isoformat(),
|
| 78 |
+
}
|
| 79 |
+
with open(_RESULTS_FILE, "w", encoding="utf-8") as f:
|
| 80 |
+
json.dump(payload, f, ensure_ascii=False)
|
| 81 |
+
except Exception as e:
|
| 82 |
+
_log(f"save_results failed: {e}")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def load_results() -> dict:
|
| 86 |
+
"""Read the persisted last-run results (for the frontend on page load)."""
|
| 87 |
+
import json
|
| 88 |
+
try:
|
| 89 |
+
with open(_RESULTS_FILE, encoding="utf-8") as f:
|
| 90 |
+
return json.load(f)
|
| 91 |
+
except Exception:
|
| 92 |
+
return {}
|
| 93 |
|
| 94 |
|
| 95 |
def _save_last_run(when: dt.datetime):
|
|
|
|
| 179 |
|
| 180 |
STATE["last_run"] = dt.datetime.now(NY)
|
| 181 |
_save_last_run(STATE["last_run"])
|
| 182 |
+
save_results()
|
| 183 |
+
_log("Pipeline finished — Last run updated, results saved.")
|
| 184 |
return f"Done. {summary}"
|
| 185 |
except Exception as e:
|
| 186 |
traceback.print_exc()
|
| 187 |
+
_log(f"Pipeline error (Last run not updated): {e}")
|
| 188 |
return f"Pipeline error: {e}"
|
| 189 |
finally:
|
| 190 |
STATE["running"] = False
|
|
|
|
| 210 |
def schedule_info() -> str:
|
| 211 |
now = dt.datetime.now(NY)
|
| 212 |
last = STATE["last_run"].strftime("%Y-%m-%d %H:%M ET") if STATE["last_run"] else "never"
|
| 213 |
+
armed = "✅ Scheduler armed" if _SCHED is not None else "⏳ Scheduler starting…"
|
| 214 |
return (f"**Schedule:** Mon–Fri **{RUN_HOUR:02d}:{RUN_MINUTE:02d} America/New_York** "
|
| 215 |
+
f"(after the 16:00 ET close).\n\n"
|
| 216 |
f"**Now (ET):** {now.strftime('%Y-%m-%d %H:%M')} · **Last run:** {last}\n\n"
|
| 217 |
+
f"{armed} — it runs automatically every weekday at "
|
| 218 |
+
f"{RUN_HOUR:02d}:{RUN_MINUTE:02d} ET. You can also trigger it any time with **Run now**.")
|
server.py
CHANGED
|
@@ -59,6 +59,11 @@ def _df_records(df):
|
|
| 59 |
|
| 60 |
|
| 61 |
# ───────────────────────── Signals ─────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
@app.post("/api/signals/run")
|
| 63 |
async def signals_run(req: Request):
|
| 64 |
body = await req.json()
|
|
@@ -340,7 +345,7 @@ async def send_email(req: Request):
|
|
| 340 |
# ───────────────────────── startup ─────────────────────────
|
| 341 |
def _boot():
|
| 342 |
try:
|
| 343 |
-
automation.start_scheduler()
|
| 344 |
except Exception:
|
| 345 |
pass
|
| 346 |
if os.environ.get("AUTO_LOAD_MODEL", "1") == "1":
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
# ───────────────────────── Signals ─────────────────────────
|
| 62 |
+
@app.get("/api/last-results")
|
| 63 |
+
async def last_results():
|
| 64 |
+
return JSONResponse(automation.load_results())
|
| 65 |
+
|
| 66 |
+
|
| 67 |
@app.post("/api/signals/run")
|
| 68 |
async def signals_run(req: Request):
|
| 69 |
body = await req.json()
|
|
|
|
| 345 |
# ───────────────────────── startup ─────────────────────────
|
| 346 |
def _boot():
|
| 347 |
try:
|
| 348 |
+
automation._SCHED = automation.start_scheduler()
|
| 349 |
except Exception:
|
| 350 |
pass
|
| 351 |
if os.environ.get("AUTO_LOAD_MODEL", "1") == "1":
|
ui_kits/chan-compass/api.js
CHANGED
|
@@ -39,6 +39,7 @@ window.CCApi = (function () {
|
|
| 39 |
}
|
| 40 |
|
| 41 |
return {
|
|
|
|
| 42 |
runSignals: (pool, force) => jpost("/api/signals/run", { pool, force }),
|
| 43 |
signalSummary: (ticker, onChunk) =>
|
| 44 |
stream("/api/signals/summary?ticker=" + encodeURIComponent(ticker), onChunk),
|
|
|
|
| 39 |
}
|
| 40 |
|
| 41 |
return {
|
| 42 |
+
lastResults: () => jget("/api/last-results"),
|
| 43 |
runSignals: (pool, force) => jpost("/api/signals/run", { pool, force }),
|
| 44 |
signalSummary: (ticker, onChunk) =>
|
| 45 |
stream("/api/signals/summary?ticker=" + encodeURIComponent(ticker), onChunk),
|
ui_kits/chan-compass/app.jsx
CHANGED
|
@@ -77,10 +77,34 @@ class Boundary extends React.Component {
|
|
| 77 |
function App() {
|
| 78 |
const [tab, setTab] = useS('signals');
|
| 79 |
const [mkt, setMkt] = useS({label:"…", variant:"neutral"});
|
|
|
|
| 80 |
useE(()=>{
|
| 81 |
const tick = ()=> window.CCApi && window.CCApi.marketStatus().then(setMkt).catch(()=>{});
|
| 82 |
tick(); const id=setInterval(tick, 60000); return ()=>clearInterval(id);
|
| 83 |
},[]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
const items = [
|
| 85 |
{ id:'signals', label:'Signals', icon:<Glyph name="trending-up" size={16}/> },
|
| 86 |
{ id:'rotation', label:'Sector Rotation', icon:<Glyph name="refresh-cw" size={16}/> },
|
|
@@ -116,7 +140,8 @@ function App() {
|
|
| 116 |
</div>
|
| 117 |
|
| 118 |
<main className="cc-main">
|
| 119 |
-
<Boundary key={tab}>{View ? <View/> : null}</Boundary>
|
|
|
|
| 120 |
</main>
|
| 121 |
|
| 122 |
<footer className="cc-footer">
|
|
|
|
| 77 |
function App() {
|
| 78 |
const [tab, setTab] = useS('signals');
|
| 79 |
const [mkt, setMkt] = useS({label:"…", variant:"neutral"});
|
| 80 |
+
const [seeded, setSeeded] = useS(false);
|
| 81 |
useE(()=>{
|
| 82 |
const tick = ()=> window.CCApi && window.CCApi.marketStatus().then(setMkt).catch(()=>{});
|
| 83 |
tick(); const id=setInterval(tick, 60000); return ()=>clearInterval(id);
|
| 84 |
},[]);
|
| 85 |
+
// On first load, seed the views with the LAST pipeline results from /data, so
|
| 86 |
+
// the app shows what was computed (e.g. by the 18:10 ET schedule) without a
|
| 87 |
+
// recompute. Only fills store keys that aren't already set this session.
|
| 88 |
+
useE(()=>{
|
| 89 |
+
if (!window.CCApi) return;
|
| 90 |
+
window.CCApi.lastResults().then(r=>{
|
| 91 |
+
const S = window.CCStore;
|
| 92 |
+
if (r && r.signals_rows && r.signals_rows.length) {
|
| 93 |
+
if (!("sig.rows" in S)) S["sig.rows"] = r.signals_rows;
|
| 94 |
+
if (!("sig.summary" in S)) S["sig.summary"] = r.signals_summary || "";
|
| 95 |
+
if (!("sig.sel" in S) && r.signals_rows[0]) S["sig.sel"] = r.signals_rows[0].Ticker;
|
| 96 |
+
}
|
| 97 |
+
if (r && r.rotation) {
|
| 98 |
+
if (!("rot.d1" in S)) S["rot.d1"] = r.rotation.d1 || [];
|
| 99 |
+
if (!("rot.d5" in S)) S["rot.d5"] = r.rotation.d5 || [];
|
| 100 |
+
if (!("rot.d20" in S)) S["rot.d20"] = r.rotation.d20 || [];
|
| 101 |
+
if (!("rot.asof" in S)) S["rot.asof"] = r.rotation.asof || "";
|
| 102 |
+
if (!("rot.ai" in S) && r.rotation_narrative) S["rot.ai"] = r.rotation_narrative;
|
| 103 |
+
}
|
| 104 |
+
if (r && r.news_md && !("news.out" in S)) S["news.out"] = r.news_md;
|
| 105 |
+
setSeeded(true);
|
| 106 |
+
}).catch(()=>setSeeded(true));
|
| 107 |
+
},[]);
|
| 108 |
const items = [
|
| 109 |
{ id:'signals', label:'Signals', icon:<Glyph name="trending-up" size={16}/> },
|
| 110 |
{ id:'rotation', label:'Sector Rotation', icon:<Glyph name="refresh-cw" size={16}/> },
|
|
|
|
| 140 |
</div>
|
| 141 |
|
| 142 |
<main className="cc-main">
|
| 143 |
+
{seeded ? <Boundary key={tab}>{View ? <View/> : null}</Boundary>
|
| 144 |
+
: <div className="dim" style={{padding:24}}>Loading your latest results…</div>}
|
| 145 |
</main>
|
| 146 |
|
| 147 |
<footer className="cc-footer">
|
ui_kits/chan-compass/views.jsx
CHANGED
|
@@ -149,8 +149,17 @@ function SignalsView() {
|
|
| 149 |
<Kpi label="WAIT" value={counts.WAIT||0}/>
|
| 150 |
</div>
|
| 151 |
<Card title="Tomorrow's plan" subtitle="Long-hold mode - the unchanged Chan engine runs over each ticker">
|
| 152 |
-
<div style={{display:"flex", gap:"var(--space-200)", alignItems:"flex-end", marginBottom:"
|
| 153 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
<Checkbox checked={force} onChange={e=>setForce(e.target.checked)}>Force fresh download</Checkbox>
|
| 155 |
<Button variant="accent" onClick={run} disabled={running}>
|
| 156 |
{running ? <><Icon name="loader" size={15}/> Running...</> : <><Icon name="play" size={15}/> Run analysis</>}
|
|
|
|
| 149 |
<Kpi label="WAIT" value={counts.WAIT||0}/>
|
| 150 |
</div>
|
| 151 |
<Card title="Tomorrow's plan" subtitle="Long-hold mode - the unchanged Chan engine runs over each ticker">
|
| 152 |
+
<div style={{display:"flex", gap:"var(--space-200)", alignItems:"flex-end", marginBottom:"var(--space-250)"}}>
|
| 153 |
+
<div className="grow" style={{display:"flex", flexDirection:"column", gap:4}}>
|
| 154 |
+
<label className="s2-field__label">Ticker pool</label>
|
| 155 |
+
<span className="dim" style={{fontSize:12}}>Comma separated</span>
|
| 156 |
+
<input className="s2-field__input" value={pool}
|
| 157 |
+
onChange={e=>setPool(e.target.value)}
|
| 158 |
+
style={{fontFamily:"var(--font-mono)", height:"var(--control-height-md, 36px)",
|
| 159 |
+
padding:"0 12px", border:"1px solid var(--border-field, #b1b1b1)",
|
| 160 |
+
borderRadius:"var(--field-radius, 8px)", background:"var(--surface-card, #fff)",
|
| 161 |
+
color:"var(--text-body, #292929)", fontSize:14, width:"100%", boxSizing:"border-box"}}/>
|
| 162 |
+
</div>
|
| 163 |
<Checkbox checked={force} onChange={e=>setForce(e.target.checked)}>Force fresh download</Checkbox>
|
| 164 |
<Button variant="accent" onClick={run} disabled={running}>
|
| 165 |
{running ? <><Icon name="loader" size={15}/> Running...</> : <><Icon name="play" size={15}/> Run analysis</>}
|