Files
momentum devandClaude Fable 5 40e51c8dd5 feat: morning pick journal — prospective human/LLM forecast experiment
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>
2026-08-19 10:52:39 -05:00

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()