Spaces:
Runtime error
Runtime error
Upload 15 files
Browse files- app.py +22 -12
- automation.py +10 -3
- llm_local.py +43 -21
- news_watch.py +6 -5
- research_agent.py +194 -130
- rotation.py +1 -1
app.py
CHANGED
|
@@ -90,6 +90,13 @@ table{font-size:13.5px!important;}
|
|
| 90 |
thead th{background:var(--s2-gray-75)!important;font-weight:700!important;}
|
| 91 |
|
| 92 |
.s2-footnote{color:#6e6e6e;font-size:12.5px;}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
#detail-log textarea{font-family:'Source Code Pro',monospace!important;font-size:12.5px!important;}
|
| 94 |
"""
|
| 95 |
|
|
@@ -128,14 +135,17 @@ def ui_explain_detail(ticker):
|
|
| 128 |
# keep only the ruling chain — the per-signal diagnostics block is huge and
|
| 129 |
# would cost a minute of silent CPU prompt-processing for no benefit
|
| 130 |
core = txt.split("日线买卖点逐项诊断")[0][:1500]
|
| 131 |
-
prompt = ("Below is a Chan-theory
|
| 132 |
-
"
|
| 133 |
-
"
|
| 134 |
-
"
|
| 135 |
-
"
|
|
|
|
|
|
|
|
|
|
| 136 |
yield ("🤖 _Translator sub-agent (Qwen3-1.7B · llama.cpp) is reading the "
|
| 137 |
"decision log — first words in ~5-15s…_")
|
| 138 |
-
for acc in llm_local.chat_stream(prompt, max_tokens=
|
| 139 |
yield "🤖 **Translator sub-agent (Qwen3-1.7B · llama.cpp):**\n\n" + acc
|
| 140 |
|
| 141 |
|
|
@@ -162,7 +172,7 @@ def ui_rotation_ai():
|
|
| 162 |
"No disclaimers.\n\nDATA:\n" + brief[:2200])
|
| 163 |
yield ("🤖 _Narrator sub-agent (Qwen3-1.7B · llama.cpp) is reading the flow "
|
| 164 |
"tables — first words in ~5-15s…_")
|
| 165 |
-
for acc in llm_local.chat_stream(prompt, max_tokens=340, worker="
|
| 166 |
yield "🤖 **Narrator sub-agent (Qwen3-1.7B · llama.cpp):**\n\n" + acc
|
| 167 |
|
| 168 |
|
|
@@ -227,8 +237,8 @@ with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
|
|
| 227 |
with gr.Row():
|
| 228 |
detail_pick = gr.Dropdown(choices=[], label="Ticker", scale=2)
|
| 229 |
explain_btn = gr.Button("🌐 Explain in English (local LLM)", scale=1)
|
| 230 |
-
detail_box = gr.Textbox(lines=
|
| 231 |
-
explain_box = gr.Markdown()
|
| 232 |
|
| 233 |
with gr.Tab("🔄 Sector Rotation"):
|
| 234 |
gr.Markdown("Capital rotation across the 11 SPDR sector ETFs (full S&P 500 coverage). "
|
|
@@ -243,7 +253,7 @@ with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
|
|
| 243 |
with gr.Row():
|
| 244 |
rot_5d = gr.Dataframe(label="5-Day (week trend)", interactive=False)
|
| 245 |
rot_20d = gr.Dataframe(label="20-Day (month trend)", interactive=False)
|
| 246 |
-
rot_ai = gr.Markdown(label="AI rotation narrative")
|
| 247 |
|
| 248 |
with gr.Tab("📰 Watchlist News"):
|
| 249 |
gr.Markdown("Daily rule: for each **holding**, only **today's** news is checked. "
|
|
@@ -268,14 +278,14 @@ with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
|
|
| 268 |
res_in = gr.Textbox(label="Ticker", placeholder="e.g. NVDA", scale=3)
|
| 269 |
res_btn = gr.Button("🤖 Run research agent", variant="primary", scale=1)
|
| 270 |
res_progress = gr.Markdown()
|
| 271 |
-
res_out = gr.Markdown()
|
| 272 |
gr.Markdown("**Report library** (auto + manual, stored on `/data`):",
|
| 273 |
elem_classes=["s2-footnote"])
|
| 274 |
with gr.Row():
|
| 275 |
rep_pick = gr.Dropdown(choices=research_agent.list_reports(),
|
| 276 |
label="Saved reports", scale=3)
|
| 277 |
rep_open = gr.Button("📂 Open report", scale=1)
|
| 278 |
-
rep_view = gr.Markdown()
|
| 279 |
|
| 280 |
with gr.Tab("⏰ Automation"):
|
| 281 |
gr.Markdown(paths.storage_status())
|
|
|
|
| 90 |
thead th{background:var(--s2-gray-75)!important;font-weight:700!important;}
|
| 91 |
|
| 92 |
.s2-footnote{color:#6e6e6e;font-size:12.5px;}
|
| 93 |
+
|
| 94 |
+
/* AI output panel — big, framed, unmissable */
|
| 95 |
+
.ai-panel{background:#fff;border:1.5px solid var(--s2-accent)!important;
|
| 96 |
+
border-left:6px solid var(--s2-accent)!important;border-radius:14px!important;
|
| 97 |
+
padding:18px 22px!important;min-height:240px;max-height:560px;overflow-y:auto;
|
| 98 |
+
font-size:15px;line-height:1.55;box-shadow:0 2px 10px rgba(2,101,220,.08);}
|
| 99 |
+
.ai-panel:empty::after{content:"AI output will appear here";color:#9a9a9a;}
|
| 100 |
#detail-log textarea{font-family:'Source Code Pro',monospace!important;font-size:12.5px!important;}
|
| 101 |
"""
|
| 102 |
|
|
|
|
| 135 |
# keep only the ruling chain — the per-signal diagnostics block is huge and
|
| 136 |
# would cost a minute of silent CPU prompt-processing for no benefit
|
| 137 |
core = txt.split("日线买卖点逐项诊断")[0][:1500]
|
| 138 |
+
prompt = ("Below is a Chan-theory multi-timeframe decision log in Chinese for a "
|
| 139 |
+
"US stock. Respond in ENGLISH ONLY — translate every Chinese term; no "
|
| 140 |
+
"Chinese characters may appear in your answer. Do NOT quote the log. "
|
| 141 |
+
"Give a SHORT summary in exactly this format:\n"
|
| 142 |
+
"**Action:** <BUY/SELL/HOLD/WAIT + one clause>\n"
|
| 143 |
+
"**Why:** <2-3 short bullets: which timeframe gates passed/failed>\n"
|
| 144 |
+
"**Invalidation:** <one line: what price/event flips the call>\n"
|
| 145 |
+
"Max 80 words total.\n\n" + core)
|
| 146 |
yield ("🤖 _Translator sub-agent (Qwen3-1.7B · llama.cpp) is reading the "
|
| 147 |
"decision log — first words in ~5-15s…_")
|
| 148 |
+
for acc in llm_local.chat_stream(prompt, max_tokens=220, temperature=0.1, worker="translator"):
|
| 149 |
yield "🤖 **Translator sub-agent (Qwen3-1.7B · llama.cpp):**\n\n" + acc
|
| 150 |
|
| 151 |
|
|
|
|
| 172 |
"No disclaimers.\n\nDATA:\n" + brief[:2200])
|
| 173 |
yield ("🤖 _Narrator sub-agent (Qwen3-1.7B · llama.cpp) is reading the flow "
|
| 174 |
"tables — first words in ~5-15s…_")
|
| 175 |
+
for acc in llm_local.chat_stream(prompt, max_tokens=340, worker="narrator"):
|
| 176 |
yield "🤖 **Narrator sub-agent (Qwen3-1.7B · llama.cpp):**\n\n" + acc
|
| 177 |
|
| 178 |
|
|
|
|
| 237 |
with gr.Row():
|
| 238 |
detail_pick = gr.Dropdown(choices=[], label="Ticker", scale=2)
|
| 239 |
explain_btn = gr.Button("🌐 Explain in English (local LLM)", scale=1)
|
| 240 |
+
detail_box = gr.Textbox(lines=14, label="Ruling chain", elem_id="detail-log")
|
| 241 |
+
explain_box = gr.Markdown(elem_classes=["ai-panel"])
|
| 242 |
|
| 243 |
with gr.Tab("🔄 Sector Rotation"):
|
| 244 |
gr.Markdown("Capital rotation across the 11 SPDR sector ETFs (full S&P 500 coverage). "
|
|
|
|
| 253 |
with gr.Row():
|
| 254 |
rot_5d = gr.Dataframe(label="5-Day (week trend)", interactive=False)
|
| 255 |
rot_20d = gr.Dataframe(label="20-Day (month trend)", interactive=False)
|
| 256 |
+
rot_ai = gr.Markdown(label="AI rotation narrative", elem_classes=["ai-panel"])
|
| 257 |
|
| 258 |
with gr.Tab("📰 Watchlist News"):
|
| 259 |
gr.Markdown("Daily rule: for each **holding**, only **today's** news is checked. "
|
|
|
|
| 278 |
res_in = gr.Textbox(label="Ticker", placeholder="e.g. NVDA", scale=3)
|
| 279 |
res_btn = gr.Button("🤖 Run research agent", variant="primary", scale=1)
|
| 280 |
res_progress = gr.Markdown()
|
| 281 |
+
res_out = gr.Markdown(elem_classes=["ai-panel"])
|
| 282 |
gr.Markdown("**Report library** (auto + manual, stored on `/data`):",
|
| 283 |
elem_classes=["s2-footnote"])
|
| 284 |
with gr.Row():
|
| 285 |
rep_pick = gr.Dropdown(choices=research_agent.list_reports(),
|
| 286 |
label="Saved reports", scale=3)
|
| 287 |
rep_open = gr.Button("📂 Open report", scale=1)
|
| 288 |
+
rep_view = gr.Markdown(elem_classes=["ai-panel"])
|
| 289 |
|
| 290 |
with gr.Tab("⏰ Automation"):
|
| 291 |
gr.Markdown(paths.storage_status())
|
automation.py
CHANGED
|
@@ -91,13 +91,20 @@ def run_pipeline(tickers=None, force: bool = True) -> str:
|
|
| 91 |
known = set()
|
| 92 |
current = set(df["Ticker"].tolist()) if df is not None and len(df) else set()
|
| 93 |
new_tickers = sorted(current - known)
|
|
|
|
| 94 |
for t in new_tickers[:5]: # safety cap per run
|
| 95 |
_log(f"New ticker {t} → auto-generating research report…")
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
if current:
|
| 99 |
with open(known_path, "w", encoding="utf-8") as f:
|
| 100 |
-
json.dump(sorted(
|
| 101 |
except Exception as e:
|
| 102 |
_log(f"Auto-research skipped: {e}")
|
| 103 |
|
|
|
|
| 91 |
known = set()
|
| 92 |
current = set(df["Ticker"].tolist()) if df is not None and len(df) else set()
|
| 93 |
new_tickers = sorted(current - known)
|
| 94 |
+
generated = set()
|
| 95 |
for t in new_tickers[:5]: # safety cap per run
|
| 96 |
_log(f"New ticker {t} → auto-generating research report…")
|
| 97 |
+
report, trace = research_agent.run_research(t, auto=True)
|
| 98 |
+
if report:
|
| 99 |
+
generated.add(t)
|
| 100 |
+
_log(f"Report for {t} done{' (+trace)' if trace else ''}.")
|
| 101 |
+
else:
|
| 102 |
+
_log(f"Report for {t} postponed (sub-agents still loading) — "
|
| 103 |
+
f"will retry on the next run.")
|
| 104 |
+
done_set = known | (current - (set(new_tickers) - generated))
|
| 105 |
if current:
|
| 106 |
with open(known_path, "w", encoding="utf-8") as f:
|
| 107 |
+
json.dump(sorted(done_set), f)
|
| 108 |
except Exception as e:
|
| 109 |
_log(f"Auto-research skipped: {e}")
|
| 110 |
|
llm_local.py
CHANGED
|
@@ -40,15 +40,35 @@ DEFAULT_MODEL = "Qwen3-4B · default — fast + smart, still ≤4B"
|
|
| 40 |
_THINK_RE = re.compile(r"<think>.*?</think>", re.S)
|
| 41 |
_NCPU = max(2, (os.cpu_count() or 4))
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
WORKERS = {
|
| 47 |
-
"
|
| 48 |
-
|
| 49 |
-
"
|
| 50 |
-
|
| 51 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
_install_lock = threading.Lock()
|
| 54 |
|
|
@@ -128,7 +148,8 @@ def _ensure_llama_cpp(worker: str) -> str:
|
|
| 128 |
|
| 129 |
|
| 130 |
# ─────────────────────── loading ───────────────────────
|
| 131 |
-
def load_model(name: str, worker: str = "
|
|
|
|
| 132 |
"""Load a GGUF into a worker slot. Non-blocking: if that worker is already
|
| 133 |
installing/loading, returns its live stage instead of hanging the click."""
|
| 134 |
w = WORKERS[worker]
|
|
@@ -157,11 +178,12 @@ def load_model(name: str, worker: str = "deep") -> str:
|
|
| 157 |
try:
|
| 158 |
_set_stage(worker, "loading model into RAM", name)
|
| 159 |
w["llm"] = None
|
|
|
|
| 160 |
w["llm"] = Llama(
|
| 161 |
model_path=path,
|
| 162 |
-
n_ctx=4096 if
|
| 163 |
-
n_threads=_NCPU,
|
| 164 |
-
n_threads_batch=_NCPU,
|
| 165 |
n_batch=512,
|
| 166 |
verbose=False,
|
| 167 |
)
|
|
@@ -177,16 +199,16 @@ def load_model(name: str, worker: str = "deep") -> str:
|
|
| 177 |
|
| 178 |
|
| 179 |
def auto_load_all():
|
| 180 |
-
"""Startup:
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
|
| 185 |
|
| 186 |
# ─────────────────────── status ───────────────────────
|
| 187 |
def is_loaded(worker: str = None) -> bool:
|
| 188 |
if worker:
|
| 189 |
-
return WORKERS[worker]["llm"] is not None
|
| 190 |
return any(w["llm"] is not None for w in WORKERS.values())
|
| 191 |
|
| 192 |
|
|
@@ -196,7 +218,7 @@ def available() -> bool:
|
|
| 196 |
|
| 197 |
def status() -> str:
|
| 198 |
lines = []
|
| 199 |
-
for key in ("
|
| 200 |
w = WORKERS[key]
|
| 201 |
label = WORKER_LABEL[key]
|
| 202 |
if w["llm"] is not None:
|
|
@@ -222,9 +244,9 @@ def _messages(user: str, system: str):
|
|
| 222 |
|
| 223 |
|
| 224 |
def chat(user: str, max_tokens: int = 500, temperature: float = 0.3,
|
| 225 |
-
system: str = DEFAULT_SYSTEM, worker: str = "
|
| 226 |
"""Blocking chat on one sub-agent (used by pipeline/agent code)."""
|
| 227 |
-
w = WORKERS[worker]
|
| 228 |
if w["llm"] is None:
|
| 229 |
return ""
|
| 230 |
if not w["lock"].acquire(timeout=180):
|
|
@@ -242,9 +264,9 @@ def chat(user: str, max_tokens: int = 500, temperature: float = 0.3,
|
|
| 242 |
|
| 243 |
|
| 244 |
def chat_stream(user: str, max_tokens: int = 500, temperature: float = 0.3,
|
| 245 |
-
system: str = DEFAULT_SYSTEM, worker: str = "
|
| 246 |
"""Streaming chat on one sub-agent — yields cumulative text immediately."""
|
| 247 |
-
w = WORKERS[worker]
|
| 248 |
if w["llm"] is None:
|
| 249 |
yield (f"⏳ {WORKER_LABEL[worker]} isn't ready yet — "
|
| 250 |
f"stage: {w['stage']}. Check the **Model** tab.")
|
|
@@ -274,7 +296,7 @@ def quick_test() -> str:
|
|
| 274 |
"""Sanity check both sub-agents."""
|
| 275 |
import time
|
| 276 |
outs = []
|
| 277 |
-
for key in ("
|
| 278 |
if WORKERS[key]["llm"] is None:
|
| 279 |
outs.append(f"{WORKER_LABEL[key]}: not loaded ({WORKERS[key]['stage']})")
|
| 280 |
continue
|
|
|
|
| 40 |
_THINK_RE = re.compile(r"<think>.*?</think>", re.S)
|
| 41 |
_NCPU = max(2, (os.cpu_count() or 4))
|
| 42 |
|
| 43 |
+
# One dedicated sub-agent per feature — independent locks, so Signals-Explain,
|
| 44 |
+
# Rotation narrative, News briefs and Auto-Research never fight over a model.
|
| 45 |
+
# Three tiny 1.7B instances share ONE GGUF file on disk (~2 GB RAM each) and
|
| 46 |
+
# the 4B Analyst writes reports. Total ≈ 9 GB on a 32 GB Space.
|
| 47 |
+
WORKER_LABEL = {
|
| 48 |
+
"translator": "Translator sub-agent (Signals · Explain)",
|
| 49 |
+
"narrator": "Narrator sub-agent (Sector Rotation)",
|
| 50 |
+
"reporter": "Reporter sub-agent (News · Research support)",
|
| 51 |
+
"analyst": "Analyst sub-agent (Auto Research)",
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _mk(model):
|
| 56 |
+
return {"model": model, "llm": None, "lock": threading.Lock(),
|
| 57 |
+
"load_lock": threading.Lock(), "stage": "idle", "detail": "", "ts": None}
|
| 58 |
+
|
| 59 |
|
| 60 |
WORKERS = {
|
| 61 |
+
"translator": _mk(FAST_MODEL),
|
| 62 |
+
"narrator": _mk(FAST_MODEL),
|
| 63 |
+
"reporter": _mk(FAST_MODEL),
|
| 64 |
+
"analyst": _mk(DEFAULT_MODEL),
|
| 65 |
}
|
| 66 |
+
# legacy aliases
|
| 67 |
+
_ALIAS = {"fast": "translator", "deep": "analyst"}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _wk(worker: str) -> str:
|
| 71 |
+
return _ALIAS.get(worker, worker)
|
| 72 |
|
| 73 |
_install_lock = threading.Lock()
|
| 74 |
|
|
|
|
| 148 |
|
| 149 |
|
| 150 |
# ─────────────────────── loading ───────────────────────
|
| 151 |
+
def load_model(name: str, worker: str = "analyst") -> str:
|
| 152 |
+
worker = _wk(worker)
|
| 153 |
"""Load a GGUF into a worker slot. Non-blocking: if that worker is already
|
| 154 |
installing/loading, returns its live stage instead of hanging the click."""
|
| 155 |
w = WORKERS[worker]
|
|
|
|
| 178 |
try:
|
| 179 |
_set_stage(worker, "loading model into RAM", name)
|
| 180 |
w["llm"] = None
|
| 181 |
+
small = worker != "analyst"
|
| 182 |
w["llm"] = Llama(
|
| 183 |
model_path=path,
|
| 184 |
+
n_ctx=4096 if small else 6144,
|
| 185 |
+
n_threads=(4 if small else _NCPU), # leave headroom for parallel agents
|
| 186 |
+
n_threads_batch=(6 if small else _NCPU),
|
| 187 |
n_batch=512,
|
| 188 |
verbose=False,
|
| 189 |
)
|
|
|
|
| 199 |
|
| 200 |
|
| 201 |
def auto_load_all():
|
| 202 |
+
"""Startup: tiny agents first (one small GGUF download serves all three),
|
| 203 |
+
then the Analyst. Runs in a background thread."""
|
| 204 |
+
for key in ("translator", "narrator", "reporter", "analyst"):
|
| 205 |
+
load_model(WORKERS[key]["model"], worker=key)
|
| 206 |
|
| 207 |
|
| 208 |
# ─────────────────────── status ───────────────────────
|
| 209 |
def is_loaded(worker: str = None) -> bool:
|
| 210 |
if worker:
|
| 211 |
+
return WORKERS[_wk(worker)]["llm"] is not None
|
| 212 |
return any(w["llm"] is not None for w in WORKERS.values())
|
| 213 |
|
| 214 |
|
|
|
|
| 218 |
|
| 219 |
def status() -> str:
|
| 220 |
lines = []
|
| 221 |
+
for key in ("translator", "narrator", "reporter", "analyst"):
|
| 222 |
w = WORKERS[key]
|
| 223 |
label = WORKER_LABEL[key]
|
| 224 |
if w["llm"] is not None:
|
|
|
|
| 244 |
|
| 245 |
|
| 246 |
def chat(user: str, max_tokens: int = 500, temperature: float = 0.3,
|
| 247 |
+
system: str = DEFAULT_SYSTEM, worker: str = "translator") -> str:
|
| 248 |
"""Blocking chat on one sub-agent (used by pipeline/agent code)."""
|
| 249 |
+
w = WORKERS[_wk(worker)]; worker = _wk(worker)
|
| 250 |
if w["llm"] is None:
|
| 251 |
return ""
|
| 252 |
if not w["lock"].acquire(timeout=180):
|
|
|
|
| 264 |
|
| 265 |
|
| 266 |
def chat_stream(user: str, max_tokens: int = 500, temperature: float = 0.3,
|
| 267 |
+
system: str = DEFAULT_SYSTEM, worker: str = "translator"):
|
| 268 |
"""Streaming chat on one sub-agent — yields cumulative text immediately."""
|
| 269 |
+
w = WORKERS[_wk(worker)]; worker = _wk(worker)
|
| 270 |
if w["llm"] is None:
|
| 271 |
yield (f"⏳ {WORKER_LABEL[worker]} isn't ready yet — "
|
| 272 |
f"stage: {w['stage']}. Check the **Model** tab.")
|
|
|
|
| 296 |
"""Sanity check both sub-agents."""
|
| 297 |
import time
|
| 298 |
outs = []
|
| 299 |
+
for key in ("translator", "narrator", "reporter", "analyst"):
|
| 300 |
if WORKERS[key]["llm"] is None:
|
| 301 |
outs.append(f"{WORKER_LABEL[key]}: not loaded ({WORKERS[key]['stage']})")
|
| 302 |
continue
|
news_watch.py
CHANGED
|
@@ -86,12 +86,13 @@ def _llm_brief(ticker: str, items: list) -> str:
|
|
| 86 |
prompt = (
|
| 87 |
f"You are an equity news analyst. Today's headlines for {ticker} "
|
| 88 |
f"(a stock the user currently HOLDS):\n{heads}\n\n"
|
| 89 |
-
"In
|
| 90 |
-
"
|
| 91 |
-
"
|
| 92 |
-
"
|
|
|
|
| 93 |
)
|
| 94 |
-
return llm_local.chat(prompt, max_tokens=
|
| 95 |
except Exception:
|
| 96 |
return ""
|
| 97 |
|
|
|
|
| 86 |
prompt = (
|
| 87 |
f"You are an equity news analyst. Today's headlines for {ticker} "
|
| 88 |
f"(a stock the user currently HOLDS):\n{heads}\n\n"
|
| 89 |
+
"In ENGLISH ONLY, write:\n"
|
| 90 |
+
"1) **Per-headline:** one short line per headline above — what it says "
|
| 91 |
+
"and why it matters (or 'noise') for the holding;\n"
|
| 92 |
+
"2) **Net read:** POSITIVE / NEGATIVE / NEUTRAL with one sentence why;\n"
|
| 93 |
+
"3) **Action:** one concrete suggestion. ≤180 words, no disclaimers."
|
| 94 |
)
|
| 95 |
+
return llm_local.chat(prompt, max_tokens=420, worker="reporter")
|
| 96 |
except Exception:
|
| 97 |
return ""
|
| 98 |
|
research_agent.py
CHANGED
|
@@ -31,21 +31,27 @@ import pandas as pd
|
|
| 31 |
|
| 32 |
import paths
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
("
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
("
|
| 44 |
-
|
| 45 |
-
"levels."),
|
| 46 |
-
("Risks & verdict", "Top risks, then one-line verdict: Buy / Accumulate / "
|
| 47 |
-
"Hold / Avoid, sized for a long-hold portfolio."),
|
| 48 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
# ───────────────────────── trace plumbing ─────────────────────────
|
|
@@ -146,6 +152,38 @@ def t_chan(ticker: str) -> str:
|
|
| 146 |
return ""
|
| 147 |
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
def t_news(ticker: str) -> str:
|
| 150 |
try:
|
| 151 |
import yfinance as yf
|
|
@@ -161,78 +199,108 @@ def t_news(ticker: str) -> str:
|
|
| 161 |
|
| 162 |
|
| 163 |
# ───────────────────────── the agent ─────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
def run_research(ticker: str, auto: bool = False) -> tuple:
|
| 165 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
ticker = (ticker or "").strip().upper()
|
| 167 |
if not ticker:
|
| 168 |
return "Enter a ticker symbol first.", ""
|
|
|
|
|
|
|
| 169 |
tr = Trace(ticker)
|
| 170 |
-
tr.log("PLAN", "
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
f"One line each, no preamble.", 200)
|
| 174 |
-
if not plan:
|
| 175 |
-
plan = ("1. Is the valuation justified by growth?\n2. How durable is the moat?\n"
|
| 176 |
-
"3. What breaks the bull thesis?\n4. Is now a good technical entry?")
|
| 177 |
-
tr.log("PLAN", "fallback", plan)
|
| 178 |
-
else:
|
| 179 |
-
tr.log("PLAN", "llm_response", plan)
|
| 180 |
-
|
| 181 |
-
evidence = {}
|
| 182 |
-
for name, fn in (("fundamentals", t_fundamentals), ("financials", t_financials),
|
| 183 |
-
("price", t_price), ("chan_engine", t_chan), ("news", t_news)):
|
| 184 |
-
tr.log(f"TOOL:{name}", "tool_call", f"{name}({ticker})")
|
| 185 |
-
try:
|
| 186 |
-
out = fn(ticker)
|
| 187 |
-
except Exception as e:
|
| 188 |
-
out = ""
|
| 189 |
-
tr.log(f"TOOL:{name}", "tool_error", f"{e}\n{traceback.format_exc(limit=1)}")
|
| 190 |
-
evidence[name] = out
|
| 191 |
-
brief = (out.get("plain", "")[:400] if isinstance(out, dict) else str(out)[:400])
|
| 192 |
-
tr.log(f"TOOL:{name}", "tool_result", brief or "(empty)")
|
| 193 |
-
|
| 194 |
-
fund = evidence["fundamentals"]
|
| 195 |
if isinstance(fund, dict) and not fund.get("ok"):
|
| 196 |
tr.save()
|
| 197 |
return f"⚠️ {fund.get('error', 'Could not fetch data.')}", ""
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
|
| 219 |
fund_md = fund.get("markdown", "") if isinstance(fund, dict) else ""
|
| 220 |
stamp = dt.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
| 221 |
head = (f"# {ticker} — Research Note{' (auto-generated)' if auto else ''}\n"
|
| 222 |
-
f"_{stamp} ·
|
| 223 |
-
f"**Agent plan:**\n{
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
tr.log("REPORT", "assembled", f"{len(report)} chars, {len(parts)} LLM sections")
|
| 232 |
trace_path = tr.save()
|
| 233 |
try:
|
| 234 |
-
rp = os.path.join(paths.REPORTS_DIR,
|
| 235 |
-
f"{ticker}_{tr.t0.strftime('%Y%m%d')}.md")
|
| 236 |
with open(rp, "w", encoding="utf-8") as f:
|
| 237 |
f.write(report)
|
| 238 |
except OSError:
|
|
@@ -274,81 +342,77 @@ def list_traces() -> str:
|
|
| 274 |
|
| 275 |
# ───────────────────────── streaming UI runner ─────────────────────────
|
| 276 |
def run_research_stream(ticker: str):
|
| 277 |
-
"""Generator for the
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
| 280 |
ticker = (ticker or "").strip().upper()
|
| 281 |
if not ticker:
|
| 282 |
yield "Enter a ticker symbol first.", ""
|
| 283 |
return
|
| 284 |
tr = Trace(ticker)
|
| 285 |
-
log_lines = [f"### 🤖
|
| 286 |
|
| 287 |
def show(msg):
|
| 288 |
log_lines.append(f"- {msg}")
|
| 289 |
return "\n".join(log_lines)
|
| 290 |
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
plan = ("1. Is the valuation justified by growth?\n2. How durable is the moat?\n"
|
| 296 |
-
"3. What breaks the bull thesis?\n4. Is now a good technical entry?")
|
| 297 |
-
tr.log("PLAN", "llm_response", plan)
|
| 298 |
-
yield show("Plan ready ✓"), ""
|
| 299 |
|
| 300 |
-
|
| 301 |
-
for name, fn, label in (("fundamentals", t_fundamentals, "fundamentals & valuation"),
|
| 302 |
-
("financials", t_financials, "quarterly financials"),
|
| 303 |
-
("price", t_price, "price action stats"),
|
| 304 |
-
("chan_engine", t_chan, "Chan engine verdict"),
|
| 305 |
-
("news", t_news, "recent headlines")):
|
| 306 |
-
yield show(f"**TOOL** — gathering {label}…"), ""
|
| 307 |
-
try:
|
| 308 |
-
evidence[name] = fn(ticker)
|
| 309 |
-
except Exception as e:
|
| 310 |
-
evidence[name] = ""
|
| 311 |
-
tr.log(f"TOOL:{name}", "tool_error", str(e))
|
| 312 |
-
brief = (evidence[name].get("plain", "")[:300] if isinstance(evidence[name], dict)
|
| 313 |
-
else str(evidence[name])[:300])
|
| 314 |
-
tr.log(f"TOOL:{name}", "tool_result", brief or "(empty)")
|
| 315 |
-
|
| 316 |
-
fund = evidence["fundamentals"]
|
| 317 |
if isinstance(fund, dict) and not fund.get("ok"):
|
| 318 |
tr.save()
|
| 319 |
yield show(f"⚠️ {fund.get('error', 'Data fetch failed.')}"), ""
|
| 320 |
return
|
| 321 |
-
|
| 322 |
-
ev_text = (f"FUNDAMENTALS:\n{(fund.get('plain','') if isinstance(fund, dict) else '')[:1100]}\n\n"
|
| 323 |
-
f"QUARTERLY FINANCIALS:\n{str(evidence['financials'])[:450] or 'n/a'}\n\n"
|
| 324 |
-
f"PRICE ACTION:\n{evidence['price'] or 'n/a'}\n\n"
|
| 325 |
-
f"CHAN ENGINE VERDICT:\n{str(evidence['chan_engine'])[:420] or 'n/a'}\n\n"
|
| 326 |
-
f"RECENT HEADLINES:\n{str(evidence['news'])[:420] or 'n/a'}")[:2700]
|
| 327 |
-
yield show("**ANALYZE** — writing the report (streaming)…"), ""
|
| 328 |
-
|
| 329 |
fund_md = fund.get("markdown", "") if isinstance(fund, dict) else ""
|
| 330 |
stamp = dt.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
| 331 |
-
head = (f"# {ticker} — Research Note\n_{stamp} ·
|
| 332 |
-
f"
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
report = head + body + "\n\n---\n_Agent trace saved — see the Automation tab._"
|
| 353 |
trace_path = tr.save()
|
| 354 |
try:
|
|
|
|
| 31 |
|
| 32 |
import paths
|
| 33 |
|
| 34 |
+
# Multi-agent split: the 4B Analyst writes the heavy sections while the 1.7B
|
| 35 |
+
# Reporter writes market/technical sections IN PARALLEL on its own lock —
|
| 36 |
+
# wall-clock time ≈ the slower of the two instead of their sum.
|
| 37 |
+
ANALYST_SECTIONS = [
|
| 38 |
+
("Valuation", "cheap/fair/rich vs the growth and margins in evidence; quote 2-3 numbers"),
|
| 39 |
+
("Technology moat", "the company's technological moat and how defensible it is"),
|
| 40 |
+
("Supply-chain map", "a markdown table with two columns 'Upstream suppliers' and "
|
| 41 |
+
"'Downstream customers/users': list 4-6 real companies on each side WITH stock "
|
| 42 |
+
"tickers in parentheses, e.g. TSMC (TSM); mark private companies (private)"),
|
| 43 |
+
("Bull case", "strongest 3 points FOR owning it"),
|
| 44 |
+
("Bear case", "strongest 3 points AGAINST owning it"),
|
|
|
|
|
|
|
|
|
|
| 45 |
]
|
| 46 |
+
REPORTER_SECTIONS = [
|
| 47 |
+
("Money flow & related tickers", "read the MONEY FLOW evidence: is capital "
|
| 48 |
+
"entering or leaving the stock and its sector; name the sector ETF and 2-4 "
|
| 49 |
+
"related tickers worth watching"),
|
| 50 |
+
("Technical timing (Chan theory)", "interpret the CHAN ENGINE VERDICT for a "
|
| 51 |
+
"long-term holder: act now, wait, or exit, and the key price levels"),
|
| 52 |
+
("Risks & verdict", "top risks, then one line: Buy / Accumulate / Hold / Avoid"),
|
| 53 |
+
]
|
| 54 |
+
SECTIONS = ANALYST_SECTIONS + REPORTER_SECTIONS # kept for trace readability
|
| 55 |
|
| 56 |
|
| 57 |
# ───────────────────────── trace plumbing ─────────────────────────
|
|
|
|
| 152 |
return ""
|
| 153 |
|
| 154 |
|
| 155 |
+
def t_flows(ticker: str) -> str:
|
| 156 |
+
"""Money-flow proxy (Δ% × dollar volume) for the ticker and its sector ETF,
|
| 157 |
+
1/5/20-day windows, plus related tickers via the sector mapping."""
|
| 158 |
+
try:
|
| 159 |
+
import data_us
|
| 160 |
+
import rotation as rot
|
| 161 |
+
out = []
|
| 162 |
+
d = data_us.load_level(ticker, "d")
|
| 163 |
+
for n, lab in ((1, "1D"), (5, "5D"), (20, "20D")):
|
| 164 |
+
st = rot._window_stats(d, n)
|
| 165 |
+
if st:
|
| 166 |
+
pct, dvol, flow = st
|
| 167 |
+
out.append(f"{ticker} {lab}: {pct:+.2%}, flow proxy "
|
| 168 |
+
f"${flow/1e6:+,.0f}M on ${dvol/1e9:,.1f}B avg $vol")
|
| 169 |
+
sector = ""
|
| 170 |
+
try:
|
| 171 |
+
import yfinance as yf
|
| 172 |
+
sector = (yf.Ticker(ticker).info or {}).get("sector", "")
|
| 173 |
+
except Exception:
|
| 174 |
+
pass
|
| 175 |
+
etf = next((k for k, v in rot.SECTOR_ETFS.items() if v == sector), None)
|
| 176 |
+
if etf:
|
| 177 |
+
de = data_us.load_level(etf, "d")
|
| 178 |
+
st = rot._window_stats(de, 5)
|
| 179 |
+
if st:
|
| 180 |
+
out.append(f"Sector ETF {etf} ({sector}) 5D: {st[0]:+.2%}, "
|
| 181 |
+
f"flow proxy ${st[2]/1e6:+,.0f}M")
|
| 182 |
+
return "\n".join(out)
|
| 183 |
+
except Exception:
|
| 184 |
+
return ""
|
| 185 |
+
|
| 186 |
+
|
| 187 |
def t_news(ticker: str) -> str:
|
| 188 |
try:
|
| 189 |
import yfinance as yf
|
|
|
|
| 199 |
|
| 200 |
|
| 201 |
# ───────────────────────── the agent ─────────────────────────
|
| 202 |
+
def _gather_evidence(ticker: str, tr: "Trace", on_step=None) -> dict:
|
| 203 |
+
"""Run all evidence tools IN PARALLEL (network-bound) — was serial before."""
|
| 204 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 205 |
+
tools = {"fundamentals": t_fundamentals, "financials": t_financials,
|
| 206 |
+
"price": t_price, "chan_engine": t_chan, "flows": t_flows,
|
| 207 |
+
"news": t_news}
|
| 208 |
+
evidence = {}
|
| 209 |
+
with ThreadPoolExecutor(max_workers=6) as ex:
|
| 210 |
+
futs = {name: ex.submit(fn, ticker) for name, fn in tools.items()}
|
| 211 |
+
for name, fut in futs.items():
|
| 212 |
+
try:
|
| 213 |
+
evidence[name] = fut.result(timeout=40)
|
| 214 |
+
except Exception as e:
|
| 215 |
+
evidence[name] = ""
|
| 216 |
+
tr.log(f"TOOL:{name}", "tool_error", str(e))
|
| 217 |
+
brief = (evidence[name].get("plain", "")[:300]
|
| 218 |
+
if isinstance(evidence[name], dict) else str(evidence[name])[:300])
|
| 219 |
+
tr.log(f"TOOL:{name}", "tool_result", brief or "(empty)")
|
| 220 |
+
if on_step:
|
| 221 |
+
on_step(name)
|
| 222 |
+
return evidence
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _evidence_text(evidence: dict) -> str:
|
| 226 |
+
fund = evidence.get("fundamentals")
|
| 227 |
+
return (f"FUNDAMENTALS:\n{(fund.get('plain','') if isinstance(fund, dict) else '')[:1000]}\n\n"
|
| 228 |
+
f"QUARTERLY FINANCIALS:\n{str(evidence.get('financials'))[:380] or 'n/a'}\n\n"
|
| 229 |
+
f"PRICE ACTION:\n{evidence.get('price') or 'n/a'}\n\n"
|
| 230 |
+
f"MONEY FLOW:\n{str(evidence.get('flows'))[:420] or 'n/a'}\n\n"
|
| 231 |
+
f"CHAN ENGINE VERDICT:\n{str(evidence.get('chan_engine'))[:380] or 'n/a'}\n\n"
|
| 232 |
+
f"RECENT HEADLINES:\n{str(evidence.get('news'))[:380] or 'n/a'}")[:2500]
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _sections_prompt(ticker: str, sections, ev_text: str) -> str:
|
| 236 |
+
sec_list = "\n".join(f"## {t} — {i}" for t, i in sections)
|
| 237 |
+
return (f"You are a buy-side equity analyst. Write part of a research note on "
|
| 238 |
+
f"{ticker} using ONLY the evidence below (write 'n/a' for missing facts, "
|
| 239 |
+
f"never invent numbers; company names in the supply-chain table may come "
|
| 240 |
+
f"from your own industry knowledge). Output EXACTLY these markdown "
|
| 241 |
+
f"sections, ≤70 words each, plain prose, ENGLISH ONLY, no disclaimers:\n"
|
| 242 |
+
f"{sec_list}\n\nEVIDENCE:\n{ev_text}")
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
_STATIC_PLAN = ("1. Is the valuation justified by growth?\n"
|
| 246 |
+
"2. How durable is the technology moat and supply-chain position?\n"
|
| 247 |
+
"3. Where is capital flowing — into or out of the name and sector?\n"
|
| 248 |
+
"4. Is now a good technical entry for a long-term holder?")
|
| 249 |
+
|
| 250 |
+
|
| 251 |
def run_research(ticker: str, auto: bool = False) -> tuple:
|
| 252 |
+
"""Blocking multi-agent run (used by the daily pipeline).
|
| 253 |
+
Analyst (4B) and Reporter (1.7B) write their sections in PARALLEL.
|
| 254 |
+
Returns (report_markdown, trace_path). If no model is ready, returns
|
| 255 |
+
('', '') so the caller can postpone instead of saving an empty report."""
|
| 256 |
+
import llm_local
|
| 257 |
ticker = (ticker or "").strip().upper()
|
| 258 |
if not ticker:
|
| 259 |
return "Enter a ticker symbol first.", ""
|
| 260 |
+
if not (llm_local.is_loaded("analyst") or llm_local.is_loaded("reporter")):
|
| 261 |
+
return "", "" # postpone — model still loading
|
| 262 |
tr = Trace(ticker)
|
| 263 |
+
tr.log("PLAN", "static", _STATIC_PLAN)
|
| 264 |
+
evidence = _gather_evidence(ticker, tr)
|
| 265 |
+
fund = evidence.get("fundamentals")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
if isinstance(fund, dict) and not fund.get("ok"):
|
| 267 |
tr.save()
|
| 268 |
return f"⚠️ {fund.get('error', 'Could not fetch data.')}", ""
|
| 269 |
+
ev_text = _evidence_text(evidence)
|
| 270 |
+
|
| 271 |
+
import threading
|
| 272 |
+
parts = {}
|
| 273 |
+
|
| 274 |
+
def _write(slot, sections, worker, fallback_worker):
|
| 275 |
+
wk = worker if llm_local.is_loaded(worker) else fallback_worker
|
| 276 |
+
tr.log(f"ANALYZE:{slot}", "llm_request", f"worker={wk}")
|
| 277 |
+
txt = llm_local.chat(_sections_prompt(ticker, sections, ev_text),
|
| 278 |
+
max_tokens=620 if slot == "analyst" else 360, worker=wk)
|
| 279 |
+
if txt.startswith("(") or txt.startswith("⏳"):
|
| 280 |
+
txt = ""
|
| 281 |
+
tr.log(f"ANALYZE:{slot}", "llm_response", txt[:500])
|
| 282 |
+
parts[slot] = txt
|
| 283 |
+
|
| 284 |
+
th = threading.Thread(target=_write,
|
| 285 |
+
args=("reporter", REPORTER_SECTIONS, "reporter", "analyst"))
|
| 286 |
+
th.start()
|
| 287 |
+
_write("analyst", ANALYST_SECTIONS, "analyst", "reporter")
|
| 288 |
+
th.join(timeout=300)
|
| 289 |
|
| 290 |
fund_md = fund.get("markdown", "") if isinstance(fund, dict) else ""
|
| 291 |
stamp = dt.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
| 292 |
head = (f"# {ticker} — Research Note{' (auto-generated)' if auto else ''}\n"
|
| 293 |
+
f"_{stamp} · multi-agent: Analyst (Qwen3-4B) + Reporter (Qwen3-1.7B), "
|
| 294 |
+
f"both llama.cpp local_\n\n**Agent plan:**\n{_STATIC_PLAN}\n\n{fund_md}\n\n---\n")
|
| 295 |
+
body = "\n\n".join(p for p in (parts.get("analyst"), parts.get("reporter")) if p)
|
| 296 |
+
if not body:
|
| 297 |
+
tr.save()
|
| 298 |
+
return "", "" # model produced nothing — postpone, never save a stub
|
| 299 |
+
report = head + body + "\n\n---\n_Agent trace saved — see the Automation tab._"
|
| 300 |
+
tr.log("REPORT", "assembled", f"{len(report)} chars")
|
|
|
|
|
|
|
| 301 |
trace_path = tr.save()
|
| 302 |
try:
|
| 303 |
+
rp = os.path.join(paths.REPORTS_DIR, f"{ticker}_{tr.t0.strftime('%Y%m%d')}.md")
|
|
|
|
| 304 |
with open(rp, "w", encoding="utf-8") as f:
|
| 305 |
f.write(report)
|
| 306 |
except OSError:
|
|
|
|
| 342 |
|
| 343 |
# ───────────────────────── streaming UI runner ─────────────────────────
|
| 344 |
def run_research_stream(ticker: str):
|
| 345 |
+
"""Generator for the UI. Multi-agent: evidence tools run in parallel; the
|
| 346 |
+
1.7B Reporter writes its sections in a background thread while the 4B
|
| 347 |
+
Analyst STREAMS its sections live; never saves a 'model busy' stub."""
|
| 348 |
+
import threading
|
| 349 |
+
import llm_local
|
| 350 |
ticker = (ticker or "").strip().upper()
|
| 351 |
if not ticker:
|
| 352 |
yield "Enter a ticker symbol first.", ""
|
| 353 |
return
|
| 354 |
tr = Trace(ticker)
|
| 355 |
+
log_lines = [f"### 🤖 Multi-agent research · {ticker}"]
|
| 356 |
|
| 357 |
def show(msg):
|
| 358 |
log_lines.append(f"- {msg}")
|
| 359 |
return "\n".join(log_lines)
|
| 360 |
|
| 361 |
+
tr.log("PLAN", "static", _STATIC_PLAN)
|
| 362 |
+
yield show("**PLAN** ready ✓ · **TOOLS** — gathering 6 evidence sources in parallel…"), ""
|
| 363 |
+
evidence = _gather_evidence(ticker, tr)
|
| 364 |
+
yield show("Evidence in: fundamentals · financials · price · money flow · Chan engine · news ✓"), ""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
|
| 366 |
+
fund = evidence.get("fundamentals")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
if isinstance(fund, dict) and not fund.get("ok"):
|
| 368 |
tr.save()
|
| 369 |
yield show(f"⚠️ {fund.get('error', 'Data fetch failed.')}"), ""
|
| 370 |
return
|
| 371 |
+
ev_text = _evidence_text(evidence)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
fund_md = fund.get("markdown", "") if isinstance(fund, dict) else ""
|
| 373 |
stamp = dt.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
| 374 |
+
head = (f"# {ticker} — Research Note\n_{stamp} · multi-agent: Analyst (Qwen3-4B) "
|
| 375 |
+
f"+ Reporter (Qwen3-1.7B), both llama.cpp local_\n\n"
|
| 376 |
+
f"**Agent plan:**\n{_STATIC_PLAN}\n\n{fund_md}\n\n---\n")
|
| 377 |
+
|
| 378 |
+
# Reporter sub-agent works in parallel on its own lock
|
| 379 |
+
side = {"txt": ""}
|
| 380 |
+
|
| 381 |
+
def _side():
|
| 382 |
+
wk = "reporter" if llm_local.is_loaded("reporter") else "analyst"
|
| 383 |
+
t = llm_local.chat(_sections_prompt(ticker, REPORTER_SECTIONS, ev_text),
|
| 384 |
+
max_tokens=360, worker=wk)
|
| 385 |
+
side["txt"] = "" if (t.startswith("(") or t.startswith("⏳")) else t
|
| 386 |
+
tr.log("ANALYZE:reporter", "llm_response", side["txt"][:400])
|
| 387 |
+
|
| 388 |
+
th = None
|
| 389 |
+
if llm_local.is_loaded("reporter") or llm_local.is_loaded("analyst"):
|
| 390 |
+
th = threading.Thread(target=_side, daemon=True)
|
| 391 |
+
th.start()
|
| 392 |
+
yield show("**Reporter sub-agent** writing money-flow / Chan timing / verdict "
|
| 393 |
+
"in parallel…"), head
|
| 394 |
+
# Analyst streams the main sections live
|
| 395 |
+
main = ""
|
| 396 |
+
wk_main = "analyst" if llm_local.is_loaded("analyst") else "reporter"
|
| 397 |
+
if llm_local.is_loaded(wk_main):
|
| 398 |
+
yield show(f"**Analyst sub-agent** streaming valuation / moat / supply-chain "
|
| 399 |
+
f"map / bull-bear…"), head
|
| 400 |
+
for acc in llm_local.chat_stream(
|
| 401 |
+
_sections_prompt(ticker, ANALYST_SECTIONS, ev_text),
|
| 402 |
+
max_tokens=620, worker=wk_main):
|
| 403 |
+
if acc.startswith("⏳") or acc.startswith("("):
|
| 404 |
+
continue
|
| 405 |
+
main = acc
|
| 406 |
+
yield "\n".join(log_lines), head + main
|
| 407 |
+
tr.log("ANALYZE:analyst", "llm_response", main[:500])
|
| 408 |
+
if th is not None:
|
| 409 |
+
th.join(timeout=240)
|
| 410 |
+
body = "\n\n".join(p for p in (main, side["txt"]) if p)
|
| 411 |
+
if not body:
|
| 412 |
+
tr.save()
|
| 413 |
+
yield show("⚠️ Sub-agents not ready yet (still loading) — evidence gathered "
|
| 414 |
+
"above; try again in a minute. Nothing was saved."), head
|
| 415 |
+
return
|
| 416 |
report = head + body + "\n\n---\n_Agent trace saved — see the Automation tab._"
|
| 417 |
trace_path = tr.save()
|
| 418 |
try:
|
rotation.py
CHANGED
|
@@ -150,6 +150,6 @@ def llm_narrative(df_1d, df_5d, df_20d) -> str:
|
|
| 150 |
"trend (rotation vs one-day noise); 3) one actionable watch item. "
|
| 151 |
"No disclaimers.\n\nDATA:\n" + brief
|
| 152 |
)
|
| 153 |
-
return llm_local.chat(prompt, max_tokens=380, worker="
|
| 154 |
except Exception as e:
|
| 155 |
return f"**Raw read:**\n{brief}\n\n_(LLM unavailable: {e})_"
|
|
|
|
| 150 |
"trend (rotation vs one-day noise); 3) one actionable watch item. "
|
| 151 |
"No disclaimers.\n\nDATA:\n" + brief
|
| 152 |
)
|
| 153 |
+
return llm_local.chat(prompt, max_tokens=380, worker="narrator")
|
| 154 |
except Exception as e:
|
| 155 |
return f"**Raw read:**\n{brief}\n\n_(LLM unavailable: {e})_"
|