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 ForexMAStrategy(BaseStrategy): """Forex MA cross strategy (disabled by default). Exits: hard stop loss at -stop_loss_pct, or fast MA crossing below slow MA. """ def __init__(self, ib: IB, bar_manager: BarManager, tracker: PositionTracker): super().__init__(ib, bar_manager, tracker) self.cfg = config.forex self.fast_period = self.cfg.fast_ma_period self.slow_period = self.cfg.slow_ma_period self.units = self.cfg.trade_units self.bar_seconds = parse_bar_size_seconds(self.cfg.bar_size) self.contracts: dict[str, Contract] = {} async def on_start(self): logger.info( "Forex MA strategy started: pairs=%s, fast=%d, slow=%d, units=%d, stop_loss=%.1f%%", self.cfg.pairs, self.fast_period, self.slow_period, self.units, self.cfg.stop_loss_pct, ) for pair in self.cfg.pairs: base, quote = pair.split(".") contract = Contract(secType="CASH", symbol=base, currency=quote, exchange=self.cfg.exchange) await self.ib.qualifyContractsAsync(contract) if not contract.conId: logger.error("Forex %s: failed to qualify contract, skipped", pair) continue self.contracts[pair] = contract await self.bar_manager.subscribe( contract, self.cfg.bar_size, f"{self.cfg.lookback_days} D", "MIDPOINT" ) logger.info("Forex %s: subscribed, owned=%s", pair, self.tracker.get(self.name, pair)) async def _get_df(self, contract: Contract) -> pd.DataFrame: bars = self.bar_manager.get(contract, self.cfg.bar_size, "MIDPOINT") if bars is None: bars = await self.bar_manager.subscribe( contract, self.cfg.bar_size, f"{self.cfg.lookback_days} D", "MIDPOINT" ) return to_completed_df(bars, self.bar_seconds) async def on_bar(self): for pair, contract in self.contracts.items(): try: await self._process_pair(pair, contract) except Exception as e: logger.exception("Forex %s: error: %s", pair, e) async def _process_pair(self, pair: str, contract: Contract): df = await self._get_df(contract) if len(df) < self.slow_period + 2: return if not is_market_active(df, self.bar_seconds): return df = df.copy() df["fast_ma"] = df["close"].rolling(self.fast_period).mean() df["slow_ma"] = df["close"].rolling(self.slow_period).mean() last = df.iloc[-1] prev = df.iloc[-2] fast_above = last["fast_ma"] > last["slow_ma"] prev_fast_above = prev["fast_ma"] > prev["slow_ma"] cross_up = fast_above and not prev_fast_above cross_down = not fast_above and prev_fast_above owned = self.tracker.get(self.name, pair) if owned: entry = owned["entry_price"] if last["close"] <= entry * (1 - self.cfg.stop_loss_pct / 100): logger.info( "FOREX STOP-LOSS SELL: %s close=%.5f entry=%.5f", pair, last["close"], entry, ) await self._sell(pair, contract, owned["quantity"]) elif cross_down: logger.info( "FOREX SELL %s (fast MA %.5f < slow MA %.5f)", pair, last["fast_ma"], last["slow_ma"], ) await self._sell(pair, contract, owned["quantity"]) else: if cross_up: logger.info( "FOREX BUY %s (fast MA %.5f crossed above slow MA %.5f)", pair, last["fast_ma"], last["slow_ma"], ) await self._buy(pair, contract) async def _buy(self, pair: str, contract: Contract): ref = f"{self.name}:{pair}" trade = await execute_market_order(self.ib, contract, "BUY", self.units, ref) if trade and trade.orderStatus.filled > 0: self.tracker.record_buy( self.name, pair, trade.orderStatus.filled, trade.orderStatus.avgFillPrice ) async def _sell(self, pair: str, contract: Contract, quantity: float): ref = f"{self.name}:{pair}" trade = await execute_market_order(self.ib, contract, "SELL", quantity, ref) if trade and trade.orderStatus.filled > 0: self.tracker.record_sell(self.name, pair, trade.orderStatus.filled) async def on_tick(self): pass