diff --git a/main.py b/main.py index 98f12c2..dd46e96 100644 --- a/main.py +++ b/main.py @@ -35,6 +35,13 @@ STATE_FILE = "bot_state.json" STOP_ORDERS_FILE = "stop_orders.json" +def _calc_atr_stop_price(entry_price: float, atr: float) -> float: + """根據 ATR 動態計算止損價,帶上下限。""" + atr_pct = (atr * config.ATR_SL_MULTIPLIER) / entry_price + bounded_pct = max(config.ATR_SL_MIN_PCT, min(config.ATR_SL_MAX_PCT, atr_pct)) + return round(entry_price * (1 - bounded_pct), 8) + + def _load_state() -> dict: if os.path.exists(STATE_FILE): try: @@ -131,7 +138,10 @@ def run_cycle(): if min_amt > 0 and amount < min_amt: continue - expected_stop = round(entry * (1 - config.STOP_LOSS_PCT), 8) + # Use stored stop price if available (preserves ATR-calculated value); + # fall back to fixed % for legacy positions without a stored price + tracked = tracked_stops.get(sym, {}) + expected_stop = tracked.get("stop_price") or round(entry * (1 - config.STOP_LOSS_PCT), 8) existing_stops = stop_orders_by_sym.get(sym, []) if existing_stops: @@ -155,7 +165,7 @@ def run_cycle(): time.sleep(0.3) # Wait for Bitfinex to release locked balance logger.warning("Position %s missing/outdated stop-loss, placing now", sym) - sl = trader.place_stop_loss_order(sym, amount, entry) + sl = trader.place_stop_loss_order(sym, amount, entry, stop_price=expected_stop) if sl: # Update stop_orders_by_sym so step 10 sees the correct stop ID stop_orders_by_sym[sym] = [{"id": sl["order_id"], "symbol": sym, @@ -336,7 +346,7 @@ def run_cycle(): continue ind_df = indicators_by_symbol.get(signal.get("symbol")) - validated, reject_reason = risk_manager.validate_trade(signal, port, ind_df) + validated, reject_reason = risk_manager.validate_trade(signal, port, ind_df, current_prices) if validated is None: logger.info("Signal rejected by risk manager: %s %s — %s", action, signal.get("symbol"), reject_reason) rejected.append({**signal, "reject_reason": reject_reason}) @@ -400,7 +410,21 @@ def run_cycle(): # Place new stop-loss for TOTAL position amount at new avg entry total_amount = pos.get("amount", amount) entry_price = pos.get("entry_price", price) - sl = trader.place_stop_loss_order(sym, total_amount, entry_price) + + # ATR-based dynamic stop price + atr_stop = None + ind_df = indicators_by_symbol.get(sym) + if ind_df is not None and "atr" in ind_df.columns: + atr_val = ind_df["atr"].dropna().iloc[-1] if not ind_df["atr"].dropna().empty else None + if atr_val and atr_val > 0: + atr_stop = _calc_atr_stop_price(entry_price, atr_val) + atr_pct = (1 - atr_stop / entry_price) * 100 + logger.info("ATR stop for %s: ATR=%.6g, stop=%.6g (%.1f%%)", + sym, atr_val, atr_stop, atr_pct) + if atr_stop is None: + logger.info("ATR unavailable for %s, using fixed %.0f%% stop", sym, config.STOP_LOSS_PCT * 100) + + sl = trader.place_stop_loss_order(sym, total_amount, entry_price, stop_price=atr_stop) if sl: stop_price = sl["stop_price"] # Update stop_orders_by_sym so subsequent actions in this cycle see it diff --git a/trader.py b/trader.py index a86d1a3..f021444 100644 --- a/trader.py +++ b/trader.py @@ -65,13 +65,15 @@ def execute_trade(signal: dict, current_prices: dict, mode: str | None = None) - return trade_result -def place_stop_loss_order(symbol: str, amount: float, entry_price: float, mode: str | None = None) -> dict | None: +def place_stop_loss_order(symbol: str, amount: float, entry_price: float, + stop_price: float | None = None, mode: str | None = None) -> dict | None: """Place an EXCHANGE STOP order as stop-loss after a BUY. Args: symbol: Trading pair (e.g. tBTCUST) amount: Position size in base currency (positive, will be negated for sell) entry_price: The buy entry price + stop_price: Explicit stop price (e.g. from ATR calc). Falls back to fixed % if None. mode: "paper" or "live" Returns: @@ -85,16 +87,18 @@ def place_stop_loss_order(symbol: str, amount: float, entry_price: float, mode: logger.info("Skip stop-loss for %s: amount %.6f < min %s", symbol, abs(amount), min_amt) return None - stop_price = round(entry_price * (1 - config.STOP_LOSS_PCT), 8) + if stop_price is None: + stop_price = round(entry_price * (1 - config.STOP_LOSS_PCT), 8) # Truncate to 8 decimals (Bitfinex wallet precision) to avoid "not enough balance" amount = math.floor(abs(amount) * 1e8) / 1e8 sell_amount = -amount # negative = sell if mode == "paper": order_id = f"paper_sl_{int(time.time() * 1000)}" + sl_pct = (1 - stop_price / entry_price) * 100 if entry_price else 0 logger.info( "PAPER STOP-LOSS placed: %s sell %.6f @ %.6g (entry %.6g, SL %.1f%%)", - symbol, abs(amount), stop_price, entry_price, config.STOP_LOSS_PCT * 100, + symbol, abs(amount), stop_price, entry_price, sl_pct, ) return {"order_id": order_id, "stop_price": stop_price, "symbol": symbol, "mode": "paper"}