BotDeepSeek/strategies/ma_cross.py

213 lines
9.6 KiB
Python

import logging
import pandas as pd
from ib_insync import Contract, IB
from bars import BarManager, is_market_active, parse_bar_size_seconds, to_completed_df
from config import config
from orders import execute_market_order
from state import PositionTracker
from strategies.base import BaseStrategy
logger = logging.getLogger(__name__)
class MAStockStrategy(BaseStrategy):
"""SMA fast/slow golden-cross strategy with ADX trend filter.
Exits (re-evaluated every cycle while in position):
- hard stop loss at -stop_loss_pct (always honoured)
- fast MA below slow MA, once profit >= min_profit_pct
"""
def __init__(self, ib: IB, bar_manager: BarManager, tracker: PositionTracker):
super().__init__(ib, bar_manager, tracker)
self.cfg = config.stock
self.fast_period = self.cfg.fast_ma_period
self.slow_period = self.cfg.slow_ma_period
self.bar_seconds = parse_bar_size_seconds(self.cfg.bar_size)
self.contracts: dict[str, Contract] = {}
async def on_start(self):
logger.info(
"Stock MA strategy started: symbols=%s, fast=%d, slow=%d, value=$%.0f, "
"stop_loss=%.1f%%, min_profit=%.1f%%, confirm_bars=%d",
self.cfg.symbols, self.fast_period, self.slow_period, self.cfg.trade_value_usd,
self.cfg.stop_loss_pct, self.cfg.min_profit_pct, self.cfg.entry_confirm_bars,
)
for symbol in self.cfg.symbols:
contract = self._contract(symbol)
await self.ib.qualifyContractsAsync(contract)
if not contract.conId:
logger.error("Stock %s: failed to qualify contract, skipped", symbol)
continue
self.contracts[symbol] = contract
await self.bar_manager.subscribe(
contract, self.cfg.bar_size, f"{self.cfg.lookback_days} D", "TRADES"
)
logger.info("Stock %s: subscribed, owned=%s", symbol, self.tracker.get(self.name, symbol))
@staticmethod
def _calc_adx(df: pd.DataFrame, period: int = 14) -> pd.Series:
"""ADX with standard Wilder smoothing."""
high, low, close = df["high"], df["low"], df["close"]
prev_close = close.shift(1)
tr = pd.concat([
(high - low).abs(),
(high - prev_close).abs(),
(low - prev_close).abs(),
], axis=1).max(axis=1)
up_move = high.diff()
down_move = -low.diff() # Wilder: positive only when the low moves DOWN
plus_dm = ((up_move > down_move) & (up_move > 0)).astype(float) * up_move
minus_dm = ((down_move > up_move) & (down_move > 0)).astype(float) * down_move
atr = tr.ewm(alpha=1 / period, adjust=False).mean()
plus_di = 100 * plus_dm.ewm(alpha=1 / period, adjust=False).mean() / atr
minus_di = 100 * minus_dm.ewm(alpha=1 / period, adjust=False).mean() / atr
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, float("nan"))
return dx.ewm(alpha=1 / period, adjust=False).mean()
async def _get_df(self, contract: Contract) -> pd.DataFrame:
bars = self.bar_manager.get(contract, self.cfg.bar_size, "TRADES")
if bars is None:
bars = await self.bar_manager.subscribe(
contract, self.cfg.bar_size, f"{self.cfg.lookback_days} D", "TRADES"
)
return to_completed_df(bars, self.bar_seconds)
async def on_bar(self):
await self._sync_stop_orders()
for symbol, contract in self.contracts.items():
try:
await self._process_symbol(symbol, contract)
except Exception as e:
logger.exception("Stock %s: error: %s", symbol, e)
async def _process_symbol(self, symbol: str, contract: Contract):
df = await self._get_df(contract)
if len(df) < self.slow_period + 28:
return
if not is_market_active(df, self.bar_seconds):
return # market closed / stale data - never trade
df = df.copy()
df["fast_ma"] = df["close"].rolling(self.fast_period).mean()
df["slow_ma"] = df["close"].rolling(self.slow_period).mean()
df["adx"] = self._calc_adx(df)
last = df.iloc[-1]
prev = df.iloc[-2]
fast_above = last["fast_ma"] > last["slow_ma"]
# entry confirmation: the cross happened (confirm_bars-1) bars back and
# the fast MA is still above the slow MA now -> filters 1-bar whipsaws
cb = self.cfg.entry_confirm_bars
ref = df.iloc[-cb]
ref_prev = df.iloc[-cb - 1]
cross_then = ref["fast_ma"] > ref["slow_ma"] and ref_prev["fast_ma"] <= ref_prev["slow_ma"]
confirmed = cross_then and fast_above
is_trending = last["adx"] > self.cfg.adx_min
slow_rising = True
if self.cfg.require_slow_ma_slope and len(df) >= 2:
# slow MA must itself be rising over the confirmation window
slow_rising = last["slow_ma"] > df["slow_ma"].iloc[-self.cfg.entry_confirm_bars - 1]
owned = self.tracker.get(self.name, symbol)
if owned:
entry = owned["entry_price"]
profit_pct = (last["close"] - entry) / entry * 100
# soft stop is only a fallback: the exchange-side STP order is primary
if not self._has_active_stop(symbol) and last["close"] <= entry * (1 - self.cfg.stop_loss_pct / 100):
logger.info(
"STOCK STOP-LOSS SELL (soft fallback): %s close=%.2f entry=%.2f (%.2f%%)",
symbol, last["close"], entry, profit_pct,
)
await self._sell(symbol, contract, owned["quantity"])
self._mark_stop_cooldown(symbol)
elif not fast_above:
# re-checked every cycle: exits as soon as profit requirement is met
if profit_pct >= self.cfg.min_profit_pct:
logger.info(
"STOCK SELL: %s (fast MA %.2f < slow MA %.2f, profit=%.2f%%, ADX=%.1f)",
symbol, last["fast_ma"], last["slow_ma"], profit_pct, last["adx"],
)
await self._sell(symbol, contract, owned["quantity"])
else:
logger.debug(
"STOCK SELL WAITING: %s profit %.2f%% < min %.1f%%",
symbol, profit_pct, self.cfg.min_profit_pct,
)
else:
if confirmed and is_trending and slow_rising:
if self._in_stop_cooldown(symbol):
logger.info("STOCK BUY SKIPPED: %s (stop-out cooldown %d min)",
symbol, self.cfg.stop_cooldown_minutes)
return
value = self._trade_value_usd(symbol)
ok, reason = self._can_open_position(symbol, value, last["close"])
if not ok:
logger.info("STOCK BUY SKIPPED: %s (%s)", symbol, reason)
return
qty = self._order_quantity(symbol, last["close"])
logger.info(
"STOCK BUY: %s x%d (cross confirmed over %d bars, fast MA %.2f > slow MA %.2f, ADX=%.1f)",
symbol, qty, self.cfg.entry_confirm_bars, last["fast_ma"], last["slow_ma"], last["adx"],
)
await self._buy(symbol, contract, qty)
async def _buy(self, symbol: str, contract: Contract, quantity: int):
ref = f"{self.name}:{symbol}"
trade = await execute_market_order(self.ib, contract, "BUY", quantity, ref)
if trade and trade.orderStatus.filled > 0:
self.tracker.record_buy(
self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice
)
if self._use_hard_stop():
stop_price = self._target_stop_price(
symbol, trade.orderStatus.avgFillPrice, None
)
await self._place_stop(
symbol, contract, trade.orderStatus.filled, stop_price
)
else:
# order rejected/timed out (e.g. insufficient buying power):
# cool down to avoid retrying every cycle
self._mark_cooldown(symbol, "order failed")
async def _sell(self, symbol: str, contract: Contract, quantity: float):
# cancel the hard stop first; it may have filled in the cancel race
if self._use_hard_stop():
stop_filled, stop_price, cancel_confirmed = await self._cancel_stop(symbol)
if stop_filled > 0:
logger.info("%s: stop order filled %g during cancel", symbol, stop_filled)
self.tracker.record_sell(self.name, symbol, stop_filled, stop_price)
quantity -= stop_filled
if quantity <= 0:
return
if not cancel_confirmed:
# STP may still be live - a market sell could double-sell;
# leave the position for the next cycle
logger.warning(
"STOCK SELL ABORTED: %s (stop cancel not confirmed - "
"may still be active, will retry next cycle)", symbol,
)
return
ref = f"{self.name}:{symbol}"
trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref)
if trade and trade.orderStatus.filled > 0:
self.tracker.record_sell(self.name, symbol, trade.orderStatus.filled, trade.orderStatus.avgFillPrice)
async def on_tick(self):
pass
def _contract(self, symbol: str):
return Contract(
symbol=symbol,
secType="STK",
exchange=self.cfg.exchange,
currency=self.cfg.currency,
)