-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexecutor.py
More file actions
399 lines (348 loc) · 17.2 KB
/
Copy pathexecutor.py
File metadata and controls
399 lines (348 loc) · 17.2 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
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
"""
Trade executor with integrated risk management.
Flow:
1. Receives Claude's decision (action + confidence)
2. Risk manager calculates optimal size (Kelly + ATR + confidence)
3. Executor validates safety checks (allocation cap, minimum balance)
4. Places market order + OCO stop-loss/take-profit on Binance
5. Returns detailed result with risk metadata
Code is the last line of defence — never blindly trust Claude's output.
"""
import logging
import ccxt
import config
import risk_manager
logger = logging.getLogger(__name__)
def _place_exit_orders(exchange: ccxt.binance, symbol: str,
qty: float, stop_loss: float) -> dict:
"""
Place ONE protective stop-loss covering the whole position.
Existing open orders for the symbol are cancelled first. The bot buys
repeatedly without closing each position, so per-trade stops used to
accumulate until Binance rejected new ones with MAX_NUM_ALGO_ORDERS —
leaving fresh positions unprotected. Cancelling first keeps exactly one
stop covering the whole holding.
Spot LONG-ONLY bot: it only ever holds the asset (a "sell" is a partial
trim, not a short), so the protective stop always sits on the SELL side.
Returns order refs plus `stop_protected` (True only if the stop-loss is
actually resting on the exchange) so the caller can alert when a position
is left unprotected instead of swallowing the failure.
"""
result = {"stop_loss_order": None, "take_profit_order": None, "stop_protected": False}
exit_side = "sell"
base = symbol.split("/")[0]
# Cancel any existing open orders for this symbol so the bracket doesn't
# accumulate (MAX_NUM_ALGO_ORDERS) — we re-place one covering the whole
# position below.
try:
for o in exchange.fetch_open_orders(symbol):
try:
exchange.cancel_order(o["id"], symbol)
except Exception as exc:
logger.debug("Could not cancel order %s: %s", o.get("id"), exc)
except Exception as exc:
logger.debug("Could not list open orders for %s: %s", symbol, exc)
# Protective stop-loss ONLY. We deliberately do NOT place a separate
# take-profit: on spot, a whole-position stop reserves the entire base
# balance, so a second sell order for the same units fails with
# "insufficient balance". Profit-taking is handled by the strategy (the
# analysis loop trims on sell signals), so the exchange-side order just
# needs to be the safety net. (An OCO would sell the whole position on
# either leg, which contradicts the 10%-trim exit logic.)
try:
sl_limit = _price_to_precision(exchange, symbol, stop_loss * 0.999)
result["stop_loss_order"] = exchange.create_order(
symbol=symbol,
type="STOP_LOSS_LIMIT",
side=exit_side,
amount=qty,
price=sl_limit,
params={"stopPrice": _price_to_precision(exchange, symbol, stop_loss), "timeInForce": "GTC"},
)
result["stop_protected"] = True
logger.info(
"Stop-loss placed: %s %.6f %s trigger=$%.0f limit=$%.0f",
exit_side.upper(), qty, base, stop_loss, sl_limit,
)
except Exception as exc:
logger.warning("Stop-loss order FAILED — position unprotected: %s", exc)
return result
def _amount_to_precision(exchange: ccxt.binance, symbol: str, qty: float) -> float:
"""
Round an order quantity to the exchange's LOT_SIZE step for this symbol.
ccxt normalizes the step per market; hardcoded rounding breaks the moment
a new asset with a different step is added. Falls back to the raw qty if
the market isn't loaded.
"""
try:
return float(exchange.amount_to_precision(symbol, qty))
except Exception:
return qty
def _price_to_precision(exchange: ccxt.binance, symbol: str, price: float) -> float:
"""Round a price to the exchange's PRICE_FILTER tick for this symbol."""
try:
return float(exchange.price_to_precision(symbol, price))
except Exception:
return round(price, 2)
def _min_notional(exchange: ccxt.binance, symbol: str) -> float:
"""
The exchange's real minimum order value (price * qty) for this symbol,
read from ccxt's normalized market limits. Binance rejects any order
below this with a NOTIONAL filter error — risk-managed sizing (Kelly
/ATR/confidence, or a circuit-breaker halving) can shrink well below
it, so this must be checked at order time, not assumed from config.
Falls back to a conservative constant if markets aren't loaded yet.
"""
try:
cost_min = exchange.markets.get(symbol, {}).get("limits", {}).get("cost", {}).get("min")
if cost_min:
return float(cost_min)
except Exception:
pass
return config.EXCHANGE_MIN_NOTIONAL_FALLBACK
def take_profit_sell(exchange: ccxt.binance, symbol: str, qty: float,
avg_entry: float, price: float) -> dict:
"""
Market-sell the whole position to REALISE a gain (bot-managed take-profit).
Cancels any resting stop order first so it isn't orphaned. Separate from
execute()'s "sell" path, which only trims 10% — take-profit banks the
full position and goes flat, matching the round-trip the backtest models.
"""
base = symbol.split("/")[0]
result = {
"action": "sell", "amount_usd": 0.0, "btc_amount": 0.0, "order": None,
"success": False, "error": None, "submitted": False, "take_profit": True,
}
qty = _amount_to_precision(exchange, symbol, qty)
min_qty = (exchange.markets.get(symbol, {})
.get("limits", {}).get("amount", {}).get("min", 0.00001))
if qty < min_qty:
result["error"] = f"TP qty {qty:.8f} below minimum {min_qty}"
return result
# NOTIONAL filter: Binance rejects any order below its minimum value. A
# dust position can clear LOT_SIZE and still be unsellable — 36 ETH
# take-profits were rejected this way in Aug 2026. Refuse before submit.
notional = qty * price
min_notional = _min_notional(exchange, symbol)
if notional < min_notional:
result["error"] = (f"TP notional ${notional:.2f} below exchange "
f"minimum ${min_notional:.2f}") # caller logs it
return result
# Cancel the resting stop-loss so it isn't left orphaned after we go flat.
try:
for o in exchange.fetch_open_orders(symbol):
try:
exchange.cancel_order(o["id"], symbol)
except Exception as exc:
logger.debug("TP: could not cancel order %s: %s", o.get("id"), exc)
except Exception as exc:
logger.debug("TP: could not list open orders for %s: %s", symbol, exc)
try:
result["submitted"] = True
order = exchange.create_market_sell_order(symbol, qty)
filled_qty = float(order.get("filled") or qty)
filled_usd = float(order.get("cost") or qty * price)
result.update({"success": True, "amount_usd": filled_usd,
"btc_amount": filled_qty, "order": order})
gain = (price / avg_entry - 1) * 100 if avg_entry > 0 else 0.0
logger.info(
"TAKE-PROFIT: sold %.6f %s @ $%.0f (avg entry $%.0f, +%.2f%%) for $%.2f",
filled_qty, base, price, avg_entry, gain, filled_usd,
)
except Exception as e:
result["error"] = str(e)
logger.error("Take-profit sell failed: %s", e)
return result
def _sized_amount(action: str, decision: dict, recommended_usd: float,
size_scale: float = 1.0) -> tuple[float, bool]:
"""
Final executed size from the risk-managed recommendation.
- size_scale: circuit-breaker multiplier (0.5 in drawdowns). Previously
main.py only scaled the advisory decision["trade_usd"], which execute()
ignores — so drawdown halving never affected real orders.
- Validated ML gate buys earn GATE_TRADE_MULT up to MAX_GATE_TRADE_USD
(alpha-sleeve tier; evidence basis in ROADMAP.md Phase 0 exit).
Returns (amount_usd, gate_trade).
"""
amount = recommended_usd * size_scale
gate = False
if action == "buy" and decision.get("ml_buy_signal"):
amount = min(amount * config.GATE_TRADE_MULT, config.MAX_GATE_TRADE_USD)
gate = True
return max(config.MIN_TRADE_USD, round(amount, 2)), gate
def execute(exchange: ccxt.binance, decision: dict, snapshot: dict,
portfolio: dict, size_scale: float = 1.0, dry_run: bool = False) -> dict:
"""
Size and (unless dry_run) place a market order + bracket exits.
dry_run=True runs the full sizing + gate pipeline and returns the exact
order that WOULD be submitted (planned_usd / planned_qty), without
touching the exchange. Used to show the true executed size in the LIVE
confirmation prompt, so the number you approve matches what fills.
"""
action = decision["action"]
confidence = decision.get("confidence", 0.5)
price = snapshot["price"]
# Trade the analysed symbol — the ETH cycle passes ETH/USDT snapshots.
# (portfolio["btc"] holds the base-asset amount for whichever symbol this is)
symbol = snapshot.get("symbol") or config.SYMBOL
base = symbol.split("/")[0]
# Risk manager determines optimal position size and stop levels
risk = risk_manager.assess_trade(action, confidence, snapshot, portfolio)
# Risk-managed size, then circuit-breaker scale + alpha-sleeve gate tier
amount, gate_trade = _sized_amount(action, decision, risk.recommended_usd, size_scale)
if gate_trade:
logger.info(
"Alpha-sleeve: validated ML gate — size $%.2f (base $%.2f x%.1f, cap $%.0f)",
amount, risk.recommended_usd, config.GATE_TRADE_MULT, config.MAX_GATE_TRADE_USD,
)
result = {
"action": action,
"amount_usd": 0.0,
"btc_amount": 0.0,
"order": None,
"stop_order": None,
"success": False,
"error": None,
"submitted": False, # True once an order was actually sent to the exchange
"planned_usd": 0.0, # dry_run: the size that would be ordered
"planned_qty": 0.0, # dry_run: the base qty that would be ordered
"risk_data": {
"recommended_usd": risk.recommended_usd,
"stop_loss": risk.stop_loss_price,
"take_profit": risk.take_profit_price,
"trailing_distance": risk.trailing_stop_distance,
"risk_reward": risk.risk_reward_ratio,
"kelly_fraction": risk.kelly_fraction,
"atr_multiplier": risk.atr_multiplier,
"rationale": risk.position_rationale,
},
}
if action == "buy":
# Bump to the exchange's real minimum notional BEFORE the balance
# and allocation checks below, so those checks validate the size
# that will actually be submitted — not the pre-bump one.
min_notional = _min_notional(exchange, symbol)
if amount < min_notional:
bumped = round(min_notional * 1.01, 2) # 1% buffer for price drift before fill
logger.info(
"BUY %s: sizing bumped $%.2f → $%.2f to clear exchange minimum notional ($%.2f)",
base, amount, bumped, min_notional,
)
amount = bumped
if portfolio["usdt"] < amount + 1.0:
result["error"] = f"Low USDT: have ${portfolio['usdt']:.2f}, need ${amount:.2f}+fee"
logger.warning(result["error"])
return result
btc_val = portfolio["btc"] * price
total = portfolio["usdt"] + btc_val
new_alloc = (btc_val + amount) / total if total > 0 else 1.0
if new_alloc > config.MAX_BTC_ALLOC_PCT:
result["error"] = f"Would exceed {config.MAX_BTC_ALLOC_PCT:.0%} BTC cap ({new_alloc:.1%})"
logger.warning(result["error"])
return result
# Reject if risk/reward ratio is too low
if risk.risk_reward_ratio > 0 and risk.risk_reward_ratio < 1.0:
result["error"] = f"R:R too low ({risk.risk_reward_ratio:.1f}), need ≥1.0"
logger.warning(result["error"])
return result
btc_qty = _amount_to_precision(exchange, symbol, amount / price)
if dry_run:
result.update({"planned_usd": amount, "planned_qty": btc_qty, "success": True})
return result
try:
result["submitted"] = True
order = exchange.create_market_buy_order(symbol, btc_qty)
# Prefer the exchange's actual fill over our pre-order estimate
filled_qty = float(order.get("filled") or btc_qty)
filled_usd = float(order.get("cost") or amount)
result.update({
"success": True, "amount_usd": filled_usd,
"btc_amount": filled_qty, "order": order,
})
logger.info(
"BUY %.6f %s for $%.2f @ $%.0f | SL=$%.0f TP=$%.0f R:R=%.1f",
filled_qty, base, filled_usd, price,
risk.stop_loss_price, risk.take_profit_price, risk.risk_reward_ratio,
)
# Protect the WHOLE resulting position (prior holding + this fill),
# not just this trade's slice — one bracket covers everything.
if risk.stop_loss_price > 0:
total_qty = _amount_to_precision(exchange, symbol, portfolio["btc"] + filled_qty)
exit_orders = _place_exit_orders(
exchange, symbol, total_qty, risk.stop_loss_price,
)
result["stop_order"] = exit_orders.get("stop_loss_order")
result["tp_order"] = exit_orders.get("take_profit_order")
result["stop_protected"] = exit_orders.get("stop_protected", False)
except Exception as e:
result["error"] = str(e)
logger.error("BUY failed: %s", e)
elif action == "sell":
if portfolio["btc"] <= 0:
result["error"] = f"No {base} to sell"
return result
# Risk-managed sell: use recommended amount or 10% of holdings, whichever is less
holding_usd = portfolio["btc"] * price
sell_usd = min(amount, holding_usd * 0.10)
# A 10% trim can fall below the exchange's minimum notional even
# when the recommended amount wouldn't — bump up to the minimum
# (safe: it only means trimming a slightly larger slice), unless
# the whole position is smaller than the minimum (dust — can't be
# sold via a market order at all).
min_notional = _min_notional(exchange, symbol)
if sell_usd < min_notional:
if holding_usd >= min_notional:
# Cap at the full holding — never propose selling more
# than is actually owned.
bumped = min(round(min_notional * 1.01, 2), round(holding_usd, 2))
logger.info(
"SELL %s: sizing bumped $%.2f → $%.2f (10%% trim was below exchange minimum $%.2f)",
base, sell_usd, bumped, min_notional,
)
sell_usd = bumped
else:
result["error"] = (
f"Position (${holding_usd:.2f}) below exchange minimum "
f"(${min_notional:.2f}) — too small to sell"
)
logger.warning(result["error"])
return result
btc_qty = _amount_to_precision(exchange, symbol, sell_usd / price)
min_qty = (exchange.markets.get(symbol, {})
.get("limits", {}).get("amount", {}).get("min", 0.00001))
if btc_qty < min_qty:
result["error"] = f"Sell qty {btc_qty:.8f} below minimum {min_qty}"
logger.warning(result["error"])
return result
if dry_run:
result.update({"planned_usd": sell_usd, "planned_qty": btc_qty, "success": True})
return result
try:
result["submitted"] = True
order = exchange.create_market_sell_order(symbol, btc_qty)
filled_qty = float(order.get("filled") or btc_qty)
filled_usd = float(order.get("cost") or sell_usd)
result.update({
"success": True, "amount_usd": filled_usd,
"btc_amount": filled_qty, "order": order,
})
logger.info(
"SELL %.6f %s for $%.2f @ $%.0f | SL=$%.0f",
filled_qty, base, filled_usd, price, risk.stop_loss_price,
)
# Protect the REMAINING long after the trim (prior holding − sold).
remaining_qty = _amount_to_precision(exchange, symbol, max(0.0, portfolio["btc"] - filled_qty))
if risk.stop_loss_price > 0 and remaining_qty > 0:
exit_orders = _place_exit_orders(
exchange, symbol, remaining_qty, risk.stop_loss_price,
)
result["stop_order"] = exit_orders.get("stop_loss_order")
result["tp_order"] = exit_orders.get("take_profit_order")
result["stop_protected"] = exit_orders.get("stop_protected", False)
except Exception as e:
result["error"] = str(e)
logger.error("SELL failed: %s", e)
else: # hold
result["success"] = True
logger.info("HOLD — no trade | Risk assessment: %s", risk.position_rationale)
return result