Automasi Trading
Mari kita bedah mekanik di balik automasi trading tanpa ilusi, cari tahu bot jenis apa yang benar-benar cuan di market crypto, di mana letak risiko tersembunyi, dan bagaimana cara merancang logika kodenya.
1. Arsitektur Strategi: Apa yang Benar-Benar Efektif di Lapangan
Hanya ada tiga jenis sistem terotomasi yang bisa bertahan di market. Selebihnya cuma trik marketing exchange buat ngeruk fee transaksi.
- Grid Trading: Cocok banget buat market sideways (konsolidasi). Bot bakal masang tangga limit order buat beli di bawah harga sekarang dan jual di atasnya, lalu profit dari fluktuasi di dalam rentang koridor tersebut.
- Dollar-Cost Averaging (Bot DCA): Strategi akumulasi murni. Bot nyicil aset dalam porsi yang sama di interval waktu tertentu atau pas harga turun di persentase tertentu. Cocok dipakai di market spot buat portofolio jangka panjang.
- Arbitrase: Nangkep selisih harga spot antar exchange atau spread antara market futures dan spot (funding rate arbitrage). Butuh low latency dan API yang di-optimize secara maksimal.
2. Matematika Grid Bot: Kenapa 80% User Ludes Dananya
Kebanyakan orang langsung nyalain grid bot pas altcoin volatil lagi di puncak (all-time high) pakai settingan default. Hasilnya selalu sama: jebol ke bawah, bot terpaksa jual semua aset dasarnya di harga murah, dan portofolio nyangkut dengan floating loss sampai 60%.
Biar nggak kejadian kayak gitu, kalkulasi parameternya harus ikutan aturan ketat:
Perbandingan Pendekatan Setup Grid:
- Pemilihan Aset: Kesalahan pemula – milih memecoin hype dengan volatilitas liar. Pendekatan pro – pakai koin top-20 dengan likuiditas tinggi dan level support kuat (BTC, ETH).
- Tipe Grid: Kesalahan pemula – grid aritmatika (jarak dolar sama rata). Pendekatan pro – grid geometris (jarak persentase sama rata, krusial banget buat persentase yield yang stabil).
- Rentang (Range): Kesalahan pemula – koridor sempit cuma 5% dari harga sekarang. Pendekatan pro – rentang lebar yang bisa meng-cover tren sideways historis selama 3–6 bulan terakhir.
3. Implementasi Praktis: Kode Modul Grid Trading
Di bawah ini ada potongan script Python fungsional pakai library ccxt. Ini adalah core logic untuk ngehasilin order grid dengan jarak geometris. Di bagian komentar udah gua jelasin fungsi masing-masing barisnya.
"""
Perencana grid limit order untuk Binance Spot (mode dry-run).
Model Grid
----------
Level L0 < L1 < ... < Ln membentuk n cell [L_k, L_{k+1}].
Di tiap cell hanya ada tepat satu order aktif:
- L_k < harga -> BUY di L_k. Setelah terisi, SELL dipasang di L_{k+1}
sebesar jumlah yang diterima dikurangi fee mata uang dasar.
- L_k >= harga -> SELL di L_{k+1}. Membutuhkan cadangan awal mata uang dasar
(harus dibeli lewat market order terlebih dahulu).
Level yang sama dengan harga saat ini masuk ke cell SELL (tidak dilewati).
Script ini SAMA SEKALI TIDAK mengirim apa pun ke exchange.
"""
from __future__ import annotations
import argparse
import logging
import sys
from dataclasses import dataclass
from decimal import (
ROUND_CEILING,
ROUND_FLOOR,
ROUND_HALF_UP,
Decimal,
InvalidOperation,
localcontext,
)
from typing import Any
import ccxt
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger("grid_planner")
ZERO = Decimal("0")
ONE = Decimal("1")
# --------------------------------------------------------------------------
# Utilitas Decimal
# --------------------------------------------------------------------------
def dec(value: Any, name: str) -> Decimal:
try:
result = Decimal(str(value))
except (InvalidOperation, ValueError, TypeError) as exc:
raise ValueError(f"{name}: nilai numerik tidak valid") from exc
if not result.is_finite():
raise ValueError(f"{name}: nilai harus berhingga")
return result
def text(value: Decimal) -> str:
"""Decimal -> string tanpa eksponen dan nol berlebih."""
return format(value.normalize(), "f")
def floor_to_step(value: Decimal, step: Decimal) -> Decimal:
return (value / step).to_integral_value(rounding=ROUND_FLOOR) * step
def ceil_to_step(value: Decimal, step: Decimal) -> Decimal:
return (value / step).to_integral_value(rounding=ROUND_CEILING) * step
def round_to_step(value: Decimal, step: Decimal) -> Decimal:
return (value / step).to_integral_value(rounding=ROUND_HALF_UP) * step
# --------------------------------------------------------------------------
# Aturan (Filter) Pair Binance Spot
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class SymbolRules:
tick: Decimal
step: Decimal
min_price: Decimal
max_price: Decimal | None
min_qty: Decimal
max_qty: Decimal | None
min_notional: Decimal
max_notional: Decimal | None
percent_general: tuple[Decimal, Decimal] | None # (down, up)
percent_by_side: dict[str, tuple[Decimal, Decimal]]
# Window rata-rata untuk filter persentase; 0 = menggunakan harga terakhir.
avg_price_mins: int | None
@property
def has_percent_filters(self) -> bool:
return self.percent_general is not None or bool(self.percent_by_side)
def _positive_or_none(value: Any, name: str) -> Decimal | None:
"""Dalam filter Binance, 0 berarti 'batasan dinonaktifkan'."""
if value is None:
return None
result = dec(value, name)
return result if result > 0 else None
def load_rules(market: dict) -> SymbolRules:
"""Membaca filter sekali saja. Kendala di sini berarti kesalahan konfigurasi."""
info = market.get("info") or {}
status = info.get("status")
if status is not None and status != "TRADING":
raise RuntimeError(f"Status pair adalah {status}, ekspetasinya TRADING")
if info.get("isSpotTradingAllowed") is False:
raise RuntimeError("Trading Spot dinonaktifkan untuk pair ini")
filters = {
item.get("filterType"): item
for item in info.get("filters", [])
if item.get("filterType")
}
price_filter = filters.get("PRICE_FILTER")
lot_filter = filters.get("LOT_SIZE")
if not price_filter:
raise RuntimeError("Binance PRICE_FILTER tidak ditemukan")
if not lot_filter:
raise RuntimeError("Binance LOT_SIZE tidak ditemukan")
tick = dec(price_filter["tickSize"], "PRICE_FILTER.tickSize")
step = dec(lot_filter["stepSize"], "LOT_SIZE.stepSize")
if tick <= 0 or step <= 0:
raise RuntimeError("tickSize dan stepSize harus lebih besar dari nol")
min_notional = ZERO
max_notional = None
for filter_type in ("MIN_NOTIONAL", "NOTIONAL"):
item = filters.get(filter_type)
if not item:
continue
if item.get("minNotional") is not None:
min_notional = max(
min_notional,
dec(item["minNotional"], f"{filter_type}.minNotional"),
)
if item.get("maxNotional") is not None:
max_notional = _positive_or_none(
item["maxNotional"], f"{filter_type}.maxNotional"
)
percent_general = None
item = filters.get("PERCENT_PRICE")
if item:
percent_general = (
dec(item["multiplierDown"], "PERCENT_PRICE.multiplierDown"),
dec(item["multiplierUp"], "PERCENT_PRICE.multiplierUp"),
)
percent_by_side: dict[str, tuple[Decimal, Decimal]] = {}
item = filters.get("PERCENT_PRICE_BY_SIDE")
if item:
percent_by_side["buy"] = (
dec(item["bidMultiplierDown"], "bidMultiplierDown"),
dec(item["bidMultiplierUp"], "bidMultiplierUp"),
)
percent_by_side["sell"] = (
dec(item["askMultiplierDown"], "askMultiplierDown"),
dec(item["askMultiplierUp"], "askMultiplierUp"),
)
avg_mins_values = set()
for key in ("PERCENT_PRICE", "PERCENT_PRICE_BY_SIDE"):
item = filters.get(key)
if item and item.get("avgPriceMins") is not None:
avg_mins_values.add(int(dec(item["avgPriceMins"], "avgPriceMins")))
if len(avg_mins_values) > 1:
raise RuntimeError(
f"Nilai avgPriceMins pada filter berbeda-beda: {sorted(avg_mins_values)}"
)
avg_price_mins = avg_mins_values.pop() if avg_mins_values else None
return SymbolRules(
tick=tick,
step=step,
min_price=dec(price_filter.get("minPrice", "0"), "minPrice"),
max_price=_positive_or_none(price_filter.get("maxPrice"), "maxPrice"),
min_qty=dec(lot_filter.get("minQty", "0"), "LOT_SIZE.minQty"),
max_qty=_positive_or_none(lot_filter.get("maxQty"), "LOT_SIZE.maxQty"),
min_notional=min_notional,
max_notional=max_notional,
percent_general=percent_general,
percent_by_side=percent_by_side,
avg_price_mins=avg_price_mins,
)
# --------------------------------------------------------------------------
# Grid
# --------------------------------------------------------------------------
def align_bounds(
lower: Decimal, upper: Decimal, tick: Decimal
) -> tuple[Decimal, Decimal]:
"""Batas diratakan ke arah dalam range."""
lower = ceil_to_step(lower, tick)
upper = floor_to_step(upper, tick)
if lower >= upper:
raise ValueError("Range kosong setelah disejajarkan dengan tickSize")
return lower, upper
def calculate_grid_levels(
lower_bound: Decimal,
upper_bound: Decimal,
grid_count: int,
tick: Decimal,
) -> list[Decimal]:
"""Grid geometris: grid_count interval, grid_count + 1 level."""
if not isinstance(grid_count, int) or isinstance(grid_count, bool):
raise ValueError("grid_count harus berupa integer")
if grid_count < 1:
raise ValueError("grid_count minimal harus 1")
if min(lower_bound, upper_bound, tick) <= 0:
raise ValueError("Batas dan tickSize harus lebih besar dari nol")
if lower_bound >= upper_bound:
raise ValueError("lower_bound harus lebih kecil dari upper_bound")
lower, upper = align_bounds(lower_bound, upper_bound, tick)
with localcontext() as ctx:
ctx.prec = 50
ratio = (upper / lower) ** (ONE / Decimal(grid_count))
# Setiap level dihitung dari batas bawah: mencegah akumulasi error.
levels = [
round_to_step(lower * (ratio ** i), tick)
for i in range(grid_count + 1)
]
levels[0] = lower
levels[-1] = upper
if any(a >= b for a, b in zip(levels, levels[1:])):
raise ValueError(
"Level beririsan atau urutan rusak setelah pembulatan tick. "
"Kurangi grid_count atau lebarkan range."
)
return levels
def check_grid_economics(
levels: list[Decimal],
fee_rate: Decimal,
extra_cost_rate: Decimal,
) -> None:
"""
Filter aproksimasi: yield bersih cell dengan memperhitungkan fee
di kedua sisi harus melebihi buffer biaya.
Ini bukan garansi profitabilitas.
"""
if fee_rate < 0 or extra_cost_rate < 0:
raise ValueError("Fee dan buffer biaya tidak boleh < 0")
keep = (ONE - fee_rate) ** 2
for low, high in zip(levels, levels[1:]):
net_return = (high / low) * keep - ONE
if net_return <= extra_cost_rate:
raise ValueError(
f"Step grid terlalu sempit: {text(low)} -> {text(high)}; "
f"yield bersih {net_return:.4%}, "
f"butuh di atas {extra_cost_rate:.4%}"
)
# --------------------------------------------------------------------------
# Validasi Order Berdasarkan Filter Exchange
# --------------------------------------------------------------------------
def validate_order(
rules: SymbolRules,
side: str,
price: Decimal,
amount: Decimal,
avg_price: Decimal | None = None,
) -> Decimal:
"""Mengembalikan nilai notional order atau memunculkan ValueError."""
if side not in ("buy", "sell"):
raise ValueError("side harus bernilai buy atau sell")
if price <= 0 or amount <= 0:
raise ValueError(
f"{side.upper()} @ {text(price)}: harga dan jumlah harus > 0 "
f"(amount={text(amount)})"
)
if price % rules.tick != 0:
raise ValueError(f"Harga {text(price)} bukan kelipatan tickSize")
if amount % rules.step != 0:
raise ValueError(f"Jumlah {text(amount)} bukan kelipatan stepSize")
if price < rules.min_price:
raise ValueError(f"Harga {text(price)} < minPrice {text(rules.min_price)}")
if rules.max_price is not None and price > rules.max_price:
raise ValueError(f"Harga {text(price)} > maxPrice {text(rules.max_price)}")
if amount < rules.min_qty:
raise ValueError(
f"Jumlah {text(amount)} < minQty {text(rules.min_qty)}"
)
if rules.max_qty is not None and amount > rules.max_qty:
raise ValueError(
f"Jumlah {text(amount)} > maxQty {text(rules.max_qty)}"
)
cost = price * amount
if cost < rules.min_notional:
raise ValueError(
f"Notional {text(cost)} < minNotional {text(rules.min_notional)}"
)
if rules.max_notional is not None and cost > rules.max_notional:
raise ValueError(
f"Notional {text(cost)} > maxNotional {text(rules.max_notional)}"
)
if avg_price is not None:
bounds = []
if rules.percent_general:
bounds.append(("PERCENT_PRICE", *rules.percent_general))
if side in rules.percent_by_side:
bounds.append(("PERCENT_PRICE_BY_SIDE", *rules.percent_by_side[side]))
for name, down, up in bounds:
minimum, maximum = avg_price * down, avg_price * up
if not minimum <= price <= maximum:
raise ValueError(
f"{name}: harga {text(price)} di luar rentang "
f"{text(minimum)}–{text(maximum)}"
)
return cost
# --------------------------------------------------------------------------
# Perencana Grid
# --------------------------------------------------------------------------
def build_grid_plan(
levels: list[Decimal],
current_price: Decimal,
rules: SymbolRules,
budget_per_level: Decimal,
fee_rate: Decimal,
fee_in_base: bool,
avg_price: Decimal | None,
) -> tuple[list[dict], list[str]]:
"""
Membangun order lintas cell. Mengembalikan (orders, skipped_cells).
Fee mata uang dasar mengurangi jumlah SELL pasangannya.
"""
if budget_per_level <= 0:
raise ValueError("Budget per level harus lebih besar dari nol")
base_fee = fee_rate if fee_in_base else ZERO
orders: list[dict] = []
skipped: list[str] = []
for k, (low, high) in enumerate(zip(levels, levels[1:])):
try:
if low < current_price:
# Cell BUY: beli di low, jual di high.
amount = floor_to_step(budget_per_level / low, rules.step)
cost = validate_order(rules, "buy", low, amount, avg_price)
sell_amount = floor_to_step(amount * (ONE - base_fee), rules.step)
# SELL pasangan akan dipasang nanti, filter persentase akan
# divalidasi langsung oleh exchange, jadi avg_price tidak diteruskan di sini.
validate_order(rules, "sell", high, sell_amount)
orders.append({
"cell": k,
"side": "buy",
"price": low,
"amount": amount,
"notional": cost,
"on_fill_sell_price": high,
"on_fill_sell_amount": sell_amount,
})
else:
# Cell SELL: jumlah dihitung dari harga SELL itu sendiri,
# memastikan notional tidak melebihi budget level.
amount = floor_to_step(budget_per_level / high, rules.step)
sell_cost = validate_order(
rules, "sell", high, amount, avg_price
)
orders.append({
"cell": k,
"side": "sell",
"price": high,
"amount": amount,
"notional": sell_cost,
})
except ValueError as exc:
skipped.append(f"cell {k} [{text(low)}–{text(high)}]: {exc}")
return orders, skipped
def summarize_plan(
orders: list[dict],
fee_rate: Decimal,
taker_fee_rate: Decimal,
fee_in_base: bool,
ask: Decimal,
slippage: Decimal,
bnb_price: Decimal | None,
) -> dict:
"""
Estimasi kebutuhan modal. Ini adalah ESTIMASI dengan buffer slippage,
bukan garansi: harga eksekusi aktual dan fee pembelian awal bisa berbeda.
"""
buys = [o for o in orders if o["side"] == "buy"]
sells = [o for o in orders if o["side"] == "sell"]
# Eksekusi order BUY menyerap tidak lebih dari notional: fee diambil
# dari aset yang diterima (BTC) atau dari BNB, bukan dari USDT.
quote_for_buys = sum((o["notional"] for o in buys), ZERO)
base_net_needed = sum((o["amount"] for o in sells), ZERO)
# Pembelian awal market (taker). Saat fee dalam aset dasar, kita perlu membeli
# lebih banyak agar base_net_needed tetap terpenuhi setelah dikurangi fee.
taker_base_fee = taker_fee_rate if fee_in_base else ZERO
base_gross = base_net_needed / (ONE - taker_base_fee)
initial_notional = base_gross * ask * (ONE + slippage)
bnb_fee_quote = ZERO
bnb_needed = None
if not fee_in_base:
# Limit order (BUY, SELL pasang dan awal) menggunakan fee maker,
# pembelian awal market menggunakan fee taker. Limit order yang langsung
# menembus orderbook akan tereksekusi sebagai taker: estimasi ini tidak mencakup hal tersebut.
buy_leg = sum((o["notional"] for o in buys), ZERO)
paired_sell_leg = sum(
(o["on_fill_sell_price"] * o["on_fill_sell_amount"] for o in buys),
ZERO,
)
sell_leg = sum((o["notional"] for o in sells), ZERO)
bnb_fee_quote = (
(buy_leg + paired_sell_leg + sell_leg) * fee_rate
+ initial_notional * taker_fee_rate
)
if bnb_price is not None:
bnb_needed = bnb_fee_quote / bnb_price
return {
"buy_orders": len(buys),
"sell_orders": len(sells),
"quote_for_buys": quote_for_buys,
"base_needed_for_sells": base_net_needed,
"initial_buy_cost_estimate": initial_notional,
"total_quote_estimate": quote_for_buys + initial_notional,
"bnb_fee_quote_equivalent": bnb_fee_quote,
"bnb_needed": bnb_needed,
}
# --------------------------------------------------------------------------
# Integrasi Exchange
# --------------------------------------------------------------------------
def restrict_to_spot(exchange) -> None:
"""Instruksikan ccxt untuk mengambil market spot saja (format opsi tergantung versi)."""
fetch_markets = exchange.options.get("fetchMarkets")
if isinstance(fetch_markets, list):
exchange.options["fetchMarkets"] = ["spot"]
elif isinstance(fetch_markets, dict) and "types" in fetch_markets:
exchange.options["fetchMarkets"] = {**fetch_markets, "types": ["spot"]}
def get_ticker_prices(
exchange, symbol: str
) -> tuple[Decimal, Decimal, Decimal | None]:
"""Mengembalikan (bid, ask, last)."""
ticker = exchange.fetch_ticker(symbol)
if ticker.get("bid") is None or ticker.get("ask") is None:
raise RuntimeError(f"{symbol}: exchange tidak mengembalikan bid/ask")
bid = dec(ticker["bid"], "bid")
ask = dec(ticker["ask"], "ask")
if bid <= 0 or ask <= 0 or bid > ask:
raise RuntimeError(f"{symbol}: bid/ask tidak valid {bid}/{ask}")
last = ticker.get("last")
return bid, ask, (dec(last, "last") if last is not None else None)
def fetch_percent_reference_price(
exchange, market: dict, rules: SymbolRules, last: Decimal | None
) -> Decimal:
"""
Harga referensi untuk PERCENT_PRICE*. Mereplikasi aturan Binance:
avgPriceMins == 0 -> harga terakhir, jika tidak maka rata-rata dari avgPriceMins menit.
Window yang dikembalikan /avgPrice dicocokkan dengan window dari filter.
Pemeriksaan akhir tetap divalidasi oleh exchange saat submit order.
"""
if rules.avg_price_mins == 0:
if last is None:
raise RuntimeError("Filter membutuhkan harga last, tapi exchange tidak mengembalikannya")
return last
response = exchange.publicGetAvgPrice({"symbol": market["id"]})
price = dec(response["price"], "avgPrice")
if price <= 0:
raise RuntimeError("Binance mengembalikan avgPrice yang tidak valid")
mins = response.get("mins")
if (
rules.avg_price_mins is not None
and mins is not None
and int(mins) != rules.avg_price_mins
):
raise RuntimeError(
f"Window avgPrice ({mins} min) tidak cocok dengan avgPriceMins "
f"filter ({rules.avg_price_mins} min): tidak dapat mereplikasi pemeriksaan"
)
return price
def resolve_rate(
market: dict,
key: str,
explicit: Decimal | None,
fee_in_bnb: bool,
) -> Decimal:
"""
Fee rate: eksplisit, atau dari market (maker/taker), default ke 0.1%.
Diskon fee BNB TIDAK diasumsikan: tier fee akun aktual harus
diteruskan secara eksplisit, jika tidak estimasi bersifat konservatif (tanpa diskon).
"""
if explicit is not None:
return explicit
value = market.get(key)
rate = dec(value, key) if value is not None else Decimal("0.001")
if fee_in_bnb:
logger.warning(
"Fee rate %s tidak di-set secara eksplisit: menggunakan %s tanpa diskon BNB. "
"Teruskan rate akun aktual Anda melalui "
"--fee-rate / --taker-fee-rate.",
key, rate,
)
return rate
def paired_sell_amount(
executed_qty: Decimal,
fills: list[dict],
base_asset: str,
free_balance: Decimal,
price: Decimal,
rules: SymbolRules,
) -> Decimal:
"""
Untuk modul eksekusi masa depan: jumlah SELL pasangan setelah eksekusi BUY aktual.
executed_qty — jumlah kotor yang dibeli (executedQty order, sebelum fee).
fills — eksekusi order format ccxt: masing-masing berisi
fill["fee"] = {"cost": ..., "currency": ...} atau None.
Hanya fee yang dipotong dalam mata uang dasar yang dikurangi: fee dalam BNB
atau mata uang quote tidak mengurangi jumlah aset.
free_balance — saldo bebas mata uang dasar yang tidak dicadangkan untuk order lain
(caller bertanggung jawab memastikan ini).
"""
fee_in_base = ZERO
for fill in fills:
fee = fill.get("fee")
if (
fee
and fee.get("currency") == base_asset
and fee.get("cost") is not None
):
fee_in_base += dec(fee["cost"], "fee.cost")
net = executed_qty - fee_in_base
if net <= 0:
raise ValueError("Jumlah bersih tidak positif setelah pemotongan fee")
amount = floor_to_step(min(net, free_balance), rules.step)
validate_order(rules, "sell", price, amount)
return amount
# --------------------------------------------------------------------------
# Main Execution
# --------------------------------------------------------------------------
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--symbol", default="BTC/USDT")
parser.add_argument("--lower", default="50000")
parser.add_argument("--upper", default="70000")
parser.add_argument("--grid-count", type=int, default=20)
parser.add_argument(
"--budget-per-level",
default="100",
help="Nilai notional maksimum dari satu order dalam mata uang quote",
)
parser.add_argument(
"--fee-rate", default=None,
help="Rate fee maker (fraksi), default mengikuti rate maker market",
)
parser.add_argument(
"--taker-fee-rate", default=None,
help="Rate fee taker untuk pembelian awal, default mengikuti rate taker market",
)
parser.add_argument(
"--fee-in-bnb", action="store_true",
help="Fee dibayar menggunakan BNB alih-alih aset yang diterima",
)
parser.add_argument("--extra-cost-rate", default="0.0005")
parser.add_argument(
"--slippage", default="0.002",
help="Buffer slippage pembelian awal (fraksi)",
)
return parser.parse_args(argv)
def optional_rate(value: str | None, name: str) -> Decimal | None:
if value is None:
return None
result = dec(value, name)
if not ZERO <= result < ONE:
raise ValueError(f"{name} harus berada dalam rentang [0, 1)")
return result
def run(args: argparse.Namespace) -> None:
symbol = args.symbol
lower_bound = dec(args.lower, "lower")
upper_bound = dec(args.upper, "upper")
budget = dec(args.budget_per_level, "budget_per_level")
extra_cost_rate = dec(args.extra_cost_rate, "extra_cost_rate")
slippage = optional_rate(args.slippage, "slippage")
explicit_fee = optional_rate(args.fee_rate, "fee_rate")
explicit_taker = optional_rate(args.taker_fee_rate, "taker_fee_rate")
exchange = ccxt.binance({
"enableRateLimit": True,
"timeout": 15000,
"options": {"defaultType": "spot"},
})
def money(value: Decimal) -> str:
return text(value.quantize(Decimal("0.01")))
try:
restrict_to_spot(exchange)
exchange.load_markets()
if symbol not in exchange.markets:
raise ValueError(f"Trading pair tidak ditemukan: {symbol}")
market = exchange.market(symbol)
if market.get("spot") is False:
raise RuntimeError(f"{symbol} bukan market spot")
if market.get("active") is False:
raise RuntimeError(f"Trading pair tidak aktif: {symbol}")
rules = load_rules(market)
fee_in_base = not args.fee_in_bnb
fee_rate = resolve_rate(market, "maker", explicit_fee, args.fee_in_bnb)
taker_rate = resolve_rate(
market, "taker", explicit_taker, args.fee_in_bnb
)
bid, ask, last = get_ticker_prices(exchange, symbol)
mid = (bid + ask) / Decimal("2")
levels = calculate_grid_levels(
lower_bound, upper_bound, args.grid_count, rules.tick
)
if not levels[0] <= mid <= levels[-1]:
raise ValueError(
f"Harga saat ini {text(mid)} berada di luar rentang grid "
f"{text(levels[0])}–{text(levels[-1])}"
)
check_grid_economics(levels, fee_rate, extra_cost_rate)
avg_price = (
fetch_percent_reference_price(exchange, market, rules, last)
if rules.has_percent_filters
else None
)
orders, skipped = build_grid_plan(
levels=levels,
current_price=mid,
rules=rules,
budget_per_level=budget,
fee_rate=fee_rate,
fee_in_base=fee_in_base,
avg_price=avg_price,
)
for message in skipped:
logger.warning("Dilewati: %s", message)
if not orders:
raise RuntimeError("Tidak ada order yang lolos validasi")
bnb_price = None
if args.fee_in_bnb:
bnb_symbol = f"BNB/{market['quote']}"
if bnb_symbol in exchange.markets:
b, a, _ = get_ticker_prices(exchange, bnb_symbol)
bnb_price = (b + a) / Decimal("2")
else:
logger.warning("Pair %s tidak ditemukan: estimasi penggunaan BNB dilewati", bnb_symbol)
summary = summarize_plan(
orders, fee_rate, taker_rate, fee_in_base, ask, slippage, bnb_price
)
print(f"Pair: {symbol}")
print(f"Bid / Ask: {text(bid)} / {text(ask)} (mid {text(mid)})")
print(f"Tick / Step: {text(rules.tick)} / {text(rules.step)}")
print(f"Grid: {text(levels[0])} – {text(levels[-1])}, "
f"cell: {len(levels) - 1}")
print(f"Fee Rate: maker {fee_rate:.4%}, taker {taker_rate:.4%} "
f"({'dalam aset yang diterima' if fee_in_base else 'dalam BNB'})")
if avg_price is not None:
print(f"Harga referensi filter: {text(avg_price)} "
f"(avgPriceMins={rules.avg_price_mins})")
print()
for order in orders:
line = (
f"{order['side'].upper():4} {text(order['amount'])} "
f"@ {text(order['price'])} (~{money(order['notional'])})"
)
if order["side"] == "buy":
line += (
f" -> saat terisi SELL "
f"{text(order['on_fill_sell_amount'])} "
f"@ {text(order['on_fill_sell_price'])}"
)
print(line)
quote, base = market["quote"], market["base"]
print()
print(f"Order BUY: {summary['buy_orders']}, "
f"butuh ~{money(summary['quote_for_buys'])} {quote}")
print(f"Order SELL: {summary['sell_orders']}, "
f"cadangan dibutuhkan {text(summary['base_needed_for_sells'])} {base}")
print(f"Pembelian awal ~{money(summary['initial_buy_cost_estimate'])} "
f"{quote} (pada ask, fee taker, slippage {slippage:.2%})")
print(f"Total ~{money(summary['total_quote_estimate'])} {quote} "
f"(estimasi, bukan garansi kecukupan saldo)")
if not fee_in_base:
line = (f"Fee BNB: ~{money(summary['bnb_fee_quote_equivalent'])} "
f"{quote} ekuivalen")
if summary["bnb_needed"] is not None:
line += f", ~{summary['bnb_needed']:.4f} BNB"
print(line + " (siklus penuh dari semua order)")
print()
print("Mode dry-run: order TIDAK dikirim ke exchange.")
finally:
exchange.close()
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
run(args)
except ccxt.BaseError as exc:
logger.error("Error exchange/jaringan: %s", exc)
return 1
except (ValueError, RuntimeError) as exc:
logger.error("Error kalkulasi: %s", exc)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())Simpan dan beri nama file, contohnya grid_planner.py. Cara Jalankan: python grid_planner.py --grid-count 20 --budget-per-level 100 --fee-in-bnb --fee-rate 0.00075 --taker-fee-rate 0.00075
4. Risiko Tersembunyi dan Jebakan Otomatisasi yang Dirahasiakan Exchange
Tim marketing exchange hobi banget mamerin grafik profit bot yang kelihatan manis dari hasil backtest historis. Padahal di dunia nyata, algo trading bakal nabrak tembok teknis yang bisa bikin modal amblas jauh lebih cepat dibanding kesalahan manual manusia.
- Slippage Parah Saat Volatilitas Tinggi: Begitu pasar jebol level support, likuiditas di order book hilang dalam hitungan milidetik. Bot bakal berusaha pasang atau eksekusi market order, tapi hasilnya malah dapet harga terburuk. Di altcoin yang tipis, slippage kayak gini bisa ngeruk profit grid seminggu cuma dalam sekali impuls pergerakan harga.
- Tokoitaz Fee (Maker vs Taker): Strategi grid itu pada dasarnya ngandelin limit order — alias zona Maker, tempat exchange ngasih rebate atau potongan fee paling murah. Tapi kalau logika bot lu tiba-tiba panic sell dan jualan secara instan (karena Stop-Loss atau breakdown), lu bakal kena fee Taker yang sangat mahal dan langsung merusak kalkulasi matematika strategi lu.
- Ilusi Hasil Backtest: Data historis nggak pernah memperhitungkan latency jaringan yang sebenarnya (ping ke API exchange) dan antrean di matching engine. Bot yang kelihatan mentereng dengan profit +300% di backtest periode 2024–2025 bisa aja malah keleponan dan minus pas live trading di pasar crab akibat delay eksekusi.
5. Cara Amankan Modal: Checklist Risk Management Wajib Buat Bot
Lu nggak bisa asal nyalain algoritma terus ditinggal liburan santai. Otomatisasi butuh sistem monitoring dan infrastruktur yang ketat:
Aturan Keamanan dan Kontrol Kendali:
- Kill-Switch Absolut: Harus ada fitur stop-loss ketat buat seluruh modal yang dipegang bot, baik di kodenya langsung maupun di settingan terminal. Kalau nilai portofolio gabungan dari koin base dan quote anjlok katakanlah 15%, bot harus langsung nyabut semua limit order dan mengunci kerugian saat itu juga.
- Isolasi Alokasi Modal: Jangan pernah nyampurin modal trading bot di akun atau sub-akun yang sama dengan dana tabungan jangka panjang lu. Pake API Key terisolasi dan pastiin opsi withdrawal dimatikan — cuma izinkan akses untuk trading saja.
- Monitoring via Telegram Alert: Setting notifikasi penting biar terkirim langsung ke aplikasi pesan kayak Telegram, bukan cuma nungguin notif di UI exchange. Setiap kali ada sesi terputus, API error, atau eksekusi order darurat, lu harus dapet infonya secara real-time.
Kesimpulannya
Trading bot itu ibarat pisau bedah di tangan dokter spesialis, bukan fitur autopilot di pesawat. Dia bertugas mengotomatisasi disiplin lu, bukan menggantikan fungsi otak. Mulailah dari nominal kecil pake spot grid di pasangan koin yang likuid, tes logikanya terhadap delay di kondisi nyata, dan ingat: musuh utama profit lu bukanlah market, melainkan settingan bot lu sendiri yang mengabaikan manajemen risiko.
Catatan dari Akademi EXMON: Infrastruktur yang solid dan manajemen risiko yang ketat adalah prioritas utama. Cuan sejati diraih oleh trader yang tahu kapan harus mematikan algoritmanya.