#!/usr/bin/env python3 """ Paper Trade Executor Higher-level wrapper around paper_engine that takes structured trade recommendations (e.g., from a strategy advisor) and executes them as paper trades with full validation. """ import argparse import json import os import sys from datetime import datetime, timezone # Import from paper_engine (same directory) _THIS_DIR = os.path.dirname(os.path.abspath(__file__)) if _THIS_DIR not in sys.path: sys.path.append(_THIS_DIR) from paper_engine import ( get_portfolio, place_order, close_position, fetch_midpoint, DEFAULT_FEE_RATE, ) def execute_recommendation( recommendation: dict, portfolio_name: str = "default", dry_run: bool = False, ) -> dict: """ Execute a trade recommendation from a strategy advisor. Expected recommendation format: { "token_id": "...", "side": "YES" or "NO", "action": "BUY" or "SELL" or "CLOSE", "size_usd": 50.0, # USD amount (for BUY) "size_pct": 0.05, # OR as % of portfolio (alternative to size_usd) "price": 0.45, # optional limit price "confidence": 0.75, # strategy confidence 0-1 "reasoning": "...", # why this trade "strategy": "momentum", # which strategy generated this "fee_rate": 0.0, # optional fee override } Returns execution result dict. """ token_id = recommendation.get("token_id") if not token_id: return {"status": "rejected", "reason": "Missing token_id"} action = recommendation.get("action", "BUY").upper() side = recommendation.get("side", "YES").upper() confidence = recommendation.get("confidence", 0.5) reasoning = recommendation.get("reasoning", "") strategy = recommendation.get("strategy", "unknown") fee_rate = recommendation.get("fee_rate", DEFAULT_FEE_RATE) price = recommendation.get("price") # Build reasoning string full_reasoning = f"[{strategy}] (conf={confidence:.0%}) {reasoning}" # Get current portfolio state try: portfolio = get_portfolio(portfolio_name, refresh_prices=True) except RuntimeError as exc: return {"status": "rejected", "reason": str(exc)} # Confidence gate min_confidence = 0.5 if confidence < min_confidence: return { "status": "rejected", "reason": f"Confidence {confidence:.0%} below minimum {min_confidence:.0%}", "recommendation": recommendation, } # Handle CLOSE action if action == "CLOSE": if dry_run: return { "status": "dry_run", "action": "CLOSE", "token_id": token_id, "side": side, "portfolio": _summary(portfolio), } try: result = close_position( token_id=token_id, side=side if side in ("YES", "NO") else None, portfolio_name=portfolio_name, fee_rate=fee_rate, reasoning=full_reasoning, ) return { "status": "executed", "action": "CLOSE", "result": result, "portfolio": _summary( get_portfolio(portfolio_name, refresh_prices=False) ), } except RuntimeError as exc: return {"status": "rejected", "reason": str(exc)} # Determine size in USD size_usd = recommendation.get("size_usd") size_pct = recommendation.get("size_pct") if size_usd is None and size_pct is not None: size_usd = portfolio["total_value"] * size_pct elif size_usd is None: # Default: Kelly-inspired sizing based on confidence # Half-Kelly: f = (2p - 1) where p = confidence, then halved kelly_fraction = max(0, (2 * confidence - 1)) * 0.5 # Cap at 10% of portfolio kelly_fraction = min(kelly_fraction, 0.10) size_usd = portfolio["total_value"] * kelly_fraction if size_usd <= 0: return { "status": "rejected", "reason": "Calculated trade size is zero (confidence too low for Kelly sizing)", } # Round to 2 decimal places size_usd = round(size_usd, 2) # Get current market price for context try: current_price = fetch_midpoint(token_id) except Exception: current_price = None if dry_run: return { "status": "dry_run", "action": action, "side": side, "token_id": token_id, "size_usd": size_usd, "limit_price": price, "current_price": current_price, "confidence": confidence, "strategy": strategy, "reasoning": full_reasoning, "portfolio": _summary(portfolio), } # Execute the trade try: result = place_order( token_id=token_id, side=side, size=size_usd, price=price, reasoning=full_reasoning, portfolio_name=portfolio_name, fee_rate=fee_rate, ) # Get updated portfolio updated = get_portfolio(portfolio_name, refresh_prices=False) return { "status": "executed", "action": action, "result": result, "portfolio": _summary(updated), } except RuntimeError as exc: return { "status": "rejected", "reason": str(exc), "attempted": { "token_id": token_id, "side": side, "size_usd": size_usd, "price": price, }, } def _summary(portfolio: dict) -> dict: """Compact portfolio summary for trade results.""" return { "total_value": portfolio["total_value"], "cash_balance": portfolio["cash_balance"], "pnl": portfolio["pnl"], "pnl_pct": portfolio["pnl_pct"], "num_positions": portfolio["num_open_positions"], } def execute_batch( recommendations: list[dict], portfolio_name: str = "default", dry_run: bool = False, ) -> list[dict]: """Execute a batch of recommendations sequentially.""" results = [] for rec in recommendations: result = execute_recommendation(rec, portfolio_name, dry_run) results.append(result) # Stop on risk limit errors if result["status"] == "rejected" and "drawdown" in result.get("reason", ""): for remaining in recommendations[len(results):]: results.append({ "status": "skipped", "reason": "Trading halted due to drawdown limit", "recommendation": remaining, }) break return results # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser( description="Execute paper trade recommendations", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Execute a single recommendation from JSON string %(prog)s --recommendation '{"token_id":"ABC","side":"YES","size_usd":50,"confidence":0.8}' # Execute from a JSON file %(prog)s --file recommendations.json # Dry run (no actual trades) %(prog)s --recommendation '{"token_id":"ABC","side":"YES","size_usd":50}' --dry-run """, ) parser.add_argument("--recommendation", help="JSON trade recommendation") parser.add_argument("--file", help="JSON file with recommendation(s)") parser.add_argument("--portfolio", default="default", help="Portfolio name") parser.add_argument("--dry-run", action="store_true", help="Validate without executing") parser.add_argument("--json", action="store_true", help="JSON output") args = parser.parse_args() if not args.recommendation and not args.file: parser.error("Provide --recommendation or --file") try: if args.file: with open(args.file) as f: data = json.load(f) if isinstance(data, list): results = execute_batch(data, args.portfolio, args.dry_run) else: results = [execute_recommendation(data, args.portfolio, args.dry_run)] else: rec = json.loads(args.recommendation) if isinstance(rec, list): results = execute_batch(rec, args.portfolio, args.dry_run) else: results = [execute_recommendation(rec, args.portfolio, args.dry_run)] if args.json: print(json.dumps(results if len(results) > 1 else results[0], indent=2)) else: for r in results: status = r["status"].upper() if r["status"] == "executed": res = r["result"] if isinstance(res, dict): print( f"[{status}] {res.get('action','?')} {res.get('side','?')} " f"{res.get('shares', 0):.2f} shares @ " f"${res.get('avg_price', res.get('avg_sell_price', 0)):.4f}" ) else: print(f"[{status}] {json.dumps(res)}") pf = r.get("portfolio", {}) print( f" Portfolio: ${pf.get('total_value', 0):,.2f} " f"({pf.get('pnl_pct', 0):+.2f}%)" ) elif r["status"] == "dry_run": print( f"[DRY RUN] Would {r.get('action','?')} " f"{r.get('side','?')} ${r.get('size_usd', 0):.2f} " f"(price: {r.get('current_price', '?')})" ) else: print(f"[{status}] {r.get('reason', 'Unknown error')}") except (json.JSONDecodeError, FileNotFoundError) as exc: print(f"ERROR: {exc}", file=sys.stderr) sys.exit(1) except (RuntimeError, ValueError) as exc: print(f"ERROR: {exc}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()