Add ATR-based dynamic stop-loss and pass current_prices to validate_trade

- Add _calc_atr_stop_price(): ATR × 3 with 3-8% bounds
- place_stop_loss_order() accepts explicit stop_price from ATR calc
- Stop-loss backfill uses stored stop price before falling back to fixed %
- Pass current_prices to validate_trade() for LLM SELL PnL calculation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kroutony 2026-03-15 03:26:54 +00:00
parent 7f03d479c7
commit ddc1b9e3eb
2 changed files with 35 additions and 7 deletions

32
main.py
View File

@ -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

View File

@ -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,6 +87,7 @@ 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
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
@ -92,9 +95,10 @@ def place_stop_loss_order(symbol: str, amount: float, entry_price: float, mode:
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"}