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()
|