110 lines
3.3 KiB
Python
110 lines
3.3 KiB
Python
import logging
|
|
from datetime import datetime, timedelta
|
|
|
|
import config
|
|
from db.db import mark_signal_executed, mark_signal_closed, get_executed_unclosed_signals
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _get_api():
|
|
try:
|
|
from alpaca_trade_api import REST
|
|
except ImportError:
|
|
raise ImportError("alpaca-trade-api not installed. Run: pip install alpaca-trade-api")
|
|
|
|
return REST(
|
|
key_id=config.ALPACA_KEY,
|
|
secret_key=config.ALPACA_SECRET,
|
|
base_url=config.ALPACA_BASE_URL,
|
|
)
|
|
|
|
|
|
def get_portfolio_value() -> float:
|
|
return float(_get_api().get_account().portfolio_value)
|
|
|
|
|
|
def get_open_positions_count() -> int:
|
|
return len(_get_api().list_positions())
|
|
|
|
|
|
def execute_signal(signal: dict) -> bool:
|
|
if not config.ALPACA_KEY or not config.ALPACA_SECRET:
|
|
logger.warning("Alpaca credentials not configured")
|
|
return False
|
|
|
|
ticker = signal["ticker"]
|
|
|
|
try:
|
|
api = _get_api()
|
|
|
|
if get_open_positions_count() >= config.MAX_POSITIONS:
|
|
logger.warning(f"Max positions ({config.MAX_POSITIONS}) reached, skipping {ticker}")
|
|
return False
|
|
|
|
portfolio_value = get_portfolio_value()
|
|
allocation = portfolio_value * config.POSITION_SIZE_PCT
|
|
|
|
price = float(api.get_latest_trade(ticker).price)
|
|
if price <= 0:
|
|
logger.error(f"Invalid price for {ticker}: {price}")
|
|
return False
|
|
|
|
qty = int(allocation / price)
|
|
if qty < 1:
|
|
logger.warning(f"Allocation too small for {ticker}: ${allocation:.2f} at ${price:.2f}")
|
|
return False
|
|
|
|
existing_positions = {p.symbol: p for p in api.list_positions()}
|
|
if ticker in existing_positions:
|
|
existing_pct = float(existing_positions[ticker].market_value) / portfolio_value
|
|
if existing_pct >= 0.10:
|
|
logger.warning(f"Already at 10% cap for {ticker}, skipping")
|
|
return False
|
|
|
|
order = api.submit_order(
|
|
symbol=ticker,
|
|
qty=qty,
|
|
side="buy",
|
|
type="market",
|
|
time_in_force="day",
|
|
)
|
|
logger.info(f"Order submitted: {ticker} qty={qty} order_id={order.id}")
|
|
mark_signal_executed(signal["id"])
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to execute signal for {ticker}: {e}")
|
|
return False
|
|
|
|
|
|
def close_position(ticker: str, signal_id: int) -> bool:
|
|
try:
|
|
_get_api().close_position(ticker)
|
|
mark_signal_closed(signal_id)
|
|
logger.info(f"Closed position: {ticker} (signal {signal_id})")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to close position {ticker}: {e}")
|
|
return False
|
|
|
|
|
|
def close_expired_positions():
|
|
if not config.ALPACA_KEY or not config.ALPACA_SECRET:
|
|
return
|
|
|
|
cutoff = datetime.utcnow() - timedelta(days=config.HOLDING_PERIOD_DAYS)
|
|
signals = get_executed_unclosed_signals()
|
|
|
|
for signal in signals:
|
|
executed_at_str = signal.get("executed_at")
|
|
if not executed_at_str:
|
|
continue
|
|
try:
|
|
executed_at = datetime.strptime(executed_at_str, "%Y-%m-%dT%H:%M:%SZ")
|
|
except ValueError:
|
|
continue
|
|
|
|
if executed_at <= cutoff:
|
|
close_position(signal["ticker"], signal["id"])
|