Added order execution
This commit is contained in:
@@ -5,17 +5,28 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from alpaca.trading.client import TradingClient
|
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.models import Order, Position, TradeAccount
|
||||||
from alpaca.trading.requests import GetOrdersRequest
|
from alpaca.trading.requests import GetOrdersRequest, OrderRequest
|
||||||
from alpaca.trading.enums import OrderType, QueryOrderStatus
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from trading_bot.models.prediction import (
|
||||||
|
DEFAULT_DATA_DIR,
|
||||||
|
get_target_exposure,
|
||||||
|
predict_latest_probability,
|
||||||
|
)
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
DEFAULT_SYMBOL = "SPY"
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -120,6 +131,113 @@ def _safe_ratio(numerator: float, denominator: float) -> float | None:
|
|||||||
return numerator / denominator
|
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(
|
def summarize_alpaca_portfolio(
|
||||||
account: TradeAccount,
|
account: TradeAccount,
|
||||||
positions: list[Position],
|
positions: list[Position],
|
||||||
@@ -179,7 +297,9 @@ def fetch_alpaca_portfolio_summary(
|
|||||||
secret_key: str | None = None,
|
secret_key: str | None = None,
|
||||||
paper: bool = True,
|
paper: bool = True,
|
||||||
symbol: str = DEFAULT_SYMBOL,
|
symbol: str = DEFAULT_SYMBOL,
|
||||||
|
client: TradingClient | None = None,
|
||||||
) -> AlpacaPortfolioSummary:
|
) -> AlpacaPortfolioSummary:
|
||||||
|
if client is None:
|
||||||
client = create_alpaca_trading_client(
|
client = create_alpaca_trading_client(
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
secret_key=secret_key,
|
secret_key=secret_key,
|
||||||
@@ -227,7 +347,7 @@ def print_alpaca_portfolio_summary(summary: AlpacaPortfolioSummary) -> None:
|
|||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
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(
|
parser.add_argument(
|
||||||
"--api-key",
|
"--api-key",
|
||||||
@@ -244,6 +364,47 @@ def parse_args() -> argparse.Namespace:
|
|||||||
default=DEFAULT_SYMBOL,
|
default=DEFAULT_SYMBOL,
|
||||||
help="Symbol to inspect for SPY exposure. Defaults to SPY.",
|
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(
|
parser.add_argument(
|
||||||
"--paper",
|
"--paper",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -254,13 +415,24 @@ def parse_args() -> argparse.Namespace:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
summary = fetch_alpaca_portfolio_summary(
|
summary, order = rebalance_alpaca_portfolio(
|
||||||
api_key=args.api_key,
|
api_key=args.api_key,
|
||||||
secret_key=args.secret_key,
|
secret_key=args.secret_key,
|
||||||
paper=args.paper if args.paper else True,
|
paper=args.paper if args.paper else True,
|
||||||
symbol=args.symbol,
|
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)
|
print_alpaca_portfolio_summary(summary)
|
||||||
|
if order is None:
|
||||||
|
print("No new order was placed.")
|
||||||
|
else:
|
||||||
|
print("Rebalance complete.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+127
-3
@@ -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:
|
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.open_spy_order_count == 1
|
||||||
assert summary.estimated_spy_price == 500.0
|
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:
|
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_quantity == 0.0
|
||||||
assert summary.spy_market_value == 0.0
|
assert summary.spy_market_value == 0.0
|
||||||
assert summary.current_cash_to_spy_ratio is None
|
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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user