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 <noreply@anthropic.com>
This commit is contained in:
@@ -22,30 +22,38 @@ def _resolve_audit_dir(paths: RuntimePaths) -> Path:
|
|||||||
return _audit_dir_cache[key]
|
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())
|
paths = ensure_runtime_dirs(paths or get_runtime_paths())
|
||||||
logs_dir = _resolve_audit_dir(paths)
|
logs_dir = _resolve_audit_dir(paths)
|
||||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
subdir = logs_dir / ("dryrun" if dry_run else "live")
|
||||||
return logs_dir / f"audit_{datetime.now(timezone.utc).strftime('%Y%m%d')}.jsonl"
|
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 = {
|
entry = {
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
"event": event,
|
"event": event,
|
||||||
**payload,
|
**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")
|
handle.write(json.dumps(entry, ensure_ascii=False, default=json_default) + "\n")
|
||||||
return entry
|
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())
|
paths = ensure_runtime_dirs(paths or get_runtime_paths())
|
||||||
logs_dir = _resolve_audit_dir(paths)
|
logs_dir = _resolve_audit_dir(paths)
|
||||||
if not logs_dir.exists():
|
if not logs_dir.exists():
|
||||||
return []
|
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
|
needed = offset + limit
|
||||||
chunks: list[list[dict[str, Any]]] = []
|
chunks: list[list[dict[str, Any]]] = []
|
||||||
total = 0
|
total = 0
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ JSON Output:
|
|||||||
}
|
}
|
||||||
|
|
||||||
Fields:
|
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)
|
limit – number of candles requested (int)
|
||||||
symbol – normalized trading pair
|
symbol – normalized trading pair
|
||||||
open_time – candle open timestamp in ms (int)
|
open_time – candle open timestamp in ms (int)
|
||||||
@@ -212,7 +212,7 @@ JSON Output:
|
|||||||
}
|
}
|
||||||
|
|
||||||
Fields:
|
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)
|
limit – number of candles requested (int)
|
||||||
symbol – normalized trading pair
|
symbol – normalized trading pair
|
||||||
open_time – candle open timestamp in ms (int)
|
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.",
|
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("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)")
|
klines_parser.add_argument("-l", "--limit", type=int, default=100, help="Number of candles (default: 100)")
|
||||||
_add_global_flags(klines_parser)
|
_add_global_flags(klines_parser)
|
||||||
|
|
||||||
@@ -784,6 +787,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
catlog_parser.add_argument(
|
catlog_parser.add_argument(
|
||||||
"-o", "--offset", type=int, default=0, help="Skip the most recent N entries (default: 0)"
|
"-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)
|
_add_global_flags(catlog_parser)
|
||||||
|
|
||||||
completion_parser = subparsers.add_parser(
|
completion_parser = subparsers.add_parser(
|
||||||
@@ -1057,9 +1061,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
|
|
||||||
if args.command == "catlog":
|
if args.command == "catlog":
|
||||||
with with_spinner("Reading audit logs...", enabled=not args.agent):
|
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(
|
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,
|
agent=args.agent,
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ def execute_spot_trade(
|
|||||||
dry_run=is_dry_run,
|
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:
|
if is_dry_run:
|
||||||
response = {"dry_run": True, "status": "DRY_RUN", "request": payload}
|
response = {"dry_run": True, "status": "DRY_RUN", "request": payload}
|
||||||
result = asdict(
|
result = asdict(
|
||||||
@@ -128,14 +128,14 @@ def execute_spot_trade(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
audit_event(
|
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}
|
return {"trade": result}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = spot_client.new_order(**payload)
|
response = spot_client.new_order(**payload)
|
||||||
except Exception as exc:
|
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
|
raise RuntimeError(f"Spot order failed: {exc}") from exc
|
||||||
|
|
||||||
result = asdict(
|
result = asdict(
|
||||||
@@ -151,6 +151,7 @@ def execute_spot_trade(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
audit_event(
|
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}
|
return {"trade": result}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class OpportunityServiceTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
def test_portfolio_analysis_ignores_dust_and_emits_recommendations(self):
|
def test_portfolio_analysis_ignores_dust_and_emits_recommendations(self):
|
||||||
events = []
|
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())
|
payload = opportunity_service.analyze_portfolio(self.config, spot_client=FakeSpotClient())
|
||||||
symbols = [item["symbol"] for item in payload["recommendations"]]
|
symbols = [item["symbol"] for item in payload["recommendations"]]
|
||||||
self.assertNotIn("DOGEUSDT", symbols)
|
self.assertNotIn("DOGEUSDT", symbols)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class TradeServiceTestCase(unittest.TestCase):
|
|||||||
def test_spot_market_buy_dry_run_does_not_call_client(self):
|
def test_spot_market_buy_dry_run_does_not_call_client(self):
|
||||||
events = []
|
events = []
|
||||||
with patch.object(
|
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()
|
client = FakeSpotClient()
|
||||||
payload = trade_service.execute_spot_trade(
|
payload = trade_service.execute_spot_trade(
|
||||||
|
|||||||
Reference in New Issue
Block a user