ranranrunforit commited on
Commit
b49f11d
·
verified ·
1 Parent(s): 1dba535

Upload 15 files

Browse files
Files changed (6) hide show
  1. app.py +22 -12
  2. automation.py +10 -3
  3. llm_local.py +43 -21
  4. news_watch.py +6 -5
  5. research_agent.py +194 -130
  6. 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 (缠论) multi-timeframe decision log in Chinese "
132
- "for a US stock. Do NOT repeat or quote the log. Write a fresh plain-"
133
- "English explanation for a trader: 1) the final action; 2) why each "
134
- "timeframe gate passed/failed; 3) what price/event would invalidate "
135
- "the call. ≤160 words.\n\n" + core)
 
 
 
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=340, worker="fast"):
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="fast"):
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=18, label="Ruling chain", elem_id="detail-log")
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
- _, trace = research_agent.run_research(t, auto=True)
97
- _log(f"Report for {t} done{' (+trace)' if trace else ''}.")
 
 
 
 
 
 
98
  if current:
99
  with open(known_path, "w", encoding="utf-8") as f:
100
- json.dump(sorted(known | current), f)
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
- WORKER_LABEL = {"fast": "Translator/Narrator sub-agent",
44
- "deep": "Analyst sub-agent"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  WORKERS = {
47
- "fast": {"model": FAST_MODEL, "llm": None, "lock": threading.Lock(),
48
- "load_lock": threading.Lock(), "stage": "idle", "detail": "", "ts": None},
49
- "deep": {"model": DEFAULT_MODEL, "llm": None, "lock": threading.Lock(),
50
- "load_lock": threading.Lock(), "stage": "idle", "detail": "", "ts": None},
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 = "deep") -> 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 worker == "fast" else 6144,
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: bring the fast sub-agent up first (small download, features go
181
- live quickly), then the deep one. Runs in a background thread."""
182
- load_model(WORKERS["fast"]["model"], worker="fast")
183
- load_model(WORKERS["deep"]["model"], worker="deep")
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 ("fast", "deep"):
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 = "fast") -> 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 = "fast"):
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 ("fast", "deep"):
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 English, ≤90 words: 1) one-line summary; 2) tag the net read as "
90
- "POSITIVE / NEGATIVE / NEUTRAL for the holding; 3) one suggested "
91
- "action (e.g. 'no action', 'review stop level', 'watch earnings'). "
92
- "Be specific, no disclaimers."
 
93
  )
94
- return llm_local.chat(prompt, max_tokens=240, worker="fast")
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
- SECTIONS = [
35
- ("Valuation", "Judge whether the multiples are cheap/fair/rich versus the "
36
- "growth and margins in evidence. Quote 2-3 numbers."),
37
- ("Moat & supply-chain position", "Infer the company's competitive moat and "
38
- "where it sits in its industry value chain (who depends on it, "
39
- "who it depends on)."),
40
- ("Bull case", "The strongest 3-point case FOR owning the stock, grounded in "
41
- "the evidence."),
42
- ("Bear case", "The strongest 3-point case AGAINST owning it."),
43
- ("Technical timing (Chan theory)", "Interpret the Chan engine verdict for a "
44
- "long-term holder: act now, wait, or exit and the key price "
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
- """Run the full agent. Returns (report_markdown, trace_path)."""
 
 
 
 
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", "llm_request", f"Draft research questions for {ticker}")
171
- plan = _llm(f"You are a buy-side research agent. List the 4 most important "
172
- f"questions to answer before a LONG-TERM investment in {ticker}. "
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
- ev_text = (f"FUNDAMENTALS:\n{(fund.get('plain','') if isinstance(fund, dict) else '')[:1100]}\n\n"
200
- f"QUARTERLY FINANCIALS:\n{str(evidence['financials'])[:450] or 'n/a'}\n\n"
201
- f"PRICE ACTION:\n{evidence['price'] or 'n/a'}\n\n"
202
- f"CHAN ENGINE VERDICT:\n{str(evidence['chan_engine'])[:420] or 'n/a'}\n\n"
203
- f"RECENT HEADLINES:\n{str(evidence['news'])[:420] or 'n/a'}")[:2700]
204
-
205
- # ONE LLM call for the whole analysis (six calls = six slow CPU prefills;
206
- # one call with all sections in the prompt is ~5x faster on a Space).
207
- parts = []
208
- sec_list = "\n".join(f"## {t} — {i}" for t, i in SECTIONS)
209
- tr.log("ANALYZE", "llm_request", f"single-pass report, {len(ev_text)} chars evidence")
210
- txt = _llm(f"You are a buy-side equity analyst. Write a research note on {ticker} "
211
- f"using ONLY the evidence below (write 'n/a' for missing facts, never "
212
- f"invent numbers). Output EXACTLY these markdown sections, ≤70 words "
213
- f"each, plain prose, no disclaimers:\n{sec_list}\n\nEVIDENCE:\n{ev_text}",
214
- 900)
215
- tr.log("ANALYZE", "llm_response", txt[:600])
216
- if txt:
217
- parts.append(txt)
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} · Chan Compass research agent · local llama.cpp model_\n\n"
223
- f"**Agent plan:**\n{plan}\n\n{fund_md}\n\n---\n")
224
- if parts:
225
- body = "\n\n".join(parts)
226
- else:
227
- body = ("## Analysis\n_Load a model in the **Model** tab to generate the written "
228
- "sections the agent gathered all evidence above and saved its trace._")
229
- report = head + body + "\n\n---\n_Trace saved for the open-trace badge — see the Automation tab._"
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 Gradio UI: yields live progress, then streams the
278
- LLM analysis token-by-token, then saves report + trace (same artifacts
279
- as run_research)."""
 
 
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"### 🤖 Research agent · {ticker}"]
286
 
287
  def show(msg):
288
  log_lines.append(f"- {msg}")
289
  return "\n".join(log_lines)
290
 
291
- yield show("**PLAN** — drafting research questions…"), ""
292
- plan = _llm(f"List the 4 most important questions to answer before a LONG-TERM "
293
- f"investment in {ticker}. One line each, no preamble.", 140)
294
- if not plan:
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
- evidence = {}
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} · Chan Compass research agent · "
332
- f"local llama.cpp model_\n\n**Agent plan:**\n{plan}\n\n{fund_md}\n\n---\n")
333
- sec_list = "\n".join(f"## {t}{i}" for t, i in SECTIONS)
334
- prompt = (f"You are a buy-side equity analyst. Write a research note on {ticker} "
335
- f"using ONLY the evidence below (write 'n/a' for missing facts, never "
336
- f"invent numbers). Output EXACTLY these markdown sections, ≤70 words "
337
- f"each, plain prose, no disclaimers:\n{sec_list}\n\nEVIDENCE:\n{ev_text}")
338
- tr.log("ANALYZE", "llm_request", f"single-pass report, {len(ev_text)} chars evidence")
339
- body = ""
340
- try:
341
- import llm_local
342
- wk = "deep" if llm_local.is_loaded("deep") else "fast"
343
- for acc in llm_local.chat_stream(prompt, max_tokens=900, worker=wk):
344
- body = acc
345
- yield "\n".join(log_lines), head + body
346
- except Exception as e:
347
- body = f"(model error: {e})"
348
- if not body.strip() or body.startswith("⏳"):
349
- body = ("## Analysis\n_Model not available evidence gathered above; "
350
- "trace saved._\n\n" + body)
351
- tr.log("ANALYZE", "llm_response", body[:600])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="fast")
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})_"