From a25d02020ac0dbb73691042adf3fb13cf4ac95c7 Mon Sep 17 00:00:00 2001 From: Hui Zheng Date: Fri, 24 May 2024 13:54:55 -0400 Subject: [PATCH] add more apis for placing order; fix get_order end time; improve tests; updated API reference and README --- README.md | 15 +++----- pyproject.toml | 2 +- pyschwab/market.py | 2 +- pyschwab/trading.py | 72 +++++++++++++++++++++++++++-------- pyschwab/trading_models.py | 54 +++++++++++++------------- pyschwab/types.py | 13 +++++++ pyschwab/utils.py | 28 ++++++++++++++ tests/integration/test_api.py | 40 ++++++++++--------- 8 files changed, 155 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 63a158e..17f69b1 100644 --- a/README.md +++ b/README.md @@ -52,26 +52,21 @@ print(access_token) trading_api = TradingApi(access_token, app_config['trading']) account_num = 0 # CHANGE this to your actual account number -trading_data = trading_api.fetch_trading_data(account_num) +trading_api.set_current_account_number(account_num) +trading_data = trading_api.fetch_trading_data() # List positions for position in trading_data.positions: print("position:", position) # List transactions -for transaction in trading_api.get_transactions(account_num): +for transaction in trading_api.get_transactions(): print("transaction:", transaction) # Place order -order_dict = { - "orderType": "LIMIT", "session": "NORMAL", "duration": "DAY", "orderStrategyType": "SINGLE", "price": '100.00', - "orderLegCollection": [ - {"instruction": "BUY", "quantity": 1, "instrument": {"symbol": "TSLA", "assetType": "EQUITY"}} - ] - } -trading_api.place_order(order_dict, account_num) +trading_api.buy_equity("TSLA", quantity=1, price=100) # List orders -for order in trading_api.get_orders(account_num): +for order in trading_api.get_orders(): print("order:", order) # Example usage of market APIs diff --git a/pyproject.toml b/pyproject.toml index d5a0a90..7580a1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pyschwab" -version = "0.0.1a3" +version = "0.0.1a4" description = "A Python library for the Schwab trading API" authors = ["Hui Zheng "] license = "MIT" diff --git a/pyschwab/market.py b/pyschwab/market.py index cf34809..96f624f 100644 --- a/pyschwab/market.py +++ b/pyschwab/market.py @@ -11,7 +11,7 @@ """ Schwab Market API -Reference: https://beta-developer.schwab.com/products/trader-api--individual/details/specifications/Market%20Data%20Production +Reference: https://developer.schwab.com/products/trader-api--individual/details/specifications/Market%20Data%20Production """ class MarketApi: base_market_url: str diff --git a/pyschwab/trading.py b/pyschwab/trading.py index 33d3f66..f1c7364 100644 --- a/pyschwab/trading.py +++ b/pyschwab/trading.py @@ -3,15 +3,15 @@ from dotenv import load_dotenv -from .utils import format_params, request, time_to_str, to_json_str -from .trading_models import Order, SecuritiesAccount, TradingData, Transaction, UserPreference -from .types import AssetType, MarketSession, OrderDuration, OrderInstruction, OrderStatus, OrderStrategyType, OrderType, TransactionType +from .utils import format_params, remove_none_values, request, time_to_str, to_json_str +from .trading_models import Instrument, Order, OrderLeg, SecuritiesAccount, TradingData, Transaction, UserPreference +from .types import AssetType, ExecutionType, MarketSession, OrderDuration, OrderInstruction, OrderStatus, OrderStrategyType, OrderType, TransactionType """ Schwab Trading API -Reference: https://beta-developer.schwab.com/products/trader-api--individual/details/specifications/Retail%20Trader%20API%20Production +Reference: https://developer.schwab.com/products/trader-api--individual/details/specifications/Retail%20Trader%20API%20Production """ class TradingApi: base_trader_url: str @@ -78,23 +78,37 @@ def fetch_all_trading_data(self, include_pos: bool=True) -> Dict[str, TradingDat self.accounts[account_num] = account return trading_data_map - def get_all_orders(self, start_time: datetime=None, end_time: datetime=None, status: OrderStatus=None, max_results: int=100) -> List[Order]: + def get_all_orders(self, start_time: datetime=None, end_time: datetime=None, status: OrderStatus=None, max_results: int=None) -> List[Order]: now = datetime.now() - start = time_to_str(start_time or now - timedelta(days=30)) - end = time_to_str(end_time or now) + start = time_to_str(start_time or now - timedelta(days=60)) + end = time_to_str(end_time or now + timedelta(days=1)) params = {'maxResults': max_results, 'fromEnteredTime': start, 'toEnteredTime': end, 'status': status} orders = request(f'{self.base_trader_url}/orders', headers=self.auth, params=format_params(params)).json() return [Order.from_dict(order) for order in orders] - def get_orders(self, start_time: datetime=None, end_time: datetime=None, status: OrderStatus=None, max_results: int=100) -> List[Order]: + def get_orders(self, start_time: datetime=None, end_time: datetime=None, status: OrderStatus=None, max_results: int=None) -> List[Order]: account_hash = self._get_account_hash() now = datetime.now() - start = time_to_str(start_time or now - timedelta(days=30)) - end = time_to_str(end_time or now) + start = time_to_str(start_time or now - timedelta(days=60)) + end = time_to_str(end_time or now + timedelta(days=1)) params = {'maxResults': max_results, 'fromEnteredTime': start, 'toEnteredTime': end, 'status': status} orders = request(f'{self.base_trader_url}/accounts/{account_hash}/orders', headers=self.auth, params=format_params(params)).json() return [Order.from_dict(order) for order in orders] + @staticmethod + def is_active_order(order: Order) -> bool: + if order.status.is_inactive(): + return False + + activities = order.order_activity_collection + if activities: + if activities[0].execution_type == ExecutionType.CANCELED: + return False + return order.cancel_time is None + + def get_working_orders(self, start_time: datetime=None, end_time: datetime=None) -> List[Order]: + return self.get_orders(start_time, end_time, status=OrderStatus.WORKING) + def get_order(self, order_id: str, account_num: int | str=None) -> Order: account_hash = self._get_account_hash(account_num) order = request(f'{self.base_trader_url}/accounts/{account_hash}/orders/{order_id}', headers=self.auth).json() @@ -104,6 +118,31 @@ def place_order(self, order: Dict[str, Any] | Order) -> None: account_hash = self._get_account_hash() order_dict = self._convert_order(order) request(f'{self.base_trader_url}/accounts/{account_hash}/orders', method='POST', headers=self.auth, json=order_dict) + + def place_complex_order(self, price: float, leg_collection: List[OrderLeg], + strategy_type: OrderStrategyType=OrderStrategyType.SINGLE, order_type: OrderType=OrderType.LIMIT, + duration: OrderDuration=OrderDuration.DAY, session: MarketSession=MarketSession.NORMAL) -> None: + order = Order(price=price, order_leg_collection=leg_collection, order_strategy_type=strategy_type, + order_type=order_type, duration=duration, session=session) + self.place_order(order) + + def place_single_order(self, symbol: str, quantity: int, price: float, instruction: OrderInstruction, + asset_type: AssetType=AssetType.EQUITY, order_type: OrderType=OrderType.LIMIT, + duration: OrderDuration=OrderDuration.DAY, session: MarketSession=MarketSession.NORMAL) -> None: + instrument = Instrument(symbol=symbol, asset_type=asset_type) + leg_collection = [OrderLeg(instruction=instruction, quantity=quantity, instrument=instrument)] + self.place_complex_order(price=price, leg_collection=leg_collection, strategy_type=OrderStrategyType.SINGLE, + order_type=order_type, duration=duration, session=session) + + def buy_equity(self, symbol: str, quantity: int, price: float, order_type: OrderType=OrderType.LIMIT, + duration: OrderDuration=OrderDuration.DAY, session: MarketSession=MarketSession.NORMAL) -> None: + self.place_single_order(symbol=symbol, quantity=quantity, price=price, instruction=OrderInstruction.BUY, + order_type=order_type, duration=duration, session=session) + + def sell_equity(self, symbol: str, price: float, quantity: int, order_type: OrderType=OrderType.LIMIT, + duration: OrderDuration=OrderDuration.DAY, session: MarketSession=MarketSession.NORMAL) -> None: + self.place_single_order(symbol=symbol, quantity=quantity, price=price, instruction=OrderInstruction.SELL, + order_type=order_type, duration=duration, session=session) def cancel_order(self, order_id: int | str) -> None: account_hash = self._get_account_hash() @@ -127,13 +166,14 @@ def preview_order(self, order: Dict[str, Any] | Order) -> Dict[str, Any]: return request(f'{self.base_trader_url}/accounts/{account_hash}/previewOrder', method='POST', headers=self.auth2, data=order_json).json() def _convert_order(self, order: Dict[str, Any] | Order) -> Dict[str, Any]: + order_dict = None if isinstance(order, Dict): - return order - - if isinstance(order, Order): - return order.to_dict(clean_keys=True) - - raise ValueError("Order must be a dictionary or Order object.") + order_dict = order + elif isinstance(order, Order): + order_dict = order.to_dict(clean_keys=True) + else: + raise ValueError("Order must be a dictionary or Order object.") + return remove_none_values(order_dict) def get_transactions(self, start_time: datetime=None, end_time: datetime=None, symbol: str=None, types: TransactionType=TransactionType.TRADE) -> List[Transaction]: account_hash = self._get_account_hash() diff --git a/pyschwab/trading_models.py b/pyschwab/trading_models.py index 282ef04..d07376d 100644 --- a/pyschwab/trading_models.py +++ b/pyschwab/trading_models.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Any, Dict, List, Optional -from .types import AssetType, ComplexOrderStrategyType, MarketSession, OptionActionType, \ +from .types import ActivityType, AssetType, ComplexOrderStrategyType, ExecutionType, MarketSession, OptionActionType, \ OptionAssetType, OrderDuration, OrderInstruction, OrderStatus, OrderStrategyType, OrderType, \ PositionEffect, QuantityType, RequestedDestination from .utils import camel_to_snake, dataclass_to_dict, to_time @@ -87,16 +87,16 @@ class Instrument: symbol: str asset_type: AssetType = AssetType.EQUITY cusip: str = None - net_change: float = 0.0 - instrument_id: int = 0 + net_change: float = None + instrument_id: int = None description: str = None - closing_price: float = 0.0 + closing_price: float = None status: str = None type: str = None expiration_date: datetime = None option_deliverables: List[Deliverable] = None - option_premium_multiplier: float = 0.0 - put_call: OptionActionType = OptionActionType.CALL + option_premium_multiplier: float = None + put_call: OptionActionType = None strike_price: float = None underlying_symbol: str = None underlying_cusip: str = None @@ -292,8 +292,8 @@ def from_dict(cls, data: Dict[str, Any]) -> 'ExecutionLeg': @dataclass class OrderActivity: - activity_type: str - execution_type: str + activity_type: ActivityType + execution_type: ExecutionType quantity: int order_remaining_quantity: int execution_legs: List[ExecutionLeg] @@ -301,6 +301,8 @@ class OrderActivity: @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'OrderActivity': converted_data = {camel_to_snake(key): value for key, value in data.items()} + converted_data['activity_type'] = ActivityType.from_str(converted_data.get('activity_type', None)) + converted_data['execution_type'] = ExecutionType.from_str(converted_data.get('execution_type', None)) converted_data['execution_legs'] = [ExecutionLeg.from_dict(leg) for leg in converted_data['execution_legs']] return cls(**converted_data) @@ -310,9 +312,9 @@ class OrderLeg: instrument: Instrument instruction: OrderInstruction quantity: int - position_effect: PositionEffect = PositionEffect.OPENING - order_leg_type: AssetType = AssetType.EQUITY - quantity_type: QuantityType = QuantityType.ALL_SHARES + position_effect: PositionEffect = None + order_leg_type: AssetType = None + quantity_type: QuantityType = None leg_id: int = None div_cap_gains: str = None to_symbol: str = None @@ -331,25 +333,25 @@ def from_dict(cls, data: Dict[str, Any]) -> 'OrderLeg': @dataclass class Order: price: float - entered_time: datetime - close_time: datetime - release_time: datetime + entered_time: datetime = None + close_time: datetime = None + release_time: datetime = None order_type: OrderType = OrderType.LIMIT duration: OrderDuration = OrderDuration.DAY session: MarketSession = MarketSession.NORMAL order_strategy_type: OrderStrategyType = OrderStrategyType.SINGLE - order_id: int = 0 - account_number: int = 0 - status: OrderStatus = OrderStatus.AWAITING_PARENT_ORDER - quantity: int = 0 - filled_quantity: int = 0 - remaining_quantity: int = 0 - complex_order_strategy_type: ComplexOrderStrategyType = ComplexOrderStrategyType.NONE - requested_destination: RequestedDestination = RequestedDestination.AUTO + order_id: int = None + account_number: int = None + status: OrderStatus = None + quantity: int = None + filled_quantity: int = None + remaining_quantity: int = None + complex_order_strategy_type: ComplexOrderStrategyType = None + requested_destination: RequestedDestination = None destination_link_name: str = None order_leg_collection: List[OrderLeg] = None order_activity_collection: List[OrderActivity] = None - stop_price: float = 0.0 + stop_price: float = None stop_price_link_basis: str = None stop_price_link_type: str = None stop_price_offset: float = None @@ -357,10 +359,10 @@ class Order: price_link_basis: str = None price_link_type: str = None tax_lot_method: str = None - activation_price: float = 0.0 + activation_price: float = None special_instruction: str = None - cancelable: bool = False - editable: bool = False + cancelable: bool = None + editable: bool = None cancel_time: datetime = None tag: str = None status_description: str = None diff --git a/pyschwab/types.py b/pyschwab/types.py index 8fd1a54..bd084c0 100644 --- a/pyschwab/types.py +++ b/pyschwab/types.py @@ -271,6 +271,9 @@ class OrderStatus(AutoName): PENDING_RECALL = auto() UNKNOWN = auto() + def is_inactive(self): + return self in [OrderStatus.CANCELED, OrderStatus.REJECTED, OrderStatus.EXPIRED, OrderStatus.FILLED] + class RequestedDestination(AutoName): INET = auto() @@ -337,6 +340,16 @@ class OptionStrategy(AutoName): ROLL = auto() +class ActivityType(AutoName): + EXECUTION = auto() + ORDER_ACTION = auto() + + +class ExecutionType(AutoName): + FILL = auto() + CANCELED = auto() + + class MoverSort(AutoName): VOLUME = auto() TRADES = auto() diff --git a/pyschwab/utils.py b/pyschwab/utils.py index 495944b..ccc1107 100644 --- a/pyschwab/utils.py +++ b/pyschwab/utils.py @@ -169,6 +169,34 @@ def format_list(lst: str | List) -> str: return ",".join(lst) +def remove_none_values(data: Dict[str, Any]) -> Dict[str, Any]: + """ + Recursively remove all keys with None values from the dictionary. + + Args: + data (dict): The dictionary to clean. + + Returns: + dict: The cleaned dictionary with no None values. + """ + if not isinstance(data, dict): + return data + + clean_dict = {} + for key, value in data.items(): + if isinstance(value, dict): + nested_clean_dict = remove_none_values(value) + if nested_clean_dict: # only add non-empty nested dictionaries + clean_dict[key] = nested_clean_dict + elif isinstance(value, list): + clean_list = [remove_none_values(item) for item in value] + if clean_list: # only add non-empty lists + clean_dict[key] = clean_list + elif value is not None: + clean_dict[key] = value + + return clean_dict + def to_json_str(obj): def json_encode(obj): diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 8bb3d9f..ccd3b6d 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -114,8 +114,9 @@ def check_orders(trading_api: TradingApi, orders: List[Order]): } test_account_number = 0 # CHANGE this to actual account number -test_order_id = 0 # CHANGE this to actual order id -test_order_type = [None, 'place_dict', 'place_obj', 'replace', 'cancel', 'preview'][0] # choose to test place order(dict/obj), replace, cancel, preview, or None + # choose to test different order types: place_dict, place_obj, buy_equity, replace, cancel, preview + # WARNING: some options might place or replace an actual order +test_order_type = [None, 'place_dict', 'place_obj', 'buy_equity', 'replace', 'cancel', 'preview'][0] @pytest.mark.integration @@ -180,7 +181,7 @@ def test_authentication_and_trading_data(app_config, logging_config): orders = trading_api.get_orders() check_orders(trading_api, orders) - orders = trading_api.get_all_orders(status=OrderStatus.FILLED) + orders = trading_api.get_all_orders(status=OrderStatus.FILLED, max_results=10) check_orders(trading_api, orders) if not test_account_number or not test_order_type: # no order placement, change, cancellation, or preview @@ -194,21 +195,26 @@ def test_authentication_and_trading_data(app_config, logging_config): print("Testing place order by order obj") order = Order.from_dict(order_dict) trading_api.place_order(order) + elif test_order_type == 'buy_equity': + print("Testing buy equity") + trading_api.buy_equity("TSLA", quantity=1, price=100) else: - for order in orders: - leg = order.order_leg_collection[0] - if leg.instrument.symbol == 'TSLA': - if test_order_type == 'cancel': - print("Testing cancel order") - trading_api.cancel_order(test_order_id) - elif test_order_type == 'replace': - print("Testing replace order") - order.order_id = test_order_id - order.price = 102 - # order.quantity = 2 - order.order_leg_collection[0].quantity = 2 - trading_api.replace_order(order) - break + orders = trading_api.get_working_orders() + if len(orders) == 0: + print("No working order to test replace or cancel") + else: + order = orders[0] + order_id = order.order_id + if test_order_type == 'cancel': + print("Testing cancel order with id=", order_id) + trading_api.cancel_order(order_id) + elif test_order_type == 'replace': + order.price -= 0.1 + cur_qty = order.order_leg_collection[0].quantity + qty = 2 if cur_qty == 1 else 1 # keep quantity small and avoid replacing with the same quantity + order.order_leg_collection[0].quantity = qty + print("Testing replace order with id=", order_id, " with new price =", order.price, " and quantity =", qty) + trading_api.replace_order(order) def test_order_json():