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>
This commit is contained in:
momentum devandClaude Fable 5 committed 2026-08-19 10:52:39 -05:00
1 parent 390d6660ea
commit 40e51c8dd5
6 files changed
+474

No files matched your search

+33
View File
@@ -741,6 +741,33 @@ class DashboardState:
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": str(exc)}
def add_pick(self, body: dict) -> dict:
"""Morning pick journal (research/pick_journal.py) — shadow forecast
experiment; never read by the live loop."""
from research.pick_journal import add_pick as _add
store = self.open_store()
try:
row = _add(store.con, str(body.get("symbol") or ""),
forecaster=str(body.get("forecaster") or "phil"),
direction=str(body.get("direction") or "long"),
conviction=int(body.get("conviction") or 2),
note=body.get("note"))
return {"ok": True, "valid": row["valid"], "symbol": row["symbol"],
"picked_at": row["picked_at"].isoformat()}
except ValueError as exc:
return {"ok": False, "error": str(exc)}
finally:
store.con.close()
def picks(self, for_date: str | None = None) -> list[dict]:
from research.pick_journal import list_picks
store = self.open_store()
try:
d = date.fromisoformat(for_date) if for_date else None
return list_picks(store.con, d)
finally:
store.con.close()
def _json_default(obj):
if isinstance(obj, datetime):
@@ -917,6 +944,9 @@ class DashboardHandler(BaseHTTPRequestHandler):
from research.attribution import compute_attribution
days = int((self._query().get("days") or ["7"])[0])
self._send_json(compute_attribution(days=days))
elif route == "/api/picks":
d = (self._query().get("date") or [""])[0] or None
self._send_json({"picks": self.state.picks(d)})
elif route == "/api/health":
self._send_json({"ok": True, "mode": self.state.execution_mode,
"version": os.environ.get("APP_BUILD_TAG", "dev"),
@@ -953,6 +983,9 @@ class DashboardHandler(BaseHTTPRequestHandler):
result = self.state.exit_position(str(body.get("symbol") or ""))
elif route == "/api/watch/add":
result = self.state.add_watch(str(body.get("symbol") or ""))
elif route == "/api/picks":
self._send_json(self.state.add_pick(body))
return
elif route == "/api/command":
# The command bus is token-gated by construction: without
# DASHBOARD_TOKEN a command could run unauthenticated, so refuse
+39
View File
@@ -594,6 +594,17 @@
<div id="watch-states"></div>
</details>
<details class="panel" id="p-picks" open>
<summary><h2>Morning Picks <span id="picks-count" class="count">0</span><span class="hint">prospective forecast journal &middot; picks lock at 9:30 ET &middot; shadow-scored, never traded</span></h2></summary>
<div class="inject">
<input id="pick-symbol" placeholder="symbol" maxlength="6" autocomplete="off" style="width:70px">
<select id="pick-conv"><option value="1">conv 1</option><option value="2" selected>conv 2</option><option value="3">conv 3</option></select>
<input id="pick-note" placeholder="why? (optional)" maxlength="280" autocomplete="off" style="flex:1">
<button id="pick-btn">Pick</button>
</div>
<div id="picks-list"><span class="empty">No picks today.</span></div>
</details>
<details class="panel full" id="p-daily">
<summary><h2>Daily Performance <span id="daily-count" class="count">0</span><span class="hint">per-day P&amp;L bars + cumulative equity curve &middot; click a day to open it</span></h2></summary>
<div id="daily-curve"><span class="empty">No trading days recorded yet.</span></div>
@@ -1961,6 +1972,34 @@ function doInject() {
$("inject-btn").onclick = doInject;
$("inject-input").addEventListener("keydown", (e) => { if (e.key === "Enter") doInject(); e.stopPropagation(); });
// -- morning pick journal (shadow forecast experiment; never traded) --------
async function loadPicks() {
try {
const r = await fetch("/api/picks");
const picks = (await r.json()).picks || [];
$("picks-count").textContent = picks.length;
if (!picks.length) { $("picks-list").innerHTML = '<span class="empty">No picks today.</span>'; return; }
$("picks-list").innerHTML = picks.map(p =>
`<div>${p.valid ? "✅" : "⛔"} <b>${p.symbol}</b> conv ${p.conviction}` +
` <span class="hint">${p.forecaster} · ${String(p.picked_at).slice(11, 16)}` +
`${p.valid ? "" : " · AFTER 9:30 — not scored"}${p.note ? " · " + p.note : ""}</span></div>`).join("");
} catch (e) { /* panel is best-effort */ }
}
async function doPick() {
const sym = $("pick-symbol").value.trim().toUpperCase();
if (!sym) return;
const body = { symbol: sym, conviction: +$("pick-conv").value, note: $("pick-note").value.trim() };
const r = await fetch("/api/picks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const res = await r.json();
if (res.ok) { $("pick-symbol").value = ""; $("pick-note").value = ""; loadPicks(); }
else alert(res.error || "pick failed");
}
$("pick-btn").onclick = doPick;
$("pick-symbol").addEventListener("keydown", (e) => { if (e.key === "Enter") doPick(); e.stopPropagation(); });
$("pick-note").addEventListener("keydown", (e) => { if (e.key === "Enter") doPick(); e.stopPropagation(); });
loadPicks();
setInterval(loadPicks, 60000);
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return;
const aq = latest && latest.approval_queue || [];
+148
View File
@@ -0,0 +1,148 @@
"""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()
+79
View File
@@ -0,0 +1,79 @@
"""Morning pick journal — the human/LLM forecast experiment (2026-08-19).
Tests whether a PROSPECTIVE forecaster (the owner, an LLM, anyone) carries
signal the machine levers never found. Picks land here premarket; the scorer
(research/pick_scoring.py) judges them against the same bars and cost model
as everything else. Zero trading impact — this table is never read by the
live loop.
Validity rule: a pick only counts if picked_at is BEFORE 09:30 ET on its
session date. Late picks are stored (audit) but flagged invalid and excluded
from scoring — no retroactive genius.
"""
from __future__ import annotations
import uuid
from datetime import datetime, time as dtime
from zoneinfo import ZoneInfo
ET = ZoneInfo("America/New_York")
OPEN_ET = dtime(9, 30)
DIRECTIONS = ("long", "short")
DDL = """
CREATE TABLE IF NOT EXISTS human_picks (
id VARCHAR PRIMARY KEY,
session_date DATE NOT NULL,
picked_at TIMESTAMPTZ NOT NULL,
forecaster VARCHAR NOT NULL,
symbol VARCHAR NOT NULL,
direction VARCHAR NOT NULL DEFAULT 'long',
conviction INTEGER NOT NULL DEFAULT 2,
note VARCHAR,
valid BOOLEAN NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_human_picks_session
ON human_picks(session_date, forecaster);
"""
def ensure_schema(con) -> None:
con.execute(DDL)
def add_pick(con, symbol: str, *, forecaster: str = "phil",
direction: str = "long", conviction: int = 2,
note: str | None = None, now: datetime | None = None) -> dict:
"""Insert one pick, stamping validity from the wall clock. Returns the row."""
symbol = (symbol or "").strip().upper()
if not symbol or len(symbol) > 6 or not symbol.replace(".", "").isalnum():
raise ValueError(f"bad symbol {symbol!r}")
if direction not in DIRECTIONS:
raise ValueError(f"direction must be one of {DIRECTIONS}")
conviction = max(1, min(3, int(conviction)))
now_et = (now or datetime.now(tz=ET)).astimezone(ET)
valid = now_et.timetz().replace(tzinfo=None) < OPEN_ET
row = dict(id=str(uuid.uuid4()), session_date=now_et.date(),
picked_at=now_et, forecaster=forecaster[:64], symbol=symbol,
direction=direction, conviction=conviction,
note=(note or "")[:280] or None, valid=valid)
ensure_schema(con)
con.execute(
"INSERT INTO human_picks (id, session_date, picked_at, forecaster, symbol, "
"direction, conviction, note, valid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
[row["id"], row["session_date"], row["picked_at"], row["forecaster"],
row["symbol"], row["direction"], row["conviction"], row["note"], row["valid"]])
return row
def list_picks(con, session_date=None) -> list[dict]:
ensure_schema(con)
if session_date is None:
session_date = datetime.now(tz=ET).date()
rows = con.execute(
"SELECT id, session_date, picked_at, forecaster, symbol, direction, "
"conviction, note, valid FROM human_picks WHERE session_date = ? "
"ORDER BY picked_at", [session_date]).fetchall()
cols = ["id", "session_date", "picked_at", "forecaster", "symbol",
"direction", "conviction", "note", "valid"]
return [dict(zip(cols, r)) for r in rows]
+120
View File
@@ -0,0 +1,120 @@
"""Score morning picks against bars — the human/LLM forecast experiment judge.
PRE-REGISTERED BAR (locked 2026-08-19 BEFORE any picks existed):
Per forecaster, a track record PASSES only if ALL of:
1. n >= 40 valid picks across >= 30 distinct sessions
2. mean net return >= +0.5% per pick
3. median net return > 0 (no skew-carried means)
4. positive mean in BOTH halves of the sample (split at the median session)
Scoring (fixed, same for every forecaster):
entry = first regular-hours bar OPEN on the pick's session
exit = last regular-hours bar CLOSE that session (EOD)
net = direction-signed return minus the repo NOCHASE round-trip cost
(4% sub-$3, 2% otherwise); shorts are scored but flagged
(cash account cannot trade them)
Only picks with valid=true (logged before 09:30 ET) count. Reference
baseline: the bot's replay fire population runs -0.34R fire-weighted; a
forecaster passing this bar is the first positive prospective signal in
the project's history and earns a forward sizing experiment — never a
direct live gate.
Usage:
python -m research.pick_scoring # score everything to date
python -m research.pick_scoring --date 2026-08-20 # one session's detail
"""
from __future__ import annotations
import argparse
import statistics
from collections import defaultdict
from research.multi_schema import open_research_db
from storage.event_store import EventStore
COST_SUB3, COST_OTHER = 0.04, 0.02 # labeler.py NOCHASE round-trip
def _cost(price: float) -> float:
return COST_SUB3 if price < 3.0 else COST_OTHER
def score_pick(con, symbol: str, session_date, direction: str) -> dict | None:
"""Net EOD return for one pick from minute_bars. None when no RTH bars."""
rows = con.execute(
"SELECT open, close FROM minute_bars WHERE symbol=? AND session_date=? "
"AND is_regular_hours ORDER BY timestamp", [symbol, session_date]).fetchall()
if not rows:
return None
entry = float(rows[0][0])
exit_px = float(rows[-1][1])
if entry <= 0:
return None
raw = exit_px / entry - 1.0
signed = raw if direction == "long" else -raw
return dict(entry=entry, exit=exit_px, raw=raw,
net=signed - _cost(entry))
def evaluate(events_con, research_con, for_date=None) -> dict:
from research.pick_journal import ensure_schema
ensure_schema(events_con)
where, params = "WHERE valid", []
if for_date is not None:
where += " AND session_date = ?"
params.append(for_date)
picks = events_con.execute(
f"SELECT session_date, forecaster, symbol, direction, conviction "
f"FROM human_picks {where} ORDER BY session_date, picked_at",
params).fetchall()
by_fc: dict[str, list] = defaultdict(list)
unscored = 0
for d, fc, sym, direction, conv in picks:
s = score_pick(research_con, sym, d, direction)
if s is None:
unscored += 1
continue
by_fc[fc].append(dict(d=d, sym=sym, direction=direction, conv=conv, **s))
out = {"unscored": unscored, "forecasters": {}}
for fc, rows in by_fc.items():
nets = [r["net"] for r in rows]
sessions = sorted({r["d"] for r in rows})
mid = sessions[len(sessions) // 2] if sessions else None
h1 = [r["net"] for r in rows if r["d"] < mid] if mid else []
h2 = [r["net"] for r in rows if r["d"] >= mid] if mid else []
mean = statistics.mean(nets)
med = statistics.median(nets)
ok = (len(nets) >= 40 and len(sessions) >= 30 and mean >= 0.005
and med > 0 and h1 and h2
and statistics.mean(h1) > 0 and statistics.mean(h2) > 0)
out["forecasters"][fc] = dict(
n=len(nets), sessions=len(sessions),
mean=round(mean, 5), median=round(med, 5),
win=round(sum(1 for v in nets if v > 0) / len(nets), 3),
h1=round(statistics.mean(h1), 5) if h1 else None,
h2=round(statistics.mean(h2), 5) if h2 else None,
shorts=sum(1 for r in rows if r["direction"] == "short"),
bar="PASS" if ok else ("accruing" if len(nets) < 40 else "FAIL"),
picks=[dict(d=str(r["d"]), sym=r["sym"], dir=r["direction"],
net=round(r["net"], 4)) for r in rows[-10:]])
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--date", default=None)
args = ap.parse_args()
store = EventStore()
research = open_research_db("market")
res = evaluate(store.con, research, for_date=args.date)
print(f"unscored (no bars): {res['unscored']}")
for fc, s in sorted(res["forecasters"].items()):
print(f"\n{fc}: n={s['n']} sessions={s['sessions']} "
f"mean={s['mean']*100:+.2f}% med={s['median']*100:+.2f}% "
f"win={s['win']*100:.0f}% h1={s['h1']} h2={s['h2']} "
f"shorts={s['shorts']} | BAR: {s['bar']}")
for p in s["picks"]:
print(f" {p['d']} {p['sym']:6} {p['dir']:5} net={p['net']*100:+.2f}%")
if __name__ == "__main__":
main()
+55
View File
@@ -0,0 +1,55 @@
"""Pick journal + scorer: the smallest checks that fail if the logic breaks."""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import pytest
from research.pick_journal import add_pick, ensure_schema, list_picks
from research.pick_scoring import score_pick
from storage.db_pg import get_connection
ET_ = ZoneInfo("America/New_York")
@pytest.fixture()
def con():
c = get_connection(":memory:")
yield c
c.close()
def test_pick_validity_locks_at_open(con):
early = datetime(2026, 8, 20, 9, 29, tzinfo=ET_)
late = datetime(2026, 8, 20, 9, 31, tzinfo=ET_)
assert add_pick(con, "aapl", now=early)["valid"] is True
assert add_pick(con, "TSLA", now=late)["valid"] is False
picks = list_picks(con, early.date())
assert [p["symbol"] for p in picks] == ["AAPL", "TSLA"]
assert [p["valid"] for p in picks] == [True, False]
def test_pick_rejects_garbage(con):
with pytest.raises(ValueError):
add_pick(con, "")
with pytest.raises(ValueError):
add_pick(con, "TOOLONGG")
with pytest.raises(ValueError):
add_pick(con, "AAPL", direction="sideways")
def test_score_pick_long_and_short(con):
ensure_schema(con)
d = datetime(2026, 8, 20).date()
base = datetime(2026, 8, 20, 9, 30)
for i, (o, c) in enumerate([(10.0, 10.2), (10.2, 10.5), (10.5, 11.0)]):
con.execute(
"INSERT INTO minute_bars (symbol, timestamp, session_date, is_premarket, "
"is_regular_hours, is_afterhours, open, high, low, close, volume) "
"VALUES (?, ?, ?, false, true, false, ?, ?, ?, ?, 1000)",
["TEST", base + timedelta(minutes=i), d, o, max(o, c), min(o, c), c])
s = score_pick(con, "TEST", d, "long")
# entry 10.0 open, exit 11.0 close: raw +10%, minus 2% cost (>=$3)
assert abs(s["net"] - 0.08) < 1e-9
s2 = score_pick(con, "TEST", d, "short")
assert abs(s2["net"] - (-0.12)) < 1e-9
assert score_pick(con, "NOPE", d, "long") is None