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:
parent
7f03d479c7
commit
ddc1b9e3eb
32
main.py
32
main.py
@ -35,6 +35,13 @@ STATE_FILE = "bot_state.json"
|
|||||||
STOP_ORDERS_FILE = "stop_orders.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:
|
def _load_state() -> dict:
|
||||||
if os.path.exists(STATE_FILE):
|
if os.path.exists(STATE_FILE):
|
||||||
try:
|
try:
|
||||||
@ -131,7 +138,10 @@ def run_cycle():
|
|||||||
if min_amt > 0 and amount < min_amt:
|
if min_amt > 0 and amount < min_amt:
|
||||||
continue
|
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, [])
|
existing_stops = stop_orders_by_sym.get(sym, [])
|
||||||
|
|
||||||
if existing_stops:
|
if existing_stops:
|
||||||
@ -155,7 +165,7 @@ def run_cycle():
|
|||||||
time.sleep(0.3) # Wait for Bitfinex to release locked balance
|
time.sleep(0.3) # Wait for Bitfinex to release locked balance
|
||||||
|
|
||||||
logger.warning("Position %s missing/outdated stop-loss, placing now", sym)
|
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:
|
if sl:
|
||||||
# Update stop_orders_by_sym so step 10 sees the correct stop ID
|
# Update stop_orders_by_sym so step 10 sees the correct stop ID
|
||||||
stop_orders_by_sym[sym] = [{"id": sl["order_id"], "symbol": sym,
|
stop_orders_by_sym[sym] = [{"id": sl["order_id"], "symbol": sym,
|
||||||
@ -336,7 +346,7 @@ def run_cycle():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
ind_df = indicators_by_symbol.get(signal.get("symbol"))
|
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:
|
if validated is None:
|
||||||
logger.info("Signal rejected by risk manager: %s %s — %s", action, signal.get("symbol"), reject_reason)
|
logger.info("Signal rejected by risk manager: %s %s — %s", action, signal.get("symbol"), reject_reason)
|
||||||
rejected.append({**signal, "reject_reason": 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
|
# Place new stop-loss for TOTAL position amount at new avg entry
|
||||||
total_amount = pos.get("amount", amount)
|
total_amount = pos.get("amount", amount)
|
||||||
entry_price = pos.get("entry_price", price)
|
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:
|
if sl:
|
||||||
stop_price = sl["stop_price"]
|
stop_price = sl["stop_price"]
|
||||||
# Update stop_orders_by_sym so subsequent actions in this cycle see it
|
# Update stop_orders_by_sym so subsequent actions in this cycle see it
|
||||||
|
|||||||
10
trader.py
10
trader.py
@ -65,13 +65,15 @@ def execute_trade(signal: dict, current_prices: dict, mode: str | None = None) -
|
|||||||
return trade_result
|
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.
|
"""Place an EXCHANGE STOP order as stop-loss after a BUY.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
symbol: Trading pair (e.g. tBTCUST)
|
symbol: Trading pair (e.g. tBTCUST)
|
||||||
amount: Position size in base currency (positive, will be negated for sell)
|
amount: Position size in base currency (positive, will be negated for sell)
|
||||||
entry_price: The buy entry price
|
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"
|
mode: "paper" or "live"
|
||||||
|
|
||||||
Returns:
|
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)
|
logger.info("Skip stop-loss for %s: amount %.6f < min %s", symbol, abs(amount), min_amt)
|
||||||
return None
|
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"
|
# Truncate to 8 decimals (Bitfinex wallet precision) to avoid "not enough balance"
|
||||||
amount = math.floor(abs(amount) * 1e8) / 1e8
|
amount = math.floor(abs(amount) * 1e8) / 1e8
|
||||||
sell_amount = -amount # negative = sell
|
sell_amount = -amount # negative = sell
|
||||||
|
|
||||||
if mode == "paper":
|
if mode == "paper":
|
||||||
order_id = f"paper_sl_{int(time.time() * 1000)}"
|
order_id = f"paper_sl_{int(time.time() * 1000)}"
|
||||||
|
sl_pct = (1 - stop_price / entry_price) * 100 if entry_price else 0
|
||||||
logger.info(
|
logger.info(
|
||||||
"PAPER STOP-LOSS placed: %s sell %.6f @ %.6g (entry %.6g, SL %.1f%%)",
|
"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"}
|
return {"order_id": order_id, "stop_price": stop_price, "symbol": symbol, "mode": "paper"}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user