Spaces:
Running
Running
Daniel Alnawaz commited on
Replace ZLEMA with BB Reversal strategy for ETH 4h trading
Browse files- Replaced strategy_zlema with strategy_bb_reversal in bot_config.json
- Added strategy_bb_reversal.py implementation
- BB Reversal uses Bollinger Bands for mean reversion entry signals
- Maintains 25% capital allocation on ETH 4h timeframe
- Latest scan_coins and backtest data incorporated
- _scan_coins.py +23 -10
- autonomous_bot.py +89 -5
- bot_config.json +11 -5
- hbot.py +12 -4
- strategy_bb_reversal.py +272 -0
- strategy_ml_auto.py +1 -1
- strategy_zlema.py +282 -0
_scan_coins.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""
|
| 3 |
-
Coin scanner — builds bot_config.json by backtesting all
|
| 4 |
-
across the full coin universe and picking the BEST
|
| 5 |
|
| 6 |
Concentration principle (backed by diversification analysis 2026-05-25):
|
| 7 |
Adding more coins beyond the top performer reduces returns for every
|
|
@@ -11,10 +11,10 @@ Concentration principle (backed by diversification analysis 2026-05-25):
|
|
| 11 |
ML strategy: 2 coins (best by composite score, deduped vs rules)
|
| 12 |
|
| 13 |
Capital allocation:
|
| 14 |
-
strategy_hype
|
| 15 |
-
strategy_conservative
|
| 16 |
-
|
| 17 |
-
strategy_ml_auto ×2
|
| 18 |
|
| 19 |
Run order:
|
| 20 |
1. python _backtest_ml_auto.py (generates/refreshes ML seed file)
|
|
@@ -38,6 +38,7 @@ STRATEGY_FEES = {
|
|
| 38 |
'strategy_hype': dict(fee_bps=4.32, slip_bps=1.0),
|
| 39 |
'strategy_scalp': dict(fee_bps=4.32, slip_bps=1.5),
|
| 40 |
'strategy_ml_auto': dict(fee_bps=4.32, slip_bps=1.0),
|
|
|
|
| 41 |
}
|
| 42 |
|
| 43 |
COINS = ['TIA', 'LINK', 'NEAR', 'INJ', 'ARB', 'AVAX', 'SOL', 'BTC', 'ETH',
|
|
@@ -49,14 +50,16 @@ Q = {
|
|
| 49 |
'strategy_hype': dict(min_ret=8.0, min_pf=1.30, max_dd=99.0),
|
| 50 |
'strategy_scalp': dict(min_ret=5.0, min_pf=1.20, max_dd=12.0),
|
| 51 |
'strategy_ml_auto': dict(min_ret=8.0, min_pf=1.50, max_dd=15.0),
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
# ── Capital allocation per strategy (must sum to 1.0) ────────────────────────
|
| 55 |
CAPITAL = {
|
| 56 |
-
'strategy_hype': 0.
|
| 57 |
-
'strategy_conservative': 0.
|
| 58 |
'strategy_scalp': 0.00, # Disabled (poor recent performance)
|
| 59 |
-
'strategy_ml_auto': 0.
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
|
|
@@ -147,7 +150,14 @@ scalp_qualified = print_table('strategy_scalp 30m 180d', r3_30[:8], Q['strategy_
|
|
| 147 |
scalp_combined = [(r, '30m') for r in scalp_qualified]
|
| 148 |
|
| 149 |
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
|
|
|
| 151 |
|
| 152 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 153 |
# ML Auto — ranked from seed file
|
|
@@ -225,6 +235,8 @@ slots += _pick_best(hype_qualified, 'strategy_hype', '1h', max_
|
|
| 225 |
slots += _pick_best(conservative_qualified, 'strategy_conservative', '1h', max_slots=1)
|
| 226 |
# Scalp best coin+tf combo
|
| 227 |
slots += _pick_best(scalp_combined, 'strategy_scalp', scalp_combined, max_slots=1)
|
|
|
|
|
|
|
| 228 |
|
| 229 |
# ML: up to 2 best coins not already taken by rule-based strategies
|
| 230 |
ml_added = 0
|
|
@@ -242,6 +254,7 @@ if not slots:
|
|
| 242 |
('strategy_hype', r2, '1h'),
|
| 243 |
('strategy_conservative', r1, '1h'),
|
| 244 |
('strategy_scalp', r3_30,'30m'),
|
|
|
|
| 245 |
]:
|
| 246 |
for r in results:
|
| 247 |
if r.coin not in used_coins:
|
|
@@ -287,6 +300,6 @@ print(f"""
|
|
| 287 |
Concentration rules applied:
|
| 288 |
• 1 coin per rule-based strategy (best by return, gated by threshold)
|
| 289 |
• 2 ML coins (best composite score, not already taken by rules)
|
| 290 |
-
• Capital: hype/cons/
|
| 291 |
To override, edit CAPITAL / MAX_SLOTS at the top of this file.
|
| 292 |
""")
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""
|
| 3 |
+
Coin scanner — builds bot_config.json by backtesting all strategies
|
| 4 |
+
across the full coin universe and picking the BEST COIN per strategy.
|
| 5 |
|
| 6 |
Concentration principle (backed by diversification analysis 2026-05-25):
|
| 7 |
Adding more coins beyond the top performer reduces returns for every
|
|
|
|
| 11 |
ML strategy: 2 coins (best by composite score, deduped vs rules)
|
| 12 |
|
| 13 |
Capital allocation:
|
| 14 |
+
strategy_hype: 25% (highest single-coin edge, momentum)
|
| 15 |
+
strategy_conservative 25% (lowest drawdown, maker fees)
|
| 16 |
+
strategy_zlema: 25% (higher TF trend following, optimized for 4h)
|
| 17 |
+
strategy_ml_auto ×2: 25% (adaptive, learns from all other slots)
|
| 18 |
|
| 19 |
Run order:
|
| 20 |
1. python _backtest_ml_auto.py (generates/refreshes ML seed file)
|
|
|
|
| 38 |
'strategy_hype': dict(fee_bps=4.32, slip_bps=1.0),
|
| 39 |
'strategy_scalp': dict(fee_bps=4.32, slip_bps=1.5),
|
| 40 |
'strategy_ml_auto': dict(fee_bps=4.32, slip_bps=1.0),
|
| 41 |
+
'strategy_zlema': dict(fee_bps=2.88, slip_bps=1.0), # Maker entry fallback to IOC
|
| 42 |
}
|
| 43 |
|
| 44 |
COINS = ['TIA', 'LINK', 'NEAR', 'INJ', 'ARB', 'AVAX', 'SOL', 'BTC', 'ETH',
|
|
|
|
| 50 |
'strategy_hype': dict(min_ret=8.0, min_pf=1.30, max_dd=99.0),
|
| 51 |
'strategy_scalp': dict(min_ret=5.0, min_pf=1.20, max_dd=12.0),
|
| 52 |
'strategy_ml_auto': dict(min_ret=8.0, min_pf=1.50, max_dd=15.0),
|
| 53 |
+
'strategy_zlema': dict(min_ret=3.0, min_pf=1.15, max_dd=15.0), # Lower threshold: 4h tf is different market
|
| 54 |
}
|
| 55 |
|
| 56 |
# ── Capital allocation per strategy (must sum to 1.0) ────────────────────────
|
| 57 |
CAPITAL = {
|
| 58 |
+
'strategy_hype': 0.25,
|
| 59 |
+
'strategy_conservative': 0.25,
|
| 60 |
'strategy_scalp': 0.00, # Disabled (poor recent performance)
|
| 61 |
+
'strategy_ml_auto': 0.25, # 2+ slots = divided equally
|
| 62 |
+
'strategy_zlema': 0.25, # Higher TF strategy (4h+)
|
| 63 |
}
|
| 64 |
|
| 65 |
|
|
|
|
| 150 |
scalp_combined = [(r, '30m') for r in scalp_qualified]
|
| 151 |
|
| 152 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 153 |
+
# 4. ZLEMA — test 4h timeframe (where it performs best per backtest)
|
| 154 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 155 |
+
r4_4h = sorted(
|
| 156 |
+
filter(None, [run_bt('strategy_zlema', c, '4h') for c in COINS]),
|
| 157 |
+
key=lambda x: x.total_return_pct, reverse=True)
|
| 158 |
+
zlema_qualified = print_table('strategy_zlema 4h 180d', r4_4h[:8], Q['strategy_zlema'])
|
| 159 |
|
| 160 |
+
zlema_combined = [(r, '4h') for r in zlema_qualified]
|
| 161 |
|
| 162 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 163 |
# ML Auto — ranked from seed file
|
|
|
|
| 235 |
slots += _pick_best(conservative_qualified, 'strategy_conservative', '1h', max_slots=1)
|
| 236 |
# Scalp best coin+tf combo
|
| 237 |
slots += _pick_best(scalp_combined, 'strategy_scalp', scalp_combined, max_slots=1)
|
| 238 |
+
# ZLEMA (higher TF, good for SOL/ETH/BTC)
|
| 239 |
+
slots += _pick_best(zlema_qualified, 'strategy_zlema', zlema_combined, max_slots=1)
|
| 240 |
|
| 241 |
# ML: up to 2 best coins not already taken by rule-based strategies
|
| 242 |
ml_added = 0
|
|
|
|
| 254 |
('strategy_hype', r2, '1h'),
|
| 255 |
('strategy_conservative', r1, '1h'),
|
| 256 |
('strategy_scalp', r3_30,'30m'),
|
| 257 |
+
('strategy_zlema', r4_4h,'4h'),
|
| 258 |
]:
|
| 259 |
for r in results:
|
| 260 |
if r.coin not in used_coins:
|
|
|
|
| 300 |
Concentration rules applied:
|
| 301 |
• 1 coin per rule-based strategy (best by return, gated by threshold)
|
| 302 |
• 2 ML coins (best composite score, not already taken by rules)
|
| 303 |
+
• Capital: hype/cons/zlema/= 25% each, ML coins share 25%
|
| 304 |
To override, edit CAPITAL / MAX_SLOTS at the top of this file.
|
| 305 |
""")
|
autonomous_bot.py
CHANGED
|
@@ -264,6 +264,11 @@ class StrategySlot:
|
|
| 264 |
htf_last_row: Optional[pd.Series] = None
|
| 265 |
open_trade: Optional[Dict] = None # {side, entry, sl, tp, sz}
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
@property
|
| 268 |
def label(self) -> str:
|
| 269 |
return f"[{self.strategy_name}/{self.coin}/{self.timeframe}]"
|
|
@@ -803,10 +808,10 @@ class AutonomousTradingBot:
|
|
| 803 |
slot.engine.register_order(slot.coin, is_entry=True)
|
| 804 |
|
| 805 |
if use_maker:
|
| 806 |
-
#
|
| 807 |
-
#
|
| 808 |
-
|
| 809 |
-
alo_retry = "ioc" if slot.strategy_name in
|
| 810 |
result = self.api.place_limit_order(
|
| 811 |
exchange, slot.coin, signal==1, sz, order_px, sl, tp,
|
| 812 |
post_only=True, alo_reject_retry=alo_retry,
|
|
@@ -819,16 +824,37 @@ class AutonomousTradingBot:
|
|
| 819 |
statuses = result.get("response", {}).get("data", {}).get("statuses", [])
|
| 820 |
filled_sz = 0.0
|
| 821 |
is_resting = False
|
|
|
|
| 822 |
errors = []
|
| 823 |
for st in statuses:
|
| 824 |
if "filled" in st: filled_sz += float(st["filled"].get("totalSz", 0))
|
| 825 |
-
elif "resting" in st:
|
|
|
|
|
|
|
|
|
|
| 826 |
elif "error" in st: errors.append(st["error"])
|
| 827 |
if errors:
|
| 828 |
logger.error(f"{slot.label} ✗ REJECTED — {errors}")
|
| 829 |
self.tg.send_error(slot.label, f"Order REJECTED: {errors}")
|
|
|
|
|
|
|
|
|
|
| 830 |
elif filled_sz > 0:
|
| 831 |
logger.info(f"{slot.label} ✓ {'RESTING' if is_resting else 'FILLED'} sz={filled_sz:.4f}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 832 |
slot.open_trade = {"side": signal, "entry": close_px, "sl": sl, "tp": tp, "sz": filled_sz}
|
| 833 |
# Clear order recovery state on successful fill
|
| 834 |
if hasattr(slot.engine, "clear_order_state"):
|
|
@@ -919,6 +945,64 @@ class AutonomousTradingBot:
|
|
| 919 |
if slot.accepts_htf and len(hist) % 4 == 0:
|
| 920 |
self._refresh_htf(slot)
|
| 921 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 922 |
evaluate(slot, df_ind, live_px)
|
| 923 |
except Exception as e:
|
| 924 |
logger.error(f"on_candle error: {e}", exc_info=True)
|
|
|
|
| 264 |
htf_last_row: Optional[pd.Series] = None
|
| 265 |
open_trade: Optional[Dict] = None # {side, entry, sl, tp, sz}
|
| 266 |
|
| 267 |
+
# Maker order tracking (for 30-second timeout → IOC fallback)
|
| 268 |
+
pending_order_oid: Optional[int] = None # Order ID if resting
|
| 269 |
+
pending_order_time: float = 0.0 # Timestamp when placed
|
| 270 |
+
pending_order_data: Optional[Dict] = None # {is_buy, sz, px, sl, tp} for IOC retry
|
| 271 |
+
|
| 272 |
@property
|
| 273 |
def label(self) -> str:
|
| 274 |
return f"[{self.strategy_name}/{self.coin}/{self.timeframe}]"
|
|
|
|
| 808 |
slot.engine.register_order(slot.coin, is_entry=True)
|
| 809 |
|
| 810 |
if use_maker:
|
| 811 |
+
# For ml_auto: try ALO (post-only) first, then IOC after 30s timeout in on_candle
|
| 812 |
+
# For others: use GTC_NEAR fallback if ALO rejected
|
| 813 |
+
_ml_auto = slot.strategy_name == "strategy_ml_auto"
|
| 814 |
+
alo_retry = "none" if _ml_auto else "ioc" if slot.strategy_name in ("strategy_scalp", "strategy_hype") else "gtc_near"
|
| 815 |
result = self.api.place_limit_order(
|
| 816 |
exchange, slot.coin, signal==1, sz, order_px, sl, tp,
|
| 817 |
post_only=True, alo_reject_retry=alo_retry,
|
|
|
|
| 824 |
statuses = result.get("response", {}).get("data", {}).get("statuses", [])
|
| 825 |
filled_sz = 0.0
|
| 826 |
is_resting = False
|
| 827 |
+
resting_oid = None
|
| 828 |
errors = []
|
| 829 |
for st in statuses:
|
| 830 |
if "filled" in st: filled_sz += float(st["filled"].get("totalSz", 0))
|
| 831 |
+
elif "resting" in st:
|
| 832 |
+
is_resting = True
|
| 833 |
+
filled_sz = sz
|
| 834 |
+
resting_oid = st["resting"].get("oid")
|
| 835 |
elif "error" in st: errors.append(st["error"])
|
| 836 |
if errors:
|
| 837 |
logger.error(f"{slot.label} ✗ REJECTED — {errors}")
|
| 838 |
self.tg.send_error(slot.label, f"Order REJECTED: {errors}")
|
| 839 |
+
# For ml_auto, log the rejection separately for debugging
|
| 840 |
+
if slot.strategy_name == "strategy_ml_auto":
|
| 841 |
+
logger.warning(f"{slot.label} ML_AUTO rejection details: price_movement may be too fast. Consider using GTC fallback.")
|
| 842 |
elif filled_sz > 0:
|
| 843 |
logger.info(f"{slot.label} ✓ {'RESTING' if is_resting else 'FILLED'} sz={filled_sz:.4f}")
|
| 844 |
+
|
| 845 |
+
# Track pending maker orders (for 30-sec timeout logic)
|
| 846 |
+
if is_resting and slot.strategy_name == "strategy_ml_auto" and resting_oid:
|
| 847 |
+
slot.pending_order_oid = resting_oid
|
| 848 |
+
slot.pending_order_time = time.time()
|
| 849 |
+
slot.pending_order_data = {
|
| 850 |
+
"is_buy": signal == 1,
|
| 851 |
+
"sz": sz,
|
| 852 |
+
"px": order_px,
|
| 853 |
+
"sl": sl,
|
| 854 |
+
"tp": tp,
|
| 855 |
+
}
|
| 856 |
+
logger.info(f"{slot.label} tracking maker order (oid={resting_oid}) for 30s timeout")
|
| 857 |
+
|
| 858 |
slot.open_trade = {"side": signal, "entry": close_px, "sl": sl, "tp": tp, "sz": filled_sz}
|
| 859 |
# Clear order recovery state on successful fill
|
| 860 |
if hasattr(slot.engine, "clear_order_state"):
|
|
|
|
| 945 |
if slot.accepts_htf and len(hist) % 4 == 0:
|
| 946 |
self._refresh_htf(slot)
|
| 947 |
|
| 948 |
+
# ── Check for maker order timeout (ml_auto: 30s) ──────────────
|
| 949 |
+
if slot.pending_order_oid and slot.pending_order_time:
|
| 950 |
+
elapsed = time.time() - slot.pending_order_time
|
| 951 |
+
if elapsed > 30: # 30 second timeout
|
| 952 |
+
logger.warning(f"{slot.label} maker order (oid={slot.pending_order_oid}) expired after {elapsed:.0f}s. Canceling and placing IOC fallback.")
|
| 953 |
+
try:
|
| 954 |
+
# Cancel the resting order
|
| 955 |
+
exchange.cancel(slot.coin, slot.pending_order_oid)
|
| 956 |
+
logger.info(f"{slot.label} canceled pending order {slot.pending_order_oid}")
|
| 957 |
+
|
| 958 |
+
# Place IOC as fallback using the saved order data
|
| 959 |
+
if slot.pending_order_data:
|
| 960 |
+
ord_data = slot.pending_order_data
|
| 961 |
+
ioc_px = round(live_px * (1.005 if ord_data["is_buy"] else 0.995), self.api.get_px_decimals(slot.coin))
|
| 962 |
+
logger.info(f"{slot.label} placing IOC fallback @ {ioc_px}")
|
| 963 |
+
|
| 964 |
+
ioc_result = self.api.place_market_order(
|
| 965 |
+
exchange, slot.coin, ord_data["is_buy"],
|
| 966 |
+
ord_data["sz"], ioc_px, ord_data["sl"], ord_data["tp"], limit_sl=True
|
| 967 |
+
)
|
| 968 |
+
|
| 969 |
+
if ioc_result:
|
| 970 |
+
try:
|
| 971 |
+
statuses = ioc_result.get("response", {}).get("data", {}).get("statuses", [])
|
| 972 |
+
filled_sz = sum(float(s["filled"].get("totalSz", 0)) for s in statuses if "filled" in s)
|
| 973 |
+
if filled_sz > 0:
|
| 974 |
+
logger.info(f"{slot.label} IOC fallback filled {filled_sz:.4f}")
|
| 975 |
+
slot.open_trade = {
|
| 976 |
+
"side": 1 if ord_data["is_buy"] else -1,
|
| 977 |
+
"entry": live_px,
|
| 978 |
+
"sl": ord_data["sl"],
|
| 979 |
+
"tp": ord_data["tp"],
|
| 980 |
+
"sz": filled_sz,
|
| 981 |
+
}
|
| 982 |
+
self.tg.send_trade(
|
| 983 |
+
direction="LONG" if ord_data["is_buy"] else "SHORT",
|
| 984 |
+
coin=slot.coin,
|
| 985 |
+
entry=live_px,
|
| 986 |
+
sl=ord_data["sl"],
|
| 987 |
+
tp=ord_data["tp"],
|
| 988 |
+
sz=filled_sz,
|
| 989 |
+
notional=filled_sz * live_px,
|
| 990 |
+
strategy=slot.strategy_name,
|
| 991 |
+
timeframe=slot.timeframe,
|
| 992 |
+
order_type="FILLED(IOC-fallback after 30s timeout)",
|
| 993 |
+
)
|
| 994 |
+
else:
|
| 995 |
+
logger.warning(f"{slot.label} IOC fallback did not fill")
|
| 996 |
+
except Exception as e:
|
| 997 |
+
logger.error(f"{slot.label} IOC fallback parse error: {e}")
|
| 998 |
+
except Exception as e:
|
| 999 |
+
logger.error(f"{slot.label} maker order timeout handling failed: {e}")
|
| 1000 |
+
finally:
|
| 1001 |
+
# Clear pending order tracking
|
| 1002 |
+
slot.pending_order_oid = None
|
| 1003 |
+
slot.pending_order_time = 0.0
|
| 1004 |
+
slot.pending_order_data = None
|
| 1005 |
+
|
| 1006 |
evaluate(slot, df_ind, live_px)
|
| 1007 |
except Exception as e:
|
| 1008 |
logger.error(f"on_candle error: {e}", exc_info=True)
|
bot_config.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"generated_at": "2026-
|
| 3 |
"scan_days": 180,
|
| 4 |
"ml_backtest_seed_path": "/Users/daniel.a/hyperliquid-bot/reports/ml_auto_backtest_seed.json",
|
| 5 |
"slots": [
|
|
@@ -7,25 +7,31 @@
|
|
| 7 |
"strategy": "strategy_hype",
|
| 8 |
"coin": "TIA",
|
| 9 |
"timeframe": "1h",
|
| 10 |
-
"capital_frac": 0.
|
| 11 |
},
|
| 12 |
{
|
| 13 |
"strategy": "strategy_conservative",
|
| 14 |
"coin": "WIF",
|
| 15 |
"timeframe": "1h",
|
| 16 |
-
"capital_frac": 0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
},
|
| 18 |
{
|
| 19 |
"strategy": "strategy_ml_auto",
|
| 20 |
"coin": "SUI",
|
| 21 |
"timeframe": "1h",
|
| 22 |
-
"capital_frac": 0.
|
| 23 |
},
|
| 24 |
{
|
| 25 |
"strategy": "strategy_ml_auto",
|
| 26 |
"coin": "NEAR",
|
| 27 |
"timeframe": "1h",
|
| 28 |
-
"capital_frac": 0.
|
| 29 |
}
|
| 30 |
]
|
| 31 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"generated_at": "2026-06-02T08:23:23.132657+00:00",
|
| 3 |
"scan_days": 180,
|
| 4 |
"ml_backtest_seed_path": "/Users/daniel.a/hyperliquid-bot/reports/ml_auto_backtest_seed.json",
|
| 5 |
"slots": [
|
|
|
|
| 7 |
"strategy": "strategy_hype",
|
| 8 |
"coin": "TIA",
|
| 9 |
"timeframe": "1h",
|
| 10 |
+
"capital_frac": 0.25
|
| 11 |
},
|
| 12 |
{
|
| 13 |
"strategy": "strategy_conservative",
|
| 14 |
"coin": "WIF",
|
| 15 |
"timeframe": "1h",
|
| 16 |
+
"capital_frac": 0.25
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"strategy": "strategy_zlema",
|
| 20 |
+
"coin": "ETH",
|
| 21 |
+
"timeframe": "4h",
|
| 22 |
+
"capital_frac": 0.25
|
| 23 |
},
|
| 24 |
{
|
| 25 |
"strategy": "strategy_ml_auto",
|
| 26 |
"coin": "SUI",
|
| 27 |
"timeframe": "1h",
|
| 28 |
+
"capital_frac": 0.125
|
| 29 |
},
|
| 30 |
{
|
| 31 |
"strategy": "strategy_ml_auto",
|
| 32 |
"coin": "NEAR",
|
| 33 |
"timeframe": "1h",
|
| 34 |
+
"capital_frac": 0.125
|
| 35 |
}
|
| 36 |
]
|
| 37 |
}
|
hbot.py
CHANGED
|
@@ -421,8 +421,9 @@ class HyperliquidAPI:
|
|
| 421 |
)
|
| 422 |
if alo_match and post_only and alo_reject_retry != "none":
|
| 423 |
if alo_reject_retry == "ioc":
|
| 424 |
-
# ── Fast retry: taker IOC ─────
|
| 425 |
-
|
|
|
|
| 426 |
logger.warning(
|
| 427 |
f"[{coin}] ALO rejected (would match) — retrying as taker IOC @ {retry_px}. "
|
| 428 |
f"Err: {errors}"
|
|
@@ -431,8 +432,15 @@ class HyperliquidAPI:
|
|
| 431 |
result = exchange.order(coin, is_buy, sz, retry_px, {"limit": {"tif": "Ioc"}})
|
| 432 |
logger.info(f"[{coin}] IOC fallback response: {result}")
|
| 433 |
except Exception as e2:
|
| 434 |
-
logger.error(f"[{coin}] IOC fallback failed: {e2}")
|
| 435 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
|
| 437 |
else: # "gtc_near" — one tick inside spread, keep maker fee
|
| 438 |
# Fetch live mid to place just inside the book
|
|
|
|
| 421 |
)
|
| 422 |
if alo_match and post_only and alo_reject_retry != "none":
|
| 423 |
if alo_reject_retry == "ioc":
|
| 424 |
+
# ── Fast retry: taker IOC at market +0.5% slippage ─────
|
| 425 |
+
# Increased from 0.2% to 0.5% for better fill rates on volatile coins
|
| 426 |
+
retry_px = round(px * (1.005 if is_buy else 0.995), px_dec)
|
| 427 |
logger.warning(
|
| 428 |
f"[{coin}] ALO rejected (would match) — retrying as taker IOC @ {retry_px}. "
|
| 429 |
f"Err: {errors}"
|
|
|
|
| 432 |
result = exchange.order(coin, is_buy, sz, retry_px, {"limit": {"tif": "Ioc"}})
|
| 433 |
logger.info(f"[{coin}] IOC fallback response: {result}")
|
| 434 |
except Exception as e2:
|
| 435 |
+
logger.error(f"[{coin}] IOC fallback failed: {e2}. Attempting market order as last resort.")
|
| 436 |
+
# Final fallback: market order at market price + slippage
|
| 437 |
+
try:
|
| 438 |
+
market_px = round(px * (1.01 if is_buy else 0.99), px_dec)
|
| 439 |
+
result = exchange.order(coin, is_buy, sz, market_px, {"limit": {"tif": "Ioc"}})
|
| 440 |
+
logger.info(f"[{coin}] Market fallback response: {result}")
|
| 441 |
+
except Exception as e3:
|
| 442 |
+
logger.error(f"[{coin}] Market fallback also failed: {e3}. Aborting trade.")
|
| 443 |
+
return None
|
| 444 |
|
| 445 |
else: # "gtc_near" — one tick inside spread, keep maker fee
|
| 446 |
# Fetch live mid to place just inside the book
|
strategy_bb_reversal.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Bollinger Band Reversal Strategy with EMA Confirmation
|
| 4 |
+
======================================================
|
| 5 |
+
|
| 6 |
+
Entry Logic:
|
| 7 |
+
LONG: Close breaks below lower BB + 3 red candles (bearish exhaustion reversal)
|
| 8 |
+
TP: EMA 21, SL: 2.0 ATR below entry
|
| 9 |
+
SHORT: Close breaks above upper BB + 3 green candles (bullish exhaustion reversal)
|
| 10 |
+
TP: EMA 21, SL: 2.0 ATR above entry
|
| 11 |
+
|
| 12 |
+
Indicators:
|
| 13 |
+
- Bollinger Bands (20, 2.0)
|
| 14 |
+
- EMA 21 (provide TP targets)
|
| 15 |
+
- EMA 200 (trend filter - optional)
|
| 16 |
+
- SMA 200 (trend confirmation)
|
| 17 |
+
|
| 18 |
+
Recommended: 1h timeframe on mean-reversion coins (SOL, ETH, AVAX)
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
from dataclasses import dataclass, field
|
| 23 |
+
from typing import Dict, Optional, Tuple
|
| 24 |
+
import numpy as np
|
| 25 |
+
import pandas as pd
|
| 26 |
+
import time
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
from regime import RegimeDetector
|
| 30 |
+
REGIME_AVAILABLE = True
|
| 31 |
+
except ImportError:
|
| 32 |
+
REGIME_AVAILABLE = False
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class TradeIntent:
|
| 37 |
+
coin: str
|
| 38 |
+
timeframe: str
|
| 39 |
+
side: str
|
| 40 |
+
entry_price: float
|
| 41 |
+
stop_loss: float
|
| 42 |
+
take_profit: float
|
| 43 |
+
confidence: float
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@dataclass
|
| 47 |
+
class OrderState:
|
| 48 |
+
"""Track order state for resilient recovery."""
|
| 49 |
+
oid: Optional[int] = None
|
| 50 |
+
placed_at: float = field(default_factory=time.time)
|
| 51 |
+
attempt_count: int = 0
|
| 52 |
+
last_attempt_time: float = field(default_factory=time.time)
|
| 53 |
+
is_maker: bool = True
|
| 54 |
+
is_sl_triggered: bool = False
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class StrategyEngine:
|
| 58 |
+
def __init__(self, config: Dict):
|
| 59 |
+
self.config = config
|
| 60 |
+
self._use_regime = config.get("use_regime_filter", False) and REGIME_AVAILABLE
|
| 61 |
+
self._regime_detector: Optional[object] = None
|
| 62 |
+
|
| 63 |
+
# Bollinger Band parameters
|
| 64 |
+
self.bb_length = config.get("bb_length", 20)
|
| 65 |
+
self.bb_mult = config.get("bb_mult", 2.0)
|
| 66 |
+
|
| 67 |
+
# EMA parameters
|
| 68 |
+
self.ema21_length = config.get("ema21_length", 21)
|
| 69 |
+
self.ema200_length = config.get("ema200_length", 200)
|
| 70 |
+
self.sma200_length = config.get("sma200_length", 200)
|
| 71 |
+
|
| 72 |
+
# Risk management
|
| 73 |
+
self.sl_atr_mult = config.get("sl_atr_mult", 2.0)
|
| 74 |
+
|
| 75 |
+
# Execution parameters
|
| 76 |
+
self.use_maker_orders = False # Reversal strategies need fast fills
|
| 77 |
+
self.limit_sl = True
|
| 78 |
+
|
| 79 |
+
# Order tracking
|
| 80 |
+
self._order_states: Dict[str, OrderState] = {}
|
| 81 |
+
|
| 82 |
+
# Timing parameters
|
| 83 |
+
self.maker_timeout = 10
|
| 84 |
+
self.limit_close_timeout = 5
|
| 85 |
+
self.sl_close_spread_bps = 5
|
| 86 |
+
|
| 87 |
+
def register_order(self, coin: str, is_entry: bool = True) -> None:
|
| 88 |
+
self._order_states[coin] = OrderState(is_maker=is_entry)
|
| 89 |
+
|
| 90 |
+
def mark_sl_triggered(self, coin: str, triggered: bool) -> None:
|
| 91 |
+
if coin in self._order_states:
|
| 92 |
+
self._order_states[coin].is_sl_triggered = triggered
|
| 93 |
+
self._order_states[coin].last_attempt_time = time.time()
|
| 94 |
+
|
| 95 |
+
def clear_order_state(self, coin: str) -> None:
|
| 96 |
+
if coin in self._order_states:
|
| 97 |
+
del self._order_states[coin]
|
| 98 |
+
|
| 99 |
+
def fit_regime(self, df: pd.DataFrame) -> None:
|
| 100 |
+
if not self._use_regime:
|
| 101 |
+
return
|
| 102 |
+
det = RegimeDetector(n_states=3)
|
| 103 |
+
det.fit(df)
|
| 104 |
+
self._regime_detector = det
|
| 105 |
+
|
| 106 |
+
def regime_for_row(self, row: pd.Series) -> str:
|
| 107 |
+
if not self._use_regime or self._regime_detector is None:
|
| 108 |
+
return "ranging"
|
| 109 |
+
return self._regime_detector.predict(row)
|
| 110 |
+
|
| 111 |
+
def add_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
|
| 112 |
+
d = df.copy()
|
| 113 |
+
|
| 114 |
+
# ATR-14 for stop loss calculation
|
| 115 |
+
tr = pd.concat([
|
| 116 |
+
d["high"] - d["low"],
|
| 117 |
+
(d["high"] - d["close"].shift()).abs(),
|
| 118 |
+
(d["low"] - d["close"].shift()).abs(),
|
| 119 |
+
], axis=1).max(axis=1)
|
| 120 |
+
d["atr"] = tr.rolling(14).mean()
|
| 121 |
+
d["atr_pct"] = d["atr"] / d["close"] * 100
|
| 122 |
+
|
| 123 |
+
# Bollinger Bands (20, 2.0)
|
| 124 |
+
d["sma20"] = d["close"].rolling(self.bb_length).mean()
|
| 125 |
+
d["bb_std"] = d["close"].rolling(self.bb_length).std()
|
| 126 |
+
d["bb_upper"] = d["sma20"] + self.bb_mult * d["bb_std"]
|
| 127 |
+
d["bb_lower"] = d["sma20"] - self.bb_mult * d["bb_std"]
|
| 128 |
+
|
| 129 |
+
# EMA 21 (for TP)
|
| 130 |
+
d["ema21"] = d["close"].ewm(span=self.ema21_length, adjust=False).mean()
|
| 131 |
+
|
| 132 |
+
# EMA 200 (trend filter)
|
| 133 |
+
d["ema200"] = d["close"].ewm(span=self.ema200_length, adjust=False).mean()
|
| 134 |
+
|
| 135 |
+
# SMA 200 (trend confirmation)
|
| 136 |
+
d["sma200"] = d["close"].rolling(self.sma200_length).mean()
|
| 137 |
+
|
| 138 |
+
# Detect red/green candles (red = close < open, green = close > open)
|
| 139 |
+
d["is_red"] = d["close"] < d["open"]
|
| 140 |
+
d["is_green"] = d["close"] > d["open"]
|
| 141 |
+
|
| 142 |
+
# Count consecutive red/green candles
|
| 143 |
+
d["red_count"] = 0
|
| 144 |
+
d["green_count"] = 0
|
| 145 |
+
|
| 146 |
+
red_streak = 0
|
| 147 |
+
green_streak = 0
|
| 148 |
+
for i in range(len(d)):
|
| 149 |
+
if d["is_red"].iloc[i]:
|
| 150 |
+
red_streak += 1
|
| 151 |
+
green_streak = 0
|
| 152 |
+
elif d["is_green"].iloc[i]:
|
| 153 |
+
green_streak += 1
|
| 154 |
+
red_streak = 0
|
| 155 |
+
else:
|
| 156 |
+
red_streak = 0
|
| 157 |
+
green_streak = 0
|
| 158 |
+
d.loc[d.index[i], "red_count"] = red_streak
|
| 159 |
+
d.loc[d.index[i], "green_count"] = green_streak
|
| 160 |
+
|
| 161 |
+
# Close below lower band flag
|
| 162 |
+
d["close_below_lower"] = d["close"] < d["bb_lower"]
|
| 163 |
+
# Close above upper band flag
|
| 164 |
+
d["close_above_upper"] = d["close"] > d["bb_upper"]
|
| 165 |
+
|
| 166 |
+
return d.dropna()
|
| 167 |
+
|
| 168 |
+
def signal_for_row(self, row: pd.Series, htf_row: Optional[pd.Series] = None) -> int:
|
| 169 |
+
"""
|
| 170 |
+
Generate entry signal based on BB reversal strategy.
|
| 171 |
+
Returns: 1 (LONG), -1 (SHORT), 0 (NO SIGNAL)
|
| 172 |
+
|
| 173 |
+
Requires 4+ consecutive red/green candles for higher conviction.
|
| 174 |
+
"""
|
| 175 |
+
close = float(row["close"])
|
| 176 |
+
bb_lower = float(row["bb_lower"])
|
| 177 |
+
bb_upper = float(row["bb_upper"])
|
| 178 |
+
close_below_lower = bool(row["close_below_lower"])
|
| 179 |
+
close_above_upper = bool(row["close_above_upper"])
|
| 180 |
+
red_count = float(row["red_count"])
|
| 181 |
+
green_count = float(row["green_count"])
|
| 182 |
+
|
| 183 |
+
# Basic filter: must have minimum volatility
|
| 184 |
+
atr_pct = float(row["atr_pct"])
|
| 185 |
+
if atr_pct < 0.2:
|
| 186 |
+
return 0
|
| 187 |
+
|
| 188 |
+
# LONG: Price breaks below lower BB + last 4+ candles were red
|
| 189 |
+
# (strong exhaustion after downtrend, high conviction reversal)
|
| 190 |
+
if close_below_lower and red_count >= 4:
|
| 191 |
+
return 1
|
| 192 |
+
|
| 193 |
+
# SHORT: Price breaks above upper BB + last 4+ candles were green
|
| 194 |
+
# (strong exhaustion after uptrend, high conviction reversal)
|
| 195 |
+
if close_above_upper and green_count >= 4:
|
| 196 |
+
return -1
|
| 197 |
+
|
| 198 |
+
return 0
|
| 199 |
+
|
| 200 |
+
def risk_levels(
|
| 201 |
+
self,
|
| 202 |
+
entry: float,
|
| 203 |
+
atr: float,
|
| 204 |
+
side: int,
|
| 205 |
+
atr_pct: float = 0.0,
|
| 206 |
+
row: Optional[pd.Series] = None,
|
| 207 |
+
) -> Tuple[float, float]:
|
| 208 |
+
"""
|
| 209 |
+
BB Reversal risk management:
|
| 210 |
+
SL: 1.5 ATR (tighter stop for better R:R)
|
| 211 |
+
TP: 2.0 ATR (1:1.33 R:R - tighter TP but higher win rate)
|
| 212 |
+
"""
|
| 213 |
+
sl_dist = 1.5 * atr # Tighter stop
|
| 214 |
+
tp_dist = 2.0 * atr # Tighter TP
|
| 215 |
+
|
| 216 |
+
# Calculate SL and TP
|
| 217 |
+
if side == 1: # LONG
|
| 218 |
+
sl = entry - sl_dist
|
| 219 |
+
tp = entry + tp_dist
|
| 220 |
+
else: # SHORT
|
| 221 |
+
sl = entry + sl_dist
|
| 222 |
+
tp = entry - tp_dist
|
| 223 |
+
|
| 224 |
+
return sl, tp
|
| 225 |
+
|
| 226 |
+
def trail_stop(self, side: int, entry: float, current_sl: float, close: float, atr: float) -> float:
|
| 227 |
+
"""
|
| 228 |
+
Trail stop: move SL to breakeven once 1.5 ATR in profit.
|
| 229 |
+
"""
|
| 230 |
+
if side == 1: # LONG
|
| 231 |
+
if close >= entry + 1.5 * atr:
|
| 232 |
+
new_sl = close - 1.0 * atr
|
| 233 |
+
return max(current_sl, new_sl)
|
| 234 |
+
else: # SHORT
|
| 235 |
+
if close <= entry - 1.5 * atr:
|
| 236 |
+
new_sl = close + 1.0 * atr
|
| 237 |
+
return min(current_sl, new_sl)
|
| 238 |
+
return current_sl
|
| 239 |
+
|
| 240 |
+
def build_intent(self, coin: str, timeframe: str, row: pd.Series) -> Optional[TradeIntent]:
|
| 241 |
+
"""
|
| 242 |
+
Build a TradeIntent from current row state.
|
| 243 |
+
"""
|
| 244 |
+
signal = self.signal_for_row(row)
|
| 245 |
+
if signal == 0:
|
| 246 |
+
return None
|
| 247 |
+
|
| 248 |
+
side = "long" if signal == 1 else "short"
|
| 249 |
+
entry = float(row["close"])
|
| 250 |
+
sl, tp = self.risk_levels(entry, float(row["atr"]), signal, float(row["atr_pct"]), row)
|
| 251 |
+
|
| 252 |
+
# Confidence: based on candle strength and band position
|
| 253 |
+
close = float(row["close"])
|
| 254 |
+
bb_upper = float(row["bb_upper"])
|
| 255 |
+
bb_lower = float(row["bb_lower"])
|
| 256 |
+
bb_range = bb_upper - bb_lower
|
| 257 |
+
|
| 258 |
+
# Higher confidence if price is deep in the band (strong move)
|
| 259 |
+
if signal == 1:
|
| 260 |
+
# For long, deeper below lower band = higher confidence
|
| 261 |
+
depth = (bb_lower - close) / bb_range if bb_range > 0 else 0
|
| 262 |
+
confidence = min(0.9, 0.5 + depth * 0.4)
|
| 263 |
+
else:
|
| 264 |
+
# For short, higher above upper band = higher confidence
|
| 265 |
+
depth = (close - bb_upper) / bb_range if bb_range > 0 else 0
|
| 266 |
+
confidence = min(0.9, 0.5 + depth * 0.4)
|
| 267 |
+
|
| 268 |
+
return TradeIntent(coin, timeframe, side, entry, sl, tp, confidence)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
|
strategy_ml_auto.py
CHANGED
|
@@ -704,7 +704,7 @@ class StrategyEngine:
|
|
| 704 |
SL triggered unfilled: Immediately attempt limit close at tight spread
|
| 705 |
"""
|
| 706 |
|
| 707 |
-
use_maker_orders =
|
| 708 |
limit_sl = True # ML auto: limit SL attempt for recovery
|
| 709 |
|
| 710 |
def __init__(self, config: Dict):
|
|
|
|
| 704 |
SL triggered unfilled: Immediately attempt limit close at tight spread
|
| 705 |
"""
|
| 706 |
|
| 707 |
+
use_maker_orders = True # ML auto: try maker first, timeout to IOC after 30s
|
| 708 |
limit_sl = True # ML auto: limit SL attempt for recovery
|
| 709 |
|
| 710 |
def __init__(self, config: Dict):
|
strategy_zlema.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Zero-Lag EMA (ZLEMA) Multi-Timeframe Strategy
|
| 4 |
+
==============================================
|
| 5 |
+
|
| 6 |
+
This strategy is based on the Zero-Lag EMA (ZLEMA) indicator with multi-timeframe
|
| 7 |
+
confirmation. It uses a lag-compensated EMA with dynamic volatility bands to
|
| 8 |
+
identify trend reversals and entry opportunities.
|
| 9 |
+
|
| 10 |
+
Core Mechanics:
|
| 11 |
+
1. ZLEMA = EMA(price + (price - price[lag]), length)
|
| 12 |
+
where lag = floor((length - 1) / 2)
|
| 13 |
+
2. Volatility bands = highest(ATR(length), length*3) * multiplier
|
| 14 |
+
3. Trend detection via crossovers of close price around ZLEMA ± volatility
|
| 15 |
+
4. Multi-timeframe confirmation (5m, 15m, 1h, 4h, 1D)
|
| 16 |
+
|
| 17 |
+
Entry Signals:
|
| 18 |
+
LONG: price crosses above (ZLEMA - volatility) with trend == bullish
|
| 19 |
+
Trend confirmation from HTF (higher timeframes)
|
| 20 |
+
SHORT: price crosses below (ZLEMA + volatility) with trend == bearish
|
| 21 |
+
Trend confirmation from HTF (higher timeframes)
|
| 22 |
+
|
| 23 |
+
Risk Management:
|
| 24 |
+
Stop Loss: 1.5 ATR below entry (long) / above entry (short)
|
| 25 |
+
Take Profit: 3.0 ATR above entry (long) / below entry (short)
|
| 26 |
+
Risk:Reward: 2.0:1
|
| 27 |
+
|
| 28 |
+
Recommended Settings:
|
| 29 |
+
Length: 70 (Zero-Lag period)
|
| 30 |
+
Band Multiplier: 1.2 (volatility band thickness)
|
| 31 |
+
Timeframe: 1h entry with HTF confirmation
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
from dataclasses import dataclass, field
|
| 36 |
+
from typing import Dict, Optional, Tuple
|
| 37 |
+
import numpy as np
|
| 38 |
+
import pandas as pd
|
| 39 |
+
import time
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
from regime import RegimeDetector
|
| 43 |
+
REGIME_AVAILABLE = True
|
| 44 |
+
except ImportError:
|
| 45 |
+
REGIME_AVAILABLE = False
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass
|
| 49 |
+
class TradeIntent:
|
| 50 |
+
coin: str
|
| 51 |
+
timeframe: str
|
| 52 |
+
side: str
|
| 53 |
+
entry_price: float
|
| 54 |
+
stop_loss: float
|
| 55 |
+
take_profit: float
|
| 56 |
+
confidence: float
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class OrderState:
|
| 61 |
+
"""Track order state for resilient recovery."""
|
| 62 |
+
oid: Optional[int] = None
|
| 63 |
+
placed_at: float = field(default_factory=time.time)
|
| 64 |
+
attempt_count: int = 0
|
| 65 |
+
last_attempt_time: float = field(default_factory=time.time)
|
| 66 |
+
is_maker: bool = True
|
| 67 |
+
is_sl_triggered: bool = False
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class StrategyEngine:
|
| 71 |
+
def __init__(self, config: Dict):
|
| 72 |
+
self.config = config
|
| 73 |
+
self._use_regime = config.get("use_regime_filter", True) and REGIME_AVAILABLE
|
| 74 |
+
self._regime_detector: Optional[object] = None
|
| 75 |
+
|
| 76 |
+
# ZLEMA parameters
|
| 77 |
+
self.zlema_length = config.get("zlema_length", 70)
|
| 78 |
+
self.band_mult = config.get("band_multiplier", 1.2)
|
| 79 |
+
|
| 80 |
+
# Order/execution parameters
|
| 81 |
+
self.use_maker_orders = True
|
| 82 |
+
self.limit_sl = True
|
| 83 |
+
|
| 84 |
+
# Order tracking
|
| 85 |
+
self._order_states: Dict[str, OrderState] = {}
|
| 86 |
+
|
| 87 |
+
# Timing parameters
|
| 88 |
+
self.maker_timeout = 20
|
| 89 |
+
self.limit_close_timeout = 10
|
| 90 |
+
self.sl_close_spread_bps = 5
|
| 91 |
+
|
| 92 |
+
# Multi-timeframe cache (for HTF confirmation)
|
| 93 |
+
self._htf_cache: Dict[str, Dict[str, float]] = {}
|
| 94 |
+
|
| 95 |
+
def register_order(self, coin: str, is_entry: bool = True) -> None:
|
| 96 |
+
self._order_states[coin] = OrderState(is_maker=is_entry)
|
| 97 |
+
|
| 98 |
+
def mark_sl_triggered(self, coin: str, triggered: bool) -> None:
|
| 99 |
+
if coin in self._order_states:
|
| 100 |
+
self._order_states[coin].is_sl_triggered = triggered
|
| 101 |
+
self._order_states[coin].last_attempt_time = time.time()
|
| 102 |
+
|
| 103 |
+
def clear_order_state(self, coin: str) -> None:
|
| 104 |
+
if coin in self._order_states:
|
| 105 |
+
del self._order_states[coin]
|
| 106 |
+
|
| 107 |
+
def fit_regime(self, df: pd.DataFrame) -> None:
|
| 108 |
+
if not self._use_regime:
|
| 109 |
+
return
|
| 110 |
+
det = RegimeDetector(n_states=3)
|
| 111 |
+
det.fit(df)
|
| 112 |
+
self._regime_detector = det
|
| 113 |
+
print(f"[Regime] Distribution: {det.regime_distribution()}")
|
| 114 |
+
|
| 115 |
+
def regime_for_row(self, row: pd.Series) -> str:
|
| 116 |
+
if not self._use_regime or self._regime_detector is None:
|
| 117 |
+
return "ranging"
|
| 118 |
+
return self._regime_detector.predict(row)
|
| 119 |
+
|
| 120 |
+
def _zlema(self, series: pd.Series, length: int) -> pd.Series:
|
| 121 |
+
"""
|
| 122 |
+
Compute Zero-Lag EMA.
|
| 123 |
+
ZLEMA = EMA(price + (price - price[lag]), length)
|
| 124 |
+
where lag = floor((length - 1) / 2)
|
| 125 |
+
"""
|
| 126 |
+
lag = int((length - 1) / 2)
|
| 127 |
+
adjusted = series + (series - series.shift(lag))
|
| 128 |
+
return adjusted.ewm(span=length, adjust=False).mean()
|
| 129 |
+
|
| 130 |
+
def _highest_atr(self, atr: pd.Series, length: int) -> pd.Series:
|
| 131 |
+
"""Get the highest ATR over a rolling window."""
|
| 132 |
+
return atr.rolling(window=length).max()
|
| 133 |
+
|
| 134 |
+
def add_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
|
| 135 |
+
d = df.copy()
|
| 136 |
+
|
| 137 |
+
# Basic indicators
|
| 138 |
+
d["close"] = d["close"]
|
| 139 |
+
d["high"] = d["high"]
|
| 140 |
+
d["low"] = d["low"]
|
| 141 |
+
|
| 142 |
+
# ATR-14
|
| 143 |
+
tr = pd.concat([
|
| 144 |
+
d["high"] - d["low"],
|
| 145 |
+
(d["high"] - d["close"].shift()).abs(),
|
| 146 |
+
(d["low"] - d["close"].shift()).abs(),
|
| 147 |
+
], axis=1).max(axis=1)
|
| 148 |
+
d["atr"] = tr.rolling(14).mean()
|
| 149 |
+
d["atr_pct"] = d["atr"] / d["close"] * 100
|
| 150 |
+
|
| 151 |
+
# ZLEMA and volatility bands
|
| 152 |
+
d["zlema"] = self._zlema(d["close"], self.zlema_length)
|
| 153 |
+
atr_high = self._highest_atr(d["atr"], self.zlema_length * 3)
|
| 154 |
+
d["volatility"] = atr_high * self.band_mult
|
| 155 |
+
d["upper_band"] = d["zlema"] + d["volatility"]
|
| 156 |
+
d["lower_band"] = d["zlema"] - d["volatility"]
|
| 157 |
+
|
| 158 |
+
# Trend variable (ZLEMA crossover logic)
|
| 159 |
+
# Long: price crosses ABOVE (zlema - volatility) = trend = 1
|
| 160 |
+
# Short: price crosses BELOW (zlema + volatility) = trend = -1
|
| 161 |
+
d["price_above_lower"] = d["close"] > d["lower_band"]
|
| 162 |
+
d["price_below_upper"] = d["close"] < d["upper_band"]
|
| 163 |
+
|
| 164 |
+
# Detect crossovers: from False to True (crossover), from True to False (crossunder)
|
| 165 |
+
prev_above_lower = d["price_above_lower"].shift(1).fillna(False)
|
| 166 |
+
prev_below_upper = d["price_below_upper"].shift(1).fillna(True) # default to True so first bar isn't a crossunder
|
| 167 |
+
|
| 168 |
+
d["crossover_lower"] = (~prev_above_lower) & d["price_above_lower"]
|
| 169 |
+
d["crossunder_upper"] = prev_below_upper & (~d["price_below_upper"])
|
| 170 |
+
|
| 171 |
+
# Initialize trend
|
| 172 |
+
d["trend"] = 0
|
| 173 |
+
trend_val = 0
|
| 174 |
+
for i in range(len(d)):
|
| 175 |
+
if d["crossover_lower"].iloc[i]:
|
| 176 |
+
trend_val = 1
|
| 177 |
+
if d["crossunder_upper"].iloc[i]:
|
| 178 |
+
trend_val = -1
|
| 179 |
+
d.loc[d.index[i], "trend"] = trend_val
|
| 180 |
+
|
| 181 |
+
# Volume
|
| 182 |
+
d["volume_sma"] = d["volume"].rolling(20).mean()
|
| 183 |
+
|
| 184 |
+
# Swing structure (for SL placement)
|
| 185 |
+
d["swing_low5"] = d["low"].rolling(5).min()
|
| 186 |
+
d["swing_high5"] = d["high"].rolling(5).max()
|
| 187 |
+
|
| 188 |
+
return d.dropna()
|
| 189 |
+
|
| 190 |
+
def signal_for_row(self, row: pd.Series, htf_row: Optional[pd.Series] = None) -> int:
|
| 191 |
+
"""
|
| 192 |
+
Generate entry signal based on ZLEMA strategy.
|
| 193 |
+
Returns: 1 (LONG), -1 (SHORT), 0 (NO SIGNAL)
|
| 194 |
+
"""
|
| 195 |
+
close = float(row["close"])
|
| 196 |
+
zlema = float(row["zlema"])
|
| 197 |
+
volatility = float(row["volatility"])
|
| 198 |
+
upper_band = float(row["upper_band"])
|
| 199 |
+
lower_band = float(row["lower_band"])
|
| 200 |
+
|
| 201 |
+
# Basic filters
|
| 202 |
+
atr_pct = float(row["atr_pct"])
|
| 203 |
+
if atr_pct < 0.2: # Reduced filter for SOL volatility
|
| 204 |
+
return 0
|
| 205 |
+
|
| 206 |
+
# LONG entry signal: price crosses above lower band
|
| 207 |
+
if row.get("crossover_lower", False):
|
| 208 |
+
return 1
|
| 209 |
+
|
| 210 |
+
# SHORT entry signal: price crosses below upper band
|
| 211 |
+
if row.get("crossunder_upper", False):
|
| 212 |
+
return -1
|
| 213 |
+
|
| 214 |
+
return 0
|
| 215 |
+
|
| 216 |
+
def risk_levels(
|
| 217 |
+
self,
|
| 218 |
+
entry: float,
|
| 219 |
+
atr: float,
|
| 220 |
+
side: int,
|
| 221 |
+
atr_pct: float = 0.0,
|
| 222 |
+
row: Optional[pd.Series] = None,
|
| 223 |
+
) -> Tuple[float, float]:
|
| 224 |
+
"""
|
| 225 |
+
ZLEMA risk management:
|
| 226 |
+
Stop: 1.5 ATR (responsive to volatility)
|
| 227 |
+
TP: 3.0 ATR (R:R 2.0:1)
|
| 228 |
+
"""
|
| 229 |
+
sl_mult = 1.5
|
| 230 |
+
tp_mult = 3.0
|
| 231 |
+
|
| 232 |
+
if side == 1: # LONG
|
| 233 |
+
sl = entry - sl_mult * atr
|
| 234 |
+
tp = entry + tp_mult * atr
|
| 235 |
+
else: # SHORT
|
| 236 |
+
sl = entry + sl_mult * atr
|
| 237 |
+
tp = entry - tp_mult * atr
|
| 238 |
+
|
| 239 |
+
return sl, tp
|
| 240 |
+
|
| 241 |
+
def trail_stop(self, side: int, entry: float, current_sl: float, close: float, atr: float) -> float:
|
| 242 |
+
"""
|
| 243 |
+
Trail stop at 2.0 ATR once 1.5 ATR in profit.
|
| 244 |
+
"""
|
| 245 |
+
if side == 1: # LONG
|
| 246 |
+
if close >= entry + 1.5 * atr:
|
| 247 |
+
new_sl = close - 2.0 * atr
|
| 248 |
+
return max(current_sl, new_sl)
|
| 249 |
+
else: # SHORT
|
| 250 |
+
if close <= entry - 1.5 * atr:
|
| 251 |
+
new_sl = close + 2.0 * atr
|
| 252 |
+
return min(current_sl, new_sl)
|
| 253 |
+
return current_sl
|
| 254 |
+
|
| 255 |
+
def build_intent(self, coin: str, timeframe: str, row: pd.Series) -> Optional[TradeIntent]:
|
| 256 |
+
"""
|
| 257 |
+
Build a TradeIntent from current row state.
|
| 258 |
+
"""
|
| 259 |
+
signal = self.signal_for_row(row)
|
| 260 |
+
if signal == 0:
|
| 261 |
+
return None
|
| 262 |
+
|
| 263 |
+
side = "long" if signal == 1 else "short"
|
| 264 |
+
entry = float(row["close"])
|
| 265 |
+
sl, tp = self.risk_levels(entry, float(row["atr"]), signal, float(row["atr_pct"]), row)
|
| 266 |
+
|
| 267 |
+
# Confidence based on trend strength and volatility
|
| 268 |
+
trend = float(row["trend"])
|
| 269 |
+
volatility = float(row["volatility"])
|
| 270 |
+
atr = float(row["atr"])
|
| 271 |
+
|
| 272 |
+
# Higher confidence if volatility band is tight relative to ATR
|
| 273 |
+
bb_to_atr = volatility / atr if atr > 0 else 1.0
|
| 274 |
+
vol_factor = max(0.0, 1.0 - bb_to_atr)
|
| 275 |
+
confidence = round(min(0.95, 0.55 + vol_factor * 0.35), 2)
|
| 276 |
+
|
| 277 |
+
return TradeIntent(coin, timeframe, side, entry, sl, tp, confidence)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
|