feat(hypotheses): add daily hypothesis runner workflow
This commit is contained in:
parent
38b9cef41c
commit
1b782b1cd6
|
|
@ -0,0 +1,74 @@
|
||||||
|
name: Hypothesis Runner
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# 8:00 AM UTC daily — runs after iterate (06:00 UTC)
|
||||||
|
- cron: "0 8 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
hypothesis_id:
|
||||||
|
description: "Run a specific hypothesis ID only (blank = all running)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
env:
|
||||||
|
PYTHON_VERSION: "3.10"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run-hypotheses:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: TradingAgent
|
||||||
|
timeout-minutes: 60
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GH_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up git identity
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install --upgrade pip && pip install -e .
|
||||||
|
|
||||||
|
- name: Run hypothesis experiments
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
|
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
FINNHUB_API_KEY: ${{ secrets.FINNHUB_API_KEY }}
|
||||||
|
ALPHA_VANTAGE_API_KEY: ${{ secrets.ALPHA_VANTAGE_API_KEY }}
|
||||||
|
FMP_API_KEY: ${{ secrets.FMP_API_KEY }}
|
||||||
|
REDDIT_CLIENT_ID: ${{ secrets.REDDIT_CLIENT_ID }}
|
||||||
|
REDDIT_CLIENT_SECRET: ${{ secrets.REDDIT_CLIENT_SECRET }}
|
||||||
|
TRADIER_API_KEY: ${{ secrets.TRADIER_API_KEY }}
|
||||||
|
FILTER_ID: ${{ inputs.hypothesis_id }}
|
||||||
|
run: |
|
||||||
|
python scripts/run_hypothesis_runner.py
|
||||||
|
|
||||||
|
- name: Commit active.json updates
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git add docs/iterations/hypotheses/active.json docs/iterations/hypotheses/concluded/ || true
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No registry changes"
|
||||||
|
else
|
||||||
|
git commit -m "chore(hypotheses): update registry $(date -u +%Y-%m-%d)"
|
||||||
|
git pull --rebase origin main
|
||||||
|
git push origin main
|
||||||
|
fi
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Hypothesis Runner — orchestrates daily experiment cycles.
|
||||||
|
|
||||||
|
For each running hypothesis in active.json:
|
||||||
|
1. Creates a git worktree for the hypothesis branch
|
||||||
|
2. Runs the daily discovery pipeline in that worktree
|
||||||
|
3. Extracts picks from the discovery result, appends to picks.json
|
||||||
|
4. Commits and pushes picks to hypothesis branch
|
||||||
|
5. Removes worktree
|
||||||
|
6. Updates active.json (days_elapsed, picks_log)
|
||||||
|
7. If days_elapsed >= min_days: concludes the hypothesis
|
||||||
|
|
||||||
|
After all hypotheses: promotes highest-priority pending → running if a slot opened.
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
FILTER_ID — if set, only run the hypothesis with this ID
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
ACTIVE_JSON = ROOT / "docs/iterations/hypotheses/active.json"
|
||||||
|
CONCLUDED_DIR = ROOT / "docs/iterations/hypotheses/concluded"
|
||||||
|
DB_PATH = ROOT / "data/recommendations/performance_database.json"
|
||||||
|
TODAY = datetime.utcnow().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def load_registry() -> dict:
|
||||||
|
with open(ACTIVE_JSON) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
def save_registry(registry: dict) -> None:
|
||||||
|
with open(ACTIVE_JSON, "w") as f:
|
||||||
|
json.dump(registry, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list, cwd: str = None, check: bool = True) -> subprocess.CompletedProcess:
|
||||||
|
print(f" $ {' '.join(cmd)}", flush=True)
|
||||||
|
return subprocess.run(cmd, cwd=cwd or str(ROOT), check=check, capture_output=False)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_picks(worktree: str, scanner: str) -> list:
|
||||||
|
"""Extract picks for the given scanner from the most recent discovery result in the worktree."""
|
||||||
|
results_dir = Path(worktree) / "results" / "discovery" / TODAY
|
||||||
|
if not results_dir.exists():
|
||||||
|
print(f" No discovery results for {TODAY} in worktree", flush=True)
|
||||||
|
return []
|
||||||
|
picks = []
|
||||||
|
for run_dir in sorted(results_dir.iterdir()):
|
||||||
|
result_file = run_dir / "discovery_result.json"
|
||||||
|
if not result_file.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(result_file) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
for item in data.get("final_ranking", []):
|
||||||
|
if item.get("strategy_match") == scanner:
|
||||||
|
picks.append({
|
||||||
|
"date": TODAY,
|
||||||
|
"ticker": item["ticker"],
|
||||||
|
"score": item.get("final_score"),
|
||||||
|
"confidence": item.get("confidence"),
|
||||||
|
"scanner": scanner,
|
||||||
|
"return_7d": None,
|
||||||
|
"win_7d": None,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Warning: could not read {result_file}: {e}", flush=True)
|
||||||
|
return picks
|
||||||
|
|
||||||
|
|
||||||
|
def load_picks_from_branch(hypothesis_id: str, branch: str) -> list:
|
||||||
|
"""Load picks.json from the hypothesis branch using git show."""
|
||||||
|
picks_path = f"docs/iterations/hypotheses/{hypothesis_id}/picks.json"
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "show", f"{branch}:{picks_path}"],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return json.loads(result.stdout).get("picks", [])
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def save_picks_to_worktree(worktree: str, hypothesis_id: str, scanner: str, picks: list) -> None:
|
||||||
|
"""Write updated picks.json into the worktree and commit."""
|
||||||
|
picks_dir = Path(worktree) / "docs" / "iterations" / "hypotheses" / hypothesis_id
|
||||||
|
picks_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
picks_file = picks_dir / "picks.json"
|
||||||
|
payload = {"hypothesis_id": hypothesis_id, "scanner": scanner, "picks": picks}
|
||||||
|
picks_file.write_text(json.dumps(payload, indent=2))
|
||||||
|
run(["git", "add", str(picks_file)], cwd=worktree)
|
||||||
|
result = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=worktree)
|
||||||
|
if result.returncode != 0:
|
||||||
|
run(
|
||||||
|
["git", "commit", "-m", f"chore(hypotheses): picks {TODAY} for {hypothesis_id}"],
|
||||||
|
cwd=worktree,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_hypothesis(hyp: dict) -> bool:
|
||||||
|
"""Run one hypothesis experiment cycle. Returns True if the experiment concluded."""
|
||||||
|
hid = hyp["id"]
|
||||||
|
branch = hyp["branch"]
|
||||||
|
scanner = hyp["scanner"]
|
||||||
|
worktree = f"/tmp/hyp-{hid}"
|
||||||
|
|
||||||
|
print(f"\n── Hypothesis: {hid} ──", flush=True)
|
||||||
|
|
||||||
|
run(["git", "fetch", "origin", branch], check=False)
|
||||||
|
run(["git", "worktree", "add", worktree, branch])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "scripts/run_daily_discovery.py", "--date", TODAY, "--no-update-positions"],
|
||||||
|
cwd=worktree,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" Discovery failed for {hid}, skipping picks update", flush=True)
|
||||||
|
else:
|
||||||
|
new_picks = extract_picks(worktree, scanner)
|
||||||
|
existing_picks = load_picks_from_branch(hid, branch)
|
||||||
|
seen = {(p["date"], p["ticker"]) for p in existing_picks}
|
||||||
|
merged = existing_picks + [p for p in new_picks if (p["date"], p["ticker"]) not in seen]
|
||||||
|
save_picks_to_worktree(worktree, hid, scanner, merged)
|
||||||
|
run(["git", "push", "origin", f"HEAD:{branch}"], cwd=worktree)
|
||||||
|
|
||||||
|
if TODAY not in hyp.get("picks_log", []):
|
||||||
|
hyp.setdefault("picks_log", []).append(TODAY)
|
||||||
|
hyp["days_elapsed"] = len(hyp["picks_log"])
|
||||||
|
|
||||||
|
if hyp["days_elapsed"] >= hyp["min_days"]:
|
||||||
|
return conclude_hypothesis(hyp)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
run(["git", "worktree", "remove", "--force", worktree], check=False)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def conclude_hypothesis(hyp: dict) -> bool:
|
||||||
|
"""Run comparison, write conclusion doc, close/merge PR. Returns True."""
|
||||||
|
hid = hyp["id"]
|
||||||
|
scanner = hyp["scanner"]
|
||||||
|
branch = hyp["branch"]
|
||||||
|
|
||||||
|
print(f"\n Concluding {hid}...", flush=True)
|
||||||
|
|
||||||
|
picks = load_picks_from_branch(hid, branch)
|
||||||
|
if not picks:
|
||||||
|
conclusion = {
|
||||||
|
"decision": "rejected",
|
||||||
|
"reason": "No picks were collected during the experiment period",
|
||||||
|
"hypothesis": {"count": 0, "evaluated": 0, "win_rate": None, "avg_return": None},
|
||||||
|
"baseline": {"count": 0, "win_rate": None, "avg_return": None},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable, "scripts/compare_hypothesis.py",
|
||||||
|
"--hypothesis-id", hid,
|
||||||
|
"--picks-json", json.dumps(picks),
|
||||||
|
"--scanner", scanner,
|
||||||
|
"--db-path", str(DB_PATH),
|
||||||
|
],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f" compare_hypothesis.py failed: {result.stderr}", flush=True)
|
||||||
|
return False
|
||||||
|
conclusion = json.loads(result.stdout)
|
||||||
|
|
||||||
|
decision = conclusion["decision"]
|
||||||
|
hyp_metrics = conclusion["hypothesis"]
|
||||||
|
base_metrics = conclusion["baseline"]
|
||||||
|
|
||||||
|
period_start = hyp.get("created_at", TODAY)
|
||||||
|
concluded_doc = CONCLUDED_DIR / f"{TODAY}-{hid}.md"
|
||||||
|
concluded_doc.write_text(
|
||||||
|
f"# Hypothesis: {hyp['title']}\n\n"
|
||||||
|
f"**Scanner:** {scanner}\n"
|
||||||
|
f"**Branch:** {branch}\n"
|
||||||
|
f"**Period:** {period_start} → {TODAY} ({hyp['days_elapsed']} days)\n"
|
||||||
|
f"**Outcome:** {'accepted ✅' if decision == 'accepted' else 'rejected ❌'}\n\n"
|
||||||
|
f"## Hypothesis\n{hyp.get('description', hyp['title'])}\n\n"
|
||||||
|
f"## Results\n\n"
|
||||||
|
f"| Metric | Baseline | Experiment | Delta |\n"
|
||||||
|
f"|---|---|---|---|\n"
|
||||||
|
f"| 7d win rate | {base_metrics.get('win_rate') or '—'}% | "
|
||||||
|
f"{hyp_metrics.get('win_rate') or '—'}% | "
|
||||||
|
f"{_delta_str(hyp_metrics.get('win_rate'), base_metrics.get('win_rate'), 'pp')} |\n"
|
||||||
|
f"| Avg return | {base_metrics.get('avg_return') or '—'}% | "
|
||||||
|
f"{hyp_metrics.get('avg_return') or '—'}% | "
|
||||||
|
f"{_delta_str(hyp_metrics.get('avg_return'), base_metrics.get('avg_return'), '%')} |\n"
|
||||||
|
f"| Picks | {base_metrics.get('count', '—')} | {hyp_metrics.get('count', '—')} | — |\n\n"
|
||||||
|
f"## Decision\n{conclusion['reason']}\n\n"
|
||||||
|
f"## Action\n"
|
||||||
|
f"{'Branch merged into main.' if decision == 'accepted' else 'Branch closed without merging.'}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
run(["git", "add", str(concluded_doc)], check=False)
|
||||||
|
|
||||||
|
pr = hyp.get("pr_number")
|
||||||
|
if pr:
|
||||||
|
if decision == "accepted":
|
||||||
|
subprocess.run(
|
||||||
|
["gh", "pr", "merge", str(pr), "--squash", "--delete-branch"],
|
||||||
|
cwd=str(ROOT), check=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
subprocess.run(
|
||||||
|
["gh", "pr", "close", str(pr), "--delete-branch"],
|
||||||
|
cwd=str(ROOT), check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
hyp["status"] = "concluded"
|
||||||
|
hyp["conclusion"] = decision
|
||||||
|
|
||||||
|
print(f" {hid}: {decision} — {conclusion['reason']}", flush=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_str(hyp_val, base_val, unit: str) -> str:
|
||||||
|
if hyp_val is None or base_val is None:
|
||||||
|
return "—"
|
||||||
|
delta = hyp_val - base_val
|
||||||
|
sign = "+" if delta >= 0 else ""
|
||||||
|
return f"{sign}{delta:.1f}{unit}"
|
||||||
|
|
||||||
|
|
||||||
|
def promote_pending(registry: dict) -> None:
|
||||||
|
"""Promote the highest-priority pending hypothesis to running if a slot is open."""
|
||||||
|
running_count = sum(1 for h in registry["hypotheses"] if h["status"] == "running")
|
||||||
|
max_active = registry.get("max_active", 5)
|
||||||
|
if running_count >= max_active:
|
||||||
|
return
|
||||||
|
pending = [h for h in registry["hypotheses"] if h["status"] == "pending"]
|
||||||
|
if not pending:
|
||||||
|
return
|
||||||
|
to_promote = max(pending, key=lambda h: h.get("priority", 0))
|
||||||
|
to_promote["status"] = "running"
|
||||||
|
print(f"\n Promoted pending hypothesis to running: {to_promote['id']}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
registry = load_registry()
|
||||||
|
filter_id = os.environ.get("FILTER_ID", "").strip()
|
||||||
|
|
||||||
|
hypotheses = registry.get("hypotheses", [])
|
||||||
|
running = [
|
||||||
|
h for h in hypotheses
|
||||||
|
if h["status"] == "running" and (not filter_id or h["id"] == filter_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not running:
|
||||||
|
print("No running hypotheses to process.", flush=True)
|
||||||
|
else:
|
||||||
|
for hyp in running:
|
||||||
|
run_hypothesis(hyp)
|
||||||
|
|
||||||
|
promote_pending(registry)
|
||||||
|
save_registry(registry)
|
||||||
|
print("\nRegistry updated.", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue