diff --git a/.env.example b/.env.example index 4f8c38cb..42a60505 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,10 @@ NTFY_TOPIC= # Binance Host (default: binance.com, use binance.us for US) # BINANCE_WS_HOST=stream.binance.com # BINANCE_API_HOST=api.binance.com +# BINANCE_BNB_VALUATION_SYMBOL=BNBUSDT +# BINANCE_FEE_LOOKUP_MAX_CHECKS=20 +# BINANCE_SLIPPAGE_WARN_PCT=0.01 +# BINANCE_SLIPPAGE_WARN_TTL_SEC=3600 # Alpha Vantage (optional, for Python sentiment lab) # ALPHA_VANTAGE_API_KEY= diff --git a/apps/neko-trade/Sources/DCTradingViewer/Models/Models.swift b/apps/neko-trade/Sources/DCTradingViewer/Models/Models.swift index fd608e82..9d223afd 100644 --- a/apps/neko-trade/Sources/DCTradingViewer/Models/Models.swift +++ b/apps/neko-trade/Sources/DCTradingViewer/Models/Models.swift @@ -207,6 +207,8 @@ struct BotStatus { let markSymbol: String let checkpointHealth: String let checkpointError: String + let exchangeHealth: String + let exchangeError: String let resourceHealth: String let resourceError: String let resourceRssMb: Double @@ -234,6 +236,10 @@ struct BotStatus { !checkpointHealth.isEmpty && checkpointHealth != "OK" } + var exchangeNeedsAttention: Bool { + !exchangeHealth.isEmpty && exchangeHealth != "OK" + } + var resourceNeedsAttention: Bool { !resourceHealth.isEmpty && resourceHealth != "OK" } diff --git a/apps/neko-trade/Sources/DCTradingViewer/Services/TursoClient.swift b/apps/neko-trade/Sources/DCTradingViewer/Services/TursoClient.swift index d77e9906..6916d8c2 100644 --- a/apps/neko-trade/Sources/DCTradingViewer/Services/TursoClient.swift +++ b/apps/neko-trade/Sources/DCTradingViewer/Services/TursoClient.swift @@ -280,6 +280,8 @@ final class TursoClient { markSymbol: getOptionalString(row, result.cols, "mark_symbol") ?? "", checkpointHealth: getOptionalString(row, result.cols, "checkpoint_health") ?? "OK", checkpointError: getOptionalString(row, result.cols, "checkpoint_error") ?? "", + exchangeHealth: getOptionalString(row, result.cols, "exchange_health") ?? "OK", + exchangeError: getOptionalString(row, result.cols, "exchange_error") ?? "", resourceHealth: getOptionalString(row, result.cols, "resource_health") ?? "OK", resourceError: getOptionalString(row, result.cols, "resource_error") ?? "", resourceRssMb: getDouble(row, result.cols, "resource_rss_mb"), diff --git a/apps/neko-trade/Sources/DCTradingViewer/Views/DashboardView.swift b/apps/neko-trade/Sources/DCTradingViewer/Views/DashboardView.swift index 0dd9321f..97a5c139 100644 --- a/apps/neko-trade/Sources/DCTradingViewer/Views/DashboardView.swift +++ b/apps/neko-trade/Sources/DCTradingViewer/Views/DashboardView.swift @@ -154,7 +154,7 @@ struct DashboardView: View { } private func botNeedsAttention(_ bs: BotStatus) -> Bool { - !bs.isLive || bs.checkpointNeedsAttention || bs.resourceNeedsAttention + !bs.isLive || bs.checkpointNeedsAttention || bs.exchangeNeedsAttention || bs.resourceNeedsAttention } private func botWarningButton(_ bs: BotStatus) -> some View { @@ -198,6 +198,9 @@ struct DashboardView: View { if bs.checkpointNeedsAttention { issues.append("checkpoint \(bs.checkpointHealth)") } + if bs.exchangeNeedsAttention { + issues.append("exchange \(bs.exchangeHealth)") + } if bs.resourceNeedsAttention { issues.append("resource \(bs.resourceHealth)") } diff --git a/apps/neko-trade/Sources/DCTradingViewer/Views/SettingsView.swift b/apps/neko-trade/Sources/DCTradingViewer/Views/SettingsView.swift index 349932bd..13eecf8e 100644 --- a/apps/neko-trade/Sources/DCTradingViewer/Views/SettingsView.swift +++ b/apps/neko-trade/Sources/DCTradingViewer/Views/SettingsView.swift @@ -143,6 +143,8 @@ struct SettingsView: View { .padding(.vertical, 10) } + exchangeStatusRow(bs) + cardDivider(statusColor) HStack { @@ -303,6 +305,35 @@ struct SettingsView: View { .padding(.vertical, 10) } + private func exchangeStatusRow(_ bs: BotStatus) -> some View { + let exchangeColor: Color = bs.exchangeNeedsAttention ? .orange : .green + let health = bs.exchangeHealth.isEmpty ? "OK" : bs.exchangeHealth + let detail = bs.exchangeError.isEmpty ? "OK" : bs.exchangeError + + return VStack(alignment: .leading, spacing: 8) { + Divider() + .overlay(exchangeColor.opacity(0.25)) + + HStack(alignment: .top, spacing: 8) { + Image(systemName: bs.exchangeNeedsAttention ? "exclamationmark.triangle.fill" : "checkmark.seal.fill") + .font(.system(.body, design: .monospaced, weight: .semibold)) + .foregroundStyle(exchangeColor) + Text("EXCHANGE \(health)") + .font(.system(.body, design: .monospaced, weight: .semibold)) + .foregroundStyle(exchangeColor) + Spacer() + } + .padding(.horizontal) + + Text(detail) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(2) + .padding(.horizontal) + } + .padding(.vertical, 10) + } + private func resourceMetric(_ title: String, _ value: String) -> some View { VStack(alignment: .leading, spacing: 2) { Text(title.uppercased()) diff --git a/apps/neko-trade/Tests/DCTradingViewerAccountingTests/AccountingTests.swift b/apps/neko-trade/Tests/DCTradingViewerAccountingTests/AccountingTests.swift index 25b88d37..04d90793 100644 --- a/apps/neko-trade/Tests/DCTradingViewerAccountingTests/AccountingTests.swift +++ b/apps/neko-trade/Tests/DCTradingViewerAccountingTests/AccountingTests.swift @@ -129,7 +129,18 @@ final class AccountingTests: XCTestCase { quoteAsset: quoteAsset, markSymbol: markSymbol, checkpointHealth: "OK", - checkpointError: "" + checkpointError: "", + exchangeHealth: "OK", + exchangeError: "", + resourceHealth: "OK", + resourceError: "", + resourceRssMb: 0, + resourceDiskFreeMb: 0, + resourceDiskUsedPct: 0, + resourceFeedGapSec: 0, + resourceWsLagSec: 0, + resourceHttpErrors: 0, + resourceHttpMaxMs: 0 ) } } diff --git a/services/dctrading-bot/AGENTS.md b/services/dctrading-bot/AGENTS.md index e99b0442..eca07f66 100644 --- a/services/dctrading-bot/AGENTS.md +++ b/services/dctrading-bot/AGENTS.md @@ -20,8 +20,8 @@ BTC algorithmic trading bot using Directional Change (DC) theory. Zig 0.16 produ - `live_loop.zig` — Extracted core order flow logic: pending order tracking, trailing stop, strategy signals, buy/sell submission, capital_reserved, and ledger vtable. Shared by `runLive()` and integration tests. - `tick_source.zig` — `TickSource` vtable interface + `SimFeed` (replays ticks from array). For testing. - `sim_exchange.zig` — `SimExchange` implementing Exchange vtable with configurable fill delay, slippage, partial fills, cancel races, failure injection, order log. For testing. -- `integration_tests.zig` — 32 end-to-end scenarios using LiveLoop + SimExchange + mock ledger. -- `tests.zig` — 182 tests covering DC detector, strategy, checkpoint, regime transitions, JSON parsing, capital accounting, double-entry transfers, exchange interface, funding rate filter, non-blocking order flow, capital_reserved, resource monitoring, integration scenarios. +- `integration_tests.zig` — 34 end-to-end scenarios using LiveLoop + SimExchange + mock ledger. +- `tests.zig` — 195 tests covering DC detector, strategy, checkpoint, regime transitions, JSON parsing, capital accounting, double-entry transfers, exchange interface, funding rate filter, non-blocking order flow, capital_reserved, resource monitoring, Binance Spot parsing, integration scenarios. - CLI `checkpoint:migrate [path] [backups]` migrates checkpoint primary/backups offline to the current DCTRADE5 layout after writing `.pre-migrate` copies. ### Scripts (`scripts/`) @@ -31,12 +31,14 @@ BTC algorithmic trading bot using Directional Change (DC) theory. Zig 0.16 produ - `setup-aws-iam.sh` — Standalone IAM instance profile creation for CloudWatch. Creates the role, attaches `CloudWatchAgentServerPolicy`, creates the instance profile, and waits for propagation. - `switch-to-gcp.sh` — Stop local bot, start GCP Tokyo instance + systemd service. Copies binary plus checkpoint primary/local backups, excluding temp files. - `switch-to-local.sh` — Stop cloud bot + instance, download checkpoint primary/local backups, start local bot in tmux. Defaults to AWS; use `CLOUD_TARGET=gcp` for the legacy GCP path. -- `nuke.sh` — Destructive reset for Turso state, local + remote checkpoint primary/backups, and Alpaca positions. Uses `CLOUD_TARGET` to stop and clear remote state. +- `nuke.sh` — Destructive reset for Turso state, local + remote checkpoint primary/backups, and exchange positions. Uses `CLOUD_TARGET` to stop and clear remote state. ### Key Patterns - **HTTP calls**: All modules use shared `HttpClient` (native `std.http.Client`). Exception: `feed.zig` bootstrap uses `popen("curl")` for Binance REST, and `telegram.zig` shutdown uses curl fallback. - **Async writes**: Turso and Telegram fire-and-forget via `std.Thread.spawn` + `detach()`. Context struct heap-allocated, freed in worker. - **Sync reads**: Startup queries (capital, position, trade count) are blocking HTTP calls. +- **Exchange selection**: Runtime-selectable via `EXCHANGE` env var. `alpaca` (paper trading) and `binance_spot` (live Spot trading) are supported. The exchange vtable pattern keeps `live_loop.zig` and `strategy.zig` venue-agnostic. +- **Spot position model**: On Binance Spot, account balance is NOT position. The bot tracks its own position via Turso `transfers` (immutable fill history). `getPosition()` replays buy/sell transfers chronologically to compute `qty` and `entry_price`. `/api/v3/account` is used only for discrepancy alerts. - **Checkpoint**: Binary file with magic number validation. DCTRADE5 writes 27 f64 scalars, including `funding_avg`, `funding_avg_updated_at`, and `funding_latest_time`, plus two ring buffers (vol, MA). DCTRADE4 and earlier DCTRADE5 funding-cache files are still accepted and migrated on the next save. Saved every minute through an atomic temp-file swap. Live mode keeps rotated local backups (`dctrading.checkpoint.bak.N`) and falls back to the newest valid backup if the primary checkpoint cannot be loaded. - **Checkpoint startup guard**: Live mode prints cwd/checkpoint path and refuses to bootstrap if any local checkpoint primary/backup exists but none can be loaded and Turso restore fails. Use `checkpoint:migrate` or manually promote a known-good backup instead. - **Regime**: `enum { bull, sideways, bear }`. Encoded as 0/1/2 in checkpoint scalar[8]. @@ -44,8 +46,15 @@ BTC algorithmic trading bot using Directional Change (DC) theory. Zig 0.16 produ ### Environment Variables | Variable | Required | Used By | |----------|----------|---------| -| `ALPACA_API_KEY` | Yes | alpaca.zig | -| `ALPACA_API_SECRET` | Yes | alpaca.zig | +| `EXCHANGE` | No | main.zig (default: `alpaca`; options: `alpaca`, `binance_spot`) | +| `ALPACA_API_KEY` | Yes (if EXCHANGE=alpaca) | alpaca.zig | +| `ALPACA_API_SECRET` | Yes (if EXCHANGE=alpaca) | alpaca.zig | +| `BINANCE_API_KEY` | Yes (if EXCHANGE=binance_spot) | binance_spot.zig | +| `BINANCE_API_SECRET` | Yes (if EXCHANGE=binance_spot) | binance_spot.zig | +| `BINANCE_BNB_VALUATION_SYMBOL` | No | binance_spot.zig (default: BNBUSDT; override for testnet/alternate quote) | +| `BINANCE_FEE_LOOKUP_MAX_CHECKS` | No | binance_spot.zig (default: 20 checks before fallback/manual reconciliation log) | +| `BINANCE_SLIPPAGE_WARN_PCT` | No | live_loop.zig (default: 0.01 = 1% post-fill warning) | +| `BINANCE_SLIPPAGE_WARN_TTL_SEC` | No | live_loop.zig/main.zig (default: 3600s; app-visible slippage warning retention) | | `TRADING_SYMBOL` | No | main.zig/alpaca.zig/feed.zig (default: BTC/USD; Binance maps USD quote to USDT) | | `TURSO_URL` | No | turso.zig | | `TURSO_TOKEN` | No | turso.zig | @@ -64,8 +73,8 @@ BTC algorithmic trading bot using Directional Change (DC) theory. Zig 0.16 produ | `RESOURCE_FEED_GAP_WARN_SEC` | No | resource_monitor.zig (default: 180) | | `RESOURCE_WS_LAG_WARN_SEC` | No | resource_monitor.zig (default: 180) | | `RESOURCE_HTTP_LATENCY_WARN_MS` | No | resource_monitor.zig (default: 5000) | -| `BINANCE_WS_HOST` | No | feed.zig (default: stream.binance.com) | -| `BINANCE_API_HOST` | No | feed.zig (default: api.binance.com) | +| `BINANCE_WS_HOST` | No | feed.zig / binance_spot.zig (default: stream.binance.com) | +| `BINANCE_API_HOST` | No | feed.zig / binance_spot.zig (default: api.binance.com; testnet: testnet.binance.vision) | | `BOT_INSTANCE` | No | main.zig (default: "local") | | `CHECKPOINT_BACKUP_RETENTION` | No | main.zig (default: 5 local rotated backups; 0 disables) | | `CHECKPOINT_REMOTE_BACKUP_INTERVAL` | No | main.zig/Turso (default: 3600 seconds; 0 disables) | @@ -90,14 +99,14 @@ BTC algorithmic trading bot using Directional Change (DC) theory. Zig 0.16 produ - `transfers` — Immutable append-only transfer log. Two-phase (pending/posted/voided). Codes: 1=deposit, 2=buy, 3=sell, 4=fee, 5=pnl. Atomic BEGIN/COMMIT pipelines. - Fee routing: adapter-provided `commission_asset` routes fees to the paying asset account (`USD`/`USDT` → cash, `BTC` → btc_position, `BNB` → bnb), even when commission is zero. Transfer `amount` is historical quote-currency value at fill time; native fee quantity is stored in transfer `size`, with the fill-time asset quote valuation rate in `price`. `strategy.size` remains whatever the exchange adapter reports as fill quantity. `fee_pct` is for backtest/simulation estimates and legacy fills with no commission metadata, not for Alpaca paper fills. - `equity_log` — Periodic snapshots (every 5 min + on trades): capital, equity, unrealized, regime, price. -- `bot_status` — Single row (id=1): regime, position, equity, version (DCTRADE5@instance), active symbol metadata, checkpoint health/error, and latest resource health/metrics for Neko. +- `bot_status` — Single row (id=1): regime, position, equity, version (DCTRADE5@instance), active symbol metadata, checkpoint health/error, exchange health/error, and latest resource health/metrics for Neko. - `resource_log` — Periodic process/feed/HTTP resource snapshots used for dashboard status and diagnostics. Resource degradation is app-visible only; Telegram/ntfy stays reserved for trading, checkpoint, funding, startup/shutdown events. - `checkpoint_backups` — Single remote checkpoint snapshot (id=1): base64-encoded DCTRADE5 checkpoint, byte length, checksum, tick count, and update time. Used only if local primary/backups cannot be loaded. ### Build ```bash zig build -Doptimize=ReleaseFast # macOS arm64 zig build -Doptimize=ReleaseFast -Dtarget=x86_64-linux # cloud Linux -zig build test # 182 tests +zig build test # 195 tests ./zig-out/bin/dctrading checkpoint:migrate dctrading.checkpoint 5 ``` diff --git a/services/dctrading-bot/README.md b/services/dctrading-bot/README.md index d848ead4..0400c237 100644 --- a/services/dctrading-bot/README.md +++ b/services/dctrading-bot/README.md @@ -19,7 +19,7 @@ Parameters: λ=0.07, 60-day MA, 3% buffer, 2% trailing stop (72h vol lookback) ## Architecture ``` -Binance WebSocket → Zig Bot → Alpaca Paper Trading +Binance WebSocket → Zig Bot → Alpaca Paper Trading / Binance Spot ↓ Turso DB ← Neko Trade App (SwiftUI) ↓ @@ -38,6 +38,7 @@ Single static binary. No Python, no Docker, no runtime dependencies (except curl | `dc_detector.zig` | Streaming DC event detector | | `exchange.zig` | Exchange vtable interface for sync and async order flow | | `alpaca.zig` | Alpaca paper trading (sync/async orders, position queries) | +| `binance_spot.zig` | Binance Spot live trading (HMAC-signed orders, fills, balances) | | `live_loop.zig` | Shared live order-flow engine and ledger interface used by production and simulation tests | | `sim_exchange.zig` | Configurable simulated exchange for integration tests | | `tick_source.zig` | Tick source interface and simulated feed | @@ -47,13 +48,15 @@ Single static binary. No Python, no Docker, no runtime dependencies (except curl | `http_client.zig` | Shared HTTP client (std.http.Client wrapper + request metrics) | | `resource_monitor.zig` | Process, disk, feed, and HTTP resource health snapshots | | `types.zig` | Tick, Trade, DC event types | -| `tests.zig` | 182 tests | +| `tests.zig` | 195 tests | ## Setup ### Prerequisites - [Zig 0.16](https://ziglang.org/download/) -- Alpaca paper trading account (free): https://app.alpaca.markets +- Exchange account (one of): + - **Alpaca** paper trading (free): https://app.alpaca.markets + - **Binance** Spot (live): https://www.binance.com — API key + secret required - Turso database (free tier): https://turso.tech - Telegram bot (optional): @BotFather - ntfy.sh (optional): https://ntfy.sh @@ -61,12 +64,20 @@ Single static binary. No Python, no Docker, no runtime dependencies (except curl ### Environment Variables ```bash -# Required +# Exchange selection (default: alpaca) +export EXCHANGE=alpaca # or: binance_spot + +# Alpaca (required if EXCHANGE=alpaca) export ALPACA_API_KEY=PK... export ALPACA_API_SECRET=... +# Binance Spot (required if EXCHANGE=binance_spot) +export BINANCE_API_KEY=... +export BINANCE_API_SECRET=... +# Required API permissions: read + spot trading. Do not enable withdrawals. + # Trading pair (optional; default: BTC/USD) -# Alpaca uses BTC/USD; Binance feed/funding maps this to BTCUSDT +# Alpaca uses BTC/USD; Binance Spot uses BTCUSDT automatically export TRADING_SYMBOL=BTC/USD # Turso (optional but recommended) @@ -79,8 +90,14 @@ export TELEGRAM_CHAT_ID=... export NTFY_TOPIC=your-topic # Binance host (default: stream.binance.com / api.binance.com) +# For testnet: testnet.binance.vision (both WS and API must match). +# BNB valuation may need a different symbol on testnet. export BINANCE_WS_HOST=stream.binance.com export BINANCE_API_HOST=api.binance.com +export BINANCE_BNB_VALUATION_SYMBOL=BNBUSDT +export BINANCE_FEE_LOOKUP_MAX_CHECKS=20 +export BINANCE_SLIPPAGE_WARN_PCT=0.01 +export BINANCE_SLIPPAGE_WARN_TTL_SEC=3600 # Funding filter (default: 0.0001 = 0.010%; 0 disables) # Funding updates are cached, checkpointed, checked hourly, and sent to Telegram/ntfy when Binance publishes a new funding print. @@ -137,6 +154,30 @@ zig build -Doptimize=ReleaseFast -Dtarget=x86_64-linux Known-good historical simulation outputs are recorded in [`docs/DCTRADING_SIMULATION_BASELINES.md`](../../docs/DCTRADING_SIMULATION_BASELINES.md). +### Binance Spot Accounting Edge Cases + +The live Spot adapter records exact exchange quote notional when Binance returns +`cummulativeQuoteQty`, and fee transfers are routed to the asset that paid the +commission. The following edge cases are intentionally documented because they +require operator awareness or follow-up work: + +- **Partially filled then canceled orders:** Binance can return a terminal + `CANCELED` order with non-zero `executedQty`. The current order-status model + only has `filled` or `cancelled`, so this path should be reconciled manually + until partial-cancel settlement is implemented. +- **Mixed commission assets:** If an order's fills are charged in different + assets, the adapter does not collapse them into one fee transfer. It leaves + the order pending until the configured lookup limit, then marks exchange + health as `EXCHANGE_RECONCILE` for manual accounting. +- **Fee lookup lag:** `/api/v3/myTrades` can lag `/api/v3/order`. The bot keeps + the fill pending while fee metadata is missing. After + `BINANCE_FEE_LOOKUP_MAX_CHECKS`, it posts using the configured fee fallback + and surfaces `EXCHANGE_RECONCILE` in Neko. +- **Slippage warnings:** Fill prices are compared to the strategy signal price. + Warnings persist in `bot_status.exchange_health` for + `BINANCE_SLIPPAGE_WARN_TTL_SEC` seconds so the dashboard does not miss a + one-tick warning. + ### AWS Tokyo Deployment ```bash @@ -196,7 +237,7 @@ CLOUD_TARGET=gcp ./scripts/switch-to-local.sh ### Nuke ```bash -# Nuke everything — stops remote bot, drops Turso tables, deletes checkpoints, closes Alpaca positions +# Nuke everything — stops remote bot, drops Turso tables, deletes checkpoints, closes exchange positions CLOUD_TARGET=aws ./scripts/nuke.sh # Or skip remote cleanup and only nuke local state + Turso + Alpaca @@ -242,6 +283,8 @@ Binary checkpoint (DCTRADE5) saves full strategy state every minute: - If any local checkpoint file exists but none can be loaded and Turso restore also fails, live mode refuses to bootstrap so it cannot overwrite possibly recoverable checkpoint state - Checkpoint problems are surfaced in `bot_status.checkpoint_health` and `bot_status.checkpoint_error`; Telegram/ntfy sends a warning when health first enters a degraded state +- Binance exchange problems that need app review are surfaced in + `bot_status.exchange_health` and `bot_status.exchange_error` ## Resource Monitoring diff --git a/services/dctrading-bot/src/alpaca.zig b/services/dctrading-bot/src/alpaca.zig index 12c4f8f4..4e76da96 100644 --- a/services/dctrading-bot/src/alpaca.zig +++ b/services/dctrading-bot/src/alpaca.zig @@ -127,7 +127,8 @@ pub const Alpaca = struct { /// Submit order without waiting for fill. Returns PendingOrder with order ID. /// Typically completes in ~200ms (just the POST, no polling). - pub fn submitOrderAsync(self: *const Alpaca, side: Side, qty: f64) ?PendingOrder { + pub fn submitOrderAsync(self: *const Alpaca, side: Side, qty: f64, signal_price: f64) ?PendingOrder { + _ = signal_price; var body_buf: [256]u8 = undefined; const side_str = if (side == .buy) "buy" else "sell"; const body = std.fmt.bufPrint(&body_buf, diff --git a/services/dctrading-bot/src/binance_spot.zig b/services/dctrading-bot/src/binance_spot.zig new file mode 100644 index 00000000..62002ad1 --- /dev/null +++ b/services/dctrading-bot/src/binance_spot.zig @@ -0,0 +1,767 @@ +/// Binance Spot trading client — sync + async order interfaces via native HTTP. +/// Implements Exchange.VTable for live trading on Binance Spot markets. +const std = @import("std"); +const http_mod = @import("http_client.zig"); +const HttpClient = http_mod.HttpClient; +const exchange_mod = @import("exchange.zig"); +const Exchange = exchange_mod.Exchange; +const OrderFill = exchange_mod.OrderFill; +const PendingOrder = exchange_mod.PendingOrder; +const OrderStatus = exchange_mod.OrderStatus; +const CancelResult = exchange_mod.CancelResult; +const Side = exchange_mod.Side; +const ExchangePosition = exchange_mod.Position; +const feed_mod = @import("feed.zig"); +const normalizeSymbol = feed_mod.normalizeSymbol; + +extern "c" fn getenv(name: [*:0]const u8) ?[*:0]const u8; +extern "c" fn usleep(usec: c_uint) c_int; +extern "c" fn time(tloc: ?*anyopaque) c_long; + +const hmac = std.crypto.auth.hmac.sha2.HmacSha256; + +/// Hex-encode bytes into out buffer. Returns slice of encoded chars. +pub fn hexEncode(bytes: []const u8, out: []u8) []const u8 { + const hex = "0123456789abcdef"; + std.debug.assert(out.len >= bytes.len * 2); + for (bytes, 0..) |b, i| { + out[i * 2] = hex[b >> 4]; + out[i * 2 + 1] = hex[b & 0xf]; + } + return out[0 .. bytes.len * 2]; +} + +/// HMAC-SHA256 sign a message with the given secret key. +pub fn hmacSign(msg: []const u8, secret: []const u8, out: *[64]u8) []const u8 { + var mac: [hmac.mac_length]u8 = undefined; + hmac.create(&mac, msg, secret); + return hexEncode(&mac, out); +} + +/// Current Unix timestamp in milliseconds. +fn nowMs() i64 { + const ts: i64 = time(null); + return ts * 1000; +} + +pub const BinanceSpot = struct { + api_key: []const u8, + api_secret: []const u8, + http: *HttpClient, + symbol: [16]u8, + symbol_len: usize, + base_url: [64]u8, + base_url_len: usize, + min_qty: f64 = 0, + step_size: f64 = 0, + min_notional: f64 = 0, + server_time_offset_ms: i64 = 0, + server_time_synced_at_ms: i64 = 0, + bnb_valuation_symbol: [16]u8 = undefined, + bnb_valuation_symbol_len: usize = 0, + fee_missing_order: [64]u8 = undefined, + fee_missing_order_len: usize = 0, + fee_missing_count: u32 = 0, + fee_missing_max_checks: u32 = 20, + exchange_health: [32]u8 = undefined, + exchange_health_len: usize = 0, + exchange_error: [128]u8 = undefined, + exchange_error_len: usize = 0, + + pub fn init(http: *HttpClient) ?BinanceSpot { + const key_ptr = getenv("BINANCE_API_KEY") orelse { + std.debug.print(" [binance_spot] BINANCE_API_KEY not set, Spot trading disabled.\n", .{}); + return null; + }; + const secret_ptr = getenv("BINANCE_API_SECRET") orelse { + std.debug.print(" [binance_spot] BINANCE_API_SECRET not set, Spot trading disabled.\n", .{}); + return null; + }; + const key = std.mem.sliceTo(key_ptr, 0); + const secret = std.mem.sliceTo(secret_ptr, 0); + const symbol = normalizeSymbol(if (getenv("TRADING_SYMBOL")) |ptr| std.mem.sliceTo(ptr, 0) else "BTC/USD"); + + const host = if (getenv("BINANCE_API_HOST")) |ptr| std.mem.sliceTo(ptr, 0) else "api.binance.com"; + var base_url: [64]u8 = undefined; + const base_url_str = std.fmt.bufPrint(&base_url, "https://{s}", .{host}) catch { + std.debug.print(" [binance_spot] Invalid API host.\n", .{}); + return null; + }; + + var client = BinanceSpot{ + .api_key = key, + .api_secret = secret, + .http = http, + .symbol = symbol.buf, + .symbol_len = symbol.len, + .base_url = base_url, + .base_url_len = base_url_str.len, + }; + const bnb_symbol = if (getenv("BINANCE_BNB_VALUATION_SYMBOL")) |ptr| std.mem.sliceTo(ptr, 0) else "BNBUSDT"; + const bnb_symbol_len = @min(bnb_symbol.len, client.bnb_valuation_symbol.len); + @memcpy(client.bnb_valuation_symbol[0..bnb_symbol_len], bnb_symbol[0..bnb_symbol_len]); + client.bnb_valuation_symbol_len = bnb_symbol_len; + if (getenv("BINANCE_FEE_LOOKUP_MAX_CHECKS")) |ptr| { + client.fee_missing_max_checks = std.fmt.parseInt(u32, std.mem.sliceTo(ptr, 0), 10) catch 20; + } + client.syncServerTime() orelse { + std.debug.print(" [binance_spot] Failed to sync server time; Spot trading disabled.\n", .{}); + return null; + }; + client.loadSymbolFilters() orelse { + std.debug.print(" [binance_spot] Failed to load symbol filters for {s}; Spot trading disabled.\n", .{symbol.slice()}); + return null; + }; + + std.debug.print(" [binance_spot] Spot trading enabled for {s} min_qty={d:.8} step={d:.8} min_notional=${d:.2}.\n", .{ + symbol.slice(), + client.min_qty, + client.step_size, + client.min_notional, + }); + return client; + } + + pub fn initForTest(http: *HttpClient, key: []const u8, secret: []const u8, trading_symbol: []const u8, base_url_str: []const u8) BinanceSpot { + const symbol = normalizeSymbol(trading_symbol); + var base_url: [64]u8 = undefined; + const base_len = @min(base_url_str.len, base_url.len); + @memcpy(base_url[0..base_len], base_url_str[0..base_len]); + var client = BinanceSpot{ + .api_key = key, + .api_secret = secret, + .http = http, + .symbol = symbol.buf, + .symbol_len = symbol.len, + .base_url = base_url, + .base_url_len = base_len, + .server_time_synced_at_ms = nowMs(), + }; + @memcpy(client.bnb_valuation_symbol[0..7], "BNBUSDT"); + client.bnb_valuation_symbol_len = 7; + return client; + } + + pub fn healthStatus(self: *const BinanceSpot) []const u8 { + if (self.exchange_health_len == 0) return "OK"; + return self.exchange_health[0..self.exchange_health_len]; + } + + pub fn healthError(self: *const BinanceSpot) []const u8 { + return self.exchange_error[0..self.exchange_error_len]; + } + + pub fn exchange(self: *const BinanceSpot) Exchange { + return .{ + .ptr = @ptrCast(self), + .vtable = &vtable, + }; + } + + const vtable = Exchange.VTable{ + .buy = @ptrCast(&buy), + .sell = @ptrCast(&sell), + .submitOrder = @ptrCast(&submitOrderAsync), + .checkOrder = @ptrCast(&checkOrderStatus), + .cancelOrder = @ptrCast(&cancelOrderAsync), + .getPosition = @ptrCast(&getPositionExchange), + }; + + fn apiKeyHeader(self: *const BinanceSpot) HttpClient.Header { + return .{ .name = "X-MBX-APIKEY", .value = self.api_key }; + } + + fn tradingSymbol(self: *const BinanceSpot) []const u8 { + return self.symbol[0..self.symbol_len]; + } + + fn baseUrl(self: *const BinanceSpot) []const u8 { + return self.base_url[0..self.base_url_len]; + } + + fn bnbValuationSymbol(self: *const BinanceSpot) []const u8 { + return self.bnb_valuation_symbol[0..self.bnb_valuation_symbol_len]; + } + + fn syncServerTime(self: *BinanceSpot) ?void { + var url_buf: [128]u8 = undefined; + const url = std.fmt.bufPrint(&url_buf, "{s}/api/v3/time", .{self.baseUrl()}) catch return null; + + const before = nowMs(); + const resp = self.http.get(url, &.{}) catch return null; + const after = nowMs(); + defer resp.deinit(); + if (!isSuccess(resp.status) or logBinanceError("time", resp.body)) return null; + + const server_time = parseJsonInt(resp.body, "\"serverTime\":") orelse return null; + const local_midpoint = @divTrunc(before + after, 2); + self.server_time_offset_ms = server_time - local_midpoint; + self.server_time_synced_at_ms = after; + std.debug.print(" [binance_spot] Server time offset: {d}ms.\n", .{self.server_time_offset_ms}); + } + + fn maybeSyncServerTime(self: *BinanceSpot) void { + const now = nowMs(); + if (self.server_time_synced_at_ms > 0 and now - self.server_time_synced_at_ms < 3600 * 1000) return; + _ = self.syncServerTime(); + } + + fn loadSymbolFilters(self: *BinanceSpot) ?void { + var url_buf: [256]u8 = undefined; + const url = std.fmt.bufPrint(&url_buf, "{s}/api/v3/exchangeInfo?symbol={s}", .{ self.baseUrl(), self.tradingSymbol() }) catch return null; + + const resp = self.http.get(url, &.{}) catch return null; + defer resp.deinit(); + if (!isSuccess(resp.status) or hasBinanceError(resp.body)) return null; + + parseSymbolFilters(resp.body, &self.min_qty, &self.step_size, &self.min_notional) orelse return null; + } + + fn normalizeOrderQty(self: *const BinanceSpot, qty: f64) ?f64 { + if (qty <= 0) return null; + + var normalized = qty; + if (self.step_size > 0) { + normalized = @floor(qty / self.step_size) * self.step_size; + } + if (normalized <= 0 or (self.min_qty > 0 and normalized + 0.000000000001 < self.min_qty)) { + std.debug.print(" [binance_spot] qty {d:.8} below minQty {d:.8} after step rounding.\n", .{ normalized, self.min_qty }); + return null; + } + + if (self.min_notional > 0) { + const px = self.queryTickerPrice(self.tradingSymbol()) orelse { + std.debug.print(" [binance_spot] cannot validate minNotional without ticker price.\n", .{}); + return null; + }; + if (normalized * px + 0.00000001 < self.min_notional) { + std.debug.print(" [binance_spot] order notional ${d:.2} below minNotional ${d:.2}.\n", .{ normalized * px, self.min_notional }); + return null; + } + } + + return normalized; + } + + fn quoteOrderAmount(self: *const BinanceSpot, qty: f64, signal_price: f64) ?f64 { + if (qty <= 0) return null; + const px = if (signal_price > 0) signal_price else self.queryTickerPrice(self.tradingSymbol()) orelse { + std.debug.print(" [binance_spot] cannot price quoteOrderQty without signal or ticker price.\n", .{}); + return null; + }; + const quote_amount = qty * px; + if (self.min_notional > 0 and quote_amount + 0.00000001 < self.min_notional) { + std.debug.print(" [binance_spot] quoteOrderQty ${d:.2} below minNotional ${d:.2}.\n", .{ quote_amount, self.min_notional }); + return null; + } + return quote_amount; + } + + fn isSuccess(status: std.http.Status) bool { + const code = @intFromEnum(status); + return code >= 200 and code < 300; + } + + fn hasBinanceError(body: []const u8) bool { + return std.mem.indexOf(u8, body, "\"code\"") != null and std.mem.indexOf(u8, body, "\"msg\"") != null; + } + + fn classifyBinanceCode(code: i64) []const u8 { + return switch (code) { + -1021 => "timestamp", + -1013 => "filter", + -2010 => "order_rejected", + -2011 => "unknown_order", + -1121 => "invalid_symbol", + -2015 => "auth", + else => "api", + }; + } + + fn logBinanceError(context: []const u8, body: []const u8) bool { + const code = parseJsonInt(body, "\"code\":") orelse return false; + const msg = parseJsonString(body, "\"msg\":\"") orelse ""; + std.debug.print(" [binance_spot] {s} error class={s} code={d} msg={s}\n", .{ context, classifyBinanceCode(code), code, msg }); + return true; + } + + fn setHealth(self: *BinanceSpot, status: []const u8, detail: []const u8) void { + const slen = @min(status.len, self.exchange_health.len); + @memcpy(self.exchange_health[0..slen], status[0..slen]); + self.exchange_health_len = slen; + const dlen = @min(detail.len, self.exchange_error.len); + @memcpy(self.exchange_error[0..dlen], detail[0..dlen]); + self.exchange_error_len = dlen; + } + + fn clearHealth(self: *BinanceSpot) void { + self.exchange_health_len = 0; + self.exchange_error_len = 0; + } + + pub fn parseSymbolFilters(json: []const u8, min_qty: *f64, step_size: *f64, min_notional: *f64) ?void { + const lot_pos = std.mem.indexOf(u8, json, "\"filterType\":\"LOT_SIZE\"") orelse return null; + const lot_end = std.mem.indexOf(u8, json[lot_pos..], "}") orelse return null; + const lot = json[lot_pos..][0..lot_end]; + min_qty.* = parseJsonFloat(lot, "\"minQty\":\"") orelse return null; + step_size.* = parseJsonFloat(lot, "\"stepSize\":\"") orelse return null; + + if (std.mem.indexOf(u8, json, "\"filterType\":\"MIN_NOTIONAL\"")) |notional_pos| { + const notional_end = std.mem.indexOf(u8, json[notional_pos..], "}") orelse return null; + const notional = json[notional_pos..][0..notional_end]; + min_notional.* = parseJsonFloat(notional, "\"minNotional\":\"") orelse 0; + } else if (std.mem.indexOf(u8, json, "\"filterType\":\"NOTIONAL\"")) |notional_pos| { + const notional_end = std.mem.indexOf(u8, json[notional_pos..], "}") orelse return null; + const notional = json[notional_pos..][0..notional_end]; + min_notional.* = parseJsonFloat(notional, "\"minNotional\":\"") orelse 0; + } else { + min_notional.* = 0; + } + + return {}; + } + + /// Build signed URL or body: append timestamp, recvWindow, and HMAC signature. + fn sign(self: *const BinanceSpot, query: []const u8, out: []u8) []const u8 { + @constCast(self).maybeSyncServerTime(); + const ts = nowMs() + self.server_time_offset_ms; + var signed_query_buf: [1024]u8 = undefined; + var signed: []const u8 = undefined; + if (query.len > 0) { + signed = std.fmt.bufPrint(&signed_query_buf, "{s}×tamp={d}&recvWindow=5000", .{ query, ts }) catch return ""; + } else { + signed = std.fmt.bufPrint(&signed_query_buf, "timestamp={d}&recvWindow=5000", .{ts}) catch return ""; + } + var sig_buf: [64]u8 = undefined; + const signature = hmacSign(signed, self.api_secret, &sig_buf); + return std.fmt.bufPrint(out, "{s}&signature={s}", .{ signed, signature }) catch ""; + } + + // ========== Sync interface (blocking) ========== + + pub fn buy(self: *const BinanceSpot, qty: f64) ?OrderFill { + return self.submitOrderSync(.buy, qty); + } + + pub fn sell(self: *const BinanceSpot, qty: f64) ?OrderFill { + return self.submitOrderSync(.sell, qty); + } + + fn submitOrderSync(self: *const BinanceSpot, side: Side, qty: f64) ?OrderFill { + const pending = self.submitOrderAsync(side, qty, 0) orelse return null; + const order_id = pending.order_id[0..pending.order_id_len]; + + var poll: u32 = 0; + while (poll < 20) : (poll += 1) { + _ = usleep(500_000); // 500ms + const status = self.checkOrderStatus(order_id); + switch (status) { + .filled => |fill| return fill, + .cancelled => return null, + .failed => return null, + .pending => {}, + } + } + // Timeout: try to cancel + _ = self.cancelOrderAsync(order_id); + return null; + } + + // ========== Async interface (non-blocking) ========== + + pub fn submitOrderAsync(self: *const BinanceSpot, side: Side, qty: f64, signal_price: f64) ?PendingOrder { + const side_str = if (side == .buy) "BUY" else "SELL"; + const quote_amount = if (side == .buy) self.quoteOrderAmount(qty, signal_price) orelse return null else 0; + const order_qty = if (side == .buy) qty else self.normalizeOrderQty(qty) orelse return null; + var query_buf: [512]u8 = undefined; + const query = if (side == .buy) + std.fmt.bufPrint( + &query_buf, + "symbol={s}&side={s}&type=MARKET"eOrderQty={d:.8}", + .{ self.tradingSymbol(), side_str, quote_amount }, + ) catch return null + else + std.fmt.bufPrint( + &query_buf, + "symbol={s}&side={s}&type=MARKET&quantity={d:.8}", + .{ self.tradingSymbol(), side_str, order_qty }, + ) catch return null; + + var signed_buf: [1024]u8 = undefined; + const signed = self.sign(query, &signed_buf); + if (signed.len == 0) return null; + + var url_buf: [256]u8 = undefined; + const url = std.fmt.bufPrint(&url_buf, "{s}/api/v3/order?{s}", .{ self.baseUrl(), signed }) catch return null; + + const h = [_]HttpClient.Header{ + self.apiKeyHeader(), + .{ .name = "Content-Type", .value = "application/x-www-form-urlencoded" }, + }; + + const resp = self.http.post(url, &h, "") catch |err| { + std.debug.print(" [binance_spot] submitOrder HTTP error: {s}\n", .{@errorName(err)}); + return null; + }; + defer resp.deinit(); + + const r = resp.body; + std.debug.print(" [binance_spot] Order submitted ({d} bytes): {s}\n", .{ r.len, r[0..@min(r.len, 300)] }); + + if (!isSuccess(resp.status) or logBinanceError("submitOrder", r)) { + return null; + } + + const pending_qty = if (side == .buy) qty else order_qty; + var pending: PendingOrder = .{ .side = side, .qty = pending_qty, .quote_amount = quote_amount, .dust_qty_threshold = self.min_qty }; + if (parseJsonString(r, "\"orderId\":")) |id_str| { + const len = @min(id_str.len, pending.order_id.len); + @memcpy(pending.order_id[0..len], id_str[0..len]); + pending.order_id_len = len; + } else { + return null; + } + + // Check if already filled (common for market orders on liquid pairs) + if (std.mem.indexOf(u8, r, "\"status\":\"FILLED\"") != null) { + std.debug.print(" [binance_spot] Immediately filled.\n", .{}); + } + + return pending; + } + + pub fn checkOrderStatus(self: *const BinanceSpot, order_id: []const u8) OrderStatus { + var query_buf: [512]u8 = undefined; + const query = std.fmt.bufPrint( + &query_buf, + "symbol={s}&orderId={s}", + .{ self.tradingSymbol(), order_id }, + ) catch return .{ .failed = {} }; + + var signed_buf: [1024]u8 = undefined; + const signed = self.sign(query, &signed_buf); + if (signed.len == 0) return .{ .failed = {} }; + + var url_buf: [512]u8 = undefined; + const url = std.fmt.bufPrint(&url_buf, "{s}/api/v3/order?{s}", .{ self.baseUrl(), signed }) catch return .{ .failed = {} }; + + const h = [_]HttpClient.Header{self.apiKeyHeader()}; + const resp = self.http.get(url, &h) catch return .{ .pending = {} }; + defer resp.deinit(); + + const r = resp.body; + if (!isSuccess(resp.status) or logBinanceError("checkOrder", r)) return .{ .failed = {} }; + + if (std.mem.indexOf(u8, r, "\"status\":\"FILLED\"") != null) { + var fill: OrderFill = .{ .fill_price = 0, .fill_qty = 0, .status = .filled }; + + // Binance returns string numbers; parseJsonFloat handles quotes + fill.fill_qty = parseJsonFloat(r, "\"executedQty\":\"") orelse parseJsonFloat(r, "\"executedQty\":") orelse 0; + const quote_qty = parseJsonFloat(r, "\"cummulativeQuoteQty\":\"") orelse parseJsonFloat(r, "\"cummulativeQuoteQty\":") orelse 0; + fill.quote_amount = quote_qty; + if (fill.fill_qty > 0) { + fill.fill_price = quote_qty / fill.fill_qty; + } + + if (!self.attachTradeCommission(order_id, &fill)) { + return .{ .pending = {} }; + } + + const len = @min(order_id.len, fill.order_id.len); + @memcpy(fill.order_id[0..len], order_id[0..len]); + fill.order_id_len = len; + + std.debug.print(" [binance_spot] checkOrder: filled price=${d:.2} qty={d:.8} comm={d:.8} {s}\n", .{ + fill.fill_price, + fill.fill_qty, + fill.commission, + fill.commission_asset[0..fill.commission_asset_len], + }); + return .{ .filled = fill }; + } + + if (std.mem.indexOf(u8, r, "\"status\":\"CANCELED\"") != null or + std.mem.indexOf(u8, r, "\"status\":\"EXPIRED\"") != null) + { + return .{ .cancelled = {} }; + } + + if (std.mem.indexOf(u8, r, "\"status\":\"REJECTED\"") != null) { + return .{ .failed = {} }; + } + + // NEW, PARTIALLY_FILLED, PENDING_NEW — keep waiting + return .{ .pending = {} }; + } + + pub fn cancelOrderAsync(self: *const BinanceSpot, order_id: []const u8) CancelResult { + var query_buf: [512]u8 = undefined; + const query = std.fmt.bufPrint( + &query_buf, + "symbol={s}&orderId={s}", + .{ self.tradingSymbol(), order_id }, + ) catch return .{ .failed = {} }; + + var signed_buf: [1024]u8 = undefined; + const signed = self.sign(query, &signed_buf); + if (signed.len == 0) return .{ .failed = {} }; + + var url_buf: [512]u8 = undefined; + const url = std.fmt.bufPrint(&url_buf, "{s}/api/v3/order?{s}", .{ self.baseUrl(), signed }) catch return .{ .failed = {} }; + + const h = [_]HttpClient.Header{self.apiKeyHeader()}; + const resp = self.http.delete(url, &h) catch { + return .{ .failed = {} }; + }; + defer resp.deinit(); + if (!isSuccess(resp.status) and resp.status != .not_found) { + _ = logBinanceError("cancelOrder", resp.body); + return .{ .failed = {} }; + } + + _ = usleep(200_000); // 200ms + + const status = self.checkOrderStatus(order_id); + return switch (status) { + .filled => |fill| .{ .filled = fill }, + .cancelled => .{ .cancelled = {} }, + .pending => .{ .cancelled = {} }, + .failed => .{ .failed = {} }, + }; + } + + // ========== Position query ========== + + /// Binance Spot has no native position endpoint. Returns null. + /// The bot's position is tracked via Turso transfers (source of truth). + fn getPositionExchange(self: *const BinanceSpot) ?ExchangePosition { + _ = self; + return null; + } + + /// Query account balance for discrepancy alerts. Not used as position source of truth. + pub fn queryAccountBalance(self: *const BinanceSpot, asset: []const u8) ?f64 { + var signed_buf: [1024]u8 = undefined; + const signed = self.sign("", &signed_buf); + if (signed.len == 0) return null; + + var url_buf: [512]u8 = undefined; + const url = std.fmt.bufPrint(&url_buf, "{s}/api/v3/account?{s}", .{ self.baseUrl(), signed }) catch return null; + + const h = [_]HttpClient.Header{self.apiKeyHeader()}; + const resp = self.http.get(url, &h) catch return null; + defer resp.deinit(); + if (!isSuccess(resp.status) or logBinanceError("account", resp.body)) return null; + + const r = resp.body; + return parseAccountBalance(r, asset); + } + + fn attachTradeCommission(self: *const BinanceSpot, order_id: []const u8, fill: *OrderFill) bool { + var query_buf: [512]u8 = undefined; + const query = std.fmt.bufPrint( + &query_buf, + "symbol={s}&orderId={s}", + .{ self.tradingSymbol(), order_id }, + ) catch return false; + + var signed_buf: [1024]u8 = undefined; + const signed = self.sign(query, &signed_buf); + if (signed.len == 0) return false; + + var url_buf: [512]u8 = undefined; + const url = std.fmt.bufPrint(&url_buf, "{s}/api/v3/myTrades?{s}", .{ self.baseUrl(), signed }) catch return false; + + const h = [_]HttpClient.Header{self.apiKeyHeader()}; + var attempt: u8 = 0; + while (attempt < 3) : (attempt += 1) { + const resp = self.http.get(url, &h) catch |err| { + std.debug.print(" [binance_spot] commission lookup failed: {s}\n", .{@errorName(err)}); + _ = usleep(150_000); + continue; + }; + defer resp.deinit(); + if (!isSuccess(resp.status) or logBinanceError("myTrades", resp.body)) { + std.debug.print(" [binance_spot] commission lookup returned API error for order {s}\n", .{order_id}); + _ = usleep(150_000); + continue; + } + + if (parseTradeCommissions(resp.body, &fill.commission, &fill.commission_asset, &fill.commission_asset_len)) { + fill.commission_usd = self.valueCommissionUsd(fill.*); + @constCast(self).resetFeeMissing(order_id); + @constCast(self).clearHealth(); + return true; + } + + _ = usleep(150_000); + } + + if (@constCast(self).recordFeeMissing(order_id)) { + std.debug.print(" [binance_spot] commission lookup timed out for order {s}; posting with configured fallback fee and manual reconciliation required.\n", .{order_id}); + @constCast(self).setHealth("EXCHANGE_RECONCILE", "binance_fee_lookup_timeout_manual_reconciliation_required"); + fill.commission = 0; + fill.commission_usd = 0; + fill.commission_asset_len = 0; + return true; + } + + std.debug.print(" [binance_spot] commission lookup returned no trade fees for order {s}; keeping order pending.\n", .{order_id}); + return false; + } + + fn resetFeeMissing(self: *BinanceSpot, order_id: []const u8) void { + if (self.fee_missing_order_len == order_id.len and std.mem.eql(u8, self.fee_missing_order[0..self.fee_missing_order_len], order_id)) { + self.fee_missing_order_len = 0; + self.fee_missing_count = 0; + } + } + + fn recordFeeMissing(self: *BinanceSpot, order_id: []const u8) bool { + if (self.fee_missing_order_len != order_id.len or !std.mem.eql(u8, self.fee_missing_order[0..self.fee_missing_order_len], order_id)) { + const len = @min(order_id.len, self.fee_missing_order.len); + @memcpy(self.fee_missing_order[0..len], order_id[0..len]); + self.fee_missing_order_len = len; + self.fee_missing_count = 0; + } + self.fee_missing_count += 1; + return self.fee_missing_max_checks > 0 and self.fee_missing_count >= self.fee_missing_max_checks; + } + + fn valueCommissionUsd(self: *const BinanceSpot, fill: OrderFill) f64 { + if (fill.commission <= 0 or fill.commission_asset_len == 0) return 0; + + const asset = fill.commission_asset[0..fill.commission_asset_len]; + if (std.mem.eql(u8, asset, "USD") or std.mem.eql(u8, asset, "USDT")) return fill.commission; + if (std.mem.eql(u8, asset, "BTC")) return 0; + if (std.mem.eql(u8, asset, "BNB")) { + if (self.queryTickerPrice(self.bnbValuationSymbol())) |bnb_usdt| { + return fill.commission * bnb_usdt; + } + } + return 0; + } + + fn queryTickerPrice(self: *const BinanceSpot, symbol: []const u8) ?f64 { + var url_buf: [256]u8 = undefined; + const url = std.fmt.bufPrint(&url_buf, "{s}/api/v3/ticker/price?symbol={s}", .{ self.baseUrl(), symbol }) catch return null; + + const resp = self.http.get(url, &.{}) catch return null; + defer resp.deinit(); + if (!isSuccess(resp.status) or logBinanceError("ticker", resp.body)) return null; + + return parseJsonFloat(resp.body, "\"price\":\"") orelse parseJsonFloat(resp.body, "\"price\":"); + } + + // ========== JSON helpers ========== + + pub fn parseJsonFloat(json: []const u8, key: []const u8) ?f64 { + const pos = std.mem.indexOf(u8, json, key) orelse return null; + var start = pos + key.len; + while (start < json.len and (json[start] == ' ' or json[start] == '"')) : (start += 1) {} + var end = start; + while (end < json.len and json[end] != ',' and json[end] != '}' and json[end] != '"') : (end += 1) {} + return std.fmt.parseFloat(f64, json[start..end]) catch null; + } + + pub fn parseJsonInt(json: []const u8, key: []const u8) ?i64 { + const pos = std.mem.indexOf(u8, json, key) orelse return null; + var start = pos + key.len; + while (start < json.len and (json[start] == ' ' or json[start] == '"')) : (start += 1) {} + var end = start; + while (end < json.len and json[end] != ',' and json[end] != '}' and json[end] != '"') : (end += 1) {} + return std.fmt.parseInt(i64, json[start..end], 10) catch null; + } + + pub fn parseJsonString(json: []const u8, key: []const u8) ?[]const u8 { + const pos = std.mem.indexOf(u8, json, key) orelse return null; + var start = pos + key.len; + while (start < json.len and json[start] == ' ') : (start += 1) {} + if (start >= json.len or json[start] != '"') { + // Try parsing as bare number (Binance sometimes returns orderId as number) + var end = start; + while (end < json.len and json[end] != ',' and json[end] != '}' and json[end] != '"' and json[end] != ' ') : (end += 1) {} + return json[start..end]; + } + start += 1; + const end = std.mem.indexOf(u8, json[start..], "\"") orelse return null; + return json[start..][0..end]; + } + + /// Parse first fill from the fills array for commission data. + pub fn parseFillCommission(json: []const u8, commission: *f64, asset_out: *[8]u8, asset_len: *usize) bool { + const fills_pos = std.mem.indexOf(u8, json, "\"fills\":[") orelse return false; + const start = fills_pos + "\"fills\":[".len; + const end = std.mem.indexOf(u8, json[start..], "]") orelse return false; + const first_fill = json[start..][0..end]; + + commission.* = parseJsonFloat(first_fill, "\"commission\":\"") orelse parseJsonFloat(first_fill, "\"commission\":") orelse 0; + if (parseJsonString(first_fill, "\"commissionAsset\":\"")) |asset| { + const len = @min(asset.len, asset_out.len); + @memcpy(asset_out[0..len], asset[0..len]); + asset_len.* = len; + } else { + asset_len.* = 0; + } + return true; + } + + /// Parse and aggregate commissions from /api/v3/myTrades. + /// Returns false if no commission metadata is present or if assets differ. + pub fn parseTradeCommissions(json: []const u8, commission: *f64, asset_out: *[8]u8, asset_len: *usize) bool { + var search = json; + var total: f64 = 0; + var found = false; + var expected_asset: [8]u8 = undefined; + var expected_len: usize = 0; + + while (std.mem.indexOf(u8, search, "\"commission\"")) |commission_pos| { + const entry_end = std.mem.indexOf(u8, search[commission_pos..], "}") orelse search.len - commission_pos; + const entry = search[commission_pos..][0..entry_end]; + const fee = parseJsonFloat(entry, "\"commission\":\"") orelse parseJsonFloat(entry, "\"commission\":") orelse return false; + const asset = parseJsonString(entry, "\"commissionAsset\":\"") orelse return false; + + if (!found) { + expected_len = @min(asset.len, expected_asset.len); + @memcpy(expected_asset[0..expected_len], asset[0..expected_len]); + found = true; + } else if (asset.len != expected_len or !std.mem.eql(u8, asset[0..expected_len], expected_asset[0..expected_len])) { + return false; + } + + total += fee; + search = search[commission_pos + entry.len ..]; + } + + if (!found) return false; + commission.* = total; + @memcpy(asset_out[0..expected_len], expected_asset[0..expected_len]); + asset_len.* = expected_len; + return true; + } + + /// Parse free balance for a given asset from /api/v3/account response. + pub fn parseAccountBalance(json: []const u8, asset: []const u8) ?f64 { + const balances_key = "\"balances\":"; + const list_pos = std.mem.indexOf(u8, json, balances_key) orelse return null; + var list_start = list_pos + balances_key.len; + while (list_start < json.len and json[list_start] != '[') : (list_start += 1) {} + if (list_start >= json.len) return null; + list_start += 1; + + var search = json[list_start..]; + while (search.len > 0) { + var asset_key_buf: [64]u8 = undefined; + const asset_key = std.fmt.bufPrint(&asset_key_buf, "\"asset\":\"{s}\"", .{asset}) catch return null; + const asset_pos = std.mem.indexOf(u8, search, asset_key) orelse break; + const entry_end = std.mem.indexOf(u8, search[asset_pos..], "}") orelse break; + const entry = search[asset_pos..][0..entry_end]; + + // Binance returns both free and locked; we want total (free + locked) + const free = parseJsonFloat(entry, "\"free\":\"") orelse 0; + const locked = parseJsonFloat(entry, "\"locked\":\"") orelse 0; + return free + locked; + } + return null; + } +}; diff --git a/services/dctrading-bot/src/exchange.zig b/services/dctrading-bot/src/exchange.zig index e880de42..6a7ebdf2 100644 --- a/services/dctrading-bot/src/exchange.zig +++ b/services/dctrading-bot/src/exchange.zig @@ -13,6 +13,9 @@ pub const OrderFill = struct { order_id_len: usize = 0, fill_price: f64, fill_qty: f64, + /// Exact quote-currency notional reported by the exchange for this fill. + /// Zero means unavailable; callers may fall back to fill_price * fill_qty. + quote_amount: f64 = 0, /// Native commission quantity in `commission_asset` units. commission: f64 = 0, /// Historical quote-currency value of `commission` at fill time. Required @@ -31,7 +34,15 @@ pub const PendingOrder = struct { order_id: [64]u8 = undefined, order_id_len: usize = 0, side: Side, + /// Submitted base-asset quantity when the exchange accepts base quantity. + /// For quote-notional buys this may be an estimate for local tracking. qty: f64, + /// Submitted quote-currency notional when the exchange accepts quoteOrderQty. + /// Zero means the order was not submitted as quote-notional. + quote_amount: f64 = 0, + /// Base-asset dust threshold for remaining qty after a sell. + /// Zero means caller default. + dust_qty_threshold: f64 = 0, }; /// Result of checking a pending order's status. @@ -77,7 +88,7 @@ pub const Exchange = struct { buy: *const fn (ptr: *const anyopaque, qty: f64) ?OrderFill, sell: *const fn (ptr: *const anyopaque, qty: f64) ?OrderFill, // Async (non-blocking) - submitOrder: *const fn (ptr: *const anyopaque, side: Side, qty: f64) ?PendingOrder, + submitOrder: *const fn (ptr: *const anyopaque, side: Side, qty: f64, signal_price: f64) ?PendingOrder, checkOrder: *const fn (ptr: *const anyopaque, order_id: []const u8) OrderStatus, cancelOrder: *const fn (ptr: *const anyopaque, order_id: []const u8) CancelResult, // Query @@ -94,8 +105,8 @@ pub const Exchange = struct { } // Async interface - pub fn submitOrder(self: Exchange, side: Side, qty: f64) ?PendingOrder { - return self.vtable.submitOrder(self.ptr, side, qty); + pub fn submitOrder(self: Exchange, side: Side, qty: f64, signal_price: f64) ?PendingOrder { + return self.vtable.submitOrder(self.ptr, side, qty, signal_price); } pub fn checkOrder(self: Exchange, order_id: []const u8) OrderStatus { diff --git a/services/dctrading-bot/src/http_client.zig b/services/dctrading-bot/src/http_client.zig index c024f1a2..2c412966 100644 --- a/services/dctrading-bot/src/http_client.zig +++ b/services/dctrading-bot/src/http_client.zig @@ -16,6 +16,7 @@ pub const HttpClient = struct { retry_count: u64 = 0, last_latency_ms: f64 = 0, max_latency_ms: f64 = 0, + mock: ?*MockTransport = null, pub fn init(allocator: std.mem.Allocator, io: Io) HttpClient { return .{ @@ -52,6 +53,52 @@ pub const HttpClient = struct { max_ms: f64 = 0, }; + pub const MockResponse = struct { + method: http.Method, + url_contains: []const u8, + status: http.Status, + body: []const u8, + }; + + pub const MockRequest = struct { + method: http.Method = .GET, + url: [1024]u8 = undefined, + url_len: usize = 0, + }; + + pub const MockTransport = struct { + responses: []const MockResponse, + index: usize = 0, + requests: [32]MockRequest = undefined, + request_count: usize = 0, + + pub fn handle(self: *MockTransport, allocator: std.mem.Allocator, method: http.Method, url: []const u8) !Response { + if (self.request_count < self.requests.len) { + const len = @min(url.len, self.requests[self.request_count].url.len); + self.requests[self.request_count].method = method; + @memcpy(self.requests[self.request_count].url[0..len], url[0..len]); + self.requests[self.request_count].url_len = len; + } + self.request_count += 1; + + if (self.index >= self.responses.len) return error.UnexpectedMockRequest; + const expected = self.responses[self.index]; + self.index += 1; + if (expected.method != method or std.mem.indexOf(u8, url, expected.url_contains) == null) { + return error.UnexpectedMockRequest; + } + + const body = try allocator.alloc(u8, expected.body.len); + @memcpy(body, expected.body); + return .{ .status = expected.status, .body = body, .allocator = allocator }; + } + + pub fn requestUrl(self: *const MockTransport, index: usize) []const u8 { + if (index >= self.request_count or index >= self.requests.len) return ""; + return self.requests[index].url[0..self.requests[index].url_len]; + } + }; + /// POST JSON to a URL with custom headers. Returns owned response body. pub fn post(self: *HttpClient, url: []const u8, headers: []const Header, body: []const u8) !Response { return self.doRequest(.POST, url, headers, body, 64 * 1024); @@ -101,6 +148,17 @@ pub const HttpClient = struct { body: ?[]const u8, max_response_bytes: usize, ) !Response { + if (self.mock) |mock| { + const result = mock.handle(self.allocator, method, url); + if (result) |resp| { + self.request_count += 1; + return resp; + } else |err| { + self.request_count += 1; + self.error_count += 1; + return err; + } + } const start_ms = self.nowMs(); // Spin-lock: Zig 0.16 atomic.Mutex only has tryLock while (!self.mutex.tryLock()) { diff --git a/services/dctrading-bot/src/integration_tests.zig b/services/dctrading-bot/src/integration_tests.zig index 2e9008ed..6aeb5231 100644 --- a/services/dctrading-bot/src/integration_tests.zig +++ b/services/dctrading-bot/src/integration_tests.zig @@ -1047,6 +1047,115 @@ test "integration: ledger records buy pending and actual fill settlement" { try testing.expectApproxEqAbs(420.0, ledger.posted[0].timestamp, 0.001); } +test "integration: buy pending reserves exchange-normalized submitted quantity" { + const allocator = testing.allocator; + var strategy = try Strategy.init(allocator, .{ + .ma_period = 5, + .ma_buffer = 0.03, + .initial_capital = 1000.0, + .fee_pct = 0.001, + }); + defer strategy.deinit(allocator); + strategy.suppress_entry = true; + + var sim = SimExchange{ .fill_delay = 1, .submitted_qty_ratio = 0.9 }; + sim.last_price = 104.0; + const ex = sim.exchange(); + var ledger = MockLedger{}; + var loop = LiveLoop.init(&strategy, ex, ledger.ledger()); + + var i: usize = 0; + while (i < 5) : (i += 1) { + loop.processTick(makeTick(100.0, @as(f64, @floatFromInt(i)) * 60.0)); + } + + loop.processTick(makeTick(104.0, 360.0)); + try testing.expectEqual(@as(u32, 1), ledger.pending_count); + try testing.expectApproxEqAbs(loop.pending_orders[0].size, ledger.last_pending.qty, 0.000001); + try testing.expectApproxEqAbs(104.0 * loop.pending_orders[0].size, ledger.last_pending.amount, 0.001); + try testing.expectApproxEqAbs(104.0 * loop.pending_orders[0].size, strategy.capital_reserved, 0.001); + + sim.advanceTick(); + loop.processTick(makeTick(104.0, 420.0)); + try testing.expectEqual(@as(u32, 1), ledger.post_fill_count); + try testing.expectApproxEqAbs(ledger.last_pending.qty, ledger.last_post_fill.actual_size, 0.000001); + try testing.expectApproxEqAbs(0.0, strategy.capital_reserved, 0.001); +} + +test "integration: buy ledger uses exchange quote amounts for reserve and fill" { + const allocator = testing.allocator; + var strategy = try Strategy.init(allocator, .{ + .ma_period = 5, + .ma_buffer = 0.03, + .initial_capital = 1000.0, + .fee_pct = 0.001, + }); + defer strategy.deinit(allocator); + strategy.suppress_entry = true; + + var sim = SimExchange{ + .fill_delay = 1, + .submitted_quote_amount = 123.45, + .fill_quote_amount = 120.12, + }; + sim.last_price = 104.0; + const ex = sim.exchange(); + var ledger = MockLedger{}; + var loop = LiveLoop.init(&strategy, ex, ledger.ledger()); + + var i: usize = 0; + while (i < 5) : (i += 1) { + loop.processTick(makeTick(100.0, @as(f64, @floatFromInt(i)) * 60.0)); + } + + loop.processTick(makeTick(104.0, 360.0)); + try testing.expectEqual(@as(u32, 1), ledger.pending_count); + try testing.expectApproxEqAbs(123.45, ledger.last_pending.amount, 0.001); + try testing.expectApproxEqAbs(123.45, strategy.capital_reserved, 0.001); + + sim.advanceTick(); + loop.processTick(makeTick(104.0, 420.0)); + try testing.expectEqual(@as(u32, 1), ledger.post_fill_count); + try testing.expectApproxEqAbs(120.12, ledger.last_post_fill.actual_amount, 0.001); + try testing.expectApproxEqAbs(0.0, strategy.capital_reserved, 0.001); +} + +test "integration: slippage warning persists until ttl expires" { + const allocator = testing.allocator; + var strategy = try Strategy.init(allocator, .{ + .ma_period = 5, + .ma_buffer = 0.03, + .initial_capital = 1000.0, + .fee_pct = 0.001, + }); + defer strategy.deinit(allocator); + strategy.suppress_entry = true; + + var sim = SimExchange{ .fill_delay = 1, .fill_price_offset = 2.0 }; + sim.last_price = 104.0; + const ex = sim.exchange(); + var loop = LiveLoop.init(&strategy, ex, null); + loop.slippage_warn_pct = 0.01; + loop.slippage_warning_ttl_sec = 120; + + var i: usize = 0; + while (i < 5) : (i += 1) { + loop.processTick(makeTick(100.0, @as(f64, @floatFromInt(i)) * 60.0)); + } + + loop.processTick(makeTick(104.0, 360.0)); + sim.advanceTick(); + loop.processTick(makeTick(104.0, 420.0)); + try testing.expectEqualStrings("EXCHANGE_SLIPPAGE", loop.last_warning.?); + try testing.expectEqualStrings("exchange_slippage_warn_threshold_exceeded", loop.last_warning_error); + + loop.processTick(makeTick(104.0, 480.0)); + try testing.expect(loop.last_warning != null); + + loop.processTick(makeTick(104.0, 540.0)); + try testing.expect(loop.last_warning == null); +} + test "integration: ledger uses actual exchange commission for buy fee" { const allocator = testing.allocator; var strategy = try Strategy.init(allocator, .{ @@ -1336,7 +1445,7 @@ test "integration: reconciled pending orders are tracked by LiveLoop" { // Simulate: a pending buy was reconciled from Turso at startup // and copied into LiveLoop (the fix) - const recon_order = ex.submitOrder(.buy, 0.1); + const recon_order = ex.submitOrder(.buy, 0.1, 0); try testing.expect(recon_order != null); const pending = recon_order.?; @@ -1387,7 +1496,7 @@ test "integration: reconciled pending sell fills correctly" { strategy.capital = 10000.0; // Reconciled pending sell - const recon_order = ex.submitOrder(.sell, 0.1); + const recon_order = ex.submitOrder(.sell, 0.1, 0); try testing.expect(recon_order != null); const pending = recon_order.?; diff --git a/services/dctrading-bot/src/live_loop.zig b/services/dctrading-bot/src/live_loop.zig index ef4e636d..a7d4c330 100644 --- a/services/dctrading-bot/src/live_loop.zig +++ b/services/dctrading-bot/src/live_loop.zig @@ -84,6 +84,9 @@ pub const PendingOrderEntry = struct { side: exchange_mod.Side, signal_price: f64, size: f64, + requested_size: f64 = 0, + reserved_amount: f64 = 0, + dust_qty_threshold: f64 = 0, transfer_id: u32 = 0, is_deposit_buy: bool = false, entry_price: f64 = 0, @@ -120,6 +123,11 @@ pub const LiveLoop = struct { last_buy_fill: ?struct { price: f64, size: f64, is_deposit: bool } = null, last_sell_fill: ?struct { price: f64, size: f64, pnl: f64, exit_type: types.Trade.ExitType } = null, last_sell_trade: ?types.Trade = null, // for printLiveTrade + last_warning: ?[]const u8 = null, + last_warning_error: []const u8 = "", + last_warning_ts: f64 = 0, + slippage_warn_pct: f64 = 0.01, + slippage_warning_ttl_sec: f64 = 3600, pub fn init(strategy: *Strategy, exchange: Exchange, ledger: ?Ledger) LiveLoop { return .{ .strategy = strategy, @@ -138,6 +146,11 @@ pub const LiveLoop = struct { self.last_buy_fill = null; self.last_sell_fill = null; self.last_sell_trade = null; + if (self.last_warning != null and self.slippage_warning_ttl_sec > 0 and self.last_warning_ts > 0 and t.timestamp - self.last_warning_ts >= self.slippage_warning_ttl_sec) { + self.last_warning = null; + self.last_warning_error = ""; + self.last_warning_ts = 0; + } self.last_price = t.price; // --- Phase 1: Check pending orders (every tick) --- @@ -190,13 +203,16 @@ pub const LiveLoop = struct { if (po.side == .buy) { self.handleBuyFill(po, fill, t); } else { - self.handleSellFill(po, fill); + self.handleSellFill(po, fill, t); } self.removePending(i); continue; // re-check swapped entry }, .cancelled, .failed => { - if (po.side == .buy) self.strategy.capital_reserved -= po.signal_price * po.size; + if (po.side == .buy) { + const reserved_amount = if (po.reserved_amount > 0) po.reserved_amount else po.signal_price * po.size; + self.strategy.capital_reserved -= reserved_amount; + } if (po.transfer_id > 0 and self.ledger != null) self.ledger.?.voidTransfer(po.transfer_id); self.removePending(i); continue; @@ -208,10 +224,14 @@ pub const LiveLoop = struct { } fn handleBuyFill(self: *LiveLoop, po: PendingOrderEntry, fill: exchange_mod.OrderFill, t: Tick) void { - self.strategy.capital_reserved -= po.signal_price * po.size; + const reserved_amount = if (po.reserved_amount > 0) po.reserved_amount else po.signal_price * po.size; + self.strategy.capital_reserved -= reserved_amount; const buy_price = if (fill.fill_price > 0) fill.fill_price else po.signal_price; const buy_size = if (fill.fill_qty > 0) fill.fill_qty else po.size; const fee = self.fillFee(fill, buy_price, buy_size); + const actual_notional = if (fill.quote_amount > 0) fill.quote_amount else buy_price * buy_size; + const unspent = @max(0, reserved_amount - actual_notional); + self.warnOnSlippage("BUY", po.signal_price, buy_price, t.timestamp); if (po.is_deposit_buy) { self.strategy.entry_price = (self.strategy.entry_price * self.strategy.size + buy_price * buy_size) / (self.strategy.size + buy_size); @@ -219,7 +239,6 @@ pub const LiveLoop = struct { self.strategy.capital -= fee; if (buy_price > self.strategy.peak_price) self.strategy.peak_price = buy_price; } else { - const unspent = (po.size - buy_size) * buy_price; self.strategy.capital += unspent; self.strategy.entry_price = buy_price; self.strategy.size = buy_size; @@ -230,7 +249,7 @@ pub const LiveLoop = struct { self.last_buy_fill = .{ .price = buy_price, .size = buy_size, .is_deposit = po.is_deposit_buy }; if (po.transfer_id > 0 and self.ledger != null) { - const buy_cost = buy_price * buy_size; + const buy_cost = actual_notional; const fee_asset_price = self.feeAssetPrice(fill, buy_price); var ud_buf: [256]u8 = undefined; const ud = std.fmt.bufPrint(&ud_buf, "BUY oid={s}", .{po.order_id[0..po.order_id_len]}) catch "BUY"; @@ -239,17 +258,26 @@ pub const LiveLoop = struct { } } - fn handleSellFill(self: *LiveLoop, po: PendingOrderEntry, fill: exchange_mod.OrderFill) void { + fn handleSellFill(self: *LiveLoop, po: PendingOrderEntry, fill: exchange_mod.OrderFill, t: Tick) void { const sell_price = if (fill.fill_price > 0) fill.fill_price else po.signal_price; const sell_fee = self.fillFee(fill, sell_price, po.size); - const pnl = (sell_price - po.entry_price) * po.size - sell_fee; - const price_diff_pnl = (sell_price - po.signal_price) * po.size; - if (price_diff_pnl != 0) self.strategy.capital += price_diff_pnl; + const sell_amount = if (fill.quote_amount > 0) fill.quote_amount else sell_price * po.size; + const pnl = sell_amount - (po.entry_price * po.size) - sell_fee; + self.strategy.capital += pnl - po.pnl; + const requested_size = if (po.requested_size > 0) po.requested_size else po.size; + const dust_size = @max(0, requested_size - po.size); + const dust_threshold = if (po.dust_qty_threshold > 0) po.dust_qty_threshold else 0.00000001; + if (dust_size >= dust_threshold) { + self.strategy.in_position = true; + self.strategy.entry_price = po.entry_price; + self.strategy.size = dust_size; + self.strategy.peak_price = @max(po.entry_price, sell_price); + } + self.warnOnSlippage("SELL", po.signal_price, sell_price, t.timestamp); self.sells_filled += 1; self.last_sell_fill = .{ .price = sell_price, .size = po.size, .pnl = pnl, .exit_type = po.exit_type }; if (po.transfer_id > 0 and self.ledger != null) { - const sell_amount = sell_price * po.size; const fee_asset_price = self.feeAssetPrice(fill, sell_price); const exit_str = switch (po.exit_type) { .dc_exit => "DC", @@ -301,19 +329,34 @@ pub const LiveLoop = struct { return turso_mod.Turso.ACCT_CASH; } + fn warnOnSlippage(self: *LiveLoop, side: []const u8, signal_price: f64, fill_price: f64, timestamp: f64) void { + if (self.slippage_warn_pct <= 0 or signal_price <= 0 or fill_price <= 0) return; + const slip = @abs(fill_price - signal_price) / signal_price; + if (slip >= self.slippage_warn_pct) { + self.last_warning = "EXCHANGE_SLIPPAGE"; + self.last_warning_error = "exchange_slippage_warn_threshold_exceeded"; + self.last_warning_ts = timestamp; + std.debug.print(" [exchange] WARNING: {s} slippage {d:.3}% signal=${d:.2} fill=${d:.2}\n", .{ side, slip * 100, signal_price, fill_price }); + } + } + pub fn submitBuy(self: *LiveLoop, price: f64, size: f64, is_deposit: bool, timestamp: f64) void { - if (self.exchange.submitOrder(.buy, size)) |pending| { + if (self.exchange.submitOrder(.buy, size, price)) |pending| { if (self.pending_count < MAX_PENDING) { + const submitted_size = if (pending.qty > 0) pending.qty else size; + const reserved_amount = if (pending.quote_amount > 0) pending.quote_amount else price * submitted_size; const oid_slice = pending.order_id[0..pending.order_id_len]; var tid: u32 = 0; if (self.ledger != null) { - const cost = price * size; - tid = self.ledger.?.createPendingTransfer(turso_mod.Turso.ACCT_BTC, turso_mod.Turso.ACCT_CASH, cost, turso_mod.Turso.CODE_BUY, "BUY pending", timestamp, price, size, oid_slice) orelse 0; + tid = self.ledger.?.createPendingTransfer(turso_mod.Turso.ACCT_BTC, turso_mod.Turso.ACCT_CASH, reserved_amount, turso_mod.Turso.CODE_BUY, "BUY pending", timestamp, price, submitted_size, oid_slice) orelse 0; } self.pending_orders[self.pending_count] = .{ .side = .buy, .signal_price = price, - .size = size, + .size = submitted_size, + .requested_size = size, + .reserved_amount = reserved_amount, + .dust_qty_threshold = pending.dust_qty_threshold, .is_deposit_buy = is_deposit, .transfer_id = tid, }; @@ -321,7 +364,7 @@ pub const LiveLoop = struct { @memcpy(self.pending_orders[self.pending_count].order_id[0..len], pending.order_id[0..len]); self.pending_orders[self.pending_count].order_id_len = len; self.pending_count += 1; - self.strategy.capital_reserved += price * size; + self.strategy.capital_reserved += reserved_amount; if (is_deposit) { self.deposit_buys_submitted += 1; } else { @@ -334,18 +377,21 @@ pub const LiveLoop = struct { fn submitSell(self: *LiveLoop, trade: Trade, timestamp: f64) void { self.closed_count += 1; self.last_sell_trade = trade; // for printLiveTrade in main.zig - if (self.exchange.submitOrder(.sell, trade.size)) |pending| { + if (self.exchange.submitOrder(.sell, trade.size, trade.exit_price)) |pending| { if (self.pending_count < MAX_PENDING) { + const submitted_size = if (pending.qty > 0) pending.qty else trade.size; const oid_slice = pending.order_id[0..pending.order_id_len]; var tid: u32 = 0; if (self.ledger != null) { - const sell_amt = trade.exit_price * trade.size; - tid = self.ledger.?.createPendingTransfer(turso_mod.Turso.ACCT_CASH, turso_mod.Turso.ACCT_BTC, sell_amt, turso_mod.Turso.CODE_SELL, "SELL pending", timestamp, trade.exit_price, trade.size, oid_slice) orelse 0; + const sell_amt = trade.exit_price * submitted_size; + tid = self.ledger.?.createPendingTransfer(turso_mod.Turso.ACCT_CASH, turso_mod.Turso.ACCT_BTC, sell_amt, turso_mod.Turso.CODE_SELL, "SELL pending", timestamp, trade.exit_price, submitted_size, oid_slice) orelse 0; } self.pending_orders[self.pending_count] = .{ .side = .sell, .signal_price = trade.exit_price, - .size = trade.size, + .size = submitted_size, + .requested_size = trade.size, + .dust_qty_threshold = pending.dust_qty_threshold, .entry_price = trade.entry_price, .pnl = trade.pnl, .exit_type = trade.exit_type, @@ -377,7 +423,8 @@ pub const LiveLoop = struct { if (self.pending_orders[i].transfer_id > 0 and self.ledger != null) { self.ledger.?.voidTransfer(self.pending_orders[i].transfer_id); } - self.strategy.capital_reserved -= self.pending_orders[i].signal_price * self.pending_orders[i].size; + const reserved_amount = if (self.pending_orders[i].reserved_amount > 0) self.pending_orders[i].reserved_amount else self.pending_orders[i].signal_price * self.pending_orders[i].size; + self.strategy.capital_reserved -= reserved_amount; }, } self.cancels_issued += 1; diff --git a/services/dctrading-bot/src/main.zig b/services/dctrading-bot/src/main.zig index 427004ce..fca59c75 100644 --- a/services/dctrading-bot/src/main.zig +++ b/services/dctrading-bot/src/main.zig @@ -4,6 +4,7 @@ const types = @import("types.zig"); const strat_mod = @import("strategy.zig"); const telegram_mod = @import("telegram.zig"); const alpaca_mod = @import("alpaca.zig"); +const binance_spot_mod = @import("binance_spot.zig"); const exchange_mod = @import("exchange.zig"); const http_mod = @import("http_client.zig"); const turso_mod = @import("turso.zig"); @@ -259,12 +260,35 @@ fn runLive(allocator: std.mem.Allocator, io: std.Io, threshold: f64, capital: f6 std.debug.print(" Local checkpoint will be rewritten from restored remote state on next save.\n", .{}); } - // Init exchange (Alpaca paper trading) - const alpaca = alpaca_mod.Alpaca.init(&http) orelse { - std.debug.print("ERROR: Exchange not configured. Set ALPACA_API_KEY + ALPACA_API_SECRET.\n", .{}); + // Init exchange (runtime selectable: alpaca | binance_spot) + var maybe_alpaca: ?alpaca_mod.Alpaca = null; + var maybe_binance_spot: ?binance_spot_mod.BinanceSpot = null; + + const exchange_cfg = getenv("EXCHANGE") orelse "alpaca"; + const exchange_cfg_str = std.mem.sliceTo(exchange_cfg, 0); + const exchange_is_spot = std.mem.eql(u8, exchange_cfg_str, "binance_spot"); + if (!exchange_is_spot and !std.mem.eql(u8, exchange_cfg_str, "alpaca")) { + std.debug.print("ERROR: Unsupported EXCHANGE={s}. Use alpaca or binance_spot.\n", .{exchange_cfg_str}); return; + } + + const exchange = if (exchange_is_spot) blk: { + maybe_binance_spot = binance_spot_mod.BinanceSpot.init(&http); + if (maybe_binance_spot) |*b| { + break :blk b.exchange(); + } else { + std.debug.print("ERROR: Binance Spot not configured. Set BINANCE_API_KEY + BINANCE_API_SECRET.\n", .{}); + return; + } + } else blk: { + maybe_alpaca = alpaca_mod.Alpaca.init(&http); + if (maybe_alpaca) |*a| { + break :blk a.exchange(); + } else { + std.debug.print("ERROR: Alpaca not configured. Set ALPACA_API_KEY + ALPACA_API_SECRET.\n", .{}); + return; + } }; - const exchange = alpaca.exchange(); // Bootstrap or catch-up if (!loaded_checkpoint) { // Fresh start: full bootstrap from 60 days of 1m klines @@ -331,22 +355,27 @@ fn runLive(allocator: std.mem.Allocator, io: std.Io, threshold: f64, capital: f6 } // Reconcile with exchange position (source of truth for execution) - if (exchange.getPosition()) |pos| { - if (pos.qty > 0) { - strategy.in_position = true; - strategy.entry_price = pos.entry_price; - strategy.size = pos.qty; - strategy.peak_price = pos.entry_price; - std.debug.print(" [exchange] Synced position: entry=${d:.2} qty={d:.8}\n", .{ pos.entry_price, pos.qty }); + // For Spot, skip exchange reconciliation — Turso is the source of truth. + if (!exchange_is_spot) { + if (exchange.getPosition()) |pos| { + if (pos.qty > 0) { + strategy.in_position = true; + strategy.entry_price = pos.entry_price; + strategy.size = pos.qty; + strategy.peak_price = pos.entry_price; + std.debug.print(" [exchange] Synced position: entry=${d:.2} qty={d:.8}\n", .{ pos.entry_price, pos.qty }); + } + } else { + // Exchange has no position — if we think we have one, clear it + if (strategy.in_position) { + std.debug.print(" [exchange] No position on exchange, clearing internal state.\n", .{}); + strategy.in_position = false; + strategy.size = 0; + strategy.capital = strategy.initial_capital; + } } } else { - // Exchange has no position — if we think we have one, clear it - if (strategy.in_position) { - std.debug.print(" [exchange] No position on exchange, clearing internal state.\n", .{}); - strategy.in_position = false; - strategy.size = 0; - strategy.capital = strategy.initial_capital; - } + std.debug.print(" [exchange] Spot mode — skipping exchange position reconciliation (Turso is source of truth).\n", .{}); } // --- Pending order tracking --- @@ -477,6 +506,8 @@ fn runLive(allocator: std.mem.Allocator, io: std.Io, threshold: f64, capital: f6 var turso_ledger = if (turso != null) live_loop_mod.TursoLedger{ .turso = &turso.? } else null; const ledger = if (turso_ledger) |*tl| tl.ledger() else null; var loop = live_loop_mod.LiveLoop.init(&strategy, exchange, ledger); + loop.slippage_warn_pct = parseEnvF64("BINANCE_SLIPPAGE_WARN_PCT", 0.01); + loop.slippage_warning_ttl_sec = parseEnvF64("BINANCE_SLIPPAGE_WARN_TTL_SEC", 3600); loop.closed_count = closed_count; // Copy reconciled pending orders into LiveLoop var pi: u8 = 0; @@ -667,7 +698,11 @@ fn runLive(allocator: std.mem.Allocator, io: std.Io, threshold: f64, capital: f6 } } } - turso.?.upsertStatus(t.timestamp, strategy.tick_count, regime_str, strategy.in_position, strategy.entry_price, equity, strategy.capital, unrealized, t.price, uptime_start, instance, symbol_info.tradingSymbol(), symbol_info.baseAsset(), symbol_info.quoteAsset(), symbol_info.markSymbol(), checkpoint_health, checkpoint_error); + const exchange_health = if (maybe_binance_spot) |*b| b.healthStatus() else "OK"; + const exchange_error = if (maybe_binance_spot) |*b| b.healthError() else ""; + const status_exchange_health = if (loop.last_warning) |warning| warning else exchange_health; + const status_exchange_error = if (loop.last_warning != null) loop.last_warning_error else exchange_error; + turso.?.upsertStatus(t.timestamp, strategy.tick_count, regime_str, strategy.in_position, strategy.entry_price, equity, strategy.capital, unrealized, t.price, uptime_start, instance, symbol_info.tradingSymbol(), symbol_info.baseAsset(), symbol_info.quoteAsset(), symbol_info.markSymbol(), checkpoint_health, checkpoint_error, status_exchange_health, status_exchange_error); if (equity_interval or traded) { turso.?.logEquity(t.timestamp, strategy.tick_count, strategy.capital, equity, unrealized, regime_str, t.price); last_equity_ts = t.timestamp; diff --git a/services/dctrading-bot/src/sim_exchange.zig b/services/dctrading-bot/src/sim_exchange.zig index bb20d46d..120d2a24 100644 --- a/services/dctrading-bot/src/sim_exchange.zig +++ b/services/dctrading-bot/src/sim_exchange.zig @@ -15,6 +15,9 @@ pub const SimExchange = struct { fill_delay: u32 = 1, // ticks before order fills (0 = immediate) fill_price_offset: f64 = 0, // slippage: actual = signal + offset partial_fill_ratio: f64 = 1.0, // 1.0 = full fill, 0.5 = half + submitted_qty_ratio: f64 = 1.0, // exchange-side quantity normalization + submitted_quote_amount: f64 = 0, + fill_quote_amount: f64 = 0, fill_commission: f64 = 0, fill_commission_usd: f64 = 0, fill_commission_asset: [8]u8 = undefined, @@ -150,18 +153,19 @@ pub const SimExchange = struct { const id = self.next_order_id; self.next_order_id += 1; + const submitted_qty = qty * self.submitted_qty_ratio; self.orders[self.order_count] = .{ .id = id, .side = side, - .qty = qty, + .qty = submitted_qty, .price = self.last_price, // market price at submission .submit_tick = self.tick_count, }; self.order_count += 1; - self.appendLog(.{ .tick = self.tick_count, .kind = .submit, .order_id = id, .side = side, .qty = qty }); + self.appendLog(.{ .tick = self.tick_count, .kind = .submit, .order_id = id, .side = side, .qty = submitted_qty }); - var pending: PendingOrder = .{ .side = side, .qty = qty }; + var pending: PendingOrder = .{ .side = side, .qty = submitted_qty, .quote_amount = self.submitted_quote_amount }; var id_buf: [16]u8 = undefined; const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{id}) catch "0"; @memcpy(pending.order_id[0..id_str.len], id_str); @@ -169,7 +173,8 @@ pub const SimExchange = struct { return pending; } - fn submitOrder(ptr: *anyopaque, side: Side, qty: f64) ?PendingOrder { + fn submitOrder(ptr: *anyopaque, side: Side, qty: f64, signal_price: f64) ?PendingOrder { + _ = signal_price; const self: *SimExchange = @ptrCast(@alignCast(ptr)); return submitOrderImpl(self, side, qty); } @@ -258,6 +263,7 @@ pub const SimExchange = struct { fn makeFill(self: *const SimExchange, fill_price: f64, fill_qty: f64) OrderFill { var fill: OrderFill = .{ .fill_price = fill_price, .fill_qty = fill_qty, .status = .filled }; + fill.quote_amount = self.fill_quote_amount; if (self.fill_commission > 0 or self.fill_commission_asset_len > 0) { fill.commission = self.fill_commission; fill.commission_usd = self.fill_commission_usd; diff --git a/services/dctrading-bot/src/tests.zig b/services/dctrading-bot/src/tests.zig index 60c35b2f..dd6ddba9 100644 --- a/services/dctrading-bot/src/tests.zig +++ b/services/dctrading-bot/src/tests.zig @@ -1137,7 +1137,7 @@ test "alpaca: parseJsonString returns null for missing key" { // ============================================================ // Shared no-op async stubs for mock exchanges (tests only use sync buy/sell) -fn noopSubmitOrder(_: *const anyopaque, _: exchange_mod.Side, _: f64) ?exchange_mod.PendingOrder { +fn noopSubmitOrder(_: *const anyopaque, _: exchange_mod.Side, _: f64, _: f64) ?exchange_mod.PendingOrder { return null; } fn noopCheckOrder(_: *const anyopaque, _: []const u8) exchange_mod.OrderStatus { @@ -2241,7 +2241,7 @@ test "non-blocking: submitOrder returns immediately, checkOrder resolves after N fn sell(_: *const anyopaque, _: f64) ?exchange_mod.OrderFill { return .{ .fill_price = 95000.0, .fill_qty = 0.01, .status = .filled }; } - fn submitOrder(_: *const anyopaque, side: exchange_mod.Side, qty: f64) ?exchange_mod.PendingOrder { + fn submitOrder(_: *const anyopaque, side: exchange_mod.Side, qty: f64, _: f64) ?exchange_mod.PendingOrder { _ = side; var po: exchange_mod.PendingOrder = .{ .side = .buy, .qty = qty }; const id = "mock-order-001"; @@ -2282,7 +2282,7 @@ test "non-blocking: submitOrder returns immediately, checkOrder resolves after N const ex = exchange_mod.Exchange{ .ptr = @ptrCast(&dummy), .vtable = &AsyncExchange.vtable }; // Submit order — returns immediately - const pending = ex.submitOrder(.buy, 0.01); + const pending = ex.submitOrder(.buy, 0.01, 0); try testing.expect(pending != null); try testing.expectEqualStrings("mock-order-001", pending.?.order_id[0..pending.?.order_id_len]); @@ -2385,7 +2385,7 @@ test "non-blocking: cancelOrder handles race condition (filled before cancel)" { fn sell(_: *const anyopaque, _: f64) ?exchange_mod.OrderFill { return null; } - fn submitOrder(_: *const anyopaque, _: exchange_mod.Side, qty: f64) ?exchange_mod.PendingOrder { + fn submitOrder(_: *const anyopaque, _: exchange_mod.Side, qty: f64, _: f64) ?exchange_mod.PendingOrder { var po: exchange_mod.PendingOrder = .{ .side = .buy, .qty = qty }; const id = "race-order-001"; @memcpy(po.order_id[0..id.len], id); @@ -2416,7 +2416,7 @@ test "non-blocking: cancelOrder handles race condition (filled before cancel)" { const ex = exchange_mod.Exchange{ .ptr = @ptrCast(&dummy), .vtable = &RaceExchange.vtable }; // Submit buy - const pending = ex.submitOrder(.buy, 0.01); + const pending = ex.submitOrder(.buy, 0.01, 0); try testing.expect(pending != null); // Try to cancel — but it already filled @@ -2447,7 +2447,7 @@ test "non-blocking: multiple pending orders tracked independently" { fn sell(_: *const anyopaque, _: f64) ?exchange_mod.OrderFill { return null; } - fn submitOrder(_: *const anyopaque, side: exchange_mod.Side, qty: f64) ?exchange_mod.PendingOrder { + fn submitOrder(_: *const anyopaque, side: exchange_mod.Side, qty: f64, _: f64) ?exchange_mod.PendingOrder { order_count += 1; var po: exchange_mod.PendingOrder = .{ .side = side, .qty = qty }; var id_buf: [20]u8 = undefined; @@ -2484,8 +2484,8 @@ test "non-blocking: multiple pending orders tracked independently" { const ex = exchange_mod.Exchange{ .ptr = @ptrCast(&dummy), .vtable = &MultiExchange.vtable }; // Submit two orders - const order1 = ex.submitOrder(.buy, 0.01).?; - const order2 = ex.submitOrder(.buy, 0.005).?; + const order1 = ex.submitOrder(.buy, 0.01, 0).?; + const order2 = ex.submitOrder(.buy, 0.005, 0).?; // They have different IDs try testing.expect(!std.mem.eql(u8, order1.order_id[0..order1.order_id_len], order2.order_id[0..order2.order_id_len])); @@ -2697,7 +2697,7 @@ test "non-blocking: fill resolves correctly after multiple pending checks" { fn sell(_: *const anyopaque, _: f64) ?exchange_mod.OrderFill { return null; } - fn submitOrder(_: *const anyopaque, side: exchange_mod.Side, qty: f64) ?exchange_mod.PendingOrder { + fn submitOrder(_: *const anyopaque, side: exchange_mod.Side, qty: f64, _: f64) ?exchange_mod.PendingOrder { submitted = true; checks = 0; var po: exchange_mod.PendingOrder = .{ .side = side, .qty = qty }; @@ -2735,7 +2735,7 @@ test "non-blocking: fill resolves correctly after multiple pending checks" { const ex = exchange_mod.Exchange{ .ptr = @ptrCast(&dummy), .vtable = &DelayedExchange.vtable }; // Submit - const pending = ex.submitOrder(.buy, 0.105).?; + const pending = ex.submitOrder(.buy, 0.105, 0).?; try testing.expect(DelayedExchange.submitted); try testing.expectEqualStrings("delayed-001", pending.order_id[0..pending.order_id_len]); @@ -2880,7 +2880,7 @@ test "non-blocking: DC exit cancels pending buys before selling" { fn sell(_: *const anyopaque, _: f64) ?exchange_mod.OrderFill { return null; } - fn submitOrder(_: *const anyopaque, side: exchange_mod.Side, qty: f64) ?exchange_mod.PendingOrder { + fn submitOrder(_: *const anyopaque, side: exchange_mod.Side, qty: f64, _: f64) ?exchange_mod.PendingOrder { submit_count += 1; var po: exchange_mod.PendingOrder = .{ .side = side, .qty = qty }; var id_buf: [20]u8 = undefined; @@ -2963,7 +2963,7 @@ test "non-blocking: DC exit cancels pending buys before selling" { try testing.expectEqual(pending_count, 0); // buy removed // Now submit sell - if (ex.submitOrder(.sell, 0.1)) |pending| { + if (ex.submitOrder(.sell, 0.1, 0)) |pending| { if (pending_count < MAX_PENDING) { pending_orders[pending_count] = .{ .side = .sell, .signal_price = 94000.0, .size = 0.1 }; const len = @min(pending.order_id_len, pending_orders[pending_count].order_id.len); @@ -3029,7 +3029,7 @@ test "non-blocking: multiple orders fill on same tick iteration" { fn sell(_: *const anyopaque, _: f64) ?exchange_mod.OrderFill { return null; } - fn submitOrder(_: *const anyopaque, side: exchange_mod.Side, qty: f64) ?exchange_mod.PendingOrder { + fn submitOrder(_: *const anyopaque, side: exchange_mod.Side, qty: f64, _: f64) ?exchange_mod.PendingOrder { var po: exchange_mod.PendingOrder = .{ .side = side, .qty = qty }; const id = "both-fill"; @memcpy(po.order_id[0..id.len], id); @@ -3416,6 +3416,170 @@ test "resource_monitor: computes ticks per minute from configured interval" { try testing.expectEqual(@as(u64, 1), sample.http_errors); } +// Binance Spot JSON parsing tests +const binance_spot_mod = @import("binance_spot.zig"); +const http_client_mod = @import("http_client.zig"); + +test "binance_spot: parseJsonFloat handles quoted string numbers" { + const json = "{\"price\":\"50000.50\",\"qty\":0.01}"; + try testing.expectApproxEqAbs(@as(f64, 50000.50), binance_spot_mod.BinanceSpot.parseJsonFloat(json, "\"price\":\"").?, 0.001); + try testing.expectApproxEqAbs(@as(f64, 0.01), binance_spot_mod.BinanceSpot.parseJsonFloat(json, "\"qty\":").?, 0.001); +} + +test "binance_spot: parseJsonString extracts orderId" { + const json = "{\"orderId\":12345,\"status\":\"FILLED\"}"; + try testing.expectEqualStrings("12345", binance_spot_mod.BinanceSpot.parseJsonString(json, "\"orderId\":").?); + try testing.expectEqualStrings("FILLED", binance_spot_mod.BinanceSpot.parseJsonString(json, "\"status\":\"").?); +} + +test "binance_spot: parseFillCommission extracts commission from fills" { + const json = "{\"fills\":[{\"price\":\"50000.00\",\"qty\":\"0.01\",\"commission\":\"0.00000100\",\"commissionAsset\":\"BTC\"}]}"; + var commission: f64 = 0; + var asset: [8]u8 = undefined; + var asset_len: usize = 0; + try testing.expect(binance_spot_mod.BinanceSpot.parseFillCommission(json, &commission, &asset, &asset_len)); + try testing.expectApproxEqAbs(@as(f64, 0.00000100), commission, 0.00000001); + try testing.expectEqualStrings("BTC", asset[0..asset_len]); +} + +test "binance_spot: parseTradeCommissions sums same-asset fills" { + const json = "[{\"id\":1,\"orderId\":12345,\"commission\":\"0.00000100\",\"commissionAsset\":\"BNB\"},{\"id\":2,\"orderId\":12345,\"commission\":\"0.00000250\",\"commissionAsset\":\"BNB\"}]"; + var commission: f64 = 0; + var asset: [8]u8 = undefined; + var asset_len: usize = 0; + try testing.expect(binance_spot_mod.BinanceSpot.parseTradeCommissions(json, &commission, &asset, &asset_len)); + try testing.expectApproxEqAbs(@as(f64, 0.00000350), commission, 0.00000001); + try testing.expectEqualStrings("BNB", asset[0..asset_len]); +} + +test "binance_spot: parseTradeCommissions rejects mixed commission assets" { + const json = "[{\"commission\":\"0.00000100\",\"commissionAsset\":\"BTC\"},{\"commission\":\"0.01000000\",\"commissionAsset\":\"USDT\"}]"; + var commission: f64 = 0; + var asset: [8]u8 = undefined; + var asset_len: usize = 0; + try testing.expect(!binance_spot_mod.BinanceSpot.parseTradeCommissions(json, &commission, &asset, &asset_len)); +} + +test "binance_spot: parseSymbolFilters extracts lot size and min notional" { + const json = + \\{"symbols":[{"symbol":"BTCUSDT","filters":[ + \\{"filterType":"PRICE_FILTER","tickSize":"0.01000000"}, + \\{"filterType":"LOT_SIZE","minQty":"0.00001000","maxQty":"9000.00000000","stepSize":"0.00001000"}, + \\{"filterType":"MIN_NOTIONAL","minNotional":"5.00000000"} + \\]}]} + ; + var min_qty: f64 = 0; + var step_size: f64 = 0; + var min_notional: f64 = 0; + try testing.expect(binance_spot_mod.BinanceSpot.parseSymbolFilters(json, &min_qty, &step_size, &min_notional) != null); + try testing.expectApproxEqAbs(@as(f64, 0.00001), min_qty, 0.00000001); + try testing.expectApproxEqAbs(@as(f64, 0.00001), step_size, 0.00000001); + try testing.expectApproxEqAbs(@as(f64, 5.0), min_notional, 0.000001); +} + +test "binance_spot: parseAccountBalance finds asset in balances array" { + const json = "{\"balances\":[{\"asset\":\"BTC\",\"free\":\"0.50000000\",\"locked\":\"0.10000000\"},{\"asset\":\"USDT\",\"free\":\"1000.00\",\"locked\":\"0.00\"}]}"; + const btc = binance_spot_mod.BinanceSpot.parseAccountBalance(json, "BTC"); + try testing.expectApproxEqAbs(@as(f64, 0.60), btc.?, 0.001); + const usdt = binance_spot_mod.BinanceSpot.parseAccountBalance(json, "USDT"); + try testing.expectApproxEqAbs(@as(f64, 1000.0), usdt.?, 0.001); + const missing = binance_spot_mod.BinanceSpot.parseAccountBalance(json, "ETH"); + try testing.expect(missing == null); +} + +test "binance_spot: hexEncode produces correct hex string" { + const bytes = &[_]u8{ 0xAB, 0xCD, 0xEF }; + var out: [6]u8 = undefined; + const hex = binance_spot_mod.hexEncode(bytes, &out); + try testing.expectEqualStrings("abcdef", hex); +} + +test "binance_spot: adapter submits quote buy and fetches myTrades commission" { + const responses = [_]http_client_mod.HttpClient.MockResponse{ + .{ + .method = .POST, + .url_contains = "/api/v3/order?", + .status = .ok, + .body = "{\"orderId\":12345,\"status\":\"NEW\"}", + }, + .{ + .method = .GET, + .url_contains = "/api/v3/order?", + .status = .ok, + .body = "{\"orderId\":12345,\"status\":\"FILLED\",\"executedQty\":\"0.01000000\",\"cummulativeQuoteQty\":\"1.04000000\"}", + }, + .{ + .method = .GET, + .url_contains = "/api/v3/myTrades?", + .status = .ok, + .body = "[{\"orderId\":12345,\"commission\":\"0.00104000\",\"commissionAsset\":\"USDT\"}]", + }, + }; + var transport = http_client_mod.HttpClient.MockTransport{ .responses = responses[0..] }; + var http = http_client_mod.HttpClient{ + .client = undefined, + .allocator = testing.allocator, + .io = undefined, + .mock = &transport, + }; + var client = binance_spot_mod.BinanceSpot.initForTest(&http, "key", "secret", "BTC/USD", "https://binance.test"); + client.min_qty = 0.00001; + client.step_size = 0.00001; + client.min_notional = 1; + + const pending = client.submitOrderAsync(.buy, 0.01, 104.0).?; + try testing.expectEqualStrings("12345", pending.order_id[0..pending.order_id_len]); + try testing.expectApproxEqAbs(@as(f64, 1.04), pending.quote_amount, 0.000001); + try testing.expect(std.mem.indexOf(u8, transport.requestUrl(0), "quoteOrderQty=1.04000000") != null); + + const status = client.checkOrderStatus(pending.order_id[0..pending.order_id_len]); + const fill = switch (status) { + .filled => |f| f, + else => return error.ExpectedFilled, + }; + try testing.expectApproxEqAbs(@as(f64, 1.04), fill.quote_amount, 0.000001); + try testing.expectApproxEqAbs(@as(f64, 104.0), fill.fill_price, 0.000001); + try testing.expectApproxEqAbs(@as(f64, 0.00104), fill.commission, 0.00000001); + try testing.expectApproxEqAbs(@as(f64, 0.00104), fill.commission_usd, 0.00000001); + try testing.expectEqualStrings("USDT", fill.commission_asset[0..fill.commission_asset_len]); + try testing.expect(std.mem.indexOf(u8, transport.requestUrl(2), "/api/v3/myTrades?") != null); +} + +test "binance_spot: adapter persists reconcile health after fee lookup timeout" { + const responses = [_]http_client_mod.HttpClient.MockResponse{ + .{ .method = .GET, .url_contains = "/api/v3/order?", .status = .ok, .body = "{\"orderId\":12345,\"status\":\"FILLED\",\"executedQty\":\"0.01000000\",\"cummulativeQuoteQty\":\"1.04000000\"}" }, + .{ .method = .GET, .url_contains = "/api/v3/myTrades?", .status = .ok, .body = "[]" }, + .{ .method = .GET, .url_contains = "/api/v3/myTrades?", .status = .ok, .body = "[]" }, + .{ .method = .GET, .url_contains = "/api/v3/myTrades?", .status = .ok, .body = "[]" }, + .{ .method = .GET, .url_contains = "/api/v3/order?", .status = .ok, .body = "{\"orderId\":12345,\"status\":\"FILLED\",\"executedQty\":\"0.01000000\",\"cummulativeQuoteQty\":\"1.04000000\"}" }, + .{ .method = .GET, .url_contains = "/api/v3/myTrades?", .status = .ok, .body = "[]" }, + .{ .method = .GET, .url_contains = "/api/v3/myTrades?", .status = .ok, .body = "[]" }, + .{ .method = .GET, .url_contains = "/api/v3/myTrades?", .status = .ok, .body = "[]" }, + }; + var transport = http_client_mod.HttpClient.MockTransport{ .responses = responses[0..] }; + var http = http_client_mod.HttpClient{ + .client = undefined, + .allocator = testing.allocator, + .io = undefined, + .mock = &transport, + }; + var client = binance_spot_mod.BinanceSpot.initForTest(&http, "key", "secret", "BTC/USD", "https://binance.test"); + client.fee_missing_max_checks = 2; + + switch (client.checkOrderStatus("12345")) { + .pending => {}, + else => return error.ExpectedPending, + } + const status = client.checkOrderStatus("12345"); + const fill = switch (status) { + .filled => |f| f, + else => return error.ExpectedFilled, + }; + try testing.expectEqual(@as(usize, 0), fill.commission_asset_len); + try testing.expectEqualStrings("EXCHANGE_RECONCILE", client.healthStatus()); + try testing.expectEqualStrings("binance_fee_lookup_timeout_manual_reconciliation_required", client.healthError()); +} + // Integration tests (LiveLoop + SimExchange + SimFeed) comptime { _ = @import("integration_tests.zig"); diff --git a/services/dctrading-bot/src/turso.zig b/services/dctrading-bot/src/turso.zig index 01406343..53495dcc 100644 --- a/services/dctrading-bot/src/turso.zig +++ b/services/dctrading-bot/src/turso.zig @@ -70,7 +70,7 @@ pub const Turso = struct { const sql_core = \\{"requests": [ \\ {"type": "execute", "stmt": {"sql": "CREATE TABLE IF NOT EXISTS equity_log (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL, tick_count INTEGER, capital REAL, equity REAL, unrealized REAL, regime TEXT, price REAL, created_at TEXT DEFAULT (datetime('now')))"}}, - \\ {"type": "execute", "stmt": {"sql": "CREATE TABLE IF NOT EXISTS bot_status (id INTEGER PRIMARY KEY CHECK (id = 1), status TEXT, last_tick REAL, tick_count INTEGER, regime TEXT, in_position INTEGER, entry_price REAL, equity REAL, capital REAL, unrealized REAL, price REAL, uptime_start REAL, version TEXT, trading_symbol TEXT DEFAULT 'BTC/USD', base_asset TEXT DEFAULT 'BTC', quote_asset TEXT DEFAULT 'USD', mark_symbol TEXT DEFAULT 'BTCUSDT', checkpoint_health TEXT DEFAULT 'OK', checkpoint_error TEXT DEFAULT '', resource_health TEXT DEFAULT 'OK', resource_error TEXT DEFAULT '', resource_rss_mb REAL DEFAULT 0, resource_disk_free_mb REAL DEFAULT 0, resource_disk_used_pct REAL DEFAULT 0, resource_feed_gap_sec REAL DEFAULT 0, resource_ws_lag_sec REAL DEFAULT 0, resource_http_errors INTEGER DEFAULT 0, resource_http_max_ms REAL DEFAULT 0, updated_at TEXT DEFAULT (datetime('now')))"}}, + \\ {"type": "execute", "stmt": {"sql": "CREATE TABLE IF NOT EXISTS bot_status (id INTEGER PRIMARY KEY CHECK (id = 1), status TEXT, last_tick REAL, tick_count INTEGER, regime TEXT, in_position INTEGER, entry_price REAL, equity REAL, capital REAL, unrealized REAL, price REAL, uptime_start REAL, version TEXT, trading_symbol TEXT DEFAULT 'BTC/USD', base_asset TEXT DEFAULT 'BTC', quote_asset TEXT DEFAULT 'USD', mark_symbol TEXT DEFAULT 'BTCUSDT', checkpoint_health TEXT DEFAULT 'OK', checkpoint_error TEXT DEFAULT '', exchange_health TEXT DEFAULT 'OK', exchange_error TEXT DEFAULT '', resource_health TEXT DEFAULT 'OK', resource_error TEXT DEFAULT '', resource_rss_mb REAL DEFAULT 0, resource_disk_free_mb REAL DEFAULT 0, resource_disk_used_pct REAL DEFAULT 0, resource_feed_gap_sec REAL DEFAULT 0, resource_ws_lag_sec REAL DEFAULT 0, resource_http_errors INTEGER DEFAULT 0, resource_http_max_ms REAL DEFAULT 0, updated_at TEXT DEFAULT (datetime('now')))"}}, \\ {"type": "execute", "stmt": {"sql": "CREATE TABLE IF NOT EXISTS checkpoint_backups (id INTEGER PRIMARY KEY CHECK (id = 1), path TEXT NOT NULL, data_base64 TEXT NOT NULL, byte_len INTEGER NOT NULL, checksum TEXT NOT NULL DEFAULT '', tick_count INTEGER NOT NULL, updated_at TEXT DEFAULT (datetime('now')))"}}, \\ {"type": "execute", "stmt": {"sql": "CREATE TABLE IF NOT EXISTS resource_log (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL NOT NULL, uptime_sec REAL NOT NULL, rss_mb REAL NOT NULL, cpu_sec REAL NOT NULL, disk_path TEXT NOT NULL, disk_free_mb REAL NOT NULL, disk_used_pct REAL NOT NULL, ticks_per_min REAL NOT NULL, feed_gap_sec REAL NOT NULL, ws_lag_sec REAL NOT NULL, reconnect_count INTEGER NOT NULL, http_requests INTEGER NOT NULL, http_errors INTEGER NOT NULL, http_retries INTEGER NOT NULL, http_last_ms REAL NOT NULL, http_max_ms REAL NOT NULL, resource_health TEXT NOT NULL, resource_error TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')))"}}, \\ {"type": "execute", "stmt": {"sql": "CREATE INDEX IF NOT EXISTS idx_resource_log_timestamp ON resource_log(timestamp)"}}, @@ -118,6 +118,12 @@ pub const Turso = struct { self.execSyncSilent( \\{"requests": [{"type": "execute", "stmt": {"sql": "ALTER TABLE bot_status ADD COLUMN checkpoint_error TEXT DEFAULT ''"}}]} ); + self.execSyncSilent( + \\{"requests": [{"type": "execute", "stmt": {"sql": "ALTER TABLE bot_status ADD COLUMN exchange_health TEXT DEFAULT 'OK'"}}]} + ); + self.execSyncSilent( + \\{"requests": [{"type": "execute", "stmt": {"sql": "ALTER TABLE bot_status ADD COLUMN exchange_error TEXT DEFAULT ''"}}]} + ); self.execSyncSilent( \\{"requests": [{"type": "execute", "stmt": {"sql": "ALTER TABLE bot_status ADD COLUMN resource_health TEXT DEFAULT 'OK'"}}]} ); @@ -215,12 +221,12 @@ pub const Turso = struct { } /// Upsert bot status (async, every tick). - pub fn upsertStatus(self: *const Turso, last_tick: f64, tick_count: u64, regime: []const u8, in_position: bool, entry_price: f64, equity: f64, capital: f64, unrealized: f64, price: f64, uptime_start: f64, instance: []const u8, trading_symbol: []const u8, base_asset: []const u8, quote_asset: []const u8, mark_symbol: []const u8, checkpoint_health: []const u8, checkpoint_error: []const u8) void { + pub fn upsertStatus(self: *const Turso, last_tick: f64, tick_count: u64, regime: []const u8, in_position: bool, entry_price: f64, equity: f64, capital: f64, unrealized: f64, price: f64, uptime_start: f64, instance: []const u8, trading_symbol: []const u8, base_asset: []const u8, quote_asset: []const u8, mark_symbol: []const u8, checkpoint_health: []const u8, checkpoint_error: []const u8, exchange_health: []const u8, exchange_error: []const u8) void { var buf: [8192]u8 = undefined; var ver_buf: [64]u8 = undefined; const ver = std.fmt.bufPrint(&ver_buf, "DCTRADE5@{s}", .{instance}) catch "DCTRADE5"; const sql = std.fmt.bufPrint(&buf, - \\{{"requests": [{{"type": "execute", "stmt": {{"sql": "INSERT INTO bot_status (id, status, last_tick, tick_count, regime, in_position, entry_price, equity, capital, unrealized, price, uptime_start, version, trading_symbol, base_asset, quote_asset, mark_symbol, checkpoint_health, checkpoint_error) VALUES (1, 'RUNNING', {d:.6}, {d}, '{s}', {d}, {d:.8}, {d:.2}, {d:.2}, {d:.2}, {d:.2}, {d:.6}, '{s}', '{s}', '{s}', '{s}', '{s}', '{s}', '{s}') ON CONFLICT(id) DO UPDATE SET status=excluded.status, last_tick=excluded.last_tick, tick_count=excluded.tick_count, regime=excluded.regime, in_position=excluded.in_position, entry_price=excluded.entry_price, equity=excluded.equity, capital=excluded.capital, unrealized=excluded.unrealized, price=excluded.price, version=excluded.version, trading_symbol=excluded.trading_symbol, base_asset=excluded.base_asset, quote_asset=excluded.quote_asset, mark_symbol=excluded.mark_symbol, checkpoint_health=excluded.checkpoint_health, checkpoint_error=excluded.checkpoint_error, updated_at=datetime('now')"}}}}]}} + \\{{"requests": [{{"type": "execute", "stmt": {{"sql": "INSERT INTO bot_status (id, status, last_tick, tick_count, regime, in_position, entry_price, equity, capital, unrealized, price, uptime_start, version, trading_symbol, base_asset, quote_asset, mark_symbol, checkpoint_health, checkpoint_error, exchange_health, exchange_error) VALUES (1, 'RUNNING', {d:.6}, {d}, '{s}', {d}, {d:.8}, {d:.2}, {d:.2}, {d:.2}, {d:.2}, {d:.6}, '{s}', '{s}', '{s}', '{s}', '{s}', '{s}', '{s}', '{s}', '{s}') ON CONFLICT(id) DO UPDATE SET status=excluded.status, last_tick=excluded.last_tick, tick_count=excluded.tick_count, regime=excluded.regime, in_position=excluded.in_position, entry_price=excluded.entry_price, equity=excluded.equity, capital=excluded.capital, unrealized=excluded.unrealized, price=excluded.price, version=excluded.version, trading_symbol=excluded.trading_symbol, base_asset=excluded.base_asset, quote_asset=excluded.quote_asset, mark_symbol=excluded.mark_symbol, checkpoint_health=excluded.checkpoint_health, checkpoint_error=excluded.checkpoint_error, exchange_health=excluded.exchange_health, exchange_error=excluded.exchange_error, updated_at=datetime('now')"}}}}]}} , .{ last_tick, tick_count, @@ -239,6 +245,8 @@ pub const Turso = struct { mark_symbol, checkpoint_health, checkpoint_error, + exchange_health, + exchange_error, }) catch return; self.execAsync(sql); }