223 lines
10 KiB
Python
223 lines
10 KiB
Python
import logging
|
|
from datetime import date
|
|
|
|
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, trade_commission
|
|
from state import PositionTracker
|
|
from strategies.base import BaseStrategy
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ShortTermMAVWAPStrategy(BaseStrategy):
|
|
"""Fast EMA cross above slow EMA with price above daily VWAP.
|
|
|
|
Exits (re-evaluated every cycle while in position):
|
|
- hard stop loss at -stop_loss_pct (always honoured)
|
|
- max holding period reached
|
|
- fast EMA below slow EMA, once profit >= min_profit_pct
|
|
"""
|
|
|
|
def __init__(self, ib: IB, bar_manager: BarManager, tracker: PositionTracker):
|
|
super().__init__(ib, bar_manager, tracker)
|
|
self.cfg = config.short_term
|
|
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):
|
|
hold_desc = "unlimited" if self.cfg.max_hold_days <= 0 else f"{self.cfg.max_hold_days} days"
|
|
logger.info(
|
|
"Short-term MA+VWAP strategy started: symbols=%s, fast=%d, slow=%d, value=$%.0f, "
|
|
"max_hold=%s, stop_loss=%.1f%%, min_profit=%.1f%%, confirm_bars=%d",
|
|
self.cfg.symbols, self.fast_period, self.slow_period, self.cfg.trade_value_usd,
|
|
hold_desc, 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("ShortTerm %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("ShortTerm %s: subscribed, owned=%s", symbol, self.tracker.get(self.name, symbol))
|
|
|
|
@staticmethod
|
|
def _calc_vwap(df: pd.DataFrame) -> pd.Series:
|
|
"""VWAP reset each trading day (vectorized, no groupby-apply)."""
|
|
typical_price = (df["high"] + df["low"] + df["close"]) / 3
|
|
pv = typical_price * df["volume"]
|
|
day = df["date"].dt.date
|
|
cum_pv = pv.groupby(day).cumsum()
|
|
cum_vol = df["volume"].groupby(day).cumsum()
|
|
return cum_pv / cum_vol.replace(0, float("nan"))
|
|
|
|
@staticmethod
|
|
def _calc_ema(series: pd.Series, period: int) -> pd.Series:
|
|
return series.ewm(span=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("ShortTerm %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 + 2:
|
|
return
|
|
if not is_market_active(df, self.bar_seconds):
|
|
return # market closed / stale data - never trade
|
|
|
|
df = df.copy()
|
|
df["fast_ema"] = self._calc_ema(df["close"], self.fast_period)
|
|
df["slow_ema"] = self._calc_ema(df["close"], self.slow_period)
|
|
df["vwap"] = self._calc_vwap(df)
|
|
|
|
last = df.iloc[-1]
|
|
prev = df.iloc[-2]
|
|
fast_above = last["fast_ema"] > last["slow_ema"]
|
|
|
|
# entry confirmation: the cross happened (confirm_bars-1) bars back and
|
|
# the fast EMA is still above the slow EMA 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_ema"] > ref["slow_ema"] and ref_prev["fast_ema"] <= ref_prev["slow_ema"]
|
|
confirmed = cross_then and fast_above
|
|
|
|
price_above_vwap = last["close"] > last["vwap"]
|
|
|
|
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(
|
|
"ShortTerm STOP-LOSS SELL (soft fallback): %s close=%.2f entry=%.2f (%.2f%%)",
|
|
symbol, last["close"], entry, profit_pct,
|
|
)
|
|
await self._sell(symbol, contract, owned["quantity"], "SoftStop")
|
|
self._mark_stop_cooldown(symbol)
|
|
return
|
|
|
|
entry_date = date.fromisoformat(owned["entry_date"]) if owned.get("entry_date") else None
|
|
if entry_date and self.cfg.max_hold_days > 0:
|
|
hold_days = (date.today() - entry_date).days
|
|
if hold_days >= self.cfg.max_hold_days:
|
|
logger.info("ShortTerm SELL %s: max hold reached (%d days)", symbol, hold_days)
|
|
await self._sell(symbol, contract, owned["quantity"], "MaxHold")
|
|
return
|
|
|
|
if 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(
|
|
"ShortTerm SELL: %s (fast EMA %.2f < slow EMA %.2f, profit=%.2f%%)",
|
|
symbol, last["fast_ema"], last["slow_ema"], profit_pct,
|
|
)
|
|
await self._sell(symbol, contract, owned["quantity"], "SignalExit")
|
|
else:
|
|
logger.debug(
|
|
"ShortTerm SELL WAITING: %s profit %.2f%% < min %.1f%%",
|
|
symbol, profit_pct, self.cfg.min_profit_pct,
|
|
)
|
|
else:
|
|
if confirmed and price_above_vwap:
|
|
if self._in_stop_cooldown(symbol):
|
|
logger.info("ShortTerm BUY SKIPPED: %s (stop-out cooldown %d min)",
|
|
symbol, self.cfg.stop_cooldown_minutes)
|
|
return
|
|
ok, reason = self._can_open_position(symbol, self._trade_value_usd(symbol), last["close"])
|
|
if not ok:
|
|
logger.info("ShortTerm BUY SKIPPED: %s (%s)", symbol, reason)
|
|
return
|
|
qty = self._order_quantity(symbol, last["close"])
|
|
logger.info(
|
|
"ShortTerm BUY: %s x%d (cross confirmed over %d bars, fast EMA %.2f > slow EMA %.2f, close %.2f > VWAP %.2f)",
|
|
symbol, qty, self.cfg.entry_confirm_bars,
|
|
last["fast_ema"], last["slow_ema"], last["close"], last["vwap"],
|
|
)
|
|
await self._buy(symbol, contract, qty)
|
|
|
|
async def _buy(self, symbol: str, contract: Contract, quantity: int,
|
|
reason: str = "EMACross+VWAP"):
|
|
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,
|
|
reason=reason, commission=trade_commission(trade),
|
|
)
|
|
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,
|
|
reason: str = ""):
|
|
# 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,
|
|
reason="HardStopDuringCancel")
|
|
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(
|
|
"ShortTerm 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,
|
|
reason=reason, commission=trade_commission(trade))
|
|
|
|
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,
|
|
)
|