From 79a45742a9a04935e1afa6efc8af41ab8c79a8f8 Mon Sep 17 00:00:00 2001 From: nathan <472688661@qq.com> Date: Thu, 12 Mar 2026 10:29:37 +0800 Subject: [PATCH 1/6] feat: Add action logging system for audit and debugging - New lib/action_logger.py module with thread-safe JSONL logging - Daily log rotation with 30-day retention - Sensitive data sanitization (private keys, API keys) - Context manager for timing and logging operations - New `polyclaw actions` command to view logs Logging added to all major operations: - markets: trending, search, details, events - wallet: status, approve - trade: buy - positions: add, close, delete - hedge: scan, analyze Log files stored at ~/.openclaw/polyclaw/logs/actions-YYYY-MM-DD.jsonl Co-Authored-By: Claude Opus 4.6 --- lib/action_logger.py | 274 +++++++++++++++++++++++++++++++++++++++++++ scripts/actions.py | 153 ++++++++++++++++++++++++ scripts/hedge.py | 252 +++++++++++++++++++++------------------ scripts/markets.py | 220 ++++++++++++++++++---------------- scripts/polyclaw.py | 11 ++ scripts/positions.py | 138 +++++++++++++--------- scripts/trade.py | 139 ++++++++++++---------- scripts/wallet.py | 106 ++++++++++------- 8 files changed, 929 insertions(+), 364 deletions(-) create mode 100644 lib/action_logger.py create mode 100644 scripts/actions.py diff --git a/lib/action_logger.py b/lib/action_logger.py new file mode 100644 index 0000000..98a925e --- /dev/null +++ b/lib/action_logger.py @@ -0,0 +1,274 @@ +"""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 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 + """ + sensitive_keys = { + "private_key", "POLYCLAW_PRIVATE_KEY", + "password", "secret", + "api_key", "OPENROUTER_API_KEY", + } + + key_lower = key.lower() + + # Check if this key is sensitive + if any(s in key_lower for s in ["private", "secret", "password", "api_key"]): + 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 hash(entry.timestamp) % 100 == 0: + 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): + 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 __enter__(self) -> "ActionLogger": + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> 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) + + log_action( + action=self.action, + params=self.params, + result=self.result, + details=self.details, + duration_ms=duration_ms, + ) + + +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) -> list[dict]: + """Get the most recent actions from today's log. + + Args: + limit: Maximum number of actions to return. + + Returns: + List of action entries, most recent first. + """ + actions = get_actions_by_date(datetime.now(timezone.utc).strftime("%Y-%m-%d")) + return actions[-limit:][::-1] # Return last N, reversed (most recent first) + + +def get_actions_by_type(action_prefix: str, limit: int = 50) -> 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. + + Returns: + List of matching action entries. + """ + actions = get_actions_by_date(datetime.now(timezone.utc).strftime("%Y-%m-%d")) + + filtered = [a for a in actions if a.get("action", "").startswith(action_prefix)] + return filtered[-limit:][::-1] # Return last N, reversed \ No newline at end of file diff --git a/scripts/actions.py b/scripts/actions.py new file mode 100644 index 0000000..9bb1761 --- /dev/null +++ b/scripts/actions.py @@ -0,0 +1,153 @@ +#!/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] + + if getattr(args, 'json', False): + print(json.dumps(entry, indent=2)) + else: + 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: + print("No log files found.") + return 0 + + print("Available log files:") + for f in log_files: + # Count lines + with open(f) as fp: + count = sum(1 for _ in fp) + print(f" {f.stem} ({count} actions)") + + return 0 + + +def main(): + 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 + 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..ad866ac 100644 --- a/scripts/hedge.py +++ b/scripts/hedge.py @@ -30,6 +30,7 @@ filter_portfolios_by_coverage, sort_portfolios, ) +from lib.action_logger import ActionLogger # ============================================================================= @@ -366,141 +367,170 @@ 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(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: + log.set_details({"markets_count": len(markets), "error": "Need at least 2 markets"}) + log.success() + 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() + with ActionLogger("hedge.analyze", { + "market_id_1": args.market_id_1, + "market_id_2": args.market_id_2, + }) as log: + 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 - - print(f"Market 1: {market1.question}", file=sys.stderr) - print(f"Market 2: {market2.question}", file=sys.stderr) + # 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: + log.failure(str(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(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) + + 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..401401e 100755 --- a/scripts/markets.py +++ b/scripts/markets.py @@ -5,12 +5,14 @@ import json import asyncio import argparse +import time from pathlib import Path # 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 +47,142 @@ 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 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}") 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 1 + 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 + 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}") - 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 Exception as e: + log.failure(str(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..6921c3c 100755 --- a/scripts/positions.py +++ b/scripts/positions.py @@ -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.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) + 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(f"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(f"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.success() + 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..8049b92 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(f" CLOB: Skipped (--skip-sell)") + print(f" 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(): From 2818fb67888a796d89b8a868432f8c85a8c6b7e7 Mon Sep 17 00:00:00 2001 From: nathan <472688661@qq.com> Date: Thu, 12 Mar 2026 22:52:48 +0800 Subject: [PATCH 2/6] docs: Add docstrings to meet 80% coverage threshold - Add docstrings to ActionLogger.__init__, __enter__, __exit__ - Add docstring to actions.py main() function Fixes docstring coverage warning from CodeRabbit review. Co-Authored-By: Claude Opus 4.6 --- lib/action_logger.py | 14 ++++++++++++++ scripts/actions.py | 1 + 2 files changed, 15 insertions(+) diff --git a/lib/action_logger.py b/lib/action_logger.py index 98a925e..36a46cc 100644 --- a/lib/action_logger.py +++ b/lib/action_logger.py @@ -175,6 +175,12 @@ class ActionLogger: """ 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 = {} @@ -195,9 +201,17 @@ def failure(self, error: str) -> None: self.details["error"] = error 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: diff --git a/scripts/actions.py b/scripts/actions.py index 9bb1761..472844f 100644 --- a/scripts/actions.py +++ b/scripts/actions.py @@ -117,6 +117,7 @@ def cmd_files(args): 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") From 2cb24e57b021711d93dcc4c5da5d92e8dd681214 Mon Sep 17 00:00:00 2001 From: nathan <472688661@qq.com> Date: Thu, 12 Mar 2026 23:04:37 +0800 Subject: [PATCH 3/6] fix: Address CodeRabbit review comments - Remove unused sensitive_keys variable in action_logger.py - Fix redundant conditional in actions.py cmd_show - Add default args attributes for actions.py default branch - Remove unused import time in markets.py - Use trunc variable in markets.py instead of hardcoded 60 - Fix f-strings without placeholders in hedge.py, positions.py, trade.py Co-Authored-By: Claude Opus 4.6 --- lib/action_logger.py | 9 +++------ scripts/actions.py | 9 +++++---- scripts/hedge.py | 6 +++--- scripts/markets.py | 3 +-- scripts/positions.py | 4 ++-- scripts/trade.py | 4 ++-- 6 files changed, 16 insertions(+), 19 deletions(-) diff --git a/lib/action_logger.py b/lib/action_logger.py index 36a46cc..aa1b187 100644 --- a/lib/action_logger.py +++ b/lib/action_logger.py @@ -55,16 +55,13 @@ def sanitize_value(value: Any, key: str = "") -> Any: - password, secret - api_key, OPENROUTER_API_KEY """ - sensitive_keys = { - "private_key", "POLYCLAW_PRIVATE_KEY", - "password", "secret", - "api_key", "OPENROUTER_API_KEY", - } + # Sensitive substrings to check in key names + sensitive_substrings = ["private", "secret", "password", "api_key"] key_lower = key.lower() # Check if this key is sensitive - if any(s in key_lower for s in ["private", "secret", "password", "api_key"]): + 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"): diff --git a/scripts/actions.py b/scripts/actions.py index 472844f..85c9e59 100644 --- a/scripts/actions.py +++ b/scripts/actions.py @@ -90,10 +90,7 @@ def cmd_show(args): entry = actions[args.index] - if getattr(args, 'json', False): - print(json.dumps(entry, indent=2)) - else: - print(json.dumps(entry, indent=2)) + print(json.dumps(entry, indent=2)) return 0 @@ -147,6 +144,10 @@ def main(): return cmd_files(args) else: # Default to list + args.json = False + args.type = None + args.date = None + args.limit = 20 return cmd_list(args) diff --git a/scripts/hedge.py b/scripts/hedge.py index ad866ac..8b08caa 100644 --- a/scripts/hedge.py +++ b/scripts/hedge.py @@ -376,7 +376,7 @@ async def cmd_scan(args): gamma = GammaClient() # Fetch markets - print(f"Fetching markets...", file=sys.stderr) + 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) @@ -457,7 +457,7 @@ async def cmd_analyze(args): # Fetch both markets try: - print(f"Fetching markets...", file=sys.stderr) + 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 Exception as e: @@ -480,7 +480,7 @@ async def cmd_analyze(args): try: # Check both directions - print(f"\nAnalyzing implications...", file=sys.stderr) + print("\nAnalyzing implications...", file=sys.stderr) # Market 1 as target covers1 = await extract_implications_for_market(market1, [market2], llm) diff --git a/scripts/markets.py b/scripts/markets.py index 401401e..5395f5f 100755 --- a/scripts/markets.py +++ b/scripts/markets.py @@ -5,7 +5,6 @@ import json import asyncio import argparse -import time from pathlib import Path # Add parent to path for lib imports @@ -63,7 +62,7 @@ async def cmd_trending(args): 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) + 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}") diff --git a/scripts/positions.py b/scripts/positions.py index 6921c3c..ac48770 100755 --- a/scripts/positions.py +++ b/scripts/positions.py @@ -226,7 +226,7 @@ def cmd_close(args): if len(matches) > 1: log.failure("Multiple matches") - print(f"Multiple matches, be more specific") + print("Multiple matches, be more specific") return 1 pos = matches[0] @@ -256,7 +256,7 @@ def cmd_delete(args): if len(matches) > 1: log.failure("Multiple matches") - print(f"Multiple matches, be more specific") + print("Multiple matches, be more specific") return 1 pos = matches[0] diff --git a/scripts/trade.py b/scripts/trade.py index 8049b92..794f7a0 100755 --- a/scripts/trade.py +++ b/scripts/trade.py @@ -288,8 +288,8 @@ async def cmd_buy(args): 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(" 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" From 363043161482ed042b95c75d920327ab8d356d3a Mon Sep 17 00:00:00 2001 From: nathan <472688661@qq.com> Date: Fri, 13 Mar 2026 09:35:55 +0800 Subject: [PATCH 4/6] fix: Address remaining CodeRabbit review comments - Implement JSON output for cmd_files in actions.py - Use log.failure() instead of log.success() when markets < 2 in hedge.py - Return 0 instead of 1 for empty search results in markets.py - Replace deprecated datetime.utcnow() with datetime.now(timezone.utc) in positions.py Co-Authored-By: Claude Opus 4.6 --- scripts/actions.py | 19 +++++++++++++++---- scripts/hedge.py | 3 +-- scripts/markets.py | 2 +- scripts/positions.py | 4 ++-- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/scripts/actions.py b/scripts/actions.py index 85c9e59..918e21d 100644 --- a/scripts/actions.py +++ b/scripts/actions.py @@ -100,15 +100,26 @@ def cmd_files(args): log_files = sorted(logs_dir.glob("actions-*.jsonl"), reverse=True) if not log_files: - print("No log files found.") + if getattr(args, 'json', False): + print(json.dumps([])) + else: + print("No log files found.") return 0 - print("Available log files:") + # Build file data + file_data = [] for f in log_files: - # Count lines with open(f) as fp: count = sum(1 for _ in fp) - print(f" {f.stem} ({count} actions)") + 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 diff --git a/scripts/hedge.py b/scripts/hedge.py index 8b08caa..b62ebf0 100644 --- a/scripts/hedge.py +++ b/scripts/hedge.py @@ -385,8 +385,7 @@ async def cmd_scan(args): print(f"Got {len(markets)} trending markets", file=sys.stderr) if len(markets) < 2: - log.set_details({"markets_count": len(markets), "error": "Need at least 2 markets"}) - log.success() + log.failure("Need at least 2 markets") print("Need at least 2 markets to find hedges") return 1 diff --git a/scripts/markets.py b/scripts/markets.py index 5395f5f..6b536a7 100755 --- a/scripts/markets.py +++ b/scripts/markets.py @@ -77,7 +77,7 @@ async def cmd_search(args): if not markets: log.success() print(f"No markets found for: {args.query}") - return 1 + return 0 log.success() diff --git a/scripts/positions.py b/scripts/positions.py index ac48770..bbfd68d 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 @@ -190,7 +190,7 @@ def cmd_add(args): question=args.question or "Manual entry", position=args.position.upper(), token_id=args.token_id or "", - entry_time=datetime.utcnow().isoformat(), + entry_time=datetime.now(timezone.utc).isoformat(), entry_amount=args.amount, entry_price=args.price, split_tx=args.tx or "manual", From d8be9abf0904aefc6fc058ab69fe4d8122b0d117 Mon Sep 17 00:00:00 2001 From: nathan <472688661@qq.com> Date: Fri, 13 Mar 2026 09:45:53 +0800 Subject: [PATCH 5/6] fix: Address CodeRabbit nitpick comments - hedge.py: Catch specific exceptions (TimeoutError, HTTPStatusError, RequestError) instead of broad Exception - markets.py: Use trunc variable consistently in cmd_search instead of hardcoded 60 - positions.py: Use log.cancelled() for user-aborted deletion instead of log.success() - action_logger.py: Add cancelled() method to ActionLogger for cancelled operations Co-Authored-By: Claude Opus 4.6 --- lib/action_logger.py | 10 ++++++++++ scripts/hedge.py | 14 ++++++++++++-- scripts/markets.py | 3 ++- scripts/positions.py | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/lib/action_logger.py b/lib/action_logger.py index aa1b187..6b5b0c2 100644 --- a/lib/action_logger.py +++ b/lib/action_logger.py @@ -197,6 +197,16 @@ def failure(self, error: str) -> None: 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 diff --git a/scripts/hedge.py b/scripts/hedge.py index b62ebf0..ba68699 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)) @@ -459,8 +461,16 @@ async def cmd_analyze(args): 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 Exception as e: - log.failure(str(e)) + except asyncio.TimeoutError as e: + log.failure(f"Timeout: {e}") + print(f"Error fetching markets: Timeout") + 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 diff --git a/scripts/markets.py b/scripts/markets.py index 6b536a7..95ea9f2 100755 --- a/scripts/markets.py +++ b/scripts/markets.py @@ -86,10 +86,11 @@ async def cmd_search(args): 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) + 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}") diff --git a/scripts/positions.py b/scripts/positions.py index bbfd68d..96b4329 100755 --- a/scripts/positions.py +++ b/scripts/positions.py @@ -265,7 +265,7 @@ def cmd_delete(args): confirm = input(f"Delete position {pos['position_id'][:12]}? [y/N]: ") if confirm.lower() != "y": log.set_details({"cancelled": True}) - log.success() + log.cancelled("User aborted deletion") print("Aborted") return 1 From 06c5b09e25fd8c3f3a89384e1042a57f180b7b8f Mon Sep 17 00:00:00 2001 From: nathan <472688661@qq.com> Date: Fri, 13 Mar 2026 10:07:55 +0800 Subject: [PATCH 6/6] fix: Address CodeRabbit inline, duplicate, and nitpick comments Inline comments: - action_logger.py: Wrap log_action call in try/except to prevent logging failures from breaking commands - markets.py: Catch specific exceptions (TimeoutError, HTTPStatusError, RequestError) instead of broad Exception Duplicate comments: - action_logger.py: Expand sensitive_substrings to include wallet/tx identifiers (address, tx_hash, transaction, tx, clob_order_id, wallet, nonce) - hedge.py: Remove unnecessary f-string from print statement Nitpick comments: - action_logger.py: Extract common storage path logic, update position_storage.py to import from action_logger - action_logger.py: Use random.random() instead of hash-based cleanup trigger - action_logger.py: Make get_recent_actions iterate backwards day-by-day with configurable max lookback Co-Authored-By: Claude Opus 4.6 --- lib/action_logger.py | 64 ++++++++++++++++++++++++++++++----------- lib/position_storage.py | 6 +--- scripts/hedge.py | 2 +- scripts/markets.py | 14 +++++++-- 4 files changed, 62 insertions(+), 24 deletions(-) diff --git a/lib/action_logger.py b/lib/action_logger.py index 6b5b0c2..963eac7 100644 --- a/lib/action_logger.py +++ b/lib/action_logger.py @@ -6,6 +6,7 @@ """ import json +import random import threading import time from dataclasses import dataclass, asdict @@ -54,9 +55,14 @@ def sanitize_value(value: Any, key: str = "") -> Any: - 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"] + sensitive_substrings = [ + "private", "secret", "password", "api_key", + "address", "tx_hash", "transaction", "tx", + "clob_order_id", "wallet", "nonce", + ] key_lower = key.lower() @@ -157,7 +163,7 @@ def log_action( # Periodically clean up old logs # (Run cleanup ~1% of the time to avoid overhead) - if hash(entry.timestamp) % 100 == 0: + if random.random() < 0.01: cleanup_old_logs() @@ -225,13 +231,19 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> None: self.result = "failure" self.details["error"] = str(exc_val) - log_action( - action=self.action, - params=self.params, - result=self.result, - details=self.details, - duration_ms=duration_ms, - ) + 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]: @@ -266,30 +278,50 @@ def get_actions_by_date(date_str: str) -> list[dict]: return actions -def get_recent_actions(limit: int = 20) -> list[dict]: - """Get the most recent actions from today's log. +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 = get_actions_by_date(datetime.now(timezone.utc).strftime("%Y-%m-%d")) - return actions[-limit:][::-1] # Return last N, reversed (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) -> list[dict]: +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_actions_by_date(datetime.now(timezone.utc).strftime("%Y-%m-%d")) + 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:][::-1] # Return last N, reversed \ No newline at end of file + 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/hedge.py b/scripts/hedge.py index ba68699..8d8ab10 100644 --- a/scripts/hedge.py +++ b/scripts/hedge.py @@ -463,7 +463,7 @@ async def cmd_analyze(args): market2 = await gamma.get_market(args.market_id_2) except asyncio.TimeoutError as e: log.failure(f"Timeout: {e}") - print(f"Error fetching markets: Timeout") + print("Error fetching markets: Timeout", file=sys.stderr) return 1 except httpx.HTTPStatusError as e: log.failure(f"HTTP error: {e}") diff --git a/scripts/markets.py b/scripts/markets.py index 95ea9f2..1872abe 100755 --- a/scripts/markets.py +++ b/scripts/markets.py @@ -7,6 +7,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)) @@ -113,8 +115,16 @@ async def cmd_details(args): else: # Assume it's an ID market = await client.get_market(args.market_id) - except Exception as e: - log.failure(str(e)) + 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