From cf26a3dd3a8e234160b211c8166f5094f4cf21d2 Mon Sep 17 00:00:00 2001 From: Tacit Lab Date: Mon, 20 Apr 2026 10:27:22 +0800 Subject: [PATCH] feat: split audit logs into live/dryrun subdirs, add catlog --dry-run, list all kline intervals - Write live trades to logs/live/ and dry-run trades to logs/dryrun/ - Add -d/--dry-run flag to catlog to read dry-run audit logs - List all 16 Binance kline interval options in --help and docs Co-Authored-By: Claude Opus 4.7 --- src/coinhunter/audit.py | 22 +++++++++++++++------- src/coinhunter/cli.py | 14 +++++++++----- src/coinhunter/services/trade_service.py | 9 +++++---- tests/test_opportunity_service.py | 2 +- tests/test_trade_service.py | 2 +- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/coinhunter/audit.py b/src/coinhunter/audit.py index a1c8f02..b9ee54a 100644 --- a/src/coinhunter/audit.py +++ b/src/coinhunter/audit.py @@ -22,30 +22,38 @@ def _resolve_audit_dir(paths: RuntimePaths) -> Path: return _audit_dir_cache[key] -def _audit_path(paths: RuntimePaths | None = None) -> Path: +def _audit_path(paths: RuntimePaths | None = None, *, dry_run: bool = False) -> Path: paths = ensure_runtime_dirs(paths or get_runtime_paths()) logs_dir = _resolve_audit_dir(paths) - logs_dir.mkdir(parents=True, exist_ok=True) - return logs_dir / f"audit_{datetime.now(timezone.utc).strftime('%Y%m%d')}.jsonl" + subdir = logs_dir / ("dryrun" if dry_run else "live") + subdir.mkdir(parents=True, exist_ok=True) + return subdir / f"audit_{datetime.now(timezone.utc).strftime('%Y%m%d')}.jsonl" -def audit_event(event: str, payload: dict[str, Any], paths: RuntimePaths | None = None) -> dict[str, Any]: +def audit_event( + event: str, payload: dict[str, Any], paths: RuntimePaths | None = None, *, dry_run: bool = False +) -> dict[str, Any]: entry = { "timestamp": datetime.now(timezone.utc).isoformat(), "event": event, **payload, } - with _audit_path(paths).open("a", encoding="utf-8") as handle: + with _audit_path(paths, dry_run=dry_run).open("a", encoding="utf-8") as handle: handle.write(json.dumps(entry, ensure_ascii=False, default=json_default) + "\n") return entry -def read_audit_log(paths: RuntimePaths | None = None, limit: int = 10, offset: int = 0) -> list[dict[str, Any]]: +def read_audit_log( + paths: RuntimePaths | None = None, limit: int = 10, offset: int = 0, *, dry_run: bool = False +) -> list[dict[str, Any]]: paths = ensure_runtime_dirs(paths or get_runtime_paths()) logs_dir = _resolve_audit_dir(paths) if not logs_dir.exists(): return [] - audit_files = sorted(logs_dir.glob("audit_*.jsonl"), reverse=True) + subdir = logs_dir / ("dryrun" if dry_run else "live") + if not subdir.exists(): + return [] + audit_files = sorted(subdir.glob("audit_*.jsonl"), reverse=True) needed = offset + limit chunks: list[list[dict[str, Any]]] = [] total = 0 diff --git a/src/coinhunter/cli.py b/src/coinhunter/cli.py index 5e33b42..5d6a41d 100644 --- a/src/coinhunter/cli.py +++ b/src/coinhunter/cli.py @@ -191,7 +191,7 @@ JSON Output: } Fields: - interval – candle interval (enum: 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w) + interval – candle interval (enum: 1s, 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d, 3d, 1w, 1M) limit – number of candles requested (int) symbol – normalized trading pair open_time – candle open timestamp in ms (int) @@ -212,7 +212,7 @@ JSON Output: } Fields: - interval – candle interval (enum: 1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w) + interval – candle interval (enum: 1s, 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d, 3d, 1w, 1M) limit – number of candles requested (int) symbol – normalized trading pair open_time – candle open timestamp in ms (int) @@ -730,7 +730,10 @@ def build_parser() -> argparse.ArgumentParser: description="Fetch OHLCV candlestick data for one or more symbols.", ) klines_parser.add_argument("symbols", nargs="+", metavar="SYM", help="Symbols to query") - klines_parser.add_argument("-i", "--interval", default="1h", help="Kline interval (default: 1h)") + klines_parser.add_argument( + "-i", "--interval", default="1h", + help="Kline interval: 1s, 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d, 3d, 1w, 1M (default: 1h)", + ) klines_parser.add_argument("-l", "--limit", type=int, default=100, help="Number of candles (default: 100)") _add_global_flags(klines_parser) @@ -784,6 +787,7 @@ def build_parser() -> argparse.ArgumentParser: catlog_parser.add_argument( "-o", "--offset", type=int, default=0, help="Skip the most recent N entries (default: 0)" ) + catlog_parser.add_argument("-d", "--dry-run", action="store_true", help="Read dry-run audit logs") _add_global_flags(catlog_parser) completion_parser = subparsers.add_parser( @@ -1057,9 +1061,9 @@ def main(argv: list[str] | None = None) -> int: if args.command == "catlog": with with_spinner("Reading audit logs...", enabled=not args.agent): - entries = read_audit_log(limit=args.limit, offset=args.offset) + entries = read_audit_log(limit=args.limit, offset=args.offset, dry_run=args.dry_run) print_output( - {"entries": entries, "limit": args.limit, "offset": args.offset, "total": len(entries)}, + {"entries": entries, "limit": args.limit, "offset": args.offset, "total": len(entries), "dry_run": args.dry_run}, agent=args.agent, ) return 0 diff --git a/src/coinhunter/services/trade_service.py b/src/coinhunter/services/trade_service.py index 224e6ad..a96d4bb 100644 --- a/src/coinhunter/services/trade_service.py +++ b/src/coinhunter/services/trade_service.py @@ -112,7 +112,7 @@ def execute_spot_trade( dry_run=is_dry_run, ) - audit_event("trade_submitted", _trade_log_payload(intent, payload, status="submitted")) + audit_event("trade_submitted", _trade_log_payload(intent, payload, status="submitted"), dry_run=intent.dry_run) if is_dry_run: response = {"dry_run": True, "status": "DRY_RUN", "request": payload} result = asdict( @@ -128,14 +128,14 @@ def execute_spot_trade( ) ) audit_event( - "trade_filled", {**_trade_log_payload(intent, payload, status="DRY_RUN"), "response_payload": response} + "trade_filled", {**_trade_log_payload(intent, payload, status="DRY_RUN"), "response_payload": response}, dry_run=intent.dry_run ) return {"trade": result} try: response = spot_client.new_order(**payload) except Exception as exc: - audit_event("trade_failed", _trade_log_payload(intent, payload, status="failed", error=str(exc))) + audit_event("trade_failed", _trade_log_payload(intent, payload, status="failed", error=str(exc)), dry_run=intent.dry_run) raise RuntimeError(f"Spot order failed: {exc}") from exc result = asdict( @@ -151,6 +151,7 @@ def execute_spot_trade( ) ) audit_event( - "trade_filled", {**_trade_log_payload(intent, payload, status=result["status"]), "response_payload": response} + "trade_filled", {**_trade_log_payload(intent, payload, status=result["status"]), "response_payload": response}, + dry_run=intent.dry_run, ) return {"trade": result} diff --git a/tests/test_opportunity_service.py b/tests/test_opportunity_service.py index 355e4b2..32dc8bf 100644 --- a/tests/test_opportunity_service.py +++ b/tests/test_opportunity_service.py @@ -122,7 +122,7 @@ class OpportunityServiceTestCase(unittest.TestCase): def test_portfolio_analysis_ignores_dust_and_emits_recommendations(self): events = [] - with patch.object(opportunity_service, "audit_event", side_effect=lambda event, payload: events.append(event)): + with patch.object(opportunity_service, "audit_event", side_effect=lambda event, payload, **kwargs: events.append(event)): payload = opportunity_service.analyze_portfolio(self.config, spot_client=FakeSpotClient()) symbols = [item["symbol"] for item in payload["recommendations"]] self.assertNotIn("DOGEUSDT", symbols) diff --git a/tests/test_trade_service.py b/tests/test_trade_service.py index fdb1eff..30c61df 100644 --- a/tests/test_trade_service.py +++ b/tests/test_trade_service.py @@ -21,7 +21,7 @@ class TradeServiceTestCase(unittest.TestCase): def test_spot_market_buy_dry_run_does_not_call_client(self): events = [] with patch.object( - trade_service, "audit_event", side_effect=lambda event, payload: events.append((event, payload)) + trade_service, "audit_event", side_effect=lambda event, payload, **kwargs: events.append((event, payload)) ): client = FakeSpotClient() payload = trade_service.execute_spot_trade(