Tests whether a PROSPECTIVE forecaster (owner or local LLM) carries signal the machine levers never found. Picks land premarket via dashboard panel or research/llm_picker.py (Ollama); research/pick_scoring.py judges them under a pre-registered bar (n>=40, >=30 sessions, mean >=+0.5%/pick net, median>0, both halves positive). Picks after 9:30 ET stored but flagged invalid. Shadow-only: human_picks is never read by the live loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
149 lines
6.1 KiB
Python
149 lines
6.1 KiB
Python
"""Premarket LLM forecaster — an Ollama model as one more pick-journal entrant.
|
|
|
|
Feeds the local LLM the SAME premarket observables the bot has (top premarket
|
|
movers: gap vs prior close, cumulative premarket volume, price, fresh
|
|
headlines) and forces it to commit up to MAX_PICKS picks BEFORE the open into
|
|
the same human_picks table the owner uses. Scored by research/pick_scoring.py
|
|
under the identical pre-registered bar. Shadow-only: nothing here is read by
|
|
the live loop.
|
|
|
|
Run premarket (any time 07:00-09:29 ET):
|
|
python -m research.llm_picker # uses OLLAMA_HOST/OLLAMA_MODEL
|
|
python -m research.llm_picker --dry-run # show candidates + response, no insert
|
|
|
|
After 09:30 ET the journal stamps picks invalid, so a late run scores nothing
|
|
— the clock, not this script, enforces honesty.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import urllib.request
|
|
from datetime import datetime
|
|
|
|
from config import OllamaConfig
|
|
from research.multi_schema import open_research_db
|
|
from research.pick_journal import ET, add_pick
|
|
from storage.event_store import EventStore
|
|
|
|
MAX_PICKS = 3
|
|
CANDIDATES = 20
|
|
|
|
|
|
def premarket_candidates(con, session_date) -> list[dict]:
|
|
"""Top premarket movers today from minute_bars + prior close, ranked by
|
|
premarket dollar volume. Same free data the bot's discovery sees."""
|
|
rows = con.execute(
|
|
"SELECT symbol, sum(volume) pm_vol, max(high) pm_high, "
|
|
" (array_agg(close ORDER BY timestamp DESC))[1] last_px "
|
|
"FROM minute_bars WHERE session_date=? AND is_premarket "
|
|
"GROUP BY symbol HAVING sum(volume) > 0", [session_date]).fetchall()
|
|
prior = dict(con.execute(
|
|
"SELECT DISTINCT ON (symbol) symbol, close FROM daily_bars "
|
|
"WHERE trade_date < ? ORDER BY symbol, trade_date DESC",
|
|
[session_date]).fetchall())
|
|
out = []
|
|
for sym, pm_vol, pm_high, last_px in rows:
|
|
pc = prior.get(sym)
|
|
if not pc or float(pc) <= 0 or last_px is None:
|
|
continue
|
|
px = float(last_px)
|
|
gap = px / float(pc) - 1.0
|
|
if gap < 0.05: # movers only; same spirit as discovery
|
|
continue
|
|
out.append(dict(symbol=sym, gap_pct=round(gap * 100, 1),
|
|
price=round(px, 2),
|
|
pm_volume=int(pm_vol),
|
|
pm_dollar_vol=int(float(pm_vol) * px),
|
|
pm_high=round(float(pm_high), 2)))
|
|
out.sort(key=lambda r: -r["pm_dollar_vol"])
|
|
return out[:CANDIDATES]
|
|
|
|
|
|
def fresh_headlines(con, symbols, session_date) -> dict[str, list[str]]:
|
|
if not symbols:
|
|
return {}
|
|
rows = con.execute(
|
|
"SELECT symbol, headline FROM news_events WHERE symbol = ANY(?) "
|
|
"AND published_at >= ?::date - interval '1 day' "
|
|
"ORDER BY published_at DESC", [list(symbols), session_date]).fetchall()
|
|
out: dict[str, list[str]] = {}
|
|
for sym, h in rows:
|
|
out.setdefault(sym, [])
|
|
if len(out[sym]) < 3:
|
|
out[sym].append(h[:160])
|
|
return out
|
|
|
|
|
|
def build_prompt(cands, heads) -> str:
|
|
lines = []
|
|
for c in cands:
|
|
hs = "; ".join(heads.get(c["symbol"], [])) or "(no headlines)"
|
|
lines.append(f"{c['symbol']}: gap +{c['gap_pct']}%, price ${c['price']}, "
|
|
f"pm volume {c['pm_volume']:,}, pm high ${c['pm_high']}. News: {hs}")
|
|
body = "\n".join(lines)
|
|
return (
|
|
"You are a momentum day-trading analyst reviewing PREMARKET movers before "
|
|
"the 9:30 ET open. From the candidates below, pick the 0-3 you judge most "
|
|
"likely to CLOSE today meaningfully above the 9:30 open price (a long "
|
|
"day-trade held open-to-close). Most gappers fade — being selective or "
|
|
"picking none is correct when nothing looks strong. Judge catalyst "
|
|
"quality, premarket structure, and dilution risk.\n\n"
|
|
f"{body}\n\n"
|
|
'Reply as JSON: {"picks": [{"symbol": str, "conviction": 1|2|3, '
|
|
'"reason": str}]}. No other keys.')
|
|
|
|
|
|
def ask_llm(prompt: str, cfg: OllamaConfig) -> list[dict]:
|
|
req = urllib.request.Request(
|
|
f"{cfg.host}/api/generate",
|
|
data=json.dumps({"model": cfg.model, "prompt": prompt, "stream": False,
|
|
"format": "json",
|
|
# thinking models (qwen3.5, deepseek-r1) put everything in
|
|
# 'thinking' and return an empty response unless disabled
|
|
"think": False,
|
|
"options": {"temperature": cfg.temperature,
|
|
"num_predict": 512}}).encode(),
|
|
headers={"Content-Type": "application/json"})
|
|
with urllib.request.urlopen(req, timeout=max(cfg.timeout_seconds, 60)) as resp:
|
|
body = json.loads(resp.read().decode())
|
|
data = json.loads(body.get("response") or "{}")
|
|
picks = data.get("picks") or []
|
|
return picks[:MAX_PICKS] if isinstance(picks, list) else []
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
args = ap.parse_args()
|
|
cfg = OllamaConfig.from_env()
|
|
today = datetime.now(tz=ET).date()
|
|
research = open_research_db("market")
|
|
cands = premarket_candidates(research, today)
|
|
if not cands:
|
|
print("no premarket movers yet — run later in premarket")
|
|
return
|
|
heads = fresh_headlines(research, [c["symbol"] for c in cands], today)
|
|
prompt = build_prompt(cands, heads)
|
|
picks = ask_llm(prompt, cfg)
|
|
print(f"candidates={len(cands)} model={cfg.model} picks={len(picks)}")
|
|
if args.dry_run:
|
|
print(prompt)
|
|
print(json.dumps(picks, indent=1))
|
|
return
|
|
store = EventStore()
|
|
allowed = {c["symbol"] for c in cands}
|
|
for p in picks:
|
|
sym = str(p.get("symbol", "")).upper()
|
|
if sym not in allowed: # no hallucinated tickers
|
|
print(f" skip {sym}: not a candidate")
|
|
continue
|
|
row = add_pick(store.con, sym, forecaster=f"llm:{cfg.model}",
|
|
conviction=int(p.get("conviction") or 2),
|
|
note=str(p.get("reason") or "")[:280])
|
|
print(f" {sym} conv={row['conviction']} valid={row['valid']}: {row['note']}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|