diff --git a/derive_client/data_types/channel_models.py b/derive_client/data_types/channel_models.py index 212468ee..295e3b6f 100644 --- a/derive_client/data_types/channel_models.py +++ b/derive_client/data_types/channel_models.py @@ -130,6 +130,12 @@ class BalanceUpdateSchema(Struct): class SubaccountIdBestQuotesChannelSchema(SubaccountIdBalancesChannelSchema): + """ + WebSocket channel schema for subscribing to best quote updates for a subaccount. + + This channel allows takers to receive real-time updates on the best available + quotes for their RFQs. The "best" quote is determined by price competitiveness. + """ pass @@ -138,6 +144,12 @@ class SubaccountIdOrdersChannelSchema(SubaccountIdBalancesChannelSchema): class SubaccountIdQuotesChannelSchema(SubaccountIdBalancesChannelSchema): + """ + WebSocket channel schema for subscribing to quote updates for a subaccount. + + This channel is used by market makers to receive updates on quotes they've submitted, + including status changes (filled, expired, cancelled) and fill percentages. + """ pass @@ -214,6 +226,12 @@ class TradeSettledPublicResponseSchema(Struct): class WalletRfqsChannelSchema(PrivateGetAllPortfoliosParamsSchema): + """ + WebSocket channel schema for subscribing to RFQ updates for a specific wallet. + + Market makers use this channel to receive incoming RFQs directed to their wallet. + When a taker creates an RFQ, it's broadcast to wallets that can provide quotes. + """ pass @@ -307,6 +325,30 @@ class SubaccountIdBalancesPubSubSchema(Struct): class QuoteResultPublicSchema(Struct): + """ + Public schema for RFQ quote results. + + This represents a quote that a market maker has submitted for an RFQ. + It contains the basic public information about the quote without sensitive + details like signatures or fee calculations. + + Attributes: + quote_id: Unique identifier for this quote + rfq_id: Reference to the RFQ this quote is responding to + direction: The direction of the quote (buy or sell from market maker's perspective) + legs: List of priced legs with specific prices for each instrument + status: Current status (open, filled, expired, cancelled) + creation_timestamp: When the quote was created (milliseconds since epoch) + last_update_timestamp: When the quote was last updated (milliseconds since epoch) + wallet: The market maker's wallet address + subaccount_id: The market maker's subaccount ID + fill_pct: Percentage of the quote that has been filled (0-100) + legs_hash: Hash of the legs for verification + liquidity_role: Role in the trade (maker or taker) + cancel_reason: Reason for cancellation if applicable + tx_status: Blockchain transaction status + tx_hash: Transaction hash if executed on-chain + """ cancel_reason: CancelReason creation_timestamp: int direction: Direction @@ -404,6 +446,12 @@ class SpotFeedCurrencyPubSubSchema(Struct): class RFQGetBestQuoteResultSchema(PrivateRfqGetBestQuoteResultSchema): + """ + Result schema for getting the best quote for an RFQ. + + This wraps the API response that contains the most competitive quote + currently available for a given RFQ. + """ pass @@ -443,11 +491,28 @@ class TickerSlimInstrumentNameIntervalNotificationParamsSchema(Struct): class WalletRfqsNotificationParamsSchema(Struct): + """ + Parameters for RFQ notifications sent to a wallet. + + Contains the list of RFQ updates that the subscribed wallet should be aware of. + """ channel: str data: List[RFQResultPublicSchema] class BestQuoteChannelResultSchema(Struct): + """ + Result schema for best quote channel updates. + + Each message on the best quotes channel contains either: + - A successful result with the best quote details + - An error if the best quote couldn't be determined + + Attributes: + rfq_id: The RFQ this best quote update relates to + result: The best quote result (if successful) + error: Error details (if failed) + """ rfq_id: str error: Optional[RPCErrorFormatSchema] = None result: Optional[RFQGetBestQuoteResultSchema] = None @@ -474,15 +539,28 @@ class WalletRfqsPubSubSchema(Struct): class SubaccountIdBestQuotesNotificationParamsSchema(Struct): + """ + Parameters for best quote notifications for a subaccount. + + Contains updates on the best quotes available for RFQs created by this subaccount. + Takers subscribe to this to track which quotes are most competitive. + """ channel: str data: List[BestQuoteChannelResultSchema] class SubaccountIdBestQuotesNotificationSchema(Struct): + """Notification wrapper for best quote updates.""" method: str params: SubaccountIdBestQuotesNotificationParamsSchema class SubaccountIdBestQuotesPubSubSchema(Struct): + """ + Complete pub/sub schema for best quotes channel. + + This is the top-level schema for the best quotes WebSocket channel, + combining channel subscription parameters with notification structure. + """ channel_params: SubaccountIdBestQuotesChannelSchema notification: SubaccountIdBestQuotesNotificationSchema diff --git a/examples/rfqs/00_create_rfq.py b/examples/rfqs/00_create_rfq.py index f9bcf632..6e9f4d2d 100644 --- a/examples/rfqs/00_create_rfq.py +++ b/examples/rfqs/00_create_rfq.py @@ -21,56 +21,78 @@ async def create_and_execute_rfq( """ Create an RFQ, wait for quotes, and execute the best one. + This function demonstrates the complete RFQ (Request for Quote) lifecycle from the taker's perspective: + 1. Connects to the WebSocket API + 2. Sends an RFQ with unpriced legs (instruments and amounts without prices) + 3. Subscribes to quote updates and tracks the best available quote + 4. Executes the best quote after waiting for market makers to respond + Args: - instrument: Instrument name (e.g. "ETH-30JUN23-1500-C") - side: "buy" or "sell" - amount: Contract amount + legs: List of unpriced legs representing the instruments and amounts to trade + Each leg specifies instrument_name, amount, and direction (buy/sell) """ - # Initialize client + # Initialize the WebSocket client with authentication credentials + # The client will be used to communicate with the Derive API client = WebSocketClient( - session_key=SESSION_KEY_PRIVATE_KEY, - wallet=OWNER_TEST_WALLET, - env=Environment.TEST, - subaccount_id=TAKER_SUBACCOUNT_ID, + session_key=SESSION_KEY_PRIVATE_KEY, # Private key for signing requests + wallet=OWNER_TEST_WALLET, # Ethereum wallet address + env=Environment.TEST, # Use test environment + subaccount_id=TAKER_SUBACCOUNT_ID, # Subaccount to execute trades under ) await client.connect() logger = get_logger() - # Send RFQ + # Send the RFQ to the network + # This broadcasts the request to all market makers subscribed to the wallet rfq_result = await client.rfq.send_rfq(legs=legs) logger.info(f"✓ RFQ created: {rfq_result.rfq_id}") - # Track best quote + # Track the best quote received from market makers + # This will be updated as better quotes arrive via the WebSocket channel best_quote: QuoteResultPublicSchema | None = None def handle_quote(quotes: List[BestQuoteChannelResultSchema]): + """ + Callback function that handles incoming quote updates. + + This is called whenever a new quote is received or an existing quote is updated. + It extracts the best quote from the update and calculates its total price. + """ nonlocal best_quote for quote in quotes: if quote.result and quote.result.best_quote: + # Update our tracking variable with the latest best quote best_quote = quote.result.best_quote + # Calculate total price by summing price * amount for all legs total_price = sum(leg.price * leg.amount for leg in best_quote.legs) logger.info(f"✓ Best quote received: {total_price}") - # Subscribe to quotes + # Subscribe to the best quotes channel for this subaccount + # This establishes a WebSocket subscription that will call handle_quote + # whenever market makers submit quotes for our RFQ await client.private_channels.best_quotes_by_subaccount_id( subaccount_id=str(TAKER_SUBACCOUNT_ID), callback=handle_quote, ) - # Wait for quotes + # Wait for market makers to respond with quotes + # In a production system, you might want to wait for a specific number of quotes + # or use a more sophisticated decision mechanism await asyncio.sleep(10) if not best_quote: logger.error("✗ No quotes received") return - # Execute quote + # Execute the best quote we received + # Note: We take the opposite direction from the quote's direction + # If the quote is buying from us (Direction.buy), we sell to them (Direction.sell) execute_direction = Direction.sell if best_quote.direction == Direction.buy else Direction.buy await client.rfq.execute_quote( direction=execute_direction, - legs=best_quote.legs, - rfq_id=best_quote.rfq_id, - quote_id=best_quote.quote_id, + legs=best_quote.legs, # Use the priced legs from the quote + rfq_id=best_quote.rfq_id, # Reference to our original RFQ + quote_id=best_quote.quote_id, # Specific quote we're accepting ) logger.info( f"✓ Quote {best_quote.quote_id} executed at total price: " @@ -79,13 +101,18 @@ def handle_quote(quotes: List[BestQuoteChannelResultSchema]): if __name__ == "__main__": - # Example usage + # Example usage: Create a simple RFQ for a single ETH put option + # In a real scenario, you might include multiple legs for complex strategies + # like spreads, straddles, or butterflies legs = [ LegUnpricedSchema( - instrument_name="ETH-20260327-4800-P", - amount=D("1"), - direction=Direction.sell, + instrument_name="ETH-20260327-4800-P", # ETH put option expiring March 27, 2026, strike 4800 + amount=D("1"), # Trade 1 contract + direction=Direction.sell, # We want to sell this option ), + # Additional legs commented out - these show how to create multi-leg strategies: + # - Multiple puts at different strikes (e.g., put spreads) + # - Combination of buys and sells (e.g., iron condors) # LegUnpricedSchema( # instrument_name="ETH-20260125-3050-P", # amount=D("1.0"), diff --git a/examples/rfqs/01_simple_rfq_quoter.py b/examples/rfqs/01_simple_rfq_quoter.py index 0324b228..52466bec 100644 --- a/examples/rfqs/01_simple_rfq_quoter.py +++ b/examples/rfqs/01_simple_rfq_quoter.py @@ -33,8 +33,22 @@ class SimpleRfqQuoter: + """ + A basic RFQ quoter implementation that acts as a market maker. + + This class demonstrates how to: + 1. Listen for incoming RFQs from takers + 2. Price the requested instruments based on current market data + 3. Send quotes back to the takers + 4. Track quote status (filled, expired, etc.) + + WARNING: This is a simplified example for demonstration purposes only. + It does NOT include proper risk management, inventory management, or hedging. + DO NOT use this as a production trading strategy. + """ logger: Logger client: WebSocketClient + # Dictionary to track active quotes by RFQ ID quotes: dict[str, PrivateSendQuoteResultSchema] = {} def __init__(self, client: WebSocketClient): @@ -42,16 +56,41 @@ def __init__(self, client: WebSocketClient): self.logger = client._logger async def price_rfq(self, rfq): + """ + Price the legs of an RFQ based on current market prices. + + This simple implementation uses mark price with a small spread: + - For buy legs (we're selling to the taker): mark_price * 1.001 (0.1% markup) + - For sell legs (we're buying from the taker): mark_price * 0.999 (0.1% discount) + + A real market maker would use more sophisticated pricing that considers: + - Order book depth and liquidity + - Volatility and Greeks (delta, gamma, vega) + - Inventory positions and risk limits + - Competition from other market makers + + Args: + rfq: The RFQ request containing unpriced legs to quote + + Returns: + List of priced legs with calculated prices + """ # Price legs using current market prices NOTE! This is just an example and not a trading strategy!!! self.logger.info(f" - Pricing legs for RFQ {rfq.rfq_id}...") priced_legs = [] for unpriced_leg in rfq.legs: + # Fetch current market data for this instrument ticker = await self.client.markets.get_ticker(instrument_name=unpriced_leg.instrument_name) + # Use mark price as the base reference price base_price = ticker.mark_price + # Apply a simple spread depending on the direction + # If the taker wants to buy (we sell), we charge slightly above mark + # If the taker wants to sell (we buy), we pay slightly below mark price = base_price * D("0.999") if unpriced_leg.direction == Direction.buy else base_price * D("1.001") + # Round price to the instrument's tick size (minimum price increment) price = price.quantize(ticker.tick_size) priced_leg = LegPricedSchema( price=price, @@ -66,23 +105,40 @@ async def price_rfq(self, rfq): return priced_legs async def on_rfq(self, rfqs: List[RFQResultPublicSchema]): + """ + Callback handler for incoming RFQ updates via WebSocket. + + This method is called whenever there are updates to RFQs we're subscribed to. + It filters for open RFQs, prices them, and sends quotes. + + Args: + rfqs: List of RFQ updates (can include new RFQs, status changes, etc.) + """ + # Clean up tracking for expired or cancelled RFQs for rfq in rfqs: if rfq.status in {Status.expired, Status.cancelled} and rfq.rfq_id in self.quotes: del self.quotes[rfq.rfq_id] + + # Filter to only process RFQs that are still accepting quotes open_rfqs = [r for r in rfqs if r.status == Status.open] if not open_rfqs: return + # Price all open RFQs concurrently for efficiency priced = await asyncio.gather(*(self.price_rfq(r) for r in open_rfqs)) + # Filter out any RFQs we couldn't price (e.g., missing market data) quotable = [(r, legs) for r, legs in zip(open_rfqs, priced) if legs] if not quotable: return + # Send all quotes concurrently + # Note: We're always on the SELL side as market makers (selling quotes to takers) results = await asyncio.gather( *(self.client.rfq.send_quote(rfq_id=r.rfq_id, legs=legs, direction=Direction.sell) for r, legs in quotable), - return_exceptions=True, + return_exceptions=True, # Don't fail entire batch if one quote fails ) + # Track successful quotes and log failures for r, result in zip(quotable, results): rfq, _ = r if isinstance(result, PrivateSendQuoteResultSchema): @@ -91,29 +147,63 @@ async def on_rfq(self, rfqs: List[RFQResultPublicSchema]): self.logger.info(f" ❌ Failed to send quote for RFQ {rfq.rfq_id}: {result}") async def on_quote(self, quotes_list: List[QuoteResultSchema]): + """ + Callback handler for quote status updates. + + This method is called when our submitted quotes change status: + - FILLED: A taker accepted our quote (we made a trade!) + - EXPIRED: The RFQ expired before the taker accepted our quote + - CANCELLED: The RFQ was cancelled by the taker + + In a real trading system, a FILLED status would typically trigger: + - Risk management updates + - Hedging activities to manage inventory + - P&L calculations + - Position rebalancing + + Args: + quotes_list: List of quote status updates + """ for quote in quotes_list: self.logger.info(f" - Quote {quote.quote_id} {quote.rfq_id}: {quote.status}") if quote.status == Status.filled and quote.rfq_id in self.quotes: + # Our quote was accepted! Remove from tracking. del self.quotes[quote.rfq_id] self.logger.info(f" ✓ Our quote {quote.quote_id} was accepted!") # Here we could proceed to perform some type of hedging or other action based on the filled quote. if quote.status == Status.expired and quote.rfq_id in self.quotes: + # Quote expired without being filled. Clean up tracking. del self.quotes[quote.rfq_id] self.logger.info(f" ✗ Our quote {quote.quote_id} expired Better luck next time!") async def run(self): + """ + Main execution loop for the RFQ quoter. + + This method: + 1. Establishes WebSocket connection to Derive + 2. Subscribes to RFQ channel for the wallet (to receive RFQ requests) + 3. Subscribes to quotes channel for the subaccount (to track our quote statuses) + 4. Keeps the connection alive indefinitely + """ await self.client.connect() + # Subscribe to RFQs directed to our wallet await self.client.private_channels.rfqs_by_wallet(wallet=TEST_WALLET, callback=self.on_rfq) + # Subscribe to quote updates for our subaccount await self.client.private_channels.quotes_by_subaccount_id( subaccount_id=str(SUBACCOUNT_ID), callback=self.on_quote, ) + # Keep the connection alive and process incoming messages await asyncio.Event().wait() # Keep the connection alive async def main(): """ - Sample of polling for RFQs and printing their status. + Main entry point for the simple RFQ quoter. + + This creates a WebSocket client and runs the quoter in a loop with error recovery. + If the connection drops, it will automatically reconnect and resume quoting. """ client = WebSocketClient( @@ -127,6 +217,7 @@ async def main(): try: await rfq_quoter.run() except KeyboardInterrupt: + # Allow clean shutdown with Ctrl+C break diff --git a/examples/rfqs/02_delta_hedged_quoter.py b/examples/rfqs/02_delta_hedged_quoter.py index 41df392d..a0018cf4 100644 --- a/examples/rfqs/02_delta_hedged_quoter.py +++ b/examples/rfqs/02_delta_hedged_quoter.py @@ -44,20 +44,32 @@ SUBACCOUNT_ID = 31049 # Quoting parameters -UNDERLYING_TO_QUOTE = "ETH" -QUOTE_SPREAD_BPS = D("0") -FALLBACK_TO_MARK_PRICE_PREMIUM_BPS = D("1000") # 10% premium if no book price available +UNDERLYING_TO_QUOTE = "ETH" # Only quote RFQs for ETH options +QUOTE_SPREAD_BPS = D("0") # Spread in basis points (currently 0 for tight pricing) +FALLBACK_TO_MARK_PRICE_PREMIUM_BPS = D("1000") # 10% premium if order book prices unavailable -# Hedging parameters -MAX_DELTA_TO_QUOTE = D("100.0") -MIN_DELTA_EXPOSURE = D("-0.1") -MAX_DELTA_EXPOSURE = D("0.1") -HEDGE_INTERVAL = 30 # seconds -HEDGE_ORDER_LABEL = "delta_hedge" -HEDGE_ORDER_TIMEOUT_S = 60 +# Hedging parameters - these control when and how we hedge our delta exposure +MAX_DELTA_TO_QUOTE = D("100.0") # Maximum absolute delta we're willing to take on per RFQ +MIN_DELTA_EXPOSURE = D("-0.1") # Minimum portfolio delta before hedging (short exposure threshold) +MAX_DELTA_EXPOSURE = D("0.1") # Maximum portfolio delta before hedging (long exposure threshold) +HEDGE_INTERVAL = 30 # How often to check portfolio delta (seconds) +HEDGE_ORDER_LABEL = "delta_hedge" # Label for identifying our hedge orders +HEDGE_ORDER_TIMEOUT_S = 60 # How long hedge orders remain valid before expiring class DeltaQuoterStrategy: + """ + Strategy for deciding whether to quote an RFQ and how to price it. + + This strategy implements delta-neutral market making by: + 1. Filtering RFQs to only quote relevant instruments (ETH options) + 2. Calculating the delta impact of quoting an RFQ + 3. Rejecting RFQs that would push delta exposure beyond limits + 4. Pricing legs based on current order book prices + + Delta is the rate of change of option price with respect to underlying price. + By managing delta exposure, we reduce directional risk from price movements. + """ client: WebSocketClient logger: Logger @@ -66,17 +78,34 @@ def __init__(self, client: WebSocketClient, logger: Logger): self.logger = logger async def should_quote(self, rfq: RFQResultPublicSchema) -> bool: + """ + Determine if we should quote this RFQ based on our strategy criteria. + + Criteria for quoting: + 1. All legs must be for our target underlying (ETH) + 2. All legs must be options (not perps or spot) + 3. Total delta impact must be within our risk limits + 4. Must be able to calculate delta (option pricing data available) + + Args: + rfq: The RFQ to evaluate + + Returns: + True if we should quote this RFQ, False otherwise + """ # Implement logic to decide whether to quote the RFQ based on current portfolio delta exposure - # we have a simple descrimintaor here that only quotes RFQs on a specific underlying + # we have a simple discriminator here that only quotes RFQs on a specific underlying is_for_target_underlying = all([UNDERLYING_TO_QUOTE in leg.instrument_name for leg in rfq.legs]) def is_option(instrument_name: str) -> bool: + """Check if instrument is an option (ends with -C for call or -P for put)""" return instrument_name.endswith(("-C", "-P")) is_only_options = all([is_option(leg.instrument_name) for leg in rfq.legs]) if not is_for_target_underlying or not is_only_options: return False + # Calculate what our delta exposure would be if we quote this RFQ total_delta, is_error = await self.calculate_delta_from_quote(rfq) self.logger.info(f" - RFQ {rfq.rfq_id} total delta impact would be {total_delta}") @@ -84,18 +113,36 @@ def is_option(instrument_name: str) -> bool: [ is_for_target_underlying, is_only_options, - abs(total_delta) <= MAX_DELTA_TO_QUOTE, - not is_error, + abs(total_delta) <= MAX_DELTA_TO_QUOTE, # Delta impact within limits + not is_error, # Successfully calculated delta ] ) async def calculate_delta_from_quote( self, quote: QuoteResultSchema | RFQResultPublicSchema ) -> tuple[Decimal, bool]: - """Calculates the hedge amount needed to neutralize delta exposure from the quote.""" + """ + Calculate the net delta exposure from a quote. + + Delta represents how much the position value changes for a $1 move in the underlying. + For example, a delta of +10 means the position gains $10 if ETH rises by $1. + + As market makers, we SELL quotes, meaning we take the opposite side of each leg: + - If the RFQ wants to BUY an option from us, we SELL it (negative delta impact) + - If the RFQ wants to SELL an option to us, we BUY it (positive delta impact) + + Args: + quote: The quote or RFQ to calculate delta for + + Returns: + Tuple of (total_delta, is_error) + - total_delta: Net delta exposure from this quote + - is_error: True if we couldn't calculate delta (missing pricing data) + """ total_delta = D("0.0") is_error = False for leg in quote.legs: + # Get current market data including option Greeks ticker = await self.client.markets.get_ticker(instrument_name=leg.instrument_name) if not ticker.option_pricing: self.logger.info( @@ -103,18 +150,40 @@ async def calculate_delta_from_quote( ) is_error = True break + # Delta per contract * number of contracts leg_delta = ticker.option_pricing.delta * leg.amount - # we are the SELLER of the quote so we are in efffect taking the opposite side of the leg direction + # we are the SELLER of the quote so we are in effect taking the opposite side of the leg direction # we therefore subtract the delta for buy legs and add for sell legs if leg.direction == Direction.sell: + # Taker sells to us = we buy = positive delta total_delta += leg_delta else: + # Taker buys from us = we sell = negative delta total_delta -= leg_delta return total_delta, is_error async def price_legs(self, rfq: RFQResultPublicSchema) -> List[LegPricedSchema]: + """ + Price the legs of an RFQ based on current order book prices. + + Pricing strategy: + 1. Use best bid/ask from order book for competitive pricing + 2. Fall back to mark price with premium if order book is empty + 3. Verify delta impact is acceptable before pricing + 4. Round prices to instrument tick size + + This approach provides tighter pricing than using mark price alone, + but requires more liquidity in the order book. + + Args: + rfq: The RFQ to price + + Returns: + List of priced legs, or empty list if we can't/won't quote + """ # Implement logic to price the legs of the RFQ priced_legs = [] + # Double-check delta impact before pricing expected_delta, is_error = await self.calculate_delta_from_quote(rfq) if is_error or abs(expected_delta) > MAX_DELTA_TO_QUOTE: return [] @@ -124,21 +193,28 @@ async def price_legs(self, rfq: RFQResultPublicSchema) -> List[LegPricedSchema]: ) # we base on the book price here if unpriced_leg.direction == Direction.buy: + # Taker wants to buy from us, so we use the ask side pricing if ticker.best_ask_price is None or ticker.best_ask_price == D("0"): self.logger.info( f" - fallback pricing used as no mark price for: {unpriced_leg.instrument_name}." ) + # No order book price available, use mark + premium as fallback base_price = ticker.mark_price * (D("1") + FALLBACK_TO_MARK_PRICE_PREMIUM_BPS / D("10000")) else: + # Use current best ask from order book base_price = ticker.best_ask_price else: + # Taker wants to sell to us, so we use the bid side pricing if ticker.best_bid_price is None or ticker.best_bid_price == D("0"): self.logger.info( f" - fallback pricing used as no mark price for: {unpriced_leg.instrument_name}." ) + # No order book price available, use mark - premium as fallback base_price = ticker.mark_price * (D("1") - FALLBACK_TO_MARK_PRICE_PREMIUM_BPS / D("10000")) else: + # Use current best bid from order book base_price = ticker.best_bid_price + # Quantize to the instrument's tick size (minimum price increment) price = base_price.quantize(ticker.tick_size) priced_leg = LegPricedSchema( price=price, @@ -151,6 +227,17 @@ async def price_legs(self, rfq: RFQResultPublicSchema) -> List[LegPricedSchema]: class PortfolioDeltaCalculator: + """ + Calculates the total delta exposure across the entire portfolio. + + This aggregates delta from three sources: + 1. Options: Uses Greeks (delta) from option pricing models + 2. Perpetuals: Each unit has delta of 1 (linear exposure) + 3. Spot: Each unit has delta of 1 (linear exposure) + + The total portfolio delta tells us how much our portfolio value will change + for a $1 movement in the underlying asset price. + """ client: WebSocketClient logger: Logger @@ -159,10 +246,24 @@ def __init__(self, client: WebSocketClient, logger: Logger): self.logger = logger async def calculate_portfolio_delta(self) -> Decimal: + """ + Calculate the current total delta exposure of the portfolio. + + Process: + 1. Fetch all open positions for the target underlying + 2. Separate positions by type (option, perp, spot) + 3. Calculate delta contribution from each position type + 4. Sum to get total portfolio delta + + Returns: + Total portfolio delta across all positions + """ # Implement logic to calculate the current portfolio delta + # Get all open positions for our target underlying positions: List[PositionResponseSchema] = await self.client.positions.list( is_open=True, currency=UNDERLYING_TO_QUOTE ) + # Filter positions by instrument type option_positions = [ p for p in positions @@ -185,7 +286,10 @@ async def calculate_portfolio_delta(self) -> Decimal: and p.instrument_type == AssetType.erc20 and p.instrument_name.startswith(UNDERLYING_TO_QUOTE) ] + # Calculate delta contributions + # Options: delta is provided by pricing model (0 to 1 for calls, -1 to 0 for puts) option_deltas = sum([p.delta * p.amount for p in option_positions]) + # Perps and spot: delta is 1 per unit (full linear exposure) perp_delta = sum([p.amount for p in perp_positions]) # Perp has delta of 1 per unit spot_delta = sum([p.amount for p in spot_positions]) # Spot has delta of 1 per unit total_delta = option_deltas + perp_delta + spot_delta @@ -193,6 +297,29 @@ async def calculate_portfolio_delta(self) -> Decimal: class DeltaHedgerRfqQuoter: + """ + Advanced RFQ quoter with automatic delta hedging. + + This market maker implementation: + 1. Receives and prices RFQs based on order book data + 2. Monitors portfolio delta exposure in real-time + 3. Automatically hedges delta exposure using perpetual futures + 4. Tracks quote fills and updates hedging strategy accordingly + + Delta hedging reduces directional risk by maintaining near-zero net delta, + allowing the market maker to profit from spreads rather than directional bets. + + Key components: + - DeltaQuoterStrategy: Decides what to quote and how to price + - PortfolioDeltaCalculator: Monitors total delta exposure + - Hedging system: Executes offsetting trades to neutralize delta + + WARNING: This is a demonstration of concepts only. Production systems require: + - More sophisticated risk management (gamma, vega, theta) + - Better order execution and slippage control + - Comprehensive error handling and recovery + - Capital efficiency optimizations + """ logger: Logger client: WebSocketClient @@ -201,16 +328,26 @@ def __init__(self, client: WebSocketClient): self.logger = client._logger self.portfolio_delta_calculator = PortfolioDeltaCalculator(client, self.logger) self.delta_quoter_strategy = DeltaQuoterStrategy(client, self.logger) + # Track active quotes by RFQ ID self.quotes: dict[str, PrivateSendQuoteResultSchema] = {} # hedging state - self.hedging_queue = asyncio.Queue() - self.hedger_task: asyncio.Task[None] - self.hedge_order: OrderResponseSchema | None = None - self.hedge_lock = asyncio.Lock() + self.hedging_queue = asyncio.Queue() # Queue of delta calculations triggering hedge checks + self.hedger_task: asyncio.Task[None] # Background task for hedging + self.hedge_order: OrderResponseSchema | None = None # Currently active hedge order + self.hedge_lock = asyncio.Lock() # Prevent concurrent hedge executions # state locks - self.quoting_lock = asyncio.Lock() + self.quoting_lock = asyncio.Lock() # Prevent race conditions in quote tracking async def create_quote(self, rfq): + """ + Create and price a quote for an RFQ if our strategy allows it. + + Args: + rfq: The RFQ to potentially quote + + Returns: + List of priced legs, or empty list if we decline to quote + """ # Price legs using current market prices NOTE! This is just an example and not a trading strategy!!! if not await self.delta_quoter_strategy.should_quote(rfq): self.logger.info(f" - Skipping quoting for RFQ {rfq.rfq_id} based on strategy decision.") @@ -222,6 +359,19 @@ async def create_quote(self, rfq): return priced_legs async def on_rfq(self, rfqs: List[RFQResultPublicSchema]): + """ + Handle incoming RFQ updates from the WebSocket channel. + + Process: + 1. Clean up expired/cancelled RFQs from tracking + 2. Filter for open RFQs that need quotes + 3. Price all quotable RFQs concurrently + 4. Submit all quotes concurrently + 5. Track successful quotes for monitoring + + Args: + rfqs: List of RFQ updates + """ for rfq in rfqs: async with self.quoting_lock: if rfq.status in {Status.expired, Status.cancelled} and rfq.rfq_id in self.quotes: @@ -250,6 +400,17 @@ async def on_rfq(self, rfqs: List[RFQResultPublicSchema]): self.logger.info(f" ❌ Failed to send quote for RFQ {rfq.rfq_id}: {result}") async def on_quote(self, quotes_list: List[QuoteResultSchema]): + """ + Handle quote status updates. + + When quotes expire or fill, we: + 1. Remove them from tracking + 2. Log the outcome + 3. (Hedging is triggered separately via trade settlements) + + Args: + quotes_list: List of quote status updates + """ for quote in quotes_list: self.logger.info(f" - Quote {quote.quote_id} {quote.rfq_id}: {quote.status}") async with self.quoting_lock: @@ -261,6 +422,18 @@ async def on_quote(self, quotes_list: List[QuoteResultSchema]): self.logger.info(f" ✓ Our quote {quote.quote_id} was accepted!") async def execute_hedge(self, delta_to_hedge: Decimal): + """ + Execute a hedge trade to neutralize delta exposure. + + Strategy: + - If portfolio delta is positive (long), sell perpetuals to reduce exposure + - If portfolio delta is negative (short), buy perpetuals to increase exposure + - Use limit orders at current best bid/ask for better execution + - Orders expire after HEDGE_ORDER_TIMEOUT_S seconds if not filled + + Args: + delta_to_hedge: The amount of delta to neutralize (signed value) + """ instrument_name = f"{UNDERLYING_TO_QUOTE}-PERP" if self.hedge_order is not None: self.logger.info( @@ -271,8 +444,10 @@ async def execute_hedge(self, delta_to_hedge: Decimal): # if we need to hedge negative delta, we need to buy the underlying perp # if we need to hedge positive delta, we need to sell the underlying perp trade_direction = Direction.sell if delta_to_hedge < 0 else Direction.buy + # Use appropriate side of the book for our direction price = ticker.best_bid_price if trade_direction == Direction.sell else ticker.best_ask_price if price is None or price == D("0"): + # Fallback to mark price if order book is empty price = ticker.mark_price trade_amount = abs(delta_to_hedge) if trade_amount < ticker.minimum_amount: @@ -281,19 +456,29 @@ async def execute_hedge(self, delta_to_hedge: Decimal): ) return self.logger.info(f" - Executing hedge for delta amount: {delta_to_hedge} in direction {trade_direction}") + # Create a limit order to hedge the delta exposure self.hedge_order = await self.client.orders.create( amount=trade_amount, instrument_name=instrument_name, limit_price=price.quantize(ticker.tick_size), direction=trade_direction, order_type=OrderType.limit, - reduce_only=False, - label=HEDGE_ORDER_LABEL, + reduce_only=False, # We want to open new positions to hedge + label=HEDGE_ORDER_LABEL, # Tag for tracking hedge orders reject_timestamp=int((datetime.now(UTC) + timedelta(seconds=HEDGE_ORDER_TIMEOUT_S)).timestamp() * 1000), ) async def on_trade_settlement(self, trades: List[TradeResponseSchema]): - """Handle trade updates if needed for more advanced hedging logic.""" + """ + Handle trade settlement notifications. + + When an RFQ trade settles (completes), we need to re-evaluate our + portfolio delta because our option positions have changed. This may + trigger a hedge if the delta is now outside our target range. + + Args: + trades: List of settled trades + """ rfq_trades = [] for trade in trades: @@ -301,13 +486,24 @@ async def on_trade_settlement(self, trades: List[TradeResponseSchema]): f" - {trade.instrument_name}-{trade.direction} {trade.trade_amount} at {trade.trade_price}" ) if trade.quote_id: + # This trade was from an RFQ quote we provided rfq_trades.append(trade) if rfq_trades: self.logger.info(f" ✓ Detected {len(rfq_trades)} RFQ trades settled, re-evaluating portfolio delta.") + # Queue up a delta calculation to potentially trigger hedging await self.hedging_queue.put(await self.portfolio_delta_calculator.calculate_portfolio_delta()) async def on_order(self, orders: List[OrderResponseSchema]): - """Handle order updates if needed for more advanced hedging logic.""" + """ + Handle order status updates. + + We monitor our hedge orders to know when they complete (fill, cancel, expire). + When a hedge order finishes, we clear the tracking variable and re-evaluate + delta to see if additional hedging is needed. + + Args: + orders: List of order updates + """ for order in orders: self.logger.info(f" - Order {order.order_id} status update: {order.order_status}") if order.label == HEDGE_ORDER_LABEL and order.order_status in { @@ -318,36 +514,69 @@ async def on_order(self, orders: List[OrderResponseSchema]): self.logger.info( f" ✓ Hedge order {order.order_id} status {order.order_status}, re-evaluating total delta." ) + # Clear the hedge order tracking self.hedge_order = None + # Re-calculate portfolio delta after hedge completes await self.hedging_queue.put(await self.portfolio_delta_calculator.calculate_portfolio_delta()) async def run(self): + """ + Main execution loop - sets up all subscriptions and background tasks. + + Subscriptions: + 1. RFQs by wallet - to receive RFQ requests to quote + 2. Quotes by subaccount - to track status of our quotes + 3. Trades by subaccount - to detect when RFQ trades settle + 4. Orders by subaccount - to track hedge order status + + Background tasks: + - Portfolio hedging task runs continuously to monitor and hedge delta + """ await self.client.connect() + # Subscribe to RFQs we can quote await self.client.private_channels.rfqs_by_wallet(wallet=TEST_WALLET, callback=self.on_rfq) + # Subscribe to our quote status updates await self.client.private_channels.quotes_by_subaccount_id( subaccount_id=str(SUBACCOUNT_ID), callback=self.on_quote, ) + # Subscribe to settled trades to trigger delta recalculation await self.client.private_channels.trades_tx_status_by_subaccount_id( subaccount_id=SUBACCOUNT_ID, callback=self.on_trade_settlement, - tx_status=TxStatus4.settled, + tx_status=TxStatus4.settled, # Only care about finalized trades ) + # Subscribe to order updates to track hedge order status await self.client.private_channels.orders_by_subaccount_id( subaccount_id=str(SUBACCOUNT_ID), callback=self.on_order, ) + # Start the background hedging task self.hedger_task = asyncio.create_task(self.portfolio_hedging_task()) # we send a request to evaluate the current delta on startup await self.hedging_queue.put(await self.portfolio_delta_calculator.calculate_portfolio_delta()) + # Keep running indefinitely await asyncio.Event().wait() async def portfolio_hedging_task(self): + """ + Background task that continuously monitors and hedges portfolio delta. + + Operation: + 1. Check queue for triggered delta recalculations (from trades/orders) + 2. If no trigger, periodically recalculate delta based on HEDGE_INTERVAL + 3. If delta exceeds thresholds (MIN_DELTA_EXPOSURE or MAX_DELTA_EXPOSURE), + execute a hedge trade to bring it back to neutral + + This runs continuously in the background, ensuring our delta exposure + stays within acceptable risk limits. + """ # Implement periodic portfolio delta checking and hedging if necessary last_check_time = datetime.now(UTC) while True: + # Drain queue of any pending delta calculations portfolio_delta: Decimal | None = None while True: try: @@ -355,17 +584,21 @@ async def portfolio_hedging_task(self): except asyncio.QueueEmpty: break + # If no triggered calculation, check if it's time for periodic check if portfolio_delta is None: now = datetime.now(UTC) if (now - last_check_time).total_seconds() >= HEDGE_INTERVAL: portfolio_delta = await self.portfolio_delta_calculator.calculate_portfolio_delta() last_check_time = now else: + # Not time yet, sleep and check again await asyncio.sleep(1) continue + # Check if delta is outside acceptable range and needs hedging async with self.hedge_lock: if portfolio_delta < MIN_DELTA_EXPOSURE or portfolio_delta > MAX_DELTA_EXPOSURE: + # Calculate hedge amount (negative of current delta to neutralize) delta_to_hedge = -portfolio_delta self.logger.info( f" - Portfolio delta {portfolio_delta} outside exposure limits, hedging {delta_to_hedge}." @@ -375,7 +608,10 @@ async def portfolio_hedging_task(self): async def main(): """ - Sample of polling for RFQs and printing their status. + Main entry point for the delta-hedged RFQ quoter. + + This creates a WebSocket client configured for the test environment + and runs the quoter with automatic reconnection on failures. """ client = WebSocketClient( @@ -389,6 +625,7 @@ async def main(): try: await rfq_quoter.run() except KeyboardInterrupt: + # Allow clean shutdown break