Chan-Compass / signal_runner.py
ranranrunforit's picture
Upload 16 files
1c06038 verified
Raw
History Blame Contribute Delete
11 kB
"""
signal_runner.py — run the (unchanged) Chan multi-level engine over a US ticker pool.
Levels per ticker: monthly / weekly (resampled from 10y daily)
daily (yfinance 1d)
60m / 30m / 15m / 5m (yfinance intraday)
1m (not available beyond 7 days on Yahoo → skipped;
MultiLevelChan degrades gracefully)
"""
from __future__ import annotations
import os
import traceback
import pandas as pd
import chan_glue # noqa: F401 (wires engine into chan_multilevel, installs cached factory)
# Import names straight from their home module — never rely on re-exports,
# so a stale chan_glue.py on the Space can't break startup.
from chan_multilevel import MultiLevelChan, resample_weekly, resample_monthly
import chan_enhance
import data_us
DEFAULT_POOL = ["AAPL", "MSFT", "NVDA", "TSLA", "AMZN", "GOOGL", "META", "AMD", "NFLX", "JPM"]
# ── LONG-HOLD mode (user requirement for the US version) ────────────────
# Operating level = weekly: ride the pivot uplift, don't get shaken out early.
# mode='long' → daily S1/S2 in a big uptrend → HOLD (armed)
# require_sublevel_sell_confirm → unconfirmed daily sells in an uptrend → HOLD
# Real exits that still fire: S3 (pivot breakdown), structural stop,
# and the armed exit line once the nested-interval top is confirmed.
MultiLevelChan.CFG["mode"] = "long"
MultiLevelChan.CFG["require_sublevel_sell_confirm"] = True
STOP_MAX_LOSS = 0.05 # same global loss cap as the user's backtest
def _structural_stop(kind: str, res) -> float | None:
"""Simplified invalidation price, lifted from the user's backtest logic:
B1 → divergence low; B2 → retest low / B1 anchor; B3 → daily pivot ZD.
Capped so a single position can never lose much more than STOP_MAX_LOSS."""
sig = res.daily.signal if (res and res.daily) else None
ex = (sig.extras if sig is not None else None) or {}
close_p = float(res.cur_price)
stop = None
if kind == "B1":
stop = ex.get("c_new_low") or ex.get("b1_price")
elif kind == "B2":
stop = ex.get("cur_low") or ex.get("b1_price")
elif kind == "B3":
stop = res.daily.zd if (res.daily and res.daily.zd) else ex.get("pull_low")
if stop is None:
stop = close_p * (1 - STOP_MAX_LOSS)
stop = max(min(float(stop), close_p * 0.999), close_p * (1 - STOP_MAX_LOSS))
return round(stop, 2)
def _next_day_plan(res) -> dict:
"""The simplified answer: do I buy/sell tomorrow, the exact BUY POINT, the
acceptable entry zone, and the invalidation price. All prices come straight
from the engine's signal.extras (same source as the user's backtest)."""
kind, act = res.final_kind, res.action
px = float(res.cur_price)
if act == "BUY" and kind in ("B1", "B2", "B3"):
stop = _structural_stop(kind, res)
# The BUY POINT = the engine's structural level for this signal type:
# B1 divergence low · B2 retest low · B3 pivot upper band (ZG).
sig = res.daily.signal if res.daily else None
ex = (sig.extras if sig else None) or {}
if kind == "B3":
point = float(res.daily.zg) if (res.daily and res.daily.zg) else stop
elif kind == "B1":
point = float(ex.get("c_new_low") or stop)
else: # B2
point = float(ex.get("cur_low") or ex.get("b1_price") or stop)
lo = min(point, stop) if kind == "B3" else point
hi = px * 1.015
return {"plan": "🟢 BUY tomorrow at open",
"point": f"${point:,.2f}",
"zone": f"${min(lo, hi):,.2f} – ${hi:,.2f}",
"stop": f"${stop:,.2f}",
"hint": f"Long-hold entry near ${point:,.2f}; keep until S3 / stop / armed exit."}
if act == "SELL":
hint = {"STOP": "Structural stop hit — exit to protect capital.",
"S3": "Pivot breakdown (S3) — the long-hold exit signal. Exit, don't average down."}
return {"plan": "🔴 SELL tomorrow at open", "point": f"≈ ${px:,.2f}",
"zone": f"≈ ${px:,.2f}", "stop": "—",
"hint": hint.get(kind, "Confirmed top (divergence verified at sub-levels) — take profit.")}
if act == "HOLD":
if res.sell_armed and res.arm_zd:
return {"plan": "🟡 HOLD (exit line armed)", "point": "—", "zone": "—",
"stop": f"${float(res.arm_zd):,.2f}",
"hint": f"Keep holding; sell only if price closes below ${float(res.arm_zd):,.2f}."}
return {"plan": "🟡 HOLD", "point": "—", "zone": "—", "stop": "—",
"hint": "Trend intact — long-hold, ignore daily noise."}
# WAIT
if kind:
hint = (f"{kind} signal on the daily chart but NOT yet confirmed down the "
f"nested sub-levels (60m→30m→15m→5m) — wait, don't chase.")
elif res.blocked_reason:
hint = "Signal blocked by a higher-timeframe gate (weekly/monthly direction). Stay out."
else:
wk = TREND_EN.get(res.weekly.trend if res.weekly else "", "?")
dy = TREND_EN.get(res.daily.trend if res.daily else "", "?")
hint = f"No buy/sell point today (weekly {wk}, daily {dy}). Stay in cash / keep watching."
return {"plan": "⚪ WAIT", "point": "—", "zone": "—", "stop": "—", "hint": hint}
import paths
OUT_DIR = paths.OUTPUT_DIR
ACTION_BADGE = {"BUY": "🟢 BUY", "SELL": "🔴 SELL", "HOLD": "🟡 HOLD", "WATCH": "⚪ WATCH"}
KIND_EN = {
"B1": "B1 · 1st buy (trend-end divergence)",
"B2": "B2 · 2nd buy (higher-low retest)",
"B3": "B3 · 3rd buy (pivot breakout retest)",
"S1": "S1 · 1st sell (top divergence)",
"S2": "S2 · 2nd sell (lower-high rebound)",
"S3": "S3 · 3rd sell (pivot breakdown)",
"STOP": "STOP · structural stop-loss",
"": "—",
}
TREND_EN = {"up_trend": "Up", "down_trend": "Down", "consolidation": "Range",
"expanding": "Expanding", "unknown": "?", "": "?"}
def stock_raw_read(ticker: str) -> str:
"""Plain-English factual snapshot of one ticker's Chan verdict — the
deterministic 'Raw read' the Signals AI narrative summarizes (mirrors the
Sector-Rotation pattern: raw read → agent → narrative)."""
row = automation_state_row(ticker)
if not row:
return ""
parts = [
f"{ticker}: close {row['Close']}, signal {row['Signal']}, "
f"confidence {row['Confidence']}.",
f"Plan tomorrow: {row['Tomorrow']}.",
]
if row.get("Buy point") not in ("—", None):
parts.append(f"Buy point {row['Buy point']}, zone {row['Buy zone']}, "
f"invalid below {row['Invalid below']}.")
parts.append(f"Note: {row['Note']}")
return " ".join(parts)
def automation_state_row(ticker: str):
import automation
df = automation.STATE.get("signals_df")
if df is None or "Ticker" not in getattr(df, "columns", []):
return None
m = df[df["Ticker"] == ticker]
return m.iloc[0].to_dict() if len(m) else None
def analyze_one(ticker: str, force: bool = False):
"""Run the simplified long-hold Chan analysis for one ticker.
Full nested-interval set: monthly/weekly (resampled) + daily +
60m/30m/15m/5m/1m confirmation (区间套). Deeper sub-levels = more precise
buy/sell points; any missing level (e.g. 1m beyond 7 days) is skipped."""
dfs = data_us.load_levels(ticker, data_us.FULL_LEVELS, force=force)
d = dfs["d"]
if d is None or len(d) < 60:
return None, f"{ticker}: not enough daily history ({0 if d is None else len(d)} bars)."
w = resample_weekly(d)
m = resample_monthly(d)
ml = MultiLevelChan(
df_daily=d, df_weekly=w, df_monthly=m,
df_60m=dfs.get("60m"), df_30m=dfs.get("30m"),
df_15m=dfs.get("15m"), df_5m=dfs.get("5m"),
df_1m=dfs.get("1m"), # finest nested-interval level when Yahoo has it
code=ticker, strict=True,
)
res = ml.analyze()
if res is None:
return None, f"{ticker}: analysis returned no result (insufficient structure)."
enh = chan_enhance.predict_enhance(res)
weight = enh.get("suggest_weight")
plan = _next_day_plan(res)
row = {
"Ticker": ticker,
"Tomorrow": plan["plan"],
"Buy point": plan["point"],
"Buy zone": plan["zone"],
"Invalid below": plan["stop"],
"Signal": KIND_EN.get(res.final_kind, res.final_kind or "—"),
"Confidence": res.confidence,
"Close": f"${res.cur_price:,.2f}",
"Weight": (f"{weight:.2f}" if weight else "—"),
"Note": plan["hint"],
"_action_raw": res.action,
"_kind_raw": res.final_kind,
"_date": res.analysis_date.strftime("%Y-%m-%d"),
}
detail = res.explain()
extra_lines = []
for k in ("l16_note", "l37_note", "evo_hint", "l92_warn"):
if enh.get(k):
extra_lines.append(" " + enh[k])
if extra_lines:
detail += "\n ── 增强提示 (chan_enhance) ──\n" + "\n".join(extra_lines)
return row, detail
def run_signals(tickers=None, force: bool = False):
"""Run the whole pool. Returns (DataFrame, {ticker: detail}, summary_str)."""
tickers = [t.strip().upper() for t in (tickers or DEFAULT_POOL) if t.strip()]
try: # parallel download phase (IO-bound) — analysis stays CPU-only, no LLM
data_us.prefetch(tickers, data_us.FULL_LEVELS, force=force, budget_s=60)
except Exception:
pass
rows, details, errors = [], {}, []
for t in tickers:
try:
row, detail = analyze_one(t, force=force)
if row is None:
errors.append(detail)
continue
rows.append(row)
details[t] = detail
except Exception as e:
traceback.print_exc()
errors.append(f"{t}: {e}")
if rows:
df = pd.DataFrame(rows)
order = {"BUY": 0, "SELL": 1, "HOLD": 2, "WATCH": 3}
df["_o"] = df["_action_raw"].map(order).fillna(9)
df = df.sort_values(["_o", "Ticker"]).drop(columns=["_o"]).reset_index(drop=True)
show = df.drop(columns=[c for c in df.columns if c.startswith("_")])
try:
df.to_csv(os.path.join(OUT_DIR, "signals_latest.csv"), index=False)
except Exception:
pass
else:
show = pd.DataFrame(columns=["Ticker", "Tomorrow", "Buy point", "Buy zone", "Invalid below", "Signal", "Confidence", "Close"])
n_buy = sum(1 for r in rows if r["_action_raw"] == "BUY")
n_sell = sum(1 for r in rows if r["_action_raw"] == "SELL")
asof = rows[0]["_date"] if rows else "—"
summary = (f"Analyzed {len(rows)}/{len(tickers)} tickers · as of {asof} · "
f"{n_buy} BUY · {n_sell} SELL")
if errors:
summary += f" · {len(errors)} skipped"
return show, details, summary, errors