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:
1 parent
390d6660ea
commit
40e51c8dd5
6 files changed
+474
No files matched your search
+33
@@ -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
|
||||
|
||||
@@ -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 · picks lock at 9:30 ET · 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&L bars + cumulative equity curve · 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 || [];
|
||||
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in new issue
Block a user