Repository navigation
Expand file tree
/
Copy pathmain_binance.py
More file actions
370 lines (312 loc) · 15 KB
/
Copy pathmain_binance.py
File metadata and controls
370 lines (312 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
"""
Entry point for the Binance TRIANGULAR ARBITRAGE bot (Phase 1: dry-run).
Separate from ``main.py`` (Polymarket) — the two strategies share the common
infrastructure (structured logging, Telegram, alerts) but run independent loops
and configs. This loop:
connect Binance → fetch order books → detect triangular arb →
(dry-run) execute best cycle leg-by-leg → log + Telegram alert → repeat
Phase 1 places NO real orders. Real trading (Phase 2) is intentionally blocked
in ``BinanceTriangularExecutor`` until explicitly authorised.
Usage
-----
python main_binance.py # dry-run loop (DRY_RUN from .env)
python main_binance.py --dry-run # force dry-run
python main_binance.py --once # one scan cycle then exit
python main_binance.py --balance # print tracked balances then exit
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import signal
import sys
from pathlib import Path
from typing import Optional
import yaml
from dotenv import load_dotenv
from broker.binance_order_executor import BinanceTriangularExecutor, CycleResult
from core.binance_capital_manager import BinanceCapitalManager
from core.triangular_arb_detector import TriangularArbDetector, TriangularOpportunity
from data.binance_client import BinanceClient
from monitoring.alerts import AlertManager, AlertLevel
from monitoring.logger import StructuredLogger
from monitoring.telegram_reporter import TelegramReporter
logger = logging.getLogger(__name__)
# All state files are anchored to the project directory, not the CWD (same
# convention as main.py) so the bot finds .env / config from any launch dir.
_PROJECT_ROOT = Path(__file__).resolve().parent
# ── Config ────────────────────────────────────────────────────────────────────
def load_config(config_path: "str | Path | None" = None) -> dict:
path = (
Path(config_path) if config_path
else _PROJECT_ROOT / "config" / "settings_binance.yaml"
)
if not path.exists():
raise FileNotFoundError(f"Config file not found: {path.absolute()}")
with path.open(encoding="utf-8") as fh:
return yaml.safe_load(fh)
def effective_fee_pct(config: dict) -> float:
"""Per-leg taker fee, honouring the optional BNB discount."""
ex = config["exchange"]
if ex.get("use_bnb_discount"):
return float(ex.get("bnb_taker_fee_pct", ex["taker_fee_pct"]))
return float(ex["taker_fee_pct"])
def build_detector_config(config: dict) -> dict:
"""Flatten the detection/sizing/fee settings into the detector's dict."""
det = config["detection"]
conf = det.get("confidence", {})
return {
"min_profit_pct": det["min_profit_pct"],
"fee_pct": effective_fee_pct(config),
"start_amount_quote": config["sizing"]["start_amount_quote"],
"evaluate_both_directions": det.get("evaluate_both_directions", True),
"high_confidence_pct": conf.get("high_pct", 0.005),
"medium_confidence_pct": conf.get("medium_pct", 0.002),
"min_leg_liquidity_quote": det.get("min_leg_liquidity_quote", 0.0),
"require_full_size": det.get("require_full_size", False),
}
# ── Logging ───────────────────────────────────────────────────────────────────
def _configure_logging(config: dict) -> None:
mon = config.get("monitoring", {})
log_dir = Path(mon.get("log_dir", "logs"))
if not log_dir.is_absolute():
log_dir = _PROJECT_ROOT / log_dir
StructuredLogger(
name="binance_arb",
log_dir=str(log_dir),
max_file_size_mb=float(mon.get("max_file_size_mb", 10)),
backup_count=int(mon.get("backup_count", 30)),
console=True,
).setup()
# ── Alerting ──────────────────────────────────────────────────────────────────
def _format_opportunity_msg(opp: TriangularOpportunity, dry_run: bool) -> str:
# ASCII-only: this string is both sent to Telegram AND logged via
# AlertManager, and the log lands on a Windows cp1252 console/redirect.
mode = "DRY RUN" if dry_run else "LIVE"
return (
f"ARBITRAGGIO TRIANGOLARE [{mode}]\n"
f"Ciclo: {opp.direction}\n"
f"Profitto netto: {opp.net_profit_pct * 100:.4f}% "
f"(~{opp.est_profit_quote:.4f} su {opp.start_amount:.0f})\n"
f"Confidence: {opp.confidence}\n"
f"Fee: {opp.fee_pct * 100:.3f}% x {len(opp.legs)} leg"
)
async def _announce_opportunity(
opp: TriangularOpportunity,
telegram: TelegramReporter,
alerts: AlertManager,
dry_run: bool,
) -> None:
"""Rate-limited alert (Telegram + structured log) for a detected cycle."""
msg = _format_opportunity_msg(opp, dry_run)
# Dedup key per cycle+direction so distinct triangles alert independently.
event = f"tri_opp:{opp.cycle_name}:{opp.direction}"
dispatched = await alerts.send(event, msg, AlertLevel.INFO,
net_profit_pct=opp.net_profit_pct)
if dispatched:
await telegram.send(msg)
# ── Main loop ─────────────────────────────────────────────────────────────────
async def run_bot(config: dict, dry_run: bool, once: bool = False) -> None:
"""Startup → scan → detect → (dry-run) execute → alert → repeat."""
if dry_run:
print("\n" + "=" * 60)
print(" BINANCE TRIANGULAR ARB — DRY RUN (no orders placed)")
print("=" * 60 + "\n")
else:
# Phase 2 guard at the entry point (the executor also blocks this).
print(
"\n[BLOCCATO] Il trading reale su Binance (Fase 2) non è abilitato.\n"
"Serve un'autorizzazione esplicita in chat prima di piazzare ordini reali.\n"
"Riavvia con --dry-run (o DRY_RUN=true).\n",
file=sys.stderr,
)
sys.exit(1)
cycles: list[dict] = config["cycles"]
quote_asset = config.get("capital", {}).get("quote_asset", "USDT")
scan_interval = float(config["markets"]["scan_interval_seconds"])
client = BinanceClient(
cycles,
dry_run=dry_run,
api_key=os.getenv("BINANCE_API_KEY", ""),
api_secret=os.getenv("BINANCE_API_SECRET", ""),
request_timeout_seconds=float(
config["exchange"].get("request_timeout_seconds", 10)
),
orderbook_depth=int(config["markets"].get("orderbook_depth", 5)),
)
detector = TriangularArbDetector(build_detector_config(config))
executor = BinanceTriangularExecutor(
client,
{"max_slippage_pct": config["execution"].get("max_slippage_pct", 0.001),
"fee_pct": effective_fee_pct(config)},
dry_run=dry_run,
)
initial_balance = float(os.getenv("DRY_RUN_INITIAL_BALANCE_USDT", "1000.0"))
capital = BinanceCapitalManager.from_disk(
quote_asset=quote_asset,
initial_quote_balance=initial_balance,
)
telegram = TelegramReporter(dry_run=dry_run)
alerts = AlertManager(
rate_limit_minutes=config["monitoring"].get("alert_rate_limit_minutes", 5),
)
shutdown_event = asyncio.Event()
def _on_signal(sig: int, _frame: object) -> None:
logger.info("shutdown_signal received sig=%d", sig)
shutdown_event.set()
signal.signal(signal.SIGINT, _on_signal)
signal.signal(signal.SIGTERM, _on_signal)
try:
try:
await client.connect()
except Exception as exc:
# A startup network / geo-block issue shouldn't dump a traceback:
# log a clear message and exit non-zero so a supervising watchdog
# (e.g. the Windows Scheduled Task) can restart and retry.
logger.error("binance_connect_failed: %s", exc)
print(
f"\n[ERRORE] Impossibile connettersi a Binance: {exc}\n"
"Verifica la connessione di rete e riprova.\n",
file=sys.stderr,
)
return
# With read-only keys, reconcile the tracked balance to the real one.
real_free = await client.fetch_free_balance()
if real_free is not None:
capital.seed_from_real_balance(real_free)
tradeable = sorted(client.tradeable_symbols)
print(
f" Mode: {'DRY RUN' if dry_run else 'LIVE'}\n"
f" Cycles: {len(cycles)}\n"
f" Tradeable syms: {len(tradeable)} {tradeable}\n"
f" Quote asset: {quote_asset}\n"
f" Balance: {capital.operational_balance:.2f} {quote_asset}\n"
f" Fee/leg: {effective_fee_pct(config) * 100:.3f}%\n"
f" Min profit: {config['detection']['min_profit_pct'] * 100:.4f}%\n"
f" Scan interval: {scan_interval}s\n"
)
logger.info(
"binance_system_online dry_run=%s cycles=%d tradeable=%d balance=%.2f",
dry_run, len(cycles), len(tradeable), capital.operational_balance,
)
await telegram.send(
f"BOT TRIANGOLARE AVVIATO — {'DRY RUN' if dry_run else 'LIVE'}\n"
f"Cicli: {len(cycles)} | Mercati: {len(tradeable)}\n"
f"Balance: {capital.operational_balance:.2f} {quote_asset}\n"
f"Profitto minimo: {config['detection']['min_profit_pct'] * 100:.4f}%"
)
telegram.start_polling()
while not shutdown_event.is_set():
try:
books = await client.fetch_books()
if not books:
logger.warning("binance_no_books — retrying next cycle")
await asyncio.sleep(scan_interval)
if once:
break
continue
opportunities = detector.scan(cycles, books)
for opp in opportunities:
logger.info(
"tri_opportunity dir=%s net=%.4f%% confidence=%s",
opp.direction, opp.net_profit_pct * 100, opp.confidence,
)
# Act on the single best opportunity this cycle.
for opp in opportunities[:1]:
await _announce_opportunity(opp, telegram, alerts, dry_run)
result: CycleResult = await executor.execute(opp)
if result.status in ("filled", "unwound"):
capital.on_cycle_executed(result.realized_profit_quote)
logger.info(
"tri_cycle_result status=%s realized=%.6f %s balance=%.4f",
result.status, result.realized_profit_quote, quote_asset,
capital.operational_balance,
)
if once:
break
await asyncio.sleep(
float(config["markets"]["scan_interval_seconds"])
)
except (asyncio.CancelledError, KeyboardInterrupt):
raise
except Exception:
logger.exception("binance_main_loop_error — retry next cycle")
await asyncio.sleep(scan_interval)
finally:
telegram.stop_polling()
capital.save()
await client.close()
logger.info("binance_system_offline")
_print_summary(capital, quote_asset)
def _print_summary(capital: BinanceCapitalManager, quote_asset: str) -> None:
print("\n" + "=" * 60)
print("BINANCE TRIANGULAR — SESSION SUMMARY")
print("=" * 60)
print(f" Cycles executed: {capital.cycles_executed}")
print(f" Realized P&L: {capital.total_profit_realized:+.6f} {quote_asset}")
print(f" Balance: {capital.operational_balance:.4f} {quote_asset}")
print("=" * 60 + "\n")
# ── Balance subcommand ────────────────────────────────────────────────────────
async def cmd_balance(config: dict, dry_run: bool) -> None:
"""Print tracked balances (and real balances if keys present), then exit."""
quote_asset = config.get("capital", {}).get("quote_asset", "USDT")
client = BinanceClient(
config["cycles"],
dry_run=dry_run,
api_key=os.getenv("BINANCE_API_KEY", ""),
api_secret=os.getenv("BINANCE_API_SECRET", ""),
)
capital = BinanceCapitalManager.from_disk(
quote_asset=quote_asset,
initial_quote_balance=float(os.getenv("DRY_RUN_INITIAL_BALANCE_USDT", "1000.0")),
)
try:
await client.connect()
real = await client.fetch_free_balance()
finally:
await client.close()
print(f"\nTracked balance: {capital.operational_balance:.4f} {quote_asset}")
print(f"Cycles executed: {capital.cycles_executed}")
print(f"Realized P&L: {capital.total_profit_realized:+.6f} {quote_asset}")
if real is not None:
print("\nReal free balance (read-only):")
for asset, amt in sorted(real.items()):
print(f" {asset:>6}: {amt}")
else:
print("\n(no API keys configured — real balance unavailable)")
print()
# ── CLI ───────────────────────────────────────────────────────────────────────
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="binance-tri-arb",
description="Binance triangular arbitrage bot (Phase 1: dry-run)",
)
parser.add_argument("--dry-run", action="store_true",
help="Force dry run regardless of DRY_RUN env var")
parser.add_argument("--once", action="store_true",
help="Run exactly one scan cycle then exit")
parser.add_argument("--balance", action="store_true",
help="Print tracked/real balances then exit")
parser.add_argument("--config", metavar="PATH",
help="Path to settings_binance.yaml")
return parser
def main() -> None:
load_dotenv(_PROJECT_ROOT / ".env")
parser = _build_parser()
args = parser.parse_args()
config = load_config(args.config)
_configure_logging(config)
dry_run = args.dry_run or (os.getenv("DRY_RUN", "true").lower() == "true")
logger.info(
"binance_config_loaded dry_run=%s cycles=%d fee=%.4f%% min_profit=%.4f%%",
dry_run, len(config["cycles"]),
effective_fee_pct(config) * 100,
config["detection"]["min_profit_pct"] * 100,
)
if args.balance:
asyncio.run(cmd_balance(config, dry_run))
else:
asyncio.run(run_bot(config, dry_run=dry_run, once=args.once))
if __name__ == "__main__":
main()