binance-quant-engine 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- binance_quant_engine/__init__.py +3 -0
- binance_quant_engine/backtest/__init__.py +1 -0
- binance_quant_engine/backtest/vectorized.py +177 -0
- binance_quant_engine/data/__init__.py +1 -0
- binance_quant_engine/data/cache.py +540 -0
- binance_quant_engine/data/klines.py +43 -0
- binance_quant_engine/execution/__init__.py +1 -0
- binance_quant_engine/execution/algo_api.py +84 -0
- binance_quant_engine/execution/brackets.py +973 -0
- binance_quant_engine/execution/host.py +65 -0
- binance_quant_engine/execution/utils.py +46 -0
- binance_quant_engine/mcp/__init__.py +1 -0
- binance_quant_engine/mcp/server.py +115 -0
- binance_quant_engine/strategy/__init__.py +1 -0
- binance_quant_engine/strategy/demo_squeeze.py +183 -0
- binance_quant_engine/strategy/protocol.py +95 -0
- binance_quant_engine-0.1.1.dist-info/METADATA +159 -0
- binance_quant_engine-0.1.1.dist-info/RECORD +21 -0
- binance_quant_engine-0.1.1.dist-info/WHEEL +4 -0
- binance_quant_engine-0.1.1.dist-info/entry_points.txt +3 -0
- binance_quant_engine-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Host interface for execution mixins.
|
|
2
|
+
|
|
3
|
+
The live trading bot is composed from mixins (``BracketMixin`` etc.) that are
|
|
4
|
+
mixed into a single ``Scalper`` class. Each mixin needs to call methods that
|
|
5
|
+
live on *sibling* mixins or on the composed host. To type-check a mixin in
|
|
6
|
+
isolation — without importing the concrete (and strategy-specific) host class —
|
|
7
|
+
we declare the surface it relies on as a ``Protocol``.
|
|
8
|
+
|
|
9
|
+
This is the strategy-agnostic slice of that interface: only the order-placement
|
|
10
|
+
and bookkeeping members the bracket manager actually touches. Concrete trailing
|
|
11
|
+
parameters come from :class:`TrailConfig` rather than any proprietary strategy
|
|
12
|
+
config, so the bracket logic is fully decoupled from alpha.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
21
|
+
from binance_quant_engine.strategy.protocol import TradingStrategy
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class TrailConfig:
|
|
26
|
+
"""Trailing-stop geometry, expressed as fractions of entry price.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
trail_distance: Callback distance the stop trails behind the peak
|
|
30
|
+
(e.g. ``0.005`` = 0.5%). Binance requires callbackRate >= 0.5%.
|
|
31
|
+
trail_activate: Unrealised gain at which the trailing stop arms
|
|
32
|
+
(e.g. ``0.02`` = +2%).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
trail_distance: float = 0.005
|
|
36
|
+
trail_activate: float = 0.02
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ScalperProtocol(Protocol):
|
|
40
|
+
"""Host surface used by :class:`BracketMixin` (strategy-agnostic)."""
|
|
41
|
+
|
|
42
|
+
# ── state ────────────────────────────────────────────────────────
|
|
43
|
+
paper_mode: bool
|
|
44
|
+
client: Any
|
|
45
|
+
discord: Any
|
|
46
|
+
strategy: "TradingStrategy"
|
|
47
|
+
trail_config: TrailConfig
|
|
48
|
+
_algo_api: Any
|
|
49
|
+
|
|
50
|
+
# ── exchange helpers (provided by other mixins) ──────────────────
|
|
51
|
+
def get_symbol_info(self, symbol: str) -> dict[str, Any]: ...
|
|
52
|
+
def _round_qty(self, qty: float, symbol: str) -> float: ...
|
|
53
|
+
def _save_state(self) -> None: ...
|
|
54
|
+
|
|
55
|
+
def _place_algo_order(self, *args: Any, **kwargs: Any) -> Any: ...
|
|
56
|
+
def _cancel_server_order(self, symbol: str, order_id: str, label: str = "order") -> bool: ...
|
|
57
|
+
def _cancel_server_stop_loss(self, symbol: str, order_id: str) -> bool: ...
|
|
58
|
+
def _verify_algo_order_active(self, algo_id: str, symbol: str) -> str | None: ...
|
|
59
|
+
def _place_trailing_stop_market(self, *args: Any, **kwargs: Any) -> Any: ...
|
|
60
|
+
def _cancel_trailing_stop_market(self, symbol: str, order_id: str) -> bool: ...
|
|
61
|
+
def _check_trail_order_status(self, symbol: str, order_id: str) -> dict[str, Any] | None: ...
|
|
62
|
+
def _check_order_filled(self, symbol: str, order_id: str) -> dict[str, Any] | None: ...
|
|
63
|
+
def _lookup_actual_fill(
|
|
64
|
+
self, symbol: str, expected_qty: str, order_type: str
|
|
65
|
+
) -> dict[str, Any] | None: ...
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Small, strategy-agnostic helpers shared across the execution layer.
|
|
2
|
+
|
|
3
|
+
Kept separate from :mod:`brackets` so they can be unit-tested (and reused by
|
|
4
|
+
a host application) without pulling in the full bracket-order state machine.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
#: Binance algo/conditional-order statuses that mean the order is no longer
|
|
12
|
+
#: live and bookkeeping can stop tracking it. Centralized here so a status
|
|
13
|
+
#: string Binance adds later only needs to be taught to one place.
|
|
14
|
+
#:
|
|
15
|
+
#: Includes both the US and UK spellings of "cancelled" — Binance's own API
|
|
16
|
+
#: is inconsistent about which one a given endpoint returns.
|
|
17
|
+
ALGO_ORDER_DEAD_STATUSES = frozenset(
|
|
18
|
+
{"CANCELLED", "CANCELED", "EXPIRED", "USER_CANCELLED", "ERROR", "REJECTED"}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def round_to_tick(price: float, tick_size: float, price_precision: int) -> float:
|
|
23
|
+
"""Snap *price* to the symbol's tick grid.
|
|
24
|
+
|
|
25
|
+
Binance rejects (or silently misprices) an order whose price isn't an
|
|
26
|
+
exact multiple of the symbol's ``tickSize`` — a raw float division
|
|
27
|
+
(``price / tick_size``) almost never lands exactly on the grid because of
|
|
28
|
+
binary floating-point rounding. Round to the nearest tick first, then to
|
|
29
|
+
the symbol's quoted decimal precision, in that order.
|
|
30
|
+
"""
|
|
31
|
+
if tick_size <= 0:
|
|
32
|
+
return round(price, price_precision)
|
|
33
|
+
return round(round(price / tick_size) * tick_size, price_precision)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def to_api_symbol(symbol: str, position: dict[str, Any] | None = None) -> str:
|
|
37
|
+
"""Strip a colon-suffixed compound key down to the bare Binance API symbol.
|
|
38
|
+
|
|
39
|
+
Two unrelated conventions both tack a suffix onto the symbol with ``:``,
|
|
40
|
+
and both need the same fix before a raw Binance REST call: a CCXT-style
|
|
41
|
+
``BASE/QUOTE:SETTLE`` key (e.g. ``"BTC/USDT:USDT"``), and a Hedge Mode
|
|
42
|
+
position key (e.g. ``"BTCUSDT:LONG"``). *position*, if given, may carry an
|
|
43
|
+
already-resolved ``symbol`` field that takes precedence over *symbol*.
|
|
44
|
+
"""
|
|
45
|
+
raw = (position or {}).get("symbol", symbol)
|
|
46
|
+
return raw.split(":")[0] if ":" in raw else raw
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional MCP (Model Context Protocol) server — install with the ``mcp`` extra."""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""MCP stdio server exposing the backtest engine to any MCP-compatible agent.
|
|
2
|
+
|
|
3
|
+
Deliberately backtest-only. This process never touches an exchange account —
|
|
4
|
+
there is no tool here that can place, modify, or cancel a live order. An
|
|
5
|
+
agent can explore the engine's demo strategy, run its own parameter sweeps,
|
|
6
|
+
and backtest its own OHLC data, but "let an LLM trade my account" is not a
|
|
7
|
+
capability this server offers. Live execution (:mod:`binance_quant_engine.execution`)
|
|
8
|
+
is a library you wire into your own bot; it is intentionally not MCP-reachable.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import csv
|
|
14
|
+
import io
|
|
15
|
+
import math
|
|
16
|
+
from dataclasses import replace
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from mcp.server.mcpserver import MCPServer
|
|
20
|
+
|
|
21
|
+
from binance_quant_engine.backtest.vectorized import run_backtest
|
|
22
|
+
from binance_quant_engine.data.klines import synth_ohlcv
|
|
23
|
+
from binance_quant_engine.strategy.demo_squeeze import SqueezeConfig, SqueezeStrategy
|
|
24
|
+
|
|
25
|
+
mcp = MCPServer("binance-quant-engine")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _finite(x: float) -> float:
|
|
29
|
+
"""JSON has no representation for inf/nan; MCP transport is JSON."""
|
|
30
|
+
return x if math.isfinite(x) else 0.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _summary_json(result: Any) -> dict[str, float]:
|
|
34
|
+
return {k: _finite(float(v)) for k, v in result.summary().items()}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@mcp.tool()
|
|
38
|
+
def run_demo_backtest(
|
|
39
|
+
n_bars: int = 2000,
|
|
40
|
+
seed: int = 7,
|
|
41
|
+
bb_period: int = 20,
|
|
42
|
+
bb_std: float = 2.0,
|
|
43
|
+
squeeze_pct: float = 0.25,
|
|
44
|
+
stop_loss: float = 0.02,
|
|
45
|
+
take_profit: float = 0.04,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Backtest the bundled Bollinger-squeeze demo strategy on synthetic OHLC.
|
|
48
|
+
|
|
49
|
+
Zero setup, no API keys, no data file — useful for confirming the engine
|
|
50
|
+
works and for exploring how the demo strategy's parameters move its
|
|
51
|
+
metrics. Not a real alpha; see the README before reading any number here
|
|
52
|
+
as investment advice.
|
|
53
|
+
"""
|
|
54
|
+
high, low, close = synth_ohlcv(n=n_bars, seed=seed)
|
|
55
|
+
config = SqueezeConfig(
|
|
56
|
+
bb_period=bb_period,
|
|
57
|
+
bb_std=bb_std,
|
|
58
|
+
squeeze_pct=squeeze_pct,
|
|
59
|
+
stop_loss=stop_loss,
|
|
60
|
+
take_profit=take_profit,
|
|
61
|
+
)
|
|
62
|
+
result = run_backtest(SqueezeStrategy(config), high, low, close)
|
|
63
|
+
return {"summary": _summary_json(result), "config": config.__dict__}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@mcp.tool()
|
|
67
|
+
def backtest_csv(csv_text: str, config_overrides: dict[str, float] | None = None) -> dict[str, Any]:
|
|
68
|
+
"""Backtest the demo squeeze strategy against caller-supplied OHLC data.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
csv_text: CSV content (not a path — this process has no filesystem
|
|
72
|
+
access to the caller's machine) with ``high``, ``low``, ``close``
|
|
73
|
+
columns, case-insensitive.
|
|
74
|
+
config_overrides: Optional subset of :class:`SqueezeConfig` fields to
|
|
75
|
+
override (e.g. ``{"stop_loss": 0.015}``).
|
|
76
|
+
"""
|
|
77
|
+
reader = csv.DictReader(io.StringIO(csv_text))
|
|
78
|
+
rows = list(reader)
|
|
79
|
+
if not rows:
|
|
80
|
+
raise ValueError("csv_text has no data rows")
|
|
81
|
+
cols = {c.lower(): c for c in rows[0]}
|
|
82
|
+
for required in ("high", "low", "close"):
|
|
83
|
+
if required not in cols:
|
|
84
|
+
raise ValueError(f"csv_text is missing a '{required}' column")
|
|
85
|
+
|
|
86
|
+
high = [float(r[cols["high"]]) for r in rows]
|
|
87
|
+
low = [float(r[cols["low"]]) for r in rows]
|
|
88
|
+
close = [float(r[cols["close"]]) for r in rows]
|
|
89
|
+
|
|
90
|
+
config = SqueezeConfig()
|
|
91
|
+
if config_overrides:
|
|
92
|
+
config = replace(config, **config_overrides)
|
|
93
|
+
|
|
94
|
+
result = run_backtest(SqueezeStrategy(config), high, low, close)
|
|
95
|
+
return {"summary": _summary_json(result), "n_bars": len(close), "config": config.__dict__}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@mcp.tool()
|
|
99
|
+
def describe_strategy_protocol() -> str:
|
|
100
|
+
"""Return the docstring of the TradingStrategy protocol.
|
|
101
|
+
|
|
102
|
+
Read this before implementing a custom strategy — it's the exact
|
|
103
|
+
interface :func:`run_backtest` (and the live engine) drives.
|
|
104
|
+
"""
|
|
105
|
+
from binance_quant_engine.strategy.protocol import TradingStrategy
|
|
106
|
+
|
|
107
|
+
return TradingStrategy.__doc__ or ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def main() -> None:
|
|
111
|
+
mcp.run()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Trading strategies (interface + reference demo)."""
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Demo strategy — Bollinger Band squeeze mean-reversion.
|
|
2
|
+
|
|
3
|
+
A deliberately simple, **public-domain** strategy that exists to exercise the
|
|
4
|
+
engine end to end, not to make money. It implements the
|
|
5
|
+
:class:`~binance_quant_engine.strategy.protocol.TradingStrategy` contract so the backtester
|
|
6
|
+
and the live scalper can both drive it without modification.
|
|
7
|
+
|
|
8
|
+
Idea (textbook):
|
|
9
|
+
* A *squeeze* is a low-volatility regime — Bollinger Band width sits in the
|
|
10
|
+
bottom decile of its recent range. Such compression often precedes
|
|
11
|
+
expansion.
|
|
12
|
+
* During a squeeze we fade extremes: a close below the lower band is a long
|
|
13
|
+
(mean-reversion up), a close above the upper band is a short.
|
|
14
|
+
* Each position carries a fixed stop-loss, take-profit, and an optional
|
|
15
|
+
trailing stop once it moves into profit.
|
|
16
|
+
|
|
17
|
+
Discipline:
|
|
18
|
+
Every indicator reads only *completed* bars (the arrays passed in exclude
|
|
19
|
+
the in-progress candle). No future data touches a signal — see
|
|
20
|
+
:mod:`binance_quant_engine.backtest.vectorized` for how the engine enforces this.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class SqueezeConfig:
|
|
32
|
+
"""Strategy parameters (transparent, public defaults)."""
|
|
33
|
+
|
|
34
|
+
bb_period: int = 20 # Bollinger Band lookback
|
|
35
|
+
bb_std: float = 2.0 # band width in standard deviations
|
|
36
|
+
squeeze_lookback: int = 100 # window for the band-width percentile
|
|
37
|
+
squeeze_pct: float = 0.25 # "squeeze" if width <= this percentile
|
|
38
|
+
atr_period: int = 14
|
|
39
|
+
stop_loss: float = 0.02 # 2% hard stop
|
|
40
|
+
take_profit: float = 0.04 # 4% target
|
|
41
|
+
trail_distance: float = 0.01 # trail 1% behind peak once armed
|
|
42
|
+
trail_activate: float = 0.02 # arm the trail at +2% unrealised
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sma(x: np.ndarray, n: int) -> float:
|
|
46
|
+
return float(np.mean(x[-n:]))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _atr_pct(high: np.ndarray, low: np.ndarray, close: np.ndarray, n: int) -> float:
|
|
50
|
+
"""ATR as a fraction of the last close (Wilder true range, simple mean)."""
|
|
51
|
+
if len(close) < n + 1:
|
|
52
|
+
return 0.0
|
|
53
|
+
tr = np.maximum.reduce([
|
|
54
|
+
high[-n:] - low[-n:],
|
|
55
|
+
np.abs(high[-n:] - close[-n - 1 : -1]),
|
|
56
|
+
np.abs(low[-n:] - close[-n - 1 : -1]),
|
|
57
|
+
])
|
|
58
|
+
last = close[-1]
|
|
59
|
+
return float(np.mean(tr) / last) if last else 0.0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SqueezeStrategy:
|
|
63
|
+
"""Reference implementation of :class:`TradingStrategy`."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, config: SqueezeConfig | None = None) -> None:
|
|
66
|
+
self.config = config or SqueezeConfig()
|
|
67
|
+
self._positions: dict[str, dict] = {}
|
|
68
|
+
self._highs: dict[str, np.ndarray] = {}
|
|
69
|
+
self._lows: dict[str, np.ndarray] = {}
|
|
70
|
+
self._atr_pct: dict[str, float] = {}
|
|
71
|
+
|
|
72
|
+
# ── position book ────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def active_positions(self) -> dict[str, dict]:
|
|
76
|
+
return {k: dict(v) for k, v in self._positions.items()}
|
|
77
|
+
|
|
78
|
+
def has_position(self, symbol: str) -> bool:
|
|
79
|
+
return symbol in self._positions
|
|
80
|
+
|
|
81
|
+
# ── data feed ────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def update_market_data(
|
|
84
|
+
self, symbol: str, high: np.ndarray, low: np.ndarray, close: np.ndarray
|
|
85
|
+
) -> None:
|
|
86
|
+
self._highs[symbol] = np.asarray(high, dtype=float)
|
|
87
|
+
self._lows[symbol] = np.asarray(low, dtype=float)
|
|
88
|
+
self._atr_pct[symbol] = _atr_pct(
|
|
89
|
+
self._highs[symbol], self._lows[symbol],
|
|
90
|
+
np.asarray(close, dtype=float), self.config.atr_period,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# ── signal ───────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def _band_width(self, close: np.ndarray) -> np.ndarray:
|
|
96
|
+
"""Rolling Bollinger band width (upper-lower) / mid, per bar."""
|
|
97
|
+
c = self.config
|
|
98
|
+
n = c.bb_period
|
|
99
|
+
widths = np.full(len(close), np.nan)
|
|
100
|
+
for i in range(n, len(close) + 1):
|
|
101
|
+
window = close[i - n : i]
|
|
102
|
+
mid = window.mean()
|
|
103
|
+
sd = window.std()
|
|
104
|
+
if mid:
|
|
105
|
+
widths[i - 1] = (2 * c.bb_std * sd) / mid
|
|
106
|
+
return widths
|
|
107
|
+
|
|
108
|
+
def on_bar(self, symbol: str, close: np.ndarray) -> int:
|
|
109
|
+
c = self.config
|
|
110
|
+
close = np.asarray(close, dtype=float)
|
|
111
|
+
if len(close) < max(c.bb_period, c.squeeze_lookback):
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
widths = self._band_width(close)
|
|
115
|
+
cur_width = widths[-1]
|
|
116
|
+
ref = widths[-c.squeeze_lookback :]
|
|
117
|
+
ref = ref[~np.isnan(ref)]
|
|
118
|
+
if np.isnan(cur_width) or len(ref) < c.squeeze_lookback // 2:
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
# In a squeeze? (band width in the bottom `squeeze_pct` of recent range)
|
|
122
|
+
threshold = np.quantile(ref, c.squeeze_pct)
|
|
123
|
+
if cur_width > threshold:
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
mid = _sma(close, c.bb_period)
|
|
127
|
+
sd = float(close[-c.bb_period :].std())
|
|
128
|
+
upper, lower = mid + c.bb_std * sd, mid - c.bb_std * sd
|
|
129
|
+
price = close[-1]
|
|
130
|
+
if price < lower:
|
|
131
|
+
return 1 # fade the downside extreme
|
|
132
|
+
if price > upper:
|
|
133
|
+
return -1 # fade the upside extreme
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
def get_last_atr_pct(self, symbol: str) -> float:
|
|
137
|
+
return self._atr_pct.get(symbol, 0.0)
|
|
138
|
+
|
|
139
|
+
# ── lifecycle ────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def open_position(
|
|
142
|
+
self, symbol: str, signal: int, entry_price: float, atr_pct: float
|
|
143
|
+
) -> None:
|
|
144
|
+
self._positions[symbol] = {
|
|
145
|
+
"signal": signal,
|
|
146
|
+
"entry_price": entry_price,
|
|
147
|
+
"atr_pct": atr_pct,
|
|
148
|
+
"peak_gain": 0.0,
|
|
149
|
+
"trail_active": False,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
def update_position(
|
|
153
|
+
self,
|
|
154
|
+
symbol: str,
|
|
155
|
+
price: float,
|
|
156
|
+
high: float | None = None,
|
|
157
|
+
low: float | None = None,
|
|
158
|
+
) -> str | None:
|
|
159
|
+
pos = self._positions.get(symbol)
|
|
160
|
+
if pos is None:
|
|
161
|
+
return None
|
|
162
|
+
c = self.config
|
|
163
|
+
direction = pos["signal"]
|
|
164
|
+
entry = pos["entry_price"]
|
|
165
|
+
# Unrealised gain in the position's favour.
|
|
166
|
+
gain = direction * (price - entry) / entry
|
|
167
|
+
|
|
168
|
+
if gain <= -c.stop_loss:
|
|
169
|
+
return "stop_loss"
|
|
170
|
+
if gain >= c.take_profit:
|
|
171
|
+
return "take_profit"
|
|
172
|
+
|
|
173
|
+
# Trailing stop: arm at +trail_activate, then exit if we give back
|
|
174
|
+
# trail_distance from the peak.
|
|
175
|
+
pos["peak_gain"] = max(pos["peak_gain"], gain)
|
|
176
|
+
if pos["peak_gain"] >= c.trail_activate:
|
|
177
|
+
pos["trail_active"] = True
|
|
178
|
+
if pos["trail_active"] and gain <= pos["peak_gain"] - c.trail_distance:
|
|
179
|
+
return "trailing_stop"
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
def close_position(self, symbol: str) -> dict | None:
|
|
183
|
+
return self._positions.pop(symbol, None)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Strategy protocol — the formal contract every strategy must satisfy.
|
|
2
|
+
|
|
3
|
+
The engine (backtester and live scalper alike) only ever talks to a strategy
|
|
4
|
+
through this interface, never to a concrete class. That decoupling is what lets
|
|
5
|
+
the *same* engine run any strategy: a strategy is just an object that ingests
|
|
6
|
+
bars, emits signals, and tracks its own open positions and exit conditions.
|
|
7
|
+
|
|
8
|
+
Implemented via :pep:`544` structural subtyping — a strategy needs no base
|
|
9
|
+
class, only these methods. ``@runtime_checkable`` allows ``isinstance`` checks
|
|
10
|
+
in tests. The bundled :class:`~binance_quant_engine.strategy.demo_squeeze.SqueezeStrategy`
|
|
11
|
+
is a reference implementation on public, textbook logic.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class TradingStrategy(Protocol):
|
|
23
|
+
"""Interface for a stateful trading strategy.
|
|
24
|
+
|
|
25
|
+
Signal convention (returned by :meth:`on_bar`):
|
|
26
|
+
``-1`` short · ``0`` flat/no-signal · ``1`` long.
|
|
27
|
+
|
|
28
|
+
Per-bar flow driven by the engine:
|
|
29
|
+
1. :meth:`update_market_data` — feed the latest OHLCV window.
|
|
30
|
+
2. :meth:`on_bar` — emit a signal for a symbol.
|
|
31
|
+
3. :meth:`open_position` / :meth:`update_position` — lifecycle.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
# ── position book ────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def active_positions(self) -> dict[str, dict]:
|
|
38
|
+
"""Snapshot of open positions keyed by symbol."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
def has_position(self, symbol: str) -> bool:
|
|
42
|
+
"""Whether a position is currently open for *symbol*."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
# ── data feed ────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def update_market_data(
|
|
48
|
+
self,
|
|
49
|
+
symbol: str,
|
|
50
|
+
high: np.ndarray,
|
|
51
|
+
low: np.ndarray,
|
|
52
|
+
close: np.ndarray,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Store the latest OHLC arrays for *symbol* before :meth:`on_bar`."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
# ── signal generation ────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def on_bar(self, symbol: str, close: np.ndarray) -> int:
|
|
60
|
+
"""Return a signal (``-1`` / ``0`` / ``1``) for the latest bar."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
def get_last_atr_pct(self, symbol: str) -> float:
|
|
64
|
+
"""Return the most recent ATR as a fraction of price (for sizing)."""
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
# ── position lifecycle ───────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
def open_position(
|
|
70
|
+
self,
|
|
71
|
+
symbol: str,
|
|
72
|
+
signal: int,
|
|
73
|
+
entry_price: float,
|
|
74
|
+
atr_pct: float,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Record a newly opened position."""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
def update_position(
|
|
80
|
+
self,
|
|
81
|
+
symbol: str,
|
|
82
|
+
price: float,
|
|
83
|
+
high: float | None = None,
|
|
84
|
+
low: float | None = None,
|
|
85
|
+
) -> str | None:
|
|
86
|
+
"""Evaluate exit conditions for an open position.
|
|
87
|
+
|
|
88
|
+
Returns an exit-reason string (e.g. ``"stop_loss"``, ``"take_profit"``,
|
|
89
|
+
``"trailing_stop"``) or ``None`` to keep the position open.
|
|
90
|
+
"""
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
def close_position(self, symbol: str) -> dict | None:
|
|
94
|
+
"""Remove and return the position record, or ``None`` if absent."""
|
|
95
|
+
...
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: binance-quant-engine
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Strategy-agnostic Binance USDT-M futures backtest & execution engine — no-look-ahead, backtest/live parity, optional MCP server
|
|
5
|
+
Project-URL: Homepage, https://pypi.org/project/binance-quant-engine/
|
|
6
|
+
Project-URL: Repository, https://github.com/younghwan91/binance-quant-engine
|
|
7
|
+
Author-email: Younghwan Chae <chyohw97@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: algo-trading,backtest,binance,execution,futures,mcp,quant,trading
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: numpy>=1.26
|
|
13
|
+
Requires-Dist: pandas>=2.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: mcp>=1.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: python-binance>=1.0.29; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
19
|
+
Provides-Extra: live
|
|
20
|
+
Requires-Dist: python-binance>=1.0.29; extra == 'live'
|
|
21
|
+
Provides-Extra: mcp
|
|
22
|
+
Requires-Dist: mcp>=1.0; extra == 'mcp'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Binance Quant Engine ⚙️
|
|
26
|
+
|
|
27
|
+
[](https://github.com/younghwan91/binance-quant-engine/actions/workflows/ci.yml)
|
|
28
|
+
[](https://pypi.org/project/binance-quant-engine/)
|
|
29
|
+
[](https://www.python.org/downloads/)
|
|
30
|
+
[](LICENSE)
|
|
31
|
+
[](https://www.linkedin.com/in/younghwan-chae/)
|
|
32
|
+
|
|
33
|
+
**A strategy-agnostic Binance USDT-M futures backtest & execution engine.** Backtest and live trading run through the *same* strategy code, so a look-ahead bug can't exist in one and not the other — and an [MCP server](#mcp-server-let-an-agent-run-your-backtests) lets any MCP-compatible agent run backtests without writing a line of Python.
|
|
34
|
+
|
|
35
|
+
Extracted from the infra layer of a real, currently-running Binance USDT-M futures bot — the alpha (strategy, parameters, live P&L) stays private; what's public is the part every retail algo trader rebuilds badly at least once: a backtester that can't cheat, and an execution layer that survives Binance's actual API quirks. The bundled demo strategy is textbook logic (Bollinger squeeze); it exists to exercise the engine end to end, not to make you money.
|
|
36
|
+
|
|
37
|
+
> This repository supersedes `quantbox-engine`, which is no longer maintained. If you have that repo cloned, switch to this one.
|
|
38
|
+
|
|
39
|
+
## Why not just write your own backtest loop?
|
|
40
|
+
|
|
41
|
+
Because the failure modes here are the ones that quietly wreck a real account, not the ones a unit test catches:
|
|
42
|
+
|
|
43
|
+
| Problem | What usually happens | What this engine does |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| Look-ahead bias | A signal computed over a full array accidentally sees future bars | The backtester physically hands the strategy only `close[: t + 1]` each step — there is no future in the array to peek at ([enforced by test](tests/test_backtest.py)) |
|
|
46
|
+
| Backtest/live divergence | Backtest logic gets "ported" to the live bot and drifts | One `TradingStrategy` object, same method calls, same order, in both paths |
|
|
47
|
+
| Binance tick-size rejection | A price a float-division away from the tick grid gets silently rejected or mispriced | [`round_to_tick`](src/binance_quant_engine/execution/utils.py) snaps every stop/limit/activation price to the symbol's grid before it leaves the process |
|
|
48
|
+
| Dead-man's switch | Bot crashes → open position has no stop-loss | SL/TP/trailing stops are placed **on Binance's Algo Order API**, so the exchange — not your process — enforces the exit |
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install binance-quant-engine
|
|
54
|
+
# or: uv add binance-quant-engine
|
|
55
|
+
|
|
56
|
+
bqe-backtest --demo # backtest the bundled demo strategy on synthetic data
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+

|
|
60
|
+
|
|
61
|
+
> ⚠️ The numbers above are from a **demo strategy on synthetic data**. They prove the engine runs; they say nothing about profitability.
|
|
62
|
+
|
|
63
|
+
## Backtest your own data
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from binance_quant_engine.backtest.vectorized import run_backtest
|
|
67
|
+
from binance_quant_engine.data.klines import load_csv
|
|
68
|
+
from binance_quant_engine.strategy.demo_squeeze import SqueezeStrategy
|
|
69
|
+
|
|
70
|
+
high, low, close = load_csv("BTCUSDT_1h.csv") # columns: high,low,close
|
|
71
|
+
result = run_backtest(SqueezeStrategy(), high, low, close, fee=0.0004, slippage=0.0002)
|
|
72
|
+
print(result.summary())
|
|
73
|
+
# {'n_trades': ..., 'total_return': ..., 'win_rate': ..., 'profit_factor': ..., 'max_drawdown': ...}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Bring your own strategy by implementing [`TradingStrategy`](src/binance_quant_engine/strategy/protocol.py) — a `typing.Protocol`, no base class required. Wiring it into live execution: [docs/USAGE.md](docs/USAGE.md).
|
|
77
|
+
|
|
78
|
+
## MCP server — let an agent run your backtests
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install "binance-quant-engine[mcp]"
|
|
82
|
+
binance-quant-engine-mcp # stdio MCP server
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Exposes `run_demo_backtest`, `backtest_csv`, and `describe_strategy_protocol` to any MCP client (Claude Code, Claude Desktop, etc.) — point an agent at a CSV of OHLC data and it can backtest a strategy idea in the same turn, no local Python environment required on the agent's side.
|
|
86
|
+
|
|
87
|
+
**This is deliberately backtest-only.** Nothing under `execution/` (order placement, cancellation, bracket management) is reachable through the MCP server — there is no tool call that can touch a live Binance order. If you want an agent that also *trades*, that's a decision you wire yourself, explicitly, outside this server.
|
|
88
|
+
|
|
89
|
+
## Design
|
|
90
|
+
|
|
91
|
+
| Design choice | How |
|
|
92
|
+
|---|---|
|
|
93
|
+
| **No-look-ahead by construction** | The backtester hands the strategy `close[:t+1]` (bars completed as of now) every step — there's no future in the array to see. |
|
|
94
|
+
| **Backtest = live, same code** | The same `TradingStrategy` object is driven by the backtester and the live bot, in the same call order. No reimplementation gap. |
|
|
95
|
+
| **Pluggable strategy** | The engine only ever talks to a strategy through [`TradingStrategy`](src/binance_quant_engine/strategy/protocol.py) (PEP 544). No inheritance required. |
|
|
96
|
+
| **Costs are net, not gross** | Taker fee + slippage charged on both entry and exit legs. |
|
|
97
|
+
| **Server-side exits** | SL/TP/trailing stops live on Binance's Algo Order API — the exchange enforces them even if your process dies. ([brackets.py](src/binance_quant_engine/execution/brackets.py)) |
|
|
98
|
+
| **Tick-safe prices** | Every price sent to the exchange is snapped to the symbol's tick grid first ([utils.py](src/binance_quant_engine/execution/utils.py)) — a category of Binance rejection this engine doesn't have. |
|
|
99
|
+
|
|
100
|
+
## Structure
|
|
101
|
+
|
|
102
|
+
**Decide** (strategy) / **measure** (backtest) / **execute** (live) are kept apart and meet only at `TradingStrategy`.
|
|
103
|
+
|
|
104
|
+
```mermaid
|
|
105
|
+
flowchart LR
|
|
106
|
+
subgraph Data["binance_quant_engine/data/"]
|
|
107
|
+
CSV[("OHLCV CSV\nload_csv()")]
|
|
108
|
+
SYN[["synth_ohlcv()\nsynthetic data"]]
|
|
109
|
+
CACHE["cache.py\nmemory + gzip cache"]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
subgraph Decide["binance_quant_engine/strategy/ — decide"]
|
|
113
|
+
PROTO{{"TradingStrategy\n(PEP 544 protocol)"}}
|
|
114
|
+
SQZ["demo_squeeze.py\nSqueezeStrategy"]
|
|
115
|
+
PROTO -.implements.-> SQZ
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
CSV --> BT
|
|
119
|
+
SYN --> BT
|
|
120
|
+
CACHE -.caches.-> CSV
|
|
121
|
+
|
|
122
|
+
subgraph Measure["binance_quant_engine/backtest/ — measure"]
|
|
123
|
+
BT["vectorized.run_backtest()\nhands close[:t+1] only\n(no look-ahead)"]
|
|
124
|
+
RES["BacktestResult\ntrades · equity curve · MDD"]
|
|
125
|
+
BT --> RES
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
BT <-->|"update_market_data / on_bar\nopen·update·close_position"| PROTO
|
|
129
|
+
|
|
130
|
+
subgraph Execute["binance_quant_engine/execution/ — execute (.[live])"]
|
|
131
|
+
HOST["host.py\nScalperProtocol (live bot host)"]
|
|
132
|
+
BRACKET["brackets.py\nBracketMixin — SL/TP/trailing"]
|
|
133
|
+
UTIL["utils.py\nround_to_tick · dead-status set"]
|
|
134
|
+
ALGO["algo_api.py\nAlgoApiClient"]
|
|
135
|
+
HOST --> BRACKET --> ALGO
|
|
136
|
+
BRACKET -.-> UTIL
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
subgraph MCP["binance_quant_engine/mcp/ — optional (.[mcp])"]
|
|
140
|
+
SRV["server.py\nrun_demo_backtest · backtest_csv"]
|
|
141
|
+
end
|
|
142
|
+
BT -.callable via.-> SRV
|
|
143
|
+
|
|
144
|
+
PROTO ==same interface\n(backtest = live)==> HOST
|
|
145
|
+
ALGO --> BINANCE[("Binance USDT-M\nFutures Algo Order API")]
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- How to plug in a strategy and wire up live execution → [docs/USAGE.md](docs/USAGE.md)
|
|
149
|
+
- Design rationale for no-look-ahead, backtest/live parity, and the cost model → [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
Bugs and questions → [Issues](https://github.com/younghwan91/binance-quant-engine/issues).
|
|
158
|
+
|
|
159
|
+
**Younghwan Chae** · [GitHub @younghwan91](https://github.com/younghwan91) · [LinkedIn](https://www.linkedin.com/in/younghwan-chae/) — other open-source quant projects (Korean equities, US equities, crypto) are on the [profile page](https://github.com/younghwan91).
|