Skip to content

Multi-Broker Integration

The platform is designed from the ground up for multi-broker support. Every broker — traditional securities (Shioaji for Taiwan futures), cryptocurrency exchanges (Gate.io), or mock simulators — implements the same abstract interface and returns the same Pydantic response models. The factory pattern uses auto-discovery: adding a new broker means creating a single Python file in the right directory. Zero changes to the factory, API layer, or any other file. A new broker integration takes 2–3 days of implementation effort.


Abstract Interface

Every broker must implement the BrokerService abstract class:

from abc import ABC, abstractmethod

class BrokerService(ABC):
    """Abstract base for all broker integrations."""

    def __init__(self, credentials, simulation=True, broker_type=""):
        self._credentials = credentials
        self._simulation = simulation
        self._broker_type = broker_type
        self._connected = False

    @abstractmethod
    def connect(self) -> ConnectionResult:
        """Authenticate and initialize broker session."""
        ...

    @abstractmethod
    def disconnect(self) -> DisconnectionResult:
        """Clean close, release resources."""
        ...

    @abstractmethod
    def get_symbols(self) -> NormalizedSymbolsResponse:
        """All tradeable symbols (flat list + grouped by category)."""
        ...

    @abstractmethod
    def get_positions(self) -> PositionsResponse:
        """Current open positions with P&L."""
        ...

    @abstractmethod
    def place_entry_order(self, symbol, quantity, action) -> OrderResult:
        """Open or add to a position."""
        ...

    @abstractmethod
    def place_exit_order(self, symbol, direction) -> OrderResult | None:
        """Close position by direction (long/short)."""
        ...

    @abstractmethod
    def close_position(self, symbol) -> OrderResult | None:
        """Auto-detect direction, reduce-only close."""
        ...

    @abstractmethod
    def check_order_status(self, order_result) -> OrderStatusResult:
        """Check fill status via cached broker reference (fast)."""
        ...

    @abstractmethod
    def check_order_status_by_id(self, order_id) -> OrderStatusResult | None:
        """Check fill status via order ID (slower, scans trade list)."""
        ...

    @abstractmethod
    def verify_credentials(self, verify_ca=False) -> CredentialVerificationResult:
        """Test login without creating a persistent worker session."""
        ...

Concrete Methods (Inherited)

The base class provides several concrete methods that all brokers inherit:

Method Purpose
place_entry_order_with_reversal(symbol, qty, action) Close opposite position first, then open new one
get_position(symbol) Find position by symbol or contract code
get_position_quantity(symbol) Signed quantity (+ long, - short)
has_position(symbol) Boolean check for open position
get_position_direction(symbol) Returns "long", "short", or None
get_code_to_symbol_mapping() Map broker contract codes to user-friendly symbols
is_connection_error(error) Classify error for reconnection decisions
is_order_error(error) Classify error as order-related (no reconnect needed)

Standardized Response Models

Every method returns Pydantic models — never raw dicts, tuples, or broker-specific types. This is what makes the rest of the system broker-agnostic. The file app/services/brokers/models.py defines the complete contract.

Why Pydantic?

Pydantic models give us automatic JSON serialization, validation, and type safety. The API layer can return any broker's response directly as JSON without transformation. The frontend receives the same shape regardless of whether the order went to a Taiwan futures broker or a crypto exchange.

NormalizedSymbol

A universal symbol representation that works across all markets (futures, stocks, crypto) and all brokers. Core fields are required; market-specific fields are optional.

Field Type Required Description
code str Yes Primary identifier: TXFR1, AAPL, BTC_USDT
name str Yes Display name: 台指期近月, Apple Inc, Bitcoin/USDT
market MarketType Yes Market type: futures, stock, crypto
category str Yes Category code: TXF, tech, defi
category_name str Yes Category display: 台指期貨, Technology, DeFi
base_symbol str No Underlying: TXF, BTC, None for stocks
base_currency str No Base currency (crypto/forex): BTC, EUR
quote_currency str No Quote currency: USDT, USD
expiry_date str No ISO date for derivatives: 2025-03-19
strike_price float No Options strike price
exchange str No Exchange: TAIFEX, NYSE, Gate.io
tick_size float No Minimum price increment
lot_size float No Minimum quantity increment
contract_size float No Contract multiplier (e.g., 0.001 BTC per contract)
min_order_size float No Minimum order size
max_order_size float No Maximum order size
leverage_min float No Min leverage (crypto)
leverage_max float No Max leverage (crypto)
broker_symbol str No Original broker symbol if different from code

Cross-market examples:

Field Taiwan Futures Crypto Perpetual US Stock
code TXFR1 BTC_USDT AAPL
name 台指期近月 BTC/USDT Perp Apple Inc
market futures crypto stock
category TXF layer1 tech
base_currency BTC
quote_currency USDT
expiry_date 2025-03-19
exchange TAIFEX Gate.io NYSE
contract_size 200 (TWD/pt) 0.001 (BTC) 1
leverage_max 100

The NormalizedSymbolsResponse wraps the flat list with hierarchical grouping metadata (markets → categories) for UI organization, plus summary statistics. The from_symbols() class method auto-computes groupings.

Position

A trading position held in the account. Core fields work for all brokers; crypto-specific fields (margin, leverage, liquidation) are optional.

Field Type Required Description
code str Yes Contract code: TXFB5, BTC_USDT
name str No Human-readable: 臺指期貨, BTC/USDT Perp
quantity int Yes Absolute size (always positive)
direction PositionDirection Yes long or short
entry_price float Yes Average entry price
last_price float Yes Current market price
unrealized_pnl float Yes Unrealized P&L
realized_pnl float No Realized P&L
market_value float No Current market value
contract_multiplier float No Contract multiplier for P&L
margin_mode str No Crypto: cross or isolated
leverage int No Crypto: leverage multiplier
liquidation_price float No Crypto: estimated liquidation
mark_price float No Crypto: mark price

Computed properties:

Property Return Logic
signed_quantity int +quantity if long, -quantity if short
is_long / is_short bool Direction check
price_change float last_price - entry_price
price_change_percent float (price_change / entry_price) * 100
calculate_pnl(multiplier) float price_change * quantity * multiplier (flipped for shorts)

The PositionsResponse wrapper provides get_position_by_code(), get_long_positions(), get_short_positions(), and total_unrealized_pnl. The signed_quantity property is critical for the auto-reversal logic — it determines how many contracts to close before placing the new entry.

OrderResult

Returned immediately after order submission. Contains the order ID for tracking, auto-reversal metadata, and a broker-specific reference for status polling.

Field Type Required Description
success bool Yes Whether order was accepted
order_id str Yes Unique order identifier
symbol str Yes Trading symbol
code str No Contract code
action BrokerAction Yes buy or sell
quantity int Yes Order quantity
status OrderStatus Yes pending, submitted, filled, cancelled, failed
message str No Status message or error
broker_order_ref Any No Broker-specific object for status checks (excluded from JSON)
broker_refs dict No Broker-specific IDs (e.g., Shioaji: {"seqno": "...", "ordno": "..."})
auto_exit bool No Whether order auto-closed opposite position
exit_quantity int No Quantity of opposite position closed
position_before int No Position quantity before order
exit_order_result OrderResult No Nested result for the close leg of a reversal
fill_price float No Average fill price (populated after status check)
reduce_only bool No Whether order can only close positions

Auto-Reversal Metadata

When a long_entry signal triggers an auto-reversal of a short position, the OrderResult captures the full context: auto_exit=true, exit_quantity=2 (contracts closed), position_before=-2 (was short 2), and exit_order_result contains the separate close order's result. This metadata is stored in order_history for audit and P&L tracking.

OrderStatusResult

Detailed execution status including partial fills. Used by the background fill verification Lambda.

Field Type Required Description
order_id str Yes Order identifier
broker_refs dict No Broker-specific IDs for matching
status OrderStatus Yes Current status
order_quantity int Yes Original quantity
filled_quantity int Yes Quantity filled so far
cancelled_quantity int No Quantity cancelled
remaining_quantity int No Quantity remaining
average_fill_price float Yes Average fill price
deals list[OrderDeal] No Individual fills (deal_id, price, quantity, timestamp)
is_complete bool Computed True if filled, cancelled, rejected, or failed
fill_rate float Computed filled_quantity / order_quantity * 100

Model Summary

Model Purpose Consumer
NormalizedSymbol Universal symbol across all markets Symbol browser, order forms, strategy builder
NormalizedSymbolsResponse Grouped symbols with market hierarchy Frontend symbol picker
Position Current position with signed quantity Dashboard, auto-reversal logic
PositionsResponse Position list with query helpers Account view
OrderResult Immediate order confirmation + reversal metadata Order confirmation, webhook response, audit log
OrderStatusResult Detailed fill status with individual deals Background Lambda fill verification
OrderDeal Single fill execution OrderStatusResult child
ConnectionResult Broker connection status Worker lifecycle
CredentialVerificationResult Credential validation status Credential setup flow
BrokerCredentials Base credential model (api_key, secret_key) Worker credential loading

Database Schema

The broker system is backed by a normalized schema that separates broker definitions, type definitions, and per-user trading accounts. This design allows adding new brokers and types purely through database inserts — no migrations needed.

erDiagram
    User ||--o{ TradingAccount : "has many"
    Broker ||--o{ BrokerSupportedType : "supports"
    BrokerType ||--o{ BrokerSupportedType : "supported by"
    Broker ||--o{ TradingAccount : "used by"
    BrokerType ||--o{ TradingAccount : "used by"
    TradingAccount ||--o{ OrderHistory : "has many"

    Broker {
        int id PK
        string code UK "shioaji, gate"
        string name "Sinopac Shioaji"
        boolean is_active
    }

    BrokerType {
        int id PK
        string code UK "future, future_usdt"
        string name "Futures and Options"
        boolean is_active
    }

    BrokerSupportedType {
        int broker_id FK
        int broker_type_id FK
        jsonb credential_schema "form config"
        boolean testnet_supported
    }

    TradingAccount {
        int id PK
        int user_id FK
        int broker_id FK
        int broker_type_id FK
        string name "Main Trading"
        string webhook_token UK
        string webhook_secret
        binary user_encryption_key "per-account"
        binary api_key_encrypted
        binary secret_key_encrypted
        boolean is_verified
        boolean is_default
    }

    OrderHistory {
        int id PK
        int trading_account_id FK
        string action "long_entry"
        string symbol "TXFR1"
        string status "filled"
        datetime created_at
    }

Key Design Decisions

Decision Rationale
Broker and BrokerType are lookup tables New brokers and types are added via DB insert, not code changes. The admin panel can manage them.
BrokerSupportedType is an association model Not just a join table — stores credential_schema (JSONB) that drives the frontend's dynamic credential form for each broker+type pair, and testnet_supported flag.
Per-account encryption keys Each TradingAccount has its own AES-256 key (user_encryption_key), encrypted with the master key. Compromising one account's key reveals nothing about others.
Per-account webhook tokens Each account gets a unique webhook_token in the URL path. One user with 3 accounts gets 3 independent webhook URLs.
UniqueConstraint on (user, broker, broker_type) A user can have at most one account per broker+type combination (e.g., one Shioaji futures account, one Gate.io USDT account).
Credentials nullable Account shell is created first (name, broker, type), credentials added separately. This supports a two-step onboarding flow.

Example Data

Table code name
brokers shioaji Sinopac Shioaji
brokers gate Gate.io
broker_types future Futures & Options
broker_types future_usdt USDT Perpetual Futures
broker_types future_btc BTC Perpetual Futures
broker_supported_types shioaji + future credential_schema: {simulation_fields: [...], real_trading_fields: [...]}
broker_supported_types gate + future_usdt testnet_supported: true
broker_supported_types gate + future_btc testnet_supported: false

Factory Auto-Discovery

flowchart TB
    A["get_broker_service('shioaji', 'future')"] --> B{"In-memory<br/>cache?"}
    B -->|Hit| RET["Return class"]
    B -->|Miss| C{"Redis cache?<br/>7-day TTL"}
    C -->|Hit| CACHE1["Cache in-memory"] --> RET
    C -->|Miss| D["importlib.import_module"]
    D --> E["inspect for BrokerService subclass"]
    E --> CACHE2["Cache in Redis + memory"] --> RET

The factory follows a file convention to discover broker implementations:

app/services/brokers/{broker_name}/{broker_type}.py

Each file must contain exactly one concrete BrokerService subclass. The factory:

  1. Checks the in-memory dict (per-process, instant lookup)
  2. Checks Redis cache (shared across processes, 7-day TTL)
  3. Falls back to full discovery via importlib + inspect (writes to both caches)
service = get_broker_service(
    broker_name="shioaji",    # → brokers/shioaji/
    broker_type="future",     # → brokers/shioaji/future.py
    credentials=creds,        # BrokerCredentials or dict
    simulation=True,          # Testnet/sandbox mode
)

No factory registration needed

When you create app/services/brokers/ib/stock.py with a class IBStockBrokerService(BrokerService), calling get_broker_service("ib", "stock", creds) will automatically discover and instantiate it. No imports, no registry, no configuration file.


Current Implementations

Broker File Markets API Notes
Shioaji shioaji/future.py Taiwan futures & options Shioaji SDK Session-based, CA certificate for production
Gate.io BTC gate/future_btc.py BTC-margined perpetual futures REST + WebSocket One-way position mode only
Gate.io USDT gate/future_usdt.py USDT-margined perpetual futures REST + WebSocket One-way position mode only
Mock mock/mock.py Simulated None For testing and demo accounts

Directory Structure

app/services/brokers/
├── base.py              # Abstract BrokerService + reversal logic
├── models.py            # Standardized Pydantic response models
├── crypto_mixin.py      # Shared crypto exchange utilities
├── __init__.py          # Factory + auto-discovery + caching
├── shioaji/             # Taiwan futures broker
│   ├── __init__.py
│   ├── base.py          # Shared Shioaji login/session logic
│   ├── future.py        # ShioajiFutureBrokerService
│   ├── models.py        # ShioajiCredentials
│   ├── contracts.py     # Contract loading/filtering helpers
│   ├── constants.py     # Market hours, symbol codes
│   └── exceptions.py    # ShioajiError, ShioajiConnectionError
├── gate/                # Gate.io crypto exchange
│   ├── base.py          # Shared Gate.io connection logic
│   ├── futures_base.py  # Shared futures order/position logic
│   ├── future_btc.py    # GateBTCFutureBrokerService
│   ├── future_usdt.py   # GateUSDTFutureBrokerService
│   ├── models.py        # GateCredentials
│   ├── categories.py    # Contract categorization
│   └── exceptions.py    # GateError, GatePositionModeError
└── mock/                # Simulation broker
    ├── __init__.py
    └── mock.py          # MockBrokerService

Auto-Reversal Logic

Built into the base class, place_entry_order_with_reversal() handles the common case where a signal flips direction. This mirrors TradingView's strategy.entry() behavior where entering a long position automatically exits any existing short.

sequenceDiagram
    participant Signal as Trading Signal
    participant Base as BrokerService (base)
    participant Broker as Concrete Broker

    Signal->>Base: place_entry_order_with_reversal<br/>(symbol="TXFR1", qty=1, action="buy")
    Base->>Base: Check current position
    Note over Base: Has short position: -2 contracts

    Base->>Broker: close_position("TXFR1")<br/>→ buy 2 (reduce_only, Cover)
    Broker-->>Base: OrderResult (success)

    loop Poll fill status (up to 10 × 0.5s)
        Base->>Broker: check_order_status(order)
        Broker-->>Base: status: filled
    end

    Base->>Broker: place_entry_order("TXFR1", 1, "buy")
    Broker-->>Base: OrderResult (success)

    Base-->>Signal: OrderResult<br/>auto_exit=true, exit_quantity=2,<br/>position_before=-2

Reversal Flow

Current position: -2 (short)    Incoming signal: buy 1

Step 1: close_position() → buy 2 (reduce_only / Cover)
Step 2: Poll fill status (10 attempts × 0.5s intervals)
Step 3: place_entry_order() → buy 1 (new long)

Result: position changes from -2 to +1

Reversal timeout

If the close order doesn't fill within 10 status checks (5 seconds), the method aborts and returns a failure with "Manual intervention may be required". This prevents the system from opening a new position while the old one is still open — which would double the exposure.


Adding a New Broker

Step-by-Step

  1. Create the directory:

    mkdir -p app/services/brokers/ib/
    touch app/services/brokers/ib/__init__.py
    
  2. Create the broker service:

    # app/services/brokers/ib/stock.py
    from app.services.brokers.base import BrokerService
    from app.services.brokers.models import (
        ConnectionResult, DisconnectionResult, NormalizedSymbolsResponse,
        PositionsResponse, OrderResult, OrderStatusResult,
        CredentialVerificationResult,
    )
    
    class IBStockBrokerService(BrokerService):
    
        @property
        def broker_name(self) -> str:
            return "ib"
    
        def __init__(self, credentials, simulation=True, broker_type="stock"):
            super().__init__(credentials, simulation, broker_type)
    
        def connect(self) -> ConnectionResult:
            # Connect to IB TWS/Gateway
            ...
    
        def get_symbols(self) -> NormalizedSymbolsResponse:
            # Return NormalizedSymbol list
            ...
    
        def place_entry_order(self, symbol, qty, action) -> OrderResult:
            # Place order via IB API, return OrderResult
            ...
    
        # ... implement all abstract methods
    
  3. Optional — credential model:

    # app/services/brokers/ib/models.py
    from app.services.brokers.models import BrokerCredentials
    
    class IBCredentials(BrokerCredentials):
        host: str = "127.0.0.1"
        port: int = 7497
        client_id: int = 1
    
  4. Optional — broker-specific exceptions:

    # app/services/brokers/ib/exceptions.py
    class IBError(Exception): ...
    class IBConnectionError(IBError): ...
    
  5. Done. Call get_broker_service("ib", "stock", creds) — auto-discovery handles the rest.

Checklist

  • [ ] Class inherits from BrokerService
  • [ ] All abstract methods implemented
  • [ ] Every method returns standardized Pydantic models
  • [ ] super().__init__() called in constructor
  • [ ] Exactly one concrete BrokerService subclass per file
  • [ ] is_connection_error() overridden for broker-specific error patterns
  • [ ] Simulation mode connects to testnet/sandbox endpoint
  • [ ] broker_order_ref stored in OrderResult for fast status checks

Error Classification

The worker uses error classification to decide whether to reconnect or return an error:

Error Type Action Examples
Connection error Invalidate connection, retry request Timeout, connection refused, token expired, 401
Order error Return error to caller, no reconnect Insufficient margin, market closed, invalid symbol
Unknown error Log, return error, consider reconnect Unexpected exceptions
if broker_class.is_connection_error(e):
    worker._invalidate_connection(conn_key)
    # Request retries with fresh connection (up to 3 times)

elif broker_class.is_order_error(e):
    return TradingResponse(success=False, error=str(e))
    # No reconnect — the error is with the order, not the connection

Subclasses override is_connection_error() and is_order_error() to classify broker-specific error types. The base class provides sensible defaults (checking for patterns like "timeout", "connection refused", "token expired", "401").


File Map

Concept File
Abstract base class + reversal logic app/services/brokers/base.py
Factory + auto-discovery + caching app/services/brokers/__init__.py
Standardized response models app/services/brokers/models.py
Shioaji futures implementation app/services/brokers/shioaji/future.py
Shioaji shared login logic app/services/brokers/shioaji/base.py
Gate.io shared futures logic app/services/brokers/gate/futures_base.py
Gate.io BTC-margined futures app/services/brokers/gate/future_btc.py
Gate.io USDT-margined futures app/services/brokers/gate/future_usdt.py
Mock broker for testing app/services/brokers/mock/mock.py
Crypto exchange mixin app/services/brokers/crypto_mixin.py