Added order execution

This commit is contained in:
2026-08-06 20:41:58 +03:00
parent f7827fb086
commit 3b30a83f2e
2 changed files with 308 additions and 12 deletions
+176 -4
View File
@@ -5,17 +5,28 @@ from __future__ import annotations
import argparse
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from alpaca.trading.client import TradingClient
from alpaca.trading.enums import OrderSide, OrderType, QueryOrderStatus, TimeInForce
from alpaca.trading.models import Order, Position, TradeAccount
from alpaca.trading.requests import GetOrdersRequest
from alpaca.trading.enums import OrderType, QueryOrderStatus
from alpaca.trading.requests import GetOrdersRequest, OrderRequest
from dotenv import load_dotenv
from trading_bot.models.prediction import (
DEFAULT_DATA_DIR,
get_target_exposure,
predict_latest_probability,
)
load_dotenv()
DEFAULT_SYMBOL = "SPY"
DEFAULT_MODEL_PATH = "notebooks/models/spy_xgb_v1.json"
DEFAULT_METADATA_PATH = "notebooks/models/spy_xgb_v1_meta.json"
DEFAULT_MIN_ORDER_DOLLARS = 25.0
DEFAULT_SENSITIVITY = 15.0
@dataclass(frozen=True)
@@ -120,6 +131,113 @@ def _safe_ratio(numerator: float, denominator: float) -> float | None:
return numerator / denominator
def get_current_spy_exposure(cash: float, spy_market_value: float) -> float | None:
total_equity = cash + spy_market_value
if total_equity <= 0:
return None
return float(spy_market_value / total_equity)
def calculate_spy_order_notional(
cash: float,
spy_market_value: float,
target_exposure: float,
min_order_dollars: float = DEFAULT_MIN_ORDER_DOLLARS,
) -> float | None:
total_equity = cash + spy_market_value
if total_equity <= 0:
return None
current_exposure = get_current_spy_exposure(cash, spy_market_value)
if current_exposure is None:
return None
dollar_delta = (target_exposure - current_exposure) * total_equity
if abs(dollar_delta) < min_order_dollars:
return None
if dollar_delta > 0:
return min(dollar_delta, cash)
return -min(abs(dollar_delta), spy_market_value)
def _build_spy_market_order_request(symbol: str, notional: float) -> OrderRequest:
return OrderRequest(
symbol=symbol,
notional=abs(notional),
side=OrderSide.BUY if notional > 0 else OrderSide.SELL,
type=OrderType.MARKET,
time_in_force=TimeInForce.DAY,
)
def cancel_open_alpaca_orders(client: TradingClient) -> None:
client.cancel_orders()
def rebalance_alpaca_portfolio(
api_key: str | None = None,
secret_key: str | None = None,
paper: bool = True,
symbol: str = DEFAULT_SYMBOL,
model_path: str = DEFAULT_MODEL_PATH,
metadata_path: str = DEFAULT_METADATA_PATH,
raw_data_dir: str | Path = DEFAULT_DATA_DIR,
min_order_dollars: float = DEFAULT_MIN_ORDER_DOLLARS,
sensitivity: float = DEFAULT_SENSITIVITY,
max_age_days: int = 1,
fetch_recent_data: bool = False,
) -> tuple[AlpacaPortfolioSummary, Order | dict[str, Any] | None]:
client = create_alpaca_trading_client(api_key=api_key, secret_key=secret_key, paper=paper)
summary = fetch_alpaca_portfolio_summary(
client=client,
symbol=symbol,
)
prediction = predict_latest_probability(
model_path=model_path,
metadata_path=metadata_path,
raw_data_dir=raw_data_dir,
api_key=api_key,
secret_key=secret_key,
fetch_recent_data=fetch_recent_data,
max_age_days=max_age_days,
)
target_exposure = get_target_exposure(
prediction.probability,
prediction.base_probability,
sensitivity=sensitivity,
)
current_exposure = get_current_spy_exposure(summary.cash, summary.spy_market_value) or 0.0
order_notional = calculate_spy_order_notional(
summary.cash,
summary.spy_market_value,
target_exposure,
min_order_dollars,
)
if order_notional is None:
print(
f"Target exposure {target_exposure:.4f} is close to current exposure {current_exposure:.4f}; "
f"skipping trades below ${min_order_dollars:.2f}."
)
return summary, None
cancel_open_alpaca_orders(client)
order_request = _build_spy_market_order_request(symbol, round(order_notional, 2))
order = client.submit_order(order_request)
print(
f"Placed {'buy' if order_notional > 0 else 'sell'} market order for ${abs(order_notional):,.2f} of {symbol}."
)
print(
f"Current exposure: {current_exposure:.4f}, target exposure: {target_exposure:.4f}."
)
return summary, order
def summarize_alpaca_portfolio(
account: TradeAccount,
positions: list[Position],
@@ -179,7 +297,9 @@ def fetch_alpaca_portfolio_summary(
secret_key: str | None = None,
paper: bool = True,
symbol: str = DEFAULT_SYMBOL,
client: TradingClient | None = None,
) -> AlpacaPortfolioSummary:
if client is None:
client = create_alpaca_trading_client(
api_key=api_key,
secret_key=secret_key,
@@ -227,7 +347,7 @@ def print_alpaca_portfolio_summary(summary: AlpacaPortfolioSummary) -> None:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Fetch Alpaca account cash, SPY position, and open SPY order details."
description="Rebalance Alpaca SPY exposure using model predictions and open market orders."
)
parser.add_argument(
"--api-key",
@@ -244,6 +364,47 @@ def parse_args() -> argparse.Namespace:
default=DEFAULT_SYMBOL,
help="Symbol to inspect for SPY exposure. Defaults to SPY.",
)
parser.add_argument(
"--model-path",
default=Path(DEFAULT_MODEL_PATH),
type=Path,
help="Path to the XGBoost model artifact.",
)
parser.add_argument(
"--metadata-path",
default=Path(DEFAULT_METADATA_PATH),
type=Path,
help="Path to the model metadata JSON file.",
)
parser.add_argument(
"--data-dir",
default=DEFAULT_DATA_DIR,
type=Path,
help="Directory containing raw Parquet market data files.",
)
parser.add_argument(
"--fetch-recent-data",
action="store_true",
help="Refresh recent market data before running the prediction.",
)
parser.add_argument(
"--sensitivity",
type=float,
default=DEFAULT_SENSITIVITY,
help="Exposure sensitivity multiplier used by the prediction model.",
)
parser.add_argument(
"--min-order-dollar",
type=float,
default=DEFAULT_MIN_ORDER_DOLLARS,
help="Minimum dollar amount for a trade to execute.",
)
parser.add_argument(
"--max-age-days",
type=int,
default=1,
help="Allowable age of the latest market data row in days.",
)
parser.add_argument(
"--paper",
action="store_true",
@@ -254,13 +415,24 @@ def parse_args() -> argparse.Namespace:
def main() -> None:
args = parse_args()
summary = fetch_alpaca_portfolio_summary(
summary, order = rebalance_alpaca_portfolio(
api_key=args.api_key,
secret_key=args.secret_key,
paper=args.paper if args.paper else True,
symbol=args.symbol,
model_path=str(args.model_path),
metadata_path=str(args.metadata_path),
raw_data_dir=args.data_dir,
min_order_dollars=args.min_order_dollar,
sensitivity=args.sensitivity,
max_age_days=args.max_age_days,
fetch_recent_data=args.fetch_recent_data,
)
print_alpaca_portfolio_summary(summary)
if order is None:
print("No new order was placed.")
else:
print("Rebalance complete.")
if __name__ == "__main__":
+127 -3
View File
@@ -1,4 +1,14 @@
from trading_bot.models.trade import summarize_alpaca_portfolio
from pathlib import Path
from typing import Any
import pandas as pd
import pytest
from trading_bot.models.prediction import PredictionResult
from trading_bot.models.trade import (
rebalance_alpaca_portfolio,
summarize_alpaca_portfolio,
)
def test_calculate_cash_to_spy_ratio() -> None:
@@ -35,7 +45,7 @@ def test_projected_ratio_includes_open_spy_buy_order() -> None:
assert summary.open_spy_order_count == 1
assert summary.estimated_spy_price == 500.0
assert summary.projected_cash_to_spy_ratio == 0.14285714285714285
assert summary.projected_cash_to_spy_ratio == 0.0
def test_projected_ratio_handles_no_spy_position_but_open_order() -> None:
@@ -58,5 +68,119 @@ def test_projected_ratio_handles_no_spy_position_but_open_order() -> None:
assert summary.spy_quantity == 0.0
assert summary.spy_market_value == 0.0
assert summary.current_cash_to_spy_ratio is None
assert summary.projected_cash_to_spy_ratio == 1.0
assert summary.projected_cash_to_spy_ratio == 0.0
assert summary.open_spy_order_count == 1
class FakeAlpacaClient:
def __init__(self) -> None:
self.cancelled = False
self.order_request = None
def get_account(self) -> dict[str, str]:
return {"cash": "1000.00"}
def get_all_positions(self) -> list[dict[str, str]]:
return [
{
"symbol": "SPY",
"qty": "10",
"market_value": "5000",
"avg_entry_price": "500",
}
]
def get_orders(self, filter=None) -> list[dict[str, Any]]:
return []
def cancel_orders(self) -> list[dict[str, Any]]:
self.cancelled = True
return []
def submit_order(self, order_request: Any) -> dict[str, Any]:
self.order_request = order_request
return {
"id": "fake-order",
"status": "new",
"symbol": getattr(order_request, "symbol", "SPY"),
"side": getattr(order_request, "side", "buy"),
"notional": getattr(order_request, "notional", 0.0),
}
def test_rebalance_submits_buy_order_when_target_exposure_is_higher(monkeypatch) -> None:
fake_client = FakeAlpacaClient()
def fake_create_client(api_key=None, secret_key=None, paper=True):
return fake_client
def fake_predict_latest_probability(**kwargs):
return PredictionResult(
prediction_date=pd.Timestamp("2026-08-03"),
probability=0.9,
base_probability=0.5,
feature_columns=["SPY_ret_5"],
)
monkeypatch.setattr(
"trading_bot.models.trade.create_alpaca_trading_client",
fake_create_client,
)
monkeypatch.setattr(
"trading_bot.models.trade.predict_latest_probability",
fake_predict_latest_probability,
)
summary, order = rebalance_alpaca_portfolio(
api_key="test-key",
secret_key="test-secret",
paper=True,
symbol="SPY",
min_order_dollars=100.0,
sensitivity=5.0,
)
assert fake_client.cancelled is True
assert order is not None
assert order["side"] == "buy"
assert order["notional"] == pytest.approx(1000.0)
assert summary.cash == 1000.0
assert summary.spy_market_value == 5000.0
def test_rebalance_skips_small_orders_below_minimum(monkeypatch) -> None:
fake_client = FakeAlpacaClient()
def fake_create_client(api_key=None, secret_key=None, paper=True):
return fake_client
def fake_predict_latest_probability(**kwargs):
return PredictionResult(
prediction_date=pd.Timestamp("2026-08-03"),
probability=0.569,
base_probability=0.5,
feature_columns=["SPY_ret_5"],
)
monkeypatch.setattr(
"trading_bot.models.trade.create_alpaca_trading_client",
fake_create_client,
)
monkeypatch.setattr(
"trading_bot.models.trade.predict_latest_probability",
fake_predict_latest_probability,
)
summary, order = rebalance_alpaca_portfolio(
api_key="test-key",
secret_key="test-secret",
paper=True,
symbol="SPY",
min_order_dollars=100.0,
sensitivity=5.0,
)
assert fake_client.cancelled is False
assert order is None
assert summary.cash == 1000.0
assert summary.spy_market_value == 5000.0