#!/usr/bin/env python3 """A minimal news-reading trading agent for Investment Bets. Reads today's market headlines, asks an LLM to pick (at most) one conviction call — ticker, direction, optional target date — and places it as a paper-trading bet through the Investment Bets API. The server records the entry price itself, so whatever track record this agent builds is verifiable: nothing here is self-reported. Usage: export IB_EMAIL=agent@example.com IB_PASSWORD=... ANTHROPIC_API_KEY=... python3 agent.py # dry run: prints the decision, places nothing python3 agent.py --yes # actually opens the bet The irreversible step (opening a bet) is protected by guards in CODE, not by prompt instructions — the model is never trusted to enforce its own limits: 1. dry-run is the default; writes need an explicit --yes 2. at most one bet per run 3. a ticker the account already has an open bet on is refused 4. a malformed or past target date is refused 5. the ticker must resolve on GET /price/{ticker} before any write """ import argparse import datetime import os import sys import xml.etree.ElementTree as ET import requests API = os.environ.get("IB_API_URL", "https://api.investment-bets.com").rstrip("/") SITE = os.environ.get("IB_SITE_URL", "https://investment-bets.com").rstrip("/") MODEL = os.environ.get("IB_AGENT_MODEL", "claude-sonnet-5") HEADLINES_RSS = ( "https://news.google.com/rss/search" "?q=stock%20market%20OR%20earnings%20when:1d&hl=en-US&gl=US&ceid=US:en" ) MAX_HEADLINES = 25 DECISION_ATTEMPTS = 2 # the initial try plus one retry with feedback DECISION_TOOL = { "name": "submit_decision", "description": "Submit your single trading decision for today.", "input_schema": { "type": "object", "properties": { "action": { "type": "string", "enum": ["bet", "pass"], "description": "Place one bet, or pass if nothing is convincing.", }, "ticker": { "type": "string", "description": "Yahoo-style symbol, e.g. AAPL, NVDA, BTC-USD.", }, "direction": {"type": "string", "enum": ["LONG", "SHORT"]}, "target_date": { "type": ["string", "null"], "description": "Optional YYYY-MM-DD the thesis should resolve by.", }, "rationale": { "type": "string", "description": "One or two sentences: which headline(s), why this direction.", }, }, "required": ["action", "rationale"], }, } def parse_titles(body): # Entity-expansion attacks (billion laughs etc.) need a DTD, and no RSS # feed legitimately carries one — refuse rather than pull in defusedxml. # NUL bytes mean a non-ASCII-compatible encoding (UTF-16/32), where the # DOCTYPE token wouldn't match byte-wise — refuse those outright too. if b"\x00" in body[:4096]: sys.exit("headline feed is not ASCII-compatible; refusing to parse it") if b" is the feed's name if not headlines: sys.exit("headline feed contained no headlines") return headlines def fetch_headlines(): try: resp = requests.get(HEADLINES_RSS, timeout=30) resp.raise_for_status() except requests.RequestException as exc: sys.exit(f"could not fetch headlines: {exc}") return parse_titles(resp.content) def api_call(session, method, path, **kwargs): """One choke point for API I/O: network failures exit cleanly.""" kwargs.setdefault("timeout", 30) try: return session.request(method, f"{API}{path}", **kwargs) except requests.RequestException as exc: sys.exit(f"API request failed ({method} {path}): {exc}") def ok_json(resp): if not resp.ok: sys.exit(f"API error {resp.status_code}: {resp.text[:200]}") try: return resp.json() except ValueError: sys.exit(f"API returned a non-JSON body ({resp.status_code}): " f"{resp.text[:200]}") def login(session): resp = api_call(session, "POST", "/login", json={ "email": os.environ["IB_EMAIL"], "password": os.environ["IB_PASSWORD"], }) if resp.status_code != 200: sys.exit(f"login failed ({resp.status_code}): {resp.text}") return resp.json() # the auth_token cookie now lives in the session def open_tickers(session): return {bet["ticker"].upper() for bet in ok_json(api_call(session, "GET", "/portfolio"))} def price_of(session, ticker): """Server-side quote, or None when the ticker doesn't resolve.""" resp = api_call(session, "GET", f"/price/{ticker}") if resp.status_code == 404: return None return ok_json(resp) def decide(headlines, held, feedback=None): # Imported lazily on purpose: everything except this one call runs (and # can be exercised with a stubbed decide()) without the SDK installed. try: import anthropic except ImportError: sys.exit("the anthropic package is not installed — pip install anthropic") prompt = ( "Here are today's market headlines:\n\n" + "\n".join(f"- {h}" for h in headlines) + "\n\nTickers this account already holds open bets on (do not pick these): " + (", ".join(sorted(held)) or "none") + "\n\nPick at most ONE conviction call and submit it with the tool. " "Pass if nothing stands out — a pass costs nothing, a bad call is public forever." ) if feedback: prompt += f"\n\nYour previous attempt was rejected: {feedback}" client = anthropic.Anthropic() resp = client.messages.create( model=MODEL, max_tokens=1024, tools=[DECISION_TOOL], tool_choice={"type": "tool", "name": "submit_decision"}, messages=[{"role": "user", "content": prompt}], ) block = next((b for b in resp.content if b.type == "tool_use"), None) if block is None: sys.exit(f"model returned no decision tool call " f"(stop_reason={resp.stop_reason})") return block.input def main(argv=None): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--yes", action="store_true", help="actually open the bet (default is a dry run)") args = parser.parse_args(argv) missing = [v for v in ("IB_EMAIL", "IB_PASSWORD", "ANTHROPIC_API_KEY") if not os.environ.get(v)] if missing: sys.exit(f"missing environment variable(s): {', '.join(missing)}") session = requests.Session() me = login(session) held = open_tickers(session) headlines = fetch_headlines() print(f"signed in as {me['username']}; open bets on {len(held)} ticker(s); " f"{len(headlines)} headlines fetched") decision, quote = None, None feedback = None for _ in range(DECISION_ATTEMPTS): decision = decide(headlines, held, feedback) if decision.get("action") == "pass": print(f"agent passed: {decision.get('rationale', '(no rationale)')}") return if decision.get("action") != "bet": feedback = f"action must be 'bet' or 'pass', got {decision.get('action')!r}" continue ticker = decision.get("ticker", "").upper() if not ticker or decision.get("direction") not in ("LONG", "SHORT"): feedback = "missing ticker or direction" continue if ticker in held: # guard 3: no duplicate position feedback = f"{ticker} is already held open" continue target = decision.get("target_date") # guard 4: refuse past target dates if target: try: parsed = datetime.date.fromisoformat(target) except ValueError: feedback = f"target_date {target!r} is not a YYYY-MM-DD date" continue if parsed < datetime.datetime.now(datetime.timezone.utc).date(): feedback = f"target_date {target} is in the past" continue quote = price_of(session, ticker) # guard 5: ticker must resolve if quote is None: feedback = f"{ticker} is not a known ticker on this platform" continue break else: sys.exit(f"no valid decision after retry (last problem: {feedback})") print(f"\ndecision: {decision['direction']} {ticker} @ {quote['price']}" + (f" until {decision['target_date']}" if decision.get("target_date") else "")) print(f"rationale: {decision.get('rationale', '(no rationale)')}") if not args.yes: # guard 1: writes are opt-in print("\ndry run — pass --yes to place this bet") return body = {"ticker": ticker, "direction": decision["direction"]} if decision.get("target_date"): body["target_date"] = decision["target_date"] resp = api_call(session, "POST", "/bet/open", json=body) if resp.status_code == 403: sys.exit("bet limit reached for this account's plan — close one first") if resp.status_code == 503: sys.exit("the server's price feed is currently stale — try again later") placed = ok_json(resp) print(f"\nbet #{placed['bet_id']} opened at server-recorded entry " f"{placed['entry_price']}") print(f"public receipt: {SITE}/user/{me['username']}") # guard 2: one bet per run — the program simply ends here. if __name__ == "__main__": main()