294 lines
12 KiB
Python
294 lines
12 KiB
Python
"""
|
||
AHAD QUANT — DCA Bot (Dollar Cost Averaging with Safety Orders)
|
||
5 built-in strategies: classic, aggressive, safe, trend, reverse
|
||
|
||
Usage (standalone):
|
||
python dca_bot.py
|
||
|
||
Or activated via .env:
|
||
DCA_BOT_ENABLED=true
|
||
DCA_PAIR=EURUSD
|
||
DCA_STRATEGY=classic
|
||
DCA_BASE_ORDER_USDT=100
|
||
DCA_SAFETY_ORDER_USDT=50
|
||
DCA_MAX_SAFETY_ORDERS=5
|
||
DCA_PRICE_DEVIATION=0.015
|
||
DCA_TAKE_PROFIT_PCT=0.02
|
||
|
||
How it works:
|
||
1. Opens a base order at market price
|
||
2. If price drops by DCA_PRICE_DEVIATION, adds a safety order (larger)
|
||
3. Safety orders scale up (each one buys more than the last)
|
||
4. Adjusts average entry price downward
|
||
5. Takes profit when price recovers to TP% above average entry
|
||
6. Repeats indefinitely
|
||
"""
|
||
|
||
import time, json, os
|
||
import config
|
||
|
||
try:
|
||
from exchange_adapter import get_exchange, ExchangeAdapter
|
||
HAS_ADAPTER = True
|
||
except ImportError:
|
||
HAS_ADAPTER = False
|
||
|
||
STRATEGIES = {
|
||
"classic": {"scale": 1.5, "dev_scale": 1.0, "desc": "Standard DCA"},
|
||
"aggressive": {"scale": 2.0, "dev_scale": 1.2, "desc": "Larger safety orders, faster recovery"},
|
||
"safe": {"scale": 1.2, "dev_scale": 0.8, "desc": "Smaller orders, more levels"},
|
||
"trend": {"scale": 1.5, "dev_scale": 1.0, "desc": "Only DCA in trend direction"},
|
||
"reverse": {"scale": 1.5, "dev_scale": 1.0, "desc": "Fades extremes — contrarian"},
|
||
}
|
||
|
||
STATE_FILE = "dca_state.json"
|
||
|
||
|
||
class DCABot:
|
||
"""
|
||
Dollar Cost Averaging bot with configurable safety orders.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
exchange: object,
|
||
coin: str = None,
|
||
strategy: str = None,
|
||
base_order_usdt: float = None,
|
||
safety_order_usdt: float = None,
|
||
max_safety_orders: int = None,
|
||
price_deviation: float = None,
|
||
take_profit_pct: float = None,
|
||
):
|
||
self.exchange = exchange
|
||
self.coin = coin or config.DCA_COIN
|
||
self.strategy = strategy or config.DCA_STRATEGY
|
||
self.base_order_usdt = base_order_usdt or config.DCA_BASE_ORDER_USDT
|
||
self.safety_order_usdt= safety_order_usdt or config.DCA_SAFETY_ORDER_USDT
|
||
self.max_safety = max_safety_orders or config.DCA_MAX_SAFETY_ORDERS
|
||
self.price_deviation = price_deviation or config.DCA_PRICE_DEVIATION
|
||
self.take_profit_pct = take_profit_pct or config.DCA_TAKE_PROFIT_PCT
|
||
|
||
cfg = STRATEGIES.get(self.strategy, STRATEGIES["classic"])
|
||
self.scale_factor = cfg["scale"] # each safety order = prev × scale
|
||
self.dev_scale = cfg["dev_scale"]# deviation multiplier per level
|
||
|
||
# Active deal state
|
||
self.active_deal: dict | None = None
|
||
self.completed_deals: int = 0
|
||
self.total_pnl: float = 0.0
|
||
self.running: bool = False
|
||
|
||
self._load_state()
|
||
print(f"[DCA] Strategy: {self.strategy} — {cfg['desc']}")
|
||
|
||
# ── Persistence ──────────────────────────────────────────────────────────
|
||
|
||
def _load_state(self):
|
||
if os.path.exists(STATE_FILE):
|
||
with open(STATE_FILE) as f:
|
||
s = json.load(f)
|
||
self.active_deal = s.get("active_deal")
|
||
self.completed_deals = s.get("completed_deals", 0)
|
||
self.total_pnl = s.get("total_pnl", 0.0)
|
||
if self.active_deal:
|
||
print(f"[DCA] Resumed deal — avg entry: "
|
||
f"{self.active_deal['avg_entry']:.4f} | "
|
||
f"safety orders used: {self.active_deal['n_safety']}")
|
||
|
||
def _save_state(self):
|
||
with open(STATE_FILE, "w") as f:
|
||
json.dump({
|
||
"active_deal": self.active_deal,
|
||
"completed_deals": self.completed_deals,
|
||
"total_pnl": self.total_pnl,
|
||
}, f, indent=2)
|
||
|
||
# ── Deal management ──────────────────────────────────────────────────────
|
||
|
||
def _open_deal(self, price: float) -> bool:
|
||
"""Open a new DCA deal with the base order."""
|
||
qty = self.base_order_usdt / price
|
||
try:
|
||
result = self.exchange.place_market_order(self.coin, "buy", qty)
|
||
if not result.get("success"):
|
||
return False
|
||
except Exception as e:
|
||
print(f"[DCA] Failed to open deal: {e}")
|
||
return False
|
||
|
||
self.active_deal = {
|
||
"base_price": price,
|
||
"avg_entry": price,
|
||
"total_qty": qty,
|
||
"total_cost": self.base_order_usdt,
|
||
"n_safety": 0,
|
||
"next_so_price": price * (1 - self.price_deviation * self.dev_scale),
|
||
"tp_price": price * (1 + self.take_profit_pct),
|
||
}
|
||
self._save_state()
|
||
print(f"[DCA] Deal opened — {self.coin} @ {price:.4f} | "
|
||
f"Qty: {qty:.4f} | TP: {self.active_deal['tp_price']:.4f}")
|
||
return True
|
||
|
||
def _add_safety_order(self, current_price: float, deal: dict) -> bool:
|
||
"""Add a safety order at current price."""
|
||
n = deal["n_safety"] + 1
|
||
# Safety order size scales up geometrically
|
||
so_usdt = self.safety_order_usdt * (self.scale_factor ** (n - 1))
|
||
qty = so_usdt / current_price
|
||
|
||
try:
|
||
result = self.exchange.place_market_order(self.coin, "buy", qty)
|
||
if not result.get("success"):
|
||
return False
|
||
except Exception as e:
|
||
print(f"[DCA] Safety order failed: {e}")
|
||
return False
|
||
|
||
# Update deal state
|
||
total_qty = deal["total_qty"] + qty
|
||
total_cost = deal["total_cost"] + so_usdt
|
||
avg_entry = total_cost / total_qty
|
||
|
||
# Next SO price (increasing deviation per level)
|
||
next_dev = self.price_deviation * self.dev_scale * (n + 1)
|
||
next_so = avg_entry * (1 - next_dev)
|
||
tp_price = avg_entry * (1 + self.take_profit_pct)
|
||
|
||
deal.update({
|
||
"avg_entry": avg_entry,
|
||
"total_qty": total_qty,
|
||
"total_cost": total_cost,
|
||
"n_safety": n,
|
||
"next_so_price": next_so,
|
||
"tp_price": tp_price,
|
||
})
|
||
self._save_state()
|
||
print(f"[DCA] Safety order #{n} — {self.coin} @ {current_price:.4f} | "
|
||
f"Qty: {qty:.4f} | Avg entry: {avg_entry:.4f} | "
|
||
f"TP now: {tp_price:.4f}")
|
||
return True
|
||
|
||
def _close_deal(self, current_price: float, deal: dict, reason: str = "tp") -> float:
|
||
"""Close the full DCA position and calculate PnL."""
|
||
qty = deal["total_qty"]
|
||
try:
|
||
self.exchange.place_market_order(self.coin, "sell", qty)
|
||
except Exception as e:
|
||
print(f"[DCA] Close failed: {e}")
|
||
return 0.0
|
||
|
||
pnl = (current_price - deal["avg_entry"]) * qty
|
||
pnl -= deal["total_cost"] * config.FEE_RATE * 2 # fees (config.FEE_RATE)
|
||
|
||
self.total_pnl += pnl
|
||
self.completed_deals += 1
|
||
self.active_deal = None
|
||
self._save_state()
|
||
|
||
sign = "+" if pnl >= 0 else ""
|
||
print(f"[DCA] Deal #{self.completed_deals} closed ({reason}) — "
|
||
f"PnL: {sign}{pnl:.2f} USD | Total PnL: {self.total_pnl:+.2f} USD")
|
||
return pnl
|
||
|
||
# ── Strategy-specific entry logic ─────────────────────────────────────────
|
||
|
||
def _should_open_deal(self, current_price: float) -> bool:
|
||
"""Strategy-specific entry condition."""
|
||
if self.strategy == "trend":
|
||
# Only open if price is above 20-period MA (uptrend)
|
||
try:
|
||
candles = self.exchange.get_candles(self.coin, "1h", 25)
|
||
ma20 = sum(c["c"] for c in candles[-20:]) / 20
|
||
return current_price > ma20
|
||
except Exception:
|
||
return True
|
||
elif self.strategy == "reverse":
|
||
# Only open if RSI is oversold (< 30)
|
||
try:
|
||
candles = self.exchange.get_candles(self.coin, "1h", 20)
|
||
closes = [c["c"] for c in candles]
|
||
changes = [closes[i] - closes[i-1] for i in range(1, len(closes))]
|
||
gains = [max(c, 0) for c in changes]
|
||
losses = [abs(min(c, 0)) for c in changes]
|
||
avg_gain = sum(gains[-14:]) / 14
|
||
avg_loss = sum(losses[-14:]) / 14
|
||
rsi = 100 - (100 / (1 + avg_gain / avg_loss)) if avg_loss else 100
|
||
return rsi < 35
|
||
except Exception:
|
||
return True
|
||
return True # classic, aggressive, safe: always open
|
||
|
||
# ── Main loop ─────────────────────────────────────────────────────────────
|
||
|
||
def start(self):
|
||
"""Start the DCA bot main loop."""
|
||
print(f"\n[DCA] Starting DCA Bot — {self.coin} | Strategy: {self.strategy}")
|
||
print(f" Base order: ${self.base_order_usdt} | "
|
||
f"Safety order: ${self.safety_order_usdt} | "
|
||
f"Max safety orders: {self.max_safety} | "
|
||
f"Deviation: {self.price_deviation:.1%} | "
|
||
f"TP: {self.take_profit_pct:.1%}")
|
||
self.running = True
|
||
|
||
while self.running:
|
||
try:
|
||
book = self.exchange.get_orderbook(self.coin)
|
||
price = book["mid"]
|
||
|
||
if self.active_deal is None:
|
||
# No active deal — check if we should open one
|
||
if self._should_open_deal(price):
|
||
self._open_deal(price)
|
||
else:
|
||
print(f"[DCA] Waiting for entry signal — "
|
||
f"{self.coin} @ {price:.4f}")
|
||
else:
|
||
deal = self.active_deal
|
||
|
||
# Check TP
|
||
if price >= deal["tp_price"]:
|
||
self._close_deal(price, deal, "tp")
|
||
time.sleep(5)
|
||
continue
|
||
|
||
# Check if safety order needed
|
||
if (deal["n_safety"] < self.max_safety and
|
||
price <= deal["next_so_price"]):
|
||
self._add_safety_order(price, deal)
|
||
else:
|
||
# Status update
|
||
unrealized = (price - deal["avg_entry"]) * deal["total_qty"]
|
||
pct = (price - deal["avg_entry"]) / deal["avg_entry"]
|
||
sign = "+" if pct >= 0 else ""
|
||
print(f"[DCA] {self.coin} @ {price:.4f} | "
|
||
f"Avg: {deal['avg_entry']:.4f} | "
|
||
f"Unrealized: {sign}{unrealized:.2f} ({sign}{pct:.2%}) | "
|
||
f"SO: {deal['n_safety']}/{self.max_safety} | "
|
||
f"TP: {deal['tp_price']:.4f}")
|
||
|
||
time.sleep(60)
|
||
|
||
except KeyboardInterrupt:
|
||
self.stop()
|
||
break
|
||
except Exception as e:
|
||
print(f"[DCA] Error: {e}")
|
||
time.sleep(15)
|
||
|
||
def stop(self):
|
||
print(f"\n[DCA] Stopped — Completed deals: {self.completed_deals} | "
|
||
f"Total PnL: {self.total_pnl:+.2f} USD")
|
||
self.running = False
|
||
|
||
|
||
if __name__ == "__main__":
|
||
if not HAS_ADAPTER:
|
||
print("[ERROR] exchange_adapter.py not found")
|
||
exit(1)
|
||
exchange = get_exchange(config.EXCHANGE)
|
||
exchange.connect()
|
||
bot = DCABot(exchange)
|
||
bot.start()
|