diff --git a/lib/action_logger.py b/lib/action_logger.py new file mode 100644 index 0000000..963eac7 --- /dev/null +++ b/lib/action_logger.py @@ -0,0 +1,327 @@ +"""Action logging for PolyClaw - records all operations to JSONL files. + +Log files are stored at ~/.openclaw/polyclaw/logs/actions-YYYY-MM-DD.jsonl +Format: JSONL (one JSON object per line, easy to append and parse) +Rotation: Daily, with 30-day retention +""" + +import json +import random +import threading +import time +from dataclasses import dataclass, asdict +from datetime import datetime, timezone, timedelta +from pathlib import Path +from typing import Optional, Any + + +def get_storage_dir() -> Path: + """Get the storage directory for PolyClaw data.""" + storage_dir = Path.home() / ".openclaw" / "polyclaw" + storage_dir.mkdir(parents=True, exist_ok=True) + return storage_dir + + +def get_logs_dir() -> Path: + """Get the logs directory.""" + logs_dir = get_storage_dir() / "logs" + logs_dir.mkdir(parents=True, exist_ok=True) + return logs_dir + + +# Global lock for thread-safe log writes +_log_lock = threading.Lock() + +# Log retention period in days +LOG_RETENTION_DAYS = 30 + + +@dataclass +class ActionEntry: + """A single action log entry.""" + + timestamp: str # ISO 8601 + action: str # "markets.trending", "trade.buy", etc. + params: dict # Input parameters (sanitized) + result: str # "success" | "failure" + details: dict # Result details (tx_hash, order_id, error, etc.) + duration_ms: int # Execution time in milliseconds + + +def sanitize_value(value: Any, key: str = "") -> Any: + """Sanitize a value for logging, masking sensitive data. + + Sensitive keys: + - private_key, POLYCLAW_PRIVATE_KEY + - password, secret + - api_key, OPENROUTER_API_KEY + - address, tx_hash, transaction, wallet identifiers + """ + # Sensitive substrings to check in key names + sensitive_substrings = [ + "private", "secret", "password", "api_key", + "address", "tx_hash", "transaction", "tx", + "clob_order_id", "wallet", "nonce", + ] + + key_lower = key.lower() + + # Check if this key is sensitive + if any(s in key_lower for s in sensitive_substrings): + if isinstance(value, str) and len(value) > 10: + # Show prefix and suffix + if value.startswith("0x"): + return f"{value[:8]}...{value[-6:]}" + elif value.startswith("sk-"): + return f"{value[:6]}...{value[-3:]}" + else: + return f"{value[:6]}...{value[-4:]}" + + return value + + +def sanitize_dict(d: dict) -> dict: + """Recursively sanitize a dictionary for logging.""" + result = {} + for key, value in d.items(): + if isinstance(value, dict): + result[key] = sanitize_dict(value) + elif isinstance(value, list): + result[key] = [sanitize_dict(v) if isinstance(v, dict) else sanitize_value(v, key) for v in value] + else: + result[key] = sanitize_value(value, key) + return result + + +def get_log_file_path(date: Optional[datetime] = None) -> Path: + """Get the log file path for a specific date. + + Args: + date: Date to get log file for. Defaults to today (UTC). + + Returns: + Path to the log file. + """ + if date is None: + date = datetime.now(timezone.utc) + + filename = f"actions-{date.strftime('%Y-%m-%d')}.jsonl" + return get_logs_dir() / filename + + +def cleanup_old_logs() -> None: + """Delete log files older than LOG_RETENTION_DAYS.""" + logs_dir = get_logs_dir() + cutoff = datetime.now(timezone.utc) - timedelta(days=LOG_RETENTION_DAYS) + + for log_file in logs_dir.glob("actions-*.jsonl"): + try: + # Extract date from filename + date_str = log_file.stem.replace("actions-", "") + file_date = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc) + + if file_date < cutoff: + log_file.unlink() + except (ValueError, OSError): + # Ignore files with invalid names or deletion errors + pass + + +def log_action( + action: str, + params: dict, + result: str, + details: dict, + duration_ms: int, +) -> None: + """Log an action to the daily log file. + + Thread-safe: uses a global lock for concurrent writes. + + Args: + action: Action type (e.g., "markets.trending", "trade.buy") + params: Input parameters (will be sanitized) + result: "success" or "failure" + details: Result details (tx_hash, order_id, error, etc.) + duration_ms: Execution time in milliseconds + """ + entry = ActionEntry( + timestamp=datetime.now(timezone.utc).isoformat(), + action=action, + params=sanitize_dict(params), + result=result, + details=sanitize_dict(details), + duration_ms=duration_ms, + ) + + log_file = get_log_file_path() + + with _log_lock: + # Append to log file + with open(log_file, "a") as f: + f.write(json.dumps(asdict(entry)) + "\n") + + # Periodically clean up old logs + # (Run cleanup ~1% of the time to avoid overhead) + if random.random() < 0.01: + cleanup_old_logs() + + +class ActionLogger: + """Context manager for timing and logging actions. + + Usage: + with ActionLogger("markets.trending", {"limit": 20}) as log: + markets = await client.get_trending_markets(limit=20) + log.set_details({"count": len(markets)}) + log.success() + """ + + def __init__(self, action: str, params: dict): + """Initialize the action logger. + + Args: + action: Action type (e.g., "markets.trending", "trade.buy"). + params: Input parameters to log. + """ + self.action = action + self.params = params + self.details: dict = {} + self.result: str = "success" + self.start_time = time.perf_counter() + + def set_details(self, details: dict) -> None: + """Set additional details for the log entry.""" + self.details.update(details) + + def success(self) -> None: + """Mark the action as successful.""" + self.result = "success" + + def failure(self, error: str) -> None: + """Mark the action as failed with an error message.""" + self.result = "failure" + self.details["error"] = error + + def cancelled(self, reason: str = "") -> None: + """Mark the action as cancelled by user. + + Args: + reason: Optional reason for cancellation. + """ + self.result = "cancelled" + if reason: + self.details["reason"] = reason + + def __enter__(self) -> "ActionLogger": + """Enter the context manager, returning self for use in the with block.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """Exit the context manager, logging the action with duration and result. + + Args: + exc_type: Exception type if an exception was raised, else None. + exc_val: Exception value if an exception was raised, else None. + exc_tb: Exception traceback if an exception was raised, else None. + """ + duration_ms = int((time.perf_counter() - self.start_time) * 1000) + + if exc_type is not None: + self.result = "failure" + self.details["error"] = str(exc_val) + + try: + log_action( + action=self.action, + params=self.params, + result=self.result, + details=self.details, + duration_ms=duration_ms, + ) + except Exception as e: + # Logging should never break command execution + # Fallback to stderr if logging fails + import sys + print(f"Logging failed: {e}", file=sys.stderr) + + +def get_actions_by_date(date_str: str) -> list[dict]: + """Get all actions for a specific date. + + Args: + date_str: Date in YYYY-MM-DD format. + + Returns: + List of action entries. + """ + try: + date = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc) + except ValueError: + return [] + + log_file = get_log_file_path(date) + + if not log_file.exists(): + return [] + + actions = [] + with open(log_file) as f: + for line in f: + line = line.strip() + if line: + try: + actions.append(json.loads(line)) + except json.JSONDecodeError: + pass + + return actions + + +def get_recent_actions(limit: int = 20, max_lookback_days: int = 7) -> list[dict]: + """Get the most recent actions across multiple days. + + Iterates backwards day-by-day until limit is reached or max lookback is hit. + + Args: + limit: Maximum number of actions to return. + max_lookback_days: Maximum number of days to look back (default: 7). + + Returns: + List of action entries, most recent first. + """ + actions = [] + current_date = datetime.now(timezone.utc) + + for _ in range(max_lookback_days): + date_str = current_date.strftime("%Y-%m-%d") + day_actions = get_actions_by_date(date_str) + + # Prepend actions from this day (they're already in chronological order) + actions = day_actions + actions + + if len(actions) >= limit: + break + + # Move to previous day + current_date = current_date - timedelta(days=1) + + # Return last N actions, reversed (most recent first) + return actions[-limit:][::-1] + + +def get_actions_by_type(action_prefix: str, limit: int = 50, max_lookback_days: int = 7) -> list[dict]: + """Get actions filtered by type (action prefix). + + Args: + action_prefix: Filter actions starting with this prefix (e.g., "trade") + limit: Maximum number of actions to return. + max_lookback_days: Maximum number of days to look back (default: 7). + + Returns: + List of matching action entries. + """ + actions = get_recent_actions(limit=limit * 2, max_lookback_days=max_lookback_days) + + filtered = [a for a in actions if a.get("action", "").startswith(action_prefix)] + return filtered[:limit] # Already most recent first from get_recent_actions \ No newline at end of file diff --git a/lib/position_storage.py b/lib/position_storage.py index c5df505..d27ed42 100644 --- a/lib/position_storage.py +++ b/lib/position_storage.py @@ -7,11 +7,7 @@ from pathlib import Path from typing import Optional -def get_storage_dir() -> Path: - """Get the storage directory for PolyClaw data.""" - storage_dir = Path.home() / ".openclaw" / "polyclaw" - storage_dir.mkdir(parents=True, exist_ok=True) - return storage_dir +from lib.action_logger import get_storage_dir POSITIONS_FILE = get_storage_dir() / "positions.json" diff --git a/scripts/actions.py b/scripts/actions.py new file mode 100644 index 0000000..918e21d --- /dev/null +++ b/scripts/actions.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Action log viewer commands.""" + +import sys +import json +import argparse +from datetime import datetime, timezone +from pathlib import Path + +# Add parent to path for lib imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from lib.action_logger import ( + get_recent_actions, + get_actions_by_type, + get_actions_by_date, + get_logs_dir, +) + + +def format_duration(ms: int) -> str: + """Format duration in human-readable form.""" + if ms < 1000: + return f"{ms}ms" + elif ms < 60000: + return f"{ms / 1000:.1f}s" + else: + return f"{ms / 60000:.1f}m" + + +def format_action_row(entry: dict, truncate: int = 40) -> str: + """Format an action entry as a table row.""" + timestamp = entry.get("timestamp", "")[11:19] # Extract HH:MM:SS + action = entry.get("action", "") + result = entry.get("result", "") + duration = format_duration(entry.get("duration_ms", 0)) + + # Color indicators for result + result_icon = "✓" if result == "success" else "✗" + + # Truncate action if needed + if len(action) > truncate: + action = action[:truncate - 3] + "..." + + return f"{timestamp} {result_icon} {action:<30} {duration:>8}" + + +def cmd_list(args): + """List recent actions.""" + if args.type: + actions = get_actions_by_type(args.type, limit=args.limit) + elif args.date: + actions = get_actions_by_date(args.date) + actions = actions[-args.limit:][::-1] + else: + actions = get_recent_actions(limit=args.limit) + + if not actions: + print("No actions found.") + if args.date: + print(f"No logs for {args.date}") + return 0 + + if args.json: + print(json.dumps(actions, indent=2)) + return 0 + + # Table output + print(f"{'Time':<10} {'R':<1} {'Action':<30} {'Duration':>8}") + print("-" * 60) + + for entry in actions: + print(format_action_row(entry)) + + print(f"\nShowing {len(actions)} actions") + return 0 + + +def cmd_show(args): + """Show details of a specific action by index.""" + actions = get_recent_actions(limit=100) + + if not actions: + print("No actions found.") + return 1 + + if args.index >= len(actions): + print(f"Index {args.index} out of range (0-{len(actions) - 1})") + return 1 + + entry = actions[args.index] + + print(json.dumps(entry, indent=2)) + return 0 + + +def cmd_files(args): + """List available log files.""" + logs_dir = get_logs_dir() + log_files = sorted(logs_dir.glob("actions-*.jsonl"), reverse=True) + + if not log_files: + if getattr(args, 'json', False): + print(json.dumps([])) + else: + print("No log files found.") + return 0 + + # Build file data + file_data = [] + for f in log_files: + with open(f) as fp: + count = sum(1 for _ in fp) + file_data.append({"name": f.stem, "count": count}) + + if getattr(args, 'json', False): + print(json.dumps(file_data, indent=2)) + return 0 + + print("Available log files:") + for fd in file_data: + print(f" {fd['name']} ({fd['count']} actions)") + + return 0 + + +def main(): + """Main entry point for the actions CLI command.""" + parser = argparse.ArgumentParser(description="View action logs") + subparsers = parser.add_subparsers(dest="command", help="Commands") + + # List (default) + list_parser = subparsers.add_parser("list", help="List recent actions") + list_parser.add_argument("--json", "-j", action="store_true", help="JSON output") + list_parser.add_argument("--type", "-t", help="Filter by action type (e.g., 'trade', 'markets')") + list_parser.add_argument("--date", "-d", help="Specific date (YYYY-MM-DD)") + list_parser.add_argument("--limit", "-n", type=int, default=20, help="Number of actions (default: 20)") + + # Show + show_parser = subparsers.add_parser("show", help="Show action details") + show_parser.add_argument("index", type=int, nargs="?", default=0, help="Action index (0 = most recent)") + show_parser.add_argument("--json", "-j", action="store_true", help="JSON output") + + # Files + files_parser = subparsers.add_parser("files", help="List available log files") + files_parser.add_argument("--json", "-j", action="store_true", help="JSON output") + + args = parser.parse_args() + + if args.command == "list": + return cmd_list(args) + elif args.command == "show": + return cmd_show(args) + elif args.command == "files": + return cmd_files(args) + else: + # Default to list + args.json = False + args.type = None + args.date = None + args.limit = 20 + return cmd_list(args) + + +if __name__ == "__main__": + sys.exit(main() or 0) \ No newline at end of file diff --git a/scripts/hedge.py b/scripts/hedge.py index 4295773..8d8ab10 100644 --- a/scripts/hedge.py +++ b/scripts/hedge.py @@ -14,6 +14,8 @@ import argparse from pathlib import Path +import httpx + # Add parent to path for lib imports sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -30,6 +32,7 @@ filter_portfolios_by_coverage, sort_portfolios, ) +from lib.action_logger import ActionLogger # ============================================================================= @@ -366,141 +369,177 @@ def print_portfolios_json(portfolios: list[dict]) -> None: async def cmd_scan(args): """Scan markets for hedging opportunities.""" - gamma = GammaClient() - - # Fetch markets - print(f"Fetching markets...", file=sys.stderr) - if args.query: - markets = await gamma.search_markets(args.query, limit=args.limit) - print(f"Found {len(markets)} markets matching '{args.query}'", file=sys.stderr) - else: - markets = await gamma.get_trending_markets(limit=args.limit) - print(f"Got {len(markets)} trending markets", file=sys.stderr) - - if len(markets) < 2: - print("Need at least 2 markets to find hedges") - return 1 + with ActionLogger("hedge.scan", { + "query": args.query, + "limit": args.limit, + "min_coverage": args.min_coverage, + "tier": args.tier, + }) as log: + gamma = GammaClient() + + # Fetch markets + print("Fetching markets...", file=sys.stderr) + if args.query: + markets = await gamma.search_markets(args.query, limit=args.limit) + print(f"Found {len(markets)} markets matching '{args.query}'", file=sys.stderr) + else: + markets = await gamma.get_trending_markets(limit=args.limit) + print(f"Got {len(markets)} trending markets", file=sys.stderr) + + if len(markets) < 2: + log.failure("Need at least 2 markets") + print("Need at least 2 markets to find hedges") + return 1 + + # Initialize LLM client + try: + llm = LLMClient(model=args.model) + except ValueError as e: + log.failure(str(e)) + print(f"Error: {e}") + return 1 - # Initialize LLM client - try: - llm = LLMClient(model=args.model) - except ValueError as e: - print(f"Error: {e}") - return 1 + all_portfolios = [] - all_portfolios = [] + # Extract implications for each market + print(f"Analyzing {len(markets)} markets for hedging relationships...", file=sys.stderr) - # Extract implications for each market - print(f"Analyzing {len(markets)} markets for hedging relationships...", file=sys.stderr) + try: + for i, target in enumerate(markets): + if not args.json: + print(f"[{i+1}/{len(markets)}] {target.question[:60]}...", file=sys.stderr) - try: - for i, target in enumerate(markets): - if not args.json: - print(f"[{i+1}/{len(markets)}] {target.question[:60]}...", file=sys.stderr) + covers = await extract_implications_for_market(target, markets, llm) - covers = await extract_implications_for_market(target, markets, llm) + if covers: + portfolios = build_portfolios_from_covers(target, covers) + all_portfolios.extend(portfolios) - if covers: - portfolios = build_portfolios_from_covers(target, covers) - all_portfolios.extend(portfolios) + if not args.json and portfolios: + print(f" Found {len(portfolios)} potential hedges", file=sys.stderr) - if not args.json and portfolios: - print(f" Found {len(portfolios)} potential hedges", file=sys.stderr) + finally: + await llm.close() - finally: - await llm.close() + # Filter and sort + if args.min_coverage: + all_portfolios = filter_portfolios_by_coverage(all_portfolios, args.min_coverage) - # Filter and sort - if args.min_coverage: - all_portfolios = filter_portfolios_by_coverage(all_portfolios, args.min_coverage) + if args.tier: + all_portfolios = filter_portfolios_by_tier(all_portfolios, args.tier) - if args.tier: - all_portfolios = filter_portfolios_by_tier(all_portfolios, args.tier) + all_portfolios = sort_portfolios(all_portfolios) - all_portfolios = sort_portfolios(all_portfolios) + log.set_details({ + "markets_scanned": len(markets), + "portfolios_found": len(all_portfolios), + "query": args.query, + }) + log.success() - # Output - print(f"\n=== Found {len(all_portfolios)} covering portfolios ===\n", file=sys.stderr) + # Output + print(f"\n=== Found {len(all_portfolios)} covering portfolios ===\n", file=sys.stderr) - if args.json: - print_portfolios_json(all_portfolios) - else: - print_portfolios_table(all_portfolios) + if args.json: + print_portfolios_json(all_portfolios) + else: + print_portfolios_table(all_portfolios) - return 0 + return 0 async def cmd_analyze(args): """Analyze a specific market pair for hedging relationship.""" - gamma = GammaClient() - - # Fetch both markets - try: - print(f"Fetching markets...", file=sys.stderr) - market1 = await gamma.get_market(args.market_id_1) - market2 = await gamma.get_market(args.market_id_2) - except Exception as e: - print(f"Error fetching markets: {e}") - return 1 + with ActionLogger("hedge.analyze", { + "market_id_1": args.market_id_1, + "market_id_2": args.market_id_2, + }) as log: + gamma = GammaClient() - print(f"Market 1: {market1.question}", file=sys.stderr) - print(f"Market 2: {market2.question}", file=sys.stderr) + # Fetch both markets + try: + print("Fetching markets...", file=sys.stderr) + market1 = await gamma.get_market(args.market_id_1) + market2 = await gamma.get_market(args.market_id_2) + except asyncio.TimeoutError as e: + log.failure(f"Timeout: {e}") + print("Error fetching markets: Timeout", file=sys.stderr) + return 1 + except httpx.HTTPStatusError as e: + log.failure(f"HTTP error: {e}") + print(f"Error fetching markets: {e}") + return 1 + except httpx.RequestError as e: + log.failure(f"Network error: {e}") + print(f"Error fetching markets: {e}") + return 1 + + print(f"Market 1: {market1.question}", file=sys.stderr) + print(f"Market 2: {market2.question}", file=sys.stderr) + + # Initialize LLM client + try: + llm = LLMClient(model=args.model) + except ValueError as e: + log.failure(str(e)) + print(f"Error: {e}") + return 1 - # Initialize LLM client - try: - llm = LLMClient(model=args.model) - except ValueError as e: - print(f"Error: {e}") - return 1 + all_portfolios = [] - all_portfolios = [] + try: + # Check both directions + print("\nAnalyzing implications...", file=sys.stderr) + + # Market 1 as target + covers1 = await extract_implications_for_market(market1, [market2], llm) + if covers1: + portfolios1 = build_portfolios_from_covers(market1, covers1) + all_portfolios.extend(portfolios1) + + # Market 2 as target + covers2 = await extract_implications_for_market(market2, [market1], llm) + if covers2: + portfolios2 = build_portfolios_from_covers(market2, covers2) + all_portfolios.extend(portfolios2) + + finally: + await llm.close() + + # Filter and sort + if args.min_coverage: + all_portfolios = filter_portfolios_by_coverage(all_portfolios, args.min_coverage) + + all_portfolios = sort_portfolios(all_portfolios) + + log.set_details({ + "market_1": market1.question[:50], + "market_2": market2.question[:50], + "portfolios_found": len(all_portfolios), + }) + log.success() + + # Output + if not all_portfolios: + print("\nNo hedging relationship found between these markets.") + print("This could mean:") + print(" - No logical implication exists (most common)") + print(" - Relationship is correlation, not causation") + print(" - Coverage is below minimum threshold") + return 0 + + print(f"\n=== Found {len(all_portfolios)} covering portfolio(s) ===\n", file=sys.stderr) + + if args.json: + print_portfolios_json(all_portfolios) + else: + print_portfolios_table(all_portfolios) + print("\nRelationships:") + for p in all_portfolios: + print(f" - {p['relationship']}") - try: - # Check both directions - print(f"\nAnalyzing implications...", file=sys.stderr) - - # Market 1 as target - covers1 = await extract_implications_for_market(market1, [market2], llm) - if covers1: - portfolios1 = build_portfolios_from_covers(market1, covers1) - all_portfolios.extend(portfolios1) - - # Market 2 as target - covers2 = await extract_implications_for_market(market2, [market1], llm) - if covers2: - portfolios2 = build_portfolios_from_covers(market2, covers2) - all_portfolios.extend(portfolios2) - - finally: - await llm.close() - - # Filter and sort - if args.min_coverage: - all_portfolios = filter_portfolios_by_coverage(all_portfolios, args.min_coverage) - - all_portfolios = sort_portfolios(all_portfolios) - - # Output - if not all_portfolios: - print("\nNo hedging relationship found between these markets.") - print("This could mean:") - print(" - No logical implication exists (most common)") - print(" - Relationship is correlation, not causation") - print(" - Coverage is below minimum threshold") return 0 - print(f"\n=== Found {len(all_portfolios)} covering portfolio(s) ===\n", file=sys.stderr) - - if args.json: - print_portfolios_json(all_portfolios) - else: - print_portfolios_table(all_portfolios) - print("\nRelationships:") - for p in all_portfolios: - print(f" - {p['relationship']}") - - return 0 - # ============================================================================= # MAIN diff --git a/scripts/markets.py b/scripts/markets.py index 668643b..1872abe 100755 --- a/scripts/markets.py +++ b/scripts/markets.py @@ -7,10 +7,13 @@ import argparse from pathlib import Path +import httpx + # Add parent to path for lib imports sys.path.insert(0, str(Path(__file__).parent.parent)) from lib.gamma_client import GammaClient +from lib.action_logger import ActionLogger def format_price(price: float) -> str: @@ -45,120 +48,151 @@ def format_market_row(market, truncate: int = 0) -> dict: async def cmd_trending(args): """Show trending markets.""" - client = GammaClient() - markets = await client.get_trending_markets(limit=args.limit) + with ActionLogger("markets.trending", {"limit": args.limit}) as log: + client = GammaClient() + markets = await client.get_trending_markets(limit=args.limit) - if args.json: - # JSON output: full questions for agent consumption - print(json.dumps([format_market_row(m) for m in markets], indent=2)) - else: - # Terminal output: truncate unless --full - trunc = 0 if args.full else 60 - print(f"{'ID':<12} {'YES':>6} {'NO':>6} {'24h Vol':>10} {'Question'}") - print("-" * 80) - for m in markets: - question = m.question if args.full else (m.question[:60] + "..." if len(m.question) > 60 else m.question) - print(f"{m.id[:12]:<12} {format_price(m.yes_price):>6} {format_price(m.no_price):>6} {format_volume(m.volume_24h):>10} {question}") + log.set_details({"count": len(markets)}) + log.success() + + if args.json: + # JSON output: full questions for agent consumption + print(json.dumps([format_market_row(m) for m in markets], indent=2)) + else: + # Terminal output: truncate unless --full + trunc = 0 if args.full else 60 + print(f"{'ID':<12} {'YES':>6} {'NO':>6} {'24h Vol':>10} {'Question'}") + print("-" * 80) + for m in markets: + question = m.question if trunc == 0 else (m.question[:trunc] + "..." if len(m.question) > trunc else m.question) + print(f"{m.id[:12]:<12} {format_price(m.yes_price):>6} {format_price(m.no_price):>6} {format_volume(m.volume_24h):>10} {question}") async def cmd_search(args): """Search markets by keyword.""" - client = GammaClient() - markets = await client.search_markets(args.query, limit=args.limit) + with ActionLogger("markets.search", {"query": args.query, "limit": args.limit}) as log: + client = GammaClient() + markets = await client.search_markets(args.query, limit=args.limit) - if not markets: - print(f"No markets found for: {args.query}") - return 1 + log.set_details({"query": args.query, "count": len(markets)}) - if args.json: - # JSON output: full questions for agent consumption - print(json.dumps([format_market_row(m) for m in markets], indent=2)) - else: - # Terminal output: truncate unless --full - print(f"{'ID':<12} {'YES':>6} {'NO':>6} {'24h Vol':>10} {'Question'}") - print("-" * 80) - for m in markets: - question = m.question if args.full else (m.question[:60] + "..." if len(m.question) > 60 else m.question) - print(f"{m.id[:12]:<12} {format_price(m.yes_price):>6} {format_price(m.no_price):>6} {format_volume(m.volume_24h):>10} {question}") + if not markets: + log.success() + print(f"No markets found for: {args.query}") + return 0 + log.success() -async def cmd_details(args): - """Show market details.""" - client = GammaClient() - - try: - if args.market_id.startswith("http"): - # Extract slug from URL - slug = args.market_id.rstrip("/").split("/")[-1] - market = await client.get_market_by_slug(slug) - elif args.market_id.isdigit(): - # Numeric IDs are Gamma market IDs - market = await client.get_market(args.market_id) - elif len(args.market_id) < 20: - # Assume it's a slug - market = await client.get_market_by_slug(args.market_id) + if args.json: + # JSON output: full questions for agent consumption + print(json.dumps([format_market_row(m) for m in markets], indent=2)) else: - # Assume it's an ID - market = await client.get_market(args.market_id) - except Exception as e: - print(f"Error: {e}") - return 1 + # Terminal output: truncate unless --full + trunc = 0 if args.full else 60 + print(f"{'ID':<12} {'YES':>6} {'NO':>6} {'24h Vol':>10} {'Question'}") + print("-" * 80) + for m in markets: + question = m.question if trunc == 0 else (m.question[:trunc] + "..." if len(m.question) > trunc else m.question) + print(f"{m.id[:12]:<12} {format_price(m.yes_price):>6} {format_price(m.no_price):>6} {format_volume(m.volume_24h):>10} {question}") - result = { - "id": market.id, - "question": market.question, - "slug": market.slug, - "condition_id": market.condition_id, - "prices": { - "yes": market.yes_price, - "no": market.no_price, - }, - "tokens": { - "yes_token_id": market.yes_token_id, - "no_token_id": market.no_token_id, - }, - "volume": { - "24h": market.volume_24h, - "total": market.volume, - }, - "liquidity": market.liquidity, - "status": { - "active": market.active, - "closed": market.closed, - "resolved": market.resolved, - "outcome": market.outcome, - }, - "end_date": market.end_date, - "url": f"https://polymarket.com/event/{market.slug}", - } - print(json.dumps(result, indent=2)) +async def cmd_details(args): + """Show market details.""" + with ActionLogger("markets.details", {"market_id": args.market_id}) as log: + client = GammaClient() + + try: + if args.market_id.startswith("http"): + # Extract slug from URL + slug = args.market_id.rstrip("/").split("/")[-1] + market = await client.get_market_by_slug(slug) + elif args.market_id.isdigit(): + # Numeric IDs are Gamma market IDs + market = await client.get_market(args.market_id) + elif len(args.market_id) < 20: + # Assume it's a slug + market = await client.get_market_by_slug(args.market_id) + else: + # Assume it's an ID + market = await client.get_market(args.market_id) + except asyncio.TimeoutError as e: + log.failure(f"Timeout: {e}") + print(f"Error: Timeout") + return 1 + except httpx.HTTPStatusError as e: + log.failure(f"HTTP error: {e}") + print(f"Error: {e}") + return 1 + except httpx.RequestError as e: + log.failure(f"Network error: {e}") + print(f"Error: {e}") + return 1 + + log.set_details({ + "market_id": market.id, + "question": market.question[:50] + "..." if len(market.question) > 50 else market.question, + }) + log.success() + + result = { + "id": market.id, + "question": market.question, + "slug": market.slug, + "condition_id": market.condition_id, + "prices": { + "yes": market.yes_price, + "no": market.no_price, + }, + "tokens": { + "yes_token_id": market.yes_token_id, + "no_token_id": market.no_token_id, + }, + "volume": { + "24h": market.volume_24h, + "total": market.volume, + }, + "liquidity": market.liquidity, + "status": { + "active": market.active, + "closed": market.closed, + "resolved": market.resolved, + "outcome": market.outcome, + }, + "end_date": market.end_date, + "url": f"https://polymarket.com/event/{market.slug}", + } + + print(json.dumps(result, indent=2)) async def cmd_events(args): """Show events/groups with markets.""" - client = GammaClient() - events = await client.get_events(limit=args.limit) - - if args.json: - # JSON output: full questions for agent consumption - result = [] - for e in events: - result.append({ - "id": e.id, - "title": e.title, - "slug": e.slug, - "markets": [format_market_row(m) for m in e.markets[:5]], - }) - print(json.dumps(result, indent=2)) - else: - for e in events: - print(f"\n{e.title}") - print(f" Slug: {e.slug}") - print(f" Markets: {len(e.markets)}") - for m in e.markets[:3]: - question = m.question if args.full else (m.question[:70] + "..." if len(m.question) > 70 else m.question) - print(f" - {question} (YES: {format_price(m.yes_price)})") + with ActionLogger("markets.events", {"limit": args.limit}) as log: + client = GammaClient() + events = await client.get_events(limit=args.limit) + + log.set_details({"count": len(events)}) + log.success() + + if args.json: + # JSON output: full questions for agent consumption + result = [] + for e in events: + result.append({ + "id": e.id, + "title": e.title, + "slug": e.slug, + "markets": [format_market_row(m) for m in e.markets[:5]], + }) + print(json.dumps(result, indent=2)) + else: + for e in events: + print(f"\n{e.title}") + print(f" Slug: {e.slug}") + print(f" Markets: {len(e.markets)}") + for m in e.markets[:3]: + question = m.question if args.full else (m.question[:70] + "..." if len(m.question) > 70 else m.question) + print(f" - {question} (YES: {format_price(m.yes_price)})") def main(): diff --git a/scripts/polyclaw.py b/scripts/polyclaw.py index 0a25169..7fe5df7 100755 --- a/scripts/polyclaw.py +++ b/scripts/polyclaw.py @@ -12,6 +12,8 @@ polyclaw hedge scan polyclaw hedge scan --query "election" polyclaw hedge analyze + polyclaw actions + polyclaw actions --type trade """ import sys @@ -78,6 +80,9 @@ def main(): elif command == "hedge": return run_script("hedge", args) + elif command == "actions": + return run_script("actions", args) + elif command == "help" or command == "--help" or command == "-h": print(__doc__) print("Commands:") @@ -100,6 +105,10 @@ def main(): print(" hedge scan --query Scan markets matching query") print(" hedge analyze Analyze pair for hedging relationship") print("") + print(" actions View recent action logs") + print(" actions --type trade Filter logs by type") + print(" actions --date YYYY-MM-DD View logs for specific date") + print("") print("Environment Variables:") print(" CHAINSTACK_NODE Polygon RPC URL (required for trading)") print(" OPENROUTER_API_KEY OpenRouter API key (required for hedge)") @@ -114,6 +123,8 @@ def main(): print(" polyclaw positions") print(" polyclaw hedge scan") print(" polyclaw hedge scan --query 'election'") + print(" polyclaw actions") + print(" polyclaw actions --type trade") return 0 elif command == "version" or command == "--version" or command == "-v": diff --git a/scripts/positions.py b/scripts/positions.py index 4e06cb6..96b4329 100755 --- a/scripts/positions.py +++ b/scripts/positions.py @@ -6,7 +6,7 @@ import asyncio import argparse import uuid -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path # Add parent to path for lib imports @@ -18,6 +18,7 @@ from lib.position_storage import PositionStorage, PositionEntry from lib.gamma_client import GammaClient +from lib.action_logger import ActionLogger def format_pnl(value: float) -> str: @@ -176,77 +177,106 @@ async def cmd_show(args): def cmd_add(args): """Manually add a position (for testing or importing).""" - storage = PositionStorage() - - entry = PositionEntry( - position_id=str(uuid.uuid4()), - market_id=args.market_id, - question=args.question or "Manual entry", - position=args.position.upper(), - token_id=args.token_id or "", - entry_time=datetime.utcnow().isoformat(), - entry_amount=args.amount, - entry_price=args.price, - split_tx=args.tx or "manual", - clob_order_id=None, - clob_filled=False, - status="open", - ) - - storage.add(entry) - print(f"Position added: {entry.position_id[:12]}") - return 0 + with ActionLogger("positions.add", { + "market_id": args.market_id, + "position": args.position, + "amount": args.amount, + }) as log: + storage = PositionStorage() + + entry = PositionEntry( + position_id=str(uuid.uuid4()), + market_id=args.market_id, + question=args.question or "Manual entry", + position=args.position.upper(), + token_id=args.token_id or "", + entry_time=datetime.now(timezone.utc).isoformat(), + entry_amount=args.amount, + entry_price=args.price, + split_tx=args.tx or "manual", + clob_order_id=None, + clob_filled=False, + status="open", + ) + + storage.add(entry) + log.set_details({ + "position_id": entry.position_id, + "market_id": entry.market_id, + }) + log.success() + + print(f"Position added: {entry.position_id[:12]}") + return 0 def cmd_close(args): """Close a position (mark as closed).""" - storage = PositionStorage() + with ActionLogger("positions.close", {"position_id": args.position_id}) as log: + storage = PositionStorage() - # Find by prefix - positions = storage.load_all() - matches = [p for p in positions if p["position_id"].startswith(args.position_id)] + # Find by prefix + positions = storage.load_all() + matches = [p for p in positions if p["position_id"].startswith(args.position_id)] - if not matches: - print(f"Position not found: {args.position_id}") - return 1 + if not matches: + log.failure("Position not found") + print(f"Position not found: {args.position_id}") + return 1 - if len(matches) > 1: - print(f"Multiple matches, be more specific") - return 1 + if len(matches) > 1: + log.failure("Multiple matches") + print("Multiple matches, be more specific") + return 1 - pos = matches[0] - storage.update_status(pos["position_id"], "closed") - print(f"Position closed: {pos['position_id'][:12]}") - return 0 + pos = matches[0] + storage.update_status(pos["position_id"], "closed") + log.set_details({ + "position_id": pos["position_id"], + "market_id": pos["market_id"], + }) + log.success() + print(f"Position closed: {pos['position_id'][:12]}") + return 0 def cmd_delete(args): """Delete a position record.""" - storage = PositionStorage() - - # Find by prefix - positions = storage.load_all() - matches = [p for p in positions if p["position_id"].startswith(args.position_id)] - - if not matches: - print(f"Position not found: {args.position_id}") - return 1 + with ActionLogger("positions.delete", {"position_id": args.position_id}) as log: + storage = PositionStorage() - if len(matches) > 1: - print(f"Multiple matches, be more specific") - return 1 + # Find by prefix + positions = storage.load_all() + matches = [p for p in positions if p["position_id"].startswith(args.position_id)] - pos = matches[0] + if not matches: + log.failure("Position not found") + print(f"Position not found: {args.position_id}") + return 1 - if not args.force: - confirm = input(f"Delete position {pos['position_id'][:12]}? [y/N]: ") - if confirm.lower() != "y": - print("Aborted") + if len(matches) > 1: + log.failure("Multiple matches") + print("Multiple matches, be more specific") return 1 - storage.delete(pos["position_id"]) - print(f"Position deleted: {pos['position_id'][:12]}") - return 0 + pos = matches[0] + + if not args.force: + confirm = input(f"Delete position {pos['position_id'][:12]}? [y/N]: ") + if confirm.lower() != "y": + log.set_details({"cancelled": True}) + log.cancelled("User aborted deletion") + print("Aborted") + return 1 + + storage.delete(pos["position_id"]) + log.set_details({ + "position_id": pos["position_id"], + "market_id": pos["market_id"], + }) + log.success() + print(f"Position deleted: {pos['position_id'][:12]}") + return 0 def main(): diff --git a/scripts/trade.py b/scripts/trade.py index d6dcc13..794f7a0 100755 --- a/scripts/trade.py +++ b/scripts/trade.py @@ -26,6 +26,7 @@ from lib.clob_client import ClobClientWrapper from lib.contracts import CONTRACTS, CTF_ABI, POLYGON_CHAIN_ID from lib.position_storage import PositionStorage, PositionEntry +from lib.action_logger import ActionLogger @dataclass @@ -240,71 +241,91 @@ async def buy_position( async def cmd_buy(args): """Execute buy command.""" - wallet = WalletManager() - - if not wallet.is_unlocked: - print("Error: No wallet configured") - print("Set POLYCLAW_PRIVATE_KEY environment variable.") - return 1 + with ActionLogger("trade.buy", { + "market_id": args.market_id, + "position": args.position, + "amount": args.amount, + "skip_sell": args.skip_sell, + }) as log: + wallet = WalletManager() + + if not wallet.is_unlocked: + log.failure("No wallet configured") + print("Error: No wallet configured") + print("Set POLYCLAW_PRIVATE_KEY environment variable.") + return 1 - try: - executor = TradeExecutor(wallet) - result = await executor.buy_position( - args.market_id, - args.position, - args.amount, - skip_clob_sell=args.skip_sell, - ) + try: + executor = TradeExecutor(wallet) + result = await executor.buy_position( + args.market_id, + args.position, + args.amount, + skip_clob_sell=args.skip_sell, + ) - print("\n" + "=" * 50) - if result.success: - print("Trade executed successfully!") - print(f" Market: {result.question[:50]}...") - print(f" Position: {result.position}") - print(f" Amount: ${result.amount:.2f}") - print(f" Split TX: {result.split_tx}") - if result.clob_filled: - print(f" CLOB Order: {result.clob_order_id} (FILLED)") - elif result.clob_order_id: - print(f" CLOB Order: {result.clob_order_id} (pending)") - elif args.skip_sell: - print(f" CLOB: Skipped (--skip-sell)") - print(f" Note: You have both YES and NO tokens") + print("\n" + "=" * 50) + if result.success: + log.set_details({ + "market_id": result.market_id, + "question": result.question[:50] if result.question else "", + "position": result.position, + "amount": result.amount, + "split_tx": result.split_tx, + "clob_order_id": result.clob_order_id, + "clob_filled": result.clob_filled, + "entry_price": result.entry_price, + }) + log.success() + + print("Trade executed successfully!") + print(f" Market: {result.question[:50]}...") + print(f" Position: {result.position}") + print(f" Amount: ${result.amount:.2f}") + print(f" Split TX: {result.split_tx}") + if result.clob_filled: + print(f" CLOB Order: {result.clob_order_id} (FILLED)") + elif result.clob_order_id: + print(f" CLOB Order: {result.clob_order_id} (pending)") + elif args.skip_sell: + print(" CLOB: Skipped (--skip-sell)") + print(" Note: You have both YES and NO tokens") + else: + print(f" CLOB: Failed - {result.error}") + unwanted = "NO" if result.position == "YES" else "YES" + print(f" Note: You have {result.amount:.0f} {unwanted} tokens to sell manually") + + # Record position + storage = PositionStorage() + position_entry = PositionEntry( + position_id=str(uuid.uuid4()), + market_id=result.market_id, + question=result.question, + position=result.position, + token_id=result.wanted_token_id, + entry_time=datetime.now(timezone.utc).isoformat(), + entry_amount=result.amount, + entry_price=result.entry_price, + split_tx=result.split_tx, + clob_order_id=result.clob_order_id, + clob_filled=result.clob_filled, + ) + storage.add(position_entry) + print(f" Position ID: {position_entry.position_id[:12]}...") else: - print(f" CLOB: Failed - {result.error}") - unwanted = "NO" if result.position == "YES" else "YES" - print(f" Note: You have {result.amount:.0f} {unwanted} tokens to sell manually") - - # Record position - storage = PositionStorage() - position_entry = PositionEntry( - position_id=str(uuid.uuid4()), - market_id=result.market_id, - question=result.question, - position=result.position, - token_id=result.wanted_token_id, - entry_time=datetime.now(timezone.utc).isoformat(), - entry_amount=result.amount, - entry_price=result.entry_price, - split_tx=result.split_tx, - clob_order_id=result.clob_order_id, - clob_filled=result.clob_filled, - ) - storage.add(position_entry) - print(f" Position ID: {position_entry.position_id[:12]}...") - else: - print(f"Trade failed: {result.error}") - return 1 + log.failure(result.error or "Unknown error") + print(f"Trade failed: {result.error}") + return 1 - # Output JSON if requested - if args.json: - print("\nJSON Result:") - print(json.dumps(asdict(result), indent=2)) + # Output JSON if requested + if args.json: + print("\nJSON Result:") + print(json.dumps(asdict(result), indent=2)) - return 0 + return 0 - finally: - wallet.lock() + finally: + wallet.lock() def main(): diff --git a/scripts/wallet.py b/scripts/wallet.py index 3b3cb37..7449ab3 100755 --- a/scripts/wallet.py +++ b/scripts/wallet.py @@ -14,58 +14,80 @@ load_dotenv(Path(__file__).parent.parent / ".env") from lib.wallet_manager import WalletManager +from lib.action_logger import ActionLogger def cmd_status(args): """Show wallet status.""" - manager = WalletManager() - - if not manager.address: - print("No wallet configured.") - print("Set POLYCLAW_PRIVATE_KEY environment variable.") - return 1 - - result = { - "address": manager.address, - "unlocked": manager.is_unlocked, - } - - try: - result["approvals_set"] = manager.check_approvals() - balances = manager.get_balances() - result["balances"] = { - "POL": f"{balances.pol:.6f}", - "USDC.e": f"{balances.usdc_e:.6f}", + with ActionLogger("wallet.status", {}) as log: + manager = WalletManager() + + if not manager.address: + log.set_details({"configured": False}) + log.success() + print("No wallet configured.") + print("Set POLYCLAW_PRIVATE_KEY environment variable.") + return 1 + + result = { + "address": manager.address, + "unlocked": manager.is_unlocked, } - except Exception as e: - result["approvals_set"] = "unknown" - result["balances"] = f"Unable to fetch: {e}" - print(json.dumps(result, indent=2)) - return 0 + try: + result["approvals_set"] = manager.check_approvals() + balances = manager.get_balances() + result["balances"] = { + "POL": f"{balances.pol:.6f}", + "USDC.e": f"{balances.usdc_e:.6f}", + } + log.set_details({ + "address": manager.address, + "approvals_set": result["approvals_set"], + "pol_balance": balances.pol, + "usdc_e_balance": balances.usdc_e, + }) + log.success() + except Exception as e: + result["approvals_set"] = "unknown" + result["balances"] = f"Unable to fetch: {e}" + log.set_details({"address": manager.address, "error": str(e)}) + log.success() # Still success, just couldn't fetch balances + + print(json.dumps(result, indent=2)) + return 0 def cmd_approve(args): """Set Polymarket contract approvals.""" - manager = WalletManager() - - if not manager.address: - print("Error: No wallet configured") - print("Set POLYCLAW_PRIVATE_KEY environment variable.") - return 1 - - print("Setting contract approvals...") - print("This will submit 6 transactions to Polygon.") - - try: - tx_hashes = manager.set_approvals() - print("Approvals set successfully!") - for i, tx in enumerate(tx_hashes, 1): - print(f" {i}. {tx}") - return 0 - except Exception as e: - print(f"Error: {e}") - return 1 + with ActionLogger("wallet.approve", {}) as log: + manager = WalletManager() + + if not manager.address: + log.failure("No wallet configured") + print("Error: No wallet configured") + print("Set POLYCLAW_PRIVATE_KEY environment variable.") + return 1 + + print("Setting contract approvals...") + print("This will submit 6 transactions to Polygon.") + + try: + tx_hashes = manager.set_approvals() + log.set_details({ + "address": manager.address, + "tx_count": len(tx_hashes), + "tx_hashes": tx_hashes, + }) + log.success() + print("Approvals set successfully!") + for i, tx in enumerate(tx_hashes, 1): + print(f" {i}. {tx}") + return 0 + except Exception as e: + log.failure(str(e)) + print(f"Error: {e}") + return 1 def main():