Skip to content

[Enhancement] Pydantic models for IBind! #105

@weklund

Description

@weklund

Describe Enhancement

Introduce Pydantic models for all request inputs and response objects within the IBind library. This includes adding response types to all mixin functions to validate we're enumerating IBKR API correctly, since currently we just dump to the response back to user. This will also include any @dataclass that we use.

Context

Currently, interacting with the IBind client requires extensive manual type checking and validation, leading to verbose and error-prone code. For instance, when executing market orders, developers must handle various type cases and validate responses manually, here's an example of what I needed to do for a market order:

def place_market_order(
    client, # IbkrClient
    account_id: str,
    symbol: str,
    side: str,
    size: int,
    logger
) -> Optional[Tuple[str, float, int]]:
    """Places a market order and waits for fill.

    Args:
        client: IbkrClient instance.
        account_id: Account ID string.
        symbol: Symbol string (e.g., 'MNQ').
        side: 'BUY' or 'SELL'.
        size: Order quantity.
        logger: Logger instance.

    Returns:
        Tuple (order_tag, fill_price, conid) if successful, else None.

    """
    logger.info(f"Attempting to place a {side} market order for {size} contract(s) of {symbol}...")
    conid = None
    order_tag = None
    fill_price = None

    try:
        logger.info(f"Looking up active front-month contract for {symbol}...")
        conid = get_active_front_month_contract(client, symbol)
        if not conid:
            logger.error(f"Could not find active contract for {symbol}")
            return None
        logger.info(f"Found active contract conid: {conid}")

        order_tag = f'{ORDER_TAG_PREFIX}_{symbol}_{side}_{size}_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}'
        order_request = OrderRequest(
            conid=conid,
            side=side.upper(),
            quantity=size,
            order_type='MKT',
            tif='DAY',
            acct_id=account_id,
            coid=order_tag
        )
        logger.info(f"Created Order Request: {order_request}")

        answers = {
            QuestionType.PRICE_PERCENTAGE_CONSTRAINT: True,
            QuestionType.ORDER_VALUE_LIMIT: True,
            "You are submitting an order without market data...": True, # Abbreviated for brevity
            'The following order .* size exceeds the Size Limit .*': True,
            "You are about to submit a stop order. Please be aware of the various stop order types available and the risks associated with each one.Are you sure you want to submit this order?": True,
            "Unforeseen new question": True,
        }
        logger.info("Defined answers for potential confirmation prompts.")

        logger.info(f"Submitting {order_request.side} order for {order_request.quantity} of conid {order_request.conid} (Tag: {order_tag})...")
        placement_result = client.place_order(order_request, answers, account_id)

        if not placement_result or (hasattr(placement_result, 'data') and not placement_result.data):
             logger.warning(f"Order placement for {symbol} might have failed or requires confirmation. Result: {placement_result}")
        else:
            logger.info(f"Order placement submitted. Result data (if any): {getattr(placement_result, 'data', 'N/A')}")

        filled_order_details = wait_for_order_fill(client, account_id, order_tag, logger)

        if filled_order_details:
            fill_price_str = filled_order_details.get('avgPrice')
            if fill_price_str is not None:
                try:
                    fill_price = float(fill_price_str)
                    logger.info(f"Market order {order_tag} filled at average price: {fill_price}")
                    return order_tag, fill_price, conid
                except ValueError:
                    logger.error(f"Could not convert fill price '{fill_price_str}' to float for order {order_tag}.")
                    return None
            else:
                logger.error(f"Filled order {order_tag} details received, but 'avgPrice' is missing: {filled_order_details}")
                return None
        else:
            logger.error(f"Market order {order_tag} did not fill within timeout.")
            # Attempt cancellation (best effort)
            logger.warning(f"Attempting to cancel potentially unfilled market order {order_tag}...")
            try:
                live_orders_result = client.live_orders(account_id=account_id)
                order_to_cancel = None
                if live_orders_result and isinstance(live_orders_result.data, dict) and 'orders' in live_orders_result.data:
                    orders_list = live_orders_result.data.get('orders', [])
                    if isinstance(orders_list, list):
                        for order in orders_list:
                            if isinstance(order, dict) and order.get('order_ref') == order_tag:
                                ib_order_id = order.get('orderId')
                                if ib_order_id and order.get('status') not in ['Filled', 'Cancelled', 'Expired', 'Inactive']:
                                    order_to_cancel = ib_order_id
                                    break
                if order_to_cancel:
                    cancel_result = client.cancel_order(order_id=order_to_cancel, account_id=account_id)
                    logger.info(f"Cancellation attempt result for IB order ID {order_to_cancel} (tag {order_tag}): {cancel_result}")
                else:
                    logger.warning(f"Could not find active, non-filled IB order ID for tag {order_tag} to cancel.")
            except Exception as cancel_err:
                logger.exception(f"Failed to attempt cancellation for order tag {order_tag}: {cancel_err}")
            return None

    except Exception as e:
        logger.exception(f"An error occurred during market order placement/monitoring for {symbol}: {e}")
        if order_tag:
             logger.error(f"An error occurred after potentially submitting order {order_tag}. State uncertain.")
        return None

The lack of structured data models necessitates repetitive and defensive programming. For example, after placing an order, I must verify the structure and types of the response data manually before proceeding. This approach is not only time-consuming but also increases the risk of runtime errors.

By adopting Pydantic models, we can:

  • Ensure data integrity through automatic validation.
  • Provide clear and informative error messages.
  • Enhance developer experience with IDE support and type hints.
  • Reduce boilerplate code and simplify unit testing.

Automatic validation

Pydantic validates types at runtime, catching issues early (e.g. string instead of float, invalid enum values). With dataclass, incorrect types silently pass through unless manually validated.

from pydantic import BaseModel, Field

class OrderRequestModel(BaseModel):
    conid: int
    side: Literal["BUY", "SELL"]
    quantity: float
    order_type: str
    acct_id: str
    price: Optional[float] = None

Passing quantity="100" would raise an error immediately — unlike a dataclass.

Reusable Schemas for Response Validation

Many IBKR responses are opaque or inconsistent. Pydantic allows us to strictly define and parse those, ensuring downstream safety.

class OrderResponseModel(BaseModel):
    order_id: int
    status: Literal["Submitted", "Filled", "Cancelled"]
    filled_quantity: float
    avg_fill_price: float

IDE support

Pydantic provides full IntelliSense and autocomplete in editors like VSCode and Intellij, improving DX and reducing error rates in larger projects.

Possible Implementation

I can make a test PR that will attempt making pydantic with a mixin that maybe is not used as much? Or maybe or straight into something like Orders? I'm open.

We can do a few things in this test PR:

  • Create BaseModel subclasses for types of a particular mixin
  • Use Pydantic’s alias feature to handle camelCase transformation
  • Add .from_dict() and .dict(by_alias=True) usage in client logic
  • Gradually roll out to a few objects.
  • Create some sort of test to see how it works in validation error scenarios to see a working example.

Once we align on a pattern for a single small PR with a working example, we could even triage the other mixins to speed up implementation.

For illustration purposes here what a model could look like:

# --- IBindOrderRequestModel ---
class IBindOrderRequestModel(BaseModel):
    conid: int
    
    # Using Literal for fields with a fixed set of string values
    side: Literal['BUY', 'SELL']
    quantity: int
    order_type: Literal['MKT', 'LMT', 'STP', 'TRAIL', 'REL', 'MIDPRICE'] # Add all valid order types from IBKR/ibind
    tif: Literal['DAY', 'GTC', 'IOC', 'FOK', 'OPG'] # Add all valid Time-In-Force values
    
    coid: str  # Client Order ID (tag)

    account_id: str = Field(alias="acctId")
    order_type: str = Field(alias="orderType")
    
    # Price is optional, typically used for LMT, STP orders
    price: Optional[float] = None
    
    model_config = ConfigDict(extra='ignore')

    # You could add validators here if needed, for example, to ensure
    # 'price' is provided for 'LMT' or 'STP' order types.
    # from pydantic import model_validator
    #
    # @model_validator(mode='after')
    # def check_price_for_relevant_order_types(self) -> 'IBindOrderRequestModel':
    #     if self.order_type in ['LMT', 'STP'] and self.price is None:
    #         raise ValueError(f"Price must be provided for order type {self.order_type}")
    #     if self.order_type == 'MKT' and self.price is not None:
    #         # Or just log a warning, as IB might ignore it for MKT orders
    #         # For strictness, you might want to ensure it's None
    #         # logger.warning("Price was provided for a MKT order and will likely be ignored.")
    #         pass # MKT orders usually don't take price, but API might allow it
    #     return self

I think this is an ideal case, but here's what my new market order code could look like:

    try:
        logger_param.info(f"Looking up active front-month contract for {symbol}...")
        # Assumption: helper returns a dict suitable for ContractDetailModel, or None if not found.
        raw_contract_data = get_active_front_month_contract(client, symbol)

        if not raw_contract_data: # Semantic check: contract not found
            logger_param.error(f"No contract data returned by helper for {symbol}.")
            return None
        
        # Pydantic parses raw_contract_data. Expects dict.
        # TypeError if not dict-like, ValidationError if fields mismatch.
        contract_detail = ContractDetailModel(**raw_contract_data)
        conid_to_return = contract_detail.conid # conid is mandatory in ContractDetailModel
        logger_param.info(f"Found active contract conid: {conid_to_return}")

        order_tag = f'{ORDER_TAG_PREFIX}_{symbol}_{side}_{size}_{datetime.datetime.now().strftime("%Y%m%d_%H%M%S")}'
        
        # Pydantic validates 'side', 'order_type', 'tif' against Literals here.
        order_request_model = IBindOrderRequestModel(
            conid=conid_to_return, side=side.upper(), quantity=size,
            order_type='MKT', tif='DAY', acct_id=account_id, coid=order_tag
        )
        logger_param.info(f"Created Pydantic Order Request: {order_request_model.model_dump_json(indent=2)}")

        answers_dict = {
            QuestionType.PRICE_PERCENTAGE_CONSTRAINT: True,
            QuestionType.ORDER_VALUE_LIMIT: True,
            "You are submitting an order without market data...": True, # Abbreviated
            "Unforeseen new question": True, # Catch-all for new questions
        } # Simplified for brevity

        logger_param.info(f"Submitting {order_request_model.side} order (Tag: {order_tag})...")
        
        # Assumption: client.place_order().data exists. Its content structure is consistent
        # (e.g., always a list of confirmations, or OrderPlacementResponseModel handles variations).
        placement_result_raw = client.place_order(order_request_model.model_dump(by_alias=True), answers_dict, account_id)

[...]
        

Not sure I did the best explaining here, but this is a really good article for additional context https://dev.to/jamesbmour/part-3-pydantic-data-models-4gnb

I'd like to bring this up for discussion now, as I think there would be throw away work if we started on unit tests, and didn't include Pydantic models in that as well. Thanks for reading!

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions