From 7db8326a7c1cba9866b3e4b3c39cd7e7d2580ca0 Mon Sep 17 00:00:00 2001 From: Geoff Taylor Date: Thu, 15 Jul 2021 21:03:22 +0100 Subject: [PATCH] More realistic market-maker is now available. --- bin/cancel-order | 5 +- bin/market-maker | 193 +++++++++++++++ mango/__init__.py | 2 +- mango/combinableinstructions.py | 12 +- mango/instructions.py | 2 +- mango/marketinstructionbuilder.py | 6 +- mango/marketmaking/desiredordersbuilder.py | 5 +- ....py => fixedratiosdesiredordersbuilder.py} | 48 ++-- mango/marketmaking/marketmaker.py | 40 ++-- mango/marketmaking/modelstate.py | 6 - mango/marketmaking/orderreconciler.py | 60 +++++ mango/marketmaking/ordertracker.py | 66 ++++++ .../{desiredorder.py => reconciledorders.py} | 29 ++- mango/marketmaking/simplemarketmaker.py | 22 +- .../marketmaking/toleranceorderreconciler.py | 100 ++++++++ mango/marketoperations.py | 14 +- mango/observables.py | 32 +++ mango/orders.py | 32 ++- mango/perpmarketinstructionbuilder.py | 6 +- mango/perpmarketoperations.py | 13 +- mango/serummarketinstructionbuilder.py | 10 +- mango/serummarketoperations.py | 15 +- mango/spotmarketinstructionbuilder.py | 14 +- mango/spotmarketoperations.py | 13 +- tests/marketmaking/__init__.py | 0 tests/marketmaking/test_orderreconciler.py | 22 ++ .../test_toleranceorderreconciler.py | 222 ++++++++++++++++++ 27 files changed, 858 insertions(+), 131 deletions(-) create mode 100755 bin/market-maker rename mango/marketmaking/{fixedratiodesiredordersbuilder.py => fixedratiosdesiredordersbuilder.py} (55%) create mode 100644 mango/marketmaking/orderreconciler.py create mode 100644 mango/marketmaking/ordertracker.py rename mango/marketmaking/{desiredorder.py => reconciledorders.py} (54%) create mode 100644 mango/marketmaking/toleranceorderreconciler.py create mode 100644 tests/marketmaking/__init__.py create mode 100644 tests/marketmaking/test_orderreconciler.py create mode 100644 tests/marketmaking/test_toleranceorderreconciler.py diff --git a/bin/cancel-order b/bin/cancel-order index 92f59e8..abdcef7 100755 --- a/bin/cancel-order +++ b/bin/cancel-order @@ -6,8 +6,6 @@ import os import os.path import sys -from decimal import Decimal - sys.path.insert(0, os.path.abspath( os.path.join(os.path.dirname(__file__), ".."))) import mango # nopep8 @@ -32,7 +30,6 @@ if market is None: raise Exception(f"Could not find market {market_symbol}") market_operations = mango.create_market_operations(context, wallet, False, market) -order = mango.Order(id=args.order_id, client_id=0, owner=wallet.address, - side=mango.Side.BUY, price=Decimal(0), size=Decimal(0)) +order = mango.Order.from_ids(id=args.order_id, client_id=0) cancellation = market_operations.cancel_order(order) print(cancellation) diff --git a/bin/market-maker b/bin/market-maker new file mode 100755 index 0000000..622d98a --- /dev/null +++ b/bin/market-maker @@ -0,0 +1,193 @@ +#!/usr/bin/env pyston3 + +import argparse +import logging +import os +import os.path +import rx +import sys +import typing + +from decimal import Decimal + +sys.path.insert(0, os.path.abspath( + os.path.join(os.path.dirname(__file__), ".."))) +import mango # nopep8 +import mango.marketmaking.fixedratiosdesiredordersbuilder # nopep8 +import mango.marketmaking.marketmaker # nopep8 +import mango.marketmaking.modelstate # nopep8 +import mango.marketmaking.toleranceorderreconciler # nopep8 + +parser = argparse.ArgumentParser(description="Shows the on-chain data of a particular account.") +mango.Context.add_command_line_parameters(parser) +mango.Wallet.add_command_line_parameters(parser) +parser.add_argument("--market", type=str, required=True, help="market symbol to make market upon (e.g. ETH/USDC)") +parser.add_argument("--account-index", type=int, default=0, + help="index of the account to use, if more than one available") +parser.add_argument("--oracle-provider", type=str, required=True, + help="name of the price provider to use (e.g. pyth)") +parser.add_argument("--spread-ratio", type=Decimal, required=True, + help="fraction of the mid price to be added and subtracted to calculate buy and sell prices") +parser.add_argument("--position-size-ratio", type=Decimal, required=True, + help="fraction of the token inventory to be bought or sold in each order") +parser.add_argument("--existing-order-tolerance", type=Decimal, default=Decimal("0.001"), + help="tolerance in price and quantity when matching existing orders or cancelling/replacing") +parser.add_argument("--dry-run", action="store_true", default=False, + help="runs as read-only and does not perform any transactions") +args = parser.parse_args() + +logging.getLogger().setLevel(args.log_level) +logging.warning(mango.WARNING_DISCLAIMER_TEXT) + + +def cleanup(context: mango.Context, wallet: mango.Wallet, dry_run: bool, market: mango.Market): + logging.info("Cleaning up.") + market_operations: mango.MarketOperations = mango.create_market_operations(context, wallet, dry_run, market) + orders = market_operations.load_my_orders() + for order in orders: + market_operations.cancel_order(order) + + +def add_file_health(name: str, event_source: mango.EventSource, disposer: mango.DisposePropagator): + perp_market_file_touch_disposer = event_source.subscribe( + mango.FileToucherObserver(f"/var/tmp/mango_healthcheck_{name}")) + disposer.add_disposable(perp_market_file_touch_disposer) + + +def build_latest_group_observer(context: mango.Context, manager: mango.WebSocketSubscriptionManager, disposer: mango.DisposePropagator, group: mango.Group) -> mango.LatestItemObserverSubscriber[mango.Group]: + group_subscription = mango.WebSocketSubscription[mango.Group]( + context, group.address, lambda account_info: mango.Group.parse(context, account_info)) + manager.add(group_subscription) + latest_group_observer = mango.LatestItemObserverSubscriber(group) + group_subscription.publisher.subscribe(latest_group_observer) + add_file_health("group_subscription", group_subscription.publisher, disposer) + return latest_group_observer + + +def build_latest_account_observer(context: mango.Context, account: mango.Account, manager: mango.WebSocketSubscriptionManager, disposer: mango.DisposePropagator, group_observer: mango.LatestItemObserverSubscriber[mango.Group]) -> mango.LatestItemObserverSubscriber[mango.Account]: + account_subscription = mango.WebSocketSubscription[mango.Account]( + context, account.address, lambda account_info: mango.Account.parse(context, account_info, group_observer.latest)) + manager.add(account_subscription) + latest_account_observer = mango.LatestItemObserverSubscriber(account) + account_subscription.publisher.subscribe(latest_account_observer) + add_file_health("account_subscription", account_subscription.publisher, disposer) + return latest_account_observer + + +def build_latest_spot_open_orders_observer(manager: mango.WebSocketSubscriptionManager, disposer: mango.DisposePropagator, spot_market: mango.SpotMarket) -> mango.LatestItemObserverSubscriber[mango.OpenOrders]: + market_index = group.find_spot_market_index(spot_market.address) + spot_open_orders_address = account.spot_open_orders[market_index] + spot_open_orders_subscription = mango.WebSocketSubscription[mango.OpenOrders]( + context, spot_open_orders_address, lambda account_info: mango.OpenOrders.parse(account_info, spot_market.base.decimals, spot_market.quote.decimals)) + manager.add(spot_open_orders_subscription) + initial_spot_open_orders = mango.OpenOrders.load( + context, spot_open_orders_address, spot_market.base.decimals, spot_market.quote.decimals) + latest_spot_open_orders_observer = mango.LatestItemObserverSubscriber(initial_spot_open_orders) + spot_open_orders_subscription.publisher.subscribe(latest_spot_open_orders_observer) + add_file_health("spot_open_orders_subscription", spot_open_orders_subscription.publisher, disposer) + return latest_spot_open_orders_observer + + +def build_latest_perp_market_observer(manager: mango.WebSocketSubscriptionManager, disposer: mango.DisposePropagator, perp_market_info: mango.PerpMarketInfo, initial_perp_market: mango.PerpMarket, group_observer: mango.LatestItemObserverSubscriber[mango.Group]) -> mango.LatestItemObserverSubscriber[mango.PerpMarket]: + perp_market_subscription = mango.WebSocketSubscription[mango.PerpMarket]( + context, perp_market_info.address, lambda account_info: mango.PerpMarket.parse(account_info, group_observer.latest)) + manager.add(perp_market_subscription) + latest_perp_market_observer = mango.LatestItemObserverSubscriber(initial_perp_market) + perp_market_subscription.publisher.subscribe(latest_perp_market_observer) + add_file_health("perp_market_subscription", perp_market_subscription.publisher, disposer) + return latest_perp_market_observer + + +def build_latest_price_observer(context: mango.Context, disposer: mango.DisposePropagator, provider_name: str, market: mango.Market) -> mango.LatestItemObserverSubscriber[mango.Price]: + oracle_provider: mango.OracleProvider = mango.create_oracle_provider(provider_name) + oracle = oracle_provider.oracle_for_market(context, market) + if oracle is None: + raise Exception(f"Could not find oracle for market {market.symbol} from provider {provider_name}.") + + initial_price = oracle.fetch_price(context) + price_feed = oracle.to_streaming_observable(context) + latest_price_observer = mango.LatestItemObserverSubscriber(initial_price) + price_disposable = price_feed.subscribe(latest_price_observer) + disposer.add_disposable(price_disposable) + add_file_health("price_subscription", price_feed, disposer) + return latest_price_observer + + +context = mango.Context.from_command_line_parameters(args) + +disposer = mango.DisposePropagator() +manager = mango.WebSocketSubscriptionManager() +disposer.add_disposable(manager) + + +wallet = mango.Wallet.from_command_line_parameters_or_raise(args) +group = mango.Group.load(context, context.group_id) +accounts = mango.Account.load_all_for_owner(context, wallet.address, group) +if len(accounts) == 0: + raise Exception(f"No mango account found for root address '{wallet.address}'.") +account = accounts[args.account_index] + +market_symbol = args.market.upper() +market = context.market_lookup.find_by_symbol(market_symbol) +if market is None: + raise Exception(f"Could not find market {market_symbol}") + +# The market index is also the index of the base token in the group's token list. +if market.quote != group.shared_quote_token.token: + raise Exception( + f"Group {group.name} uses quote token {group.shared_quote_token.token.symbol}, not use shared quote token {market.quote.symbol}.") + +cleanup(context, wallet, args.dry_run, market) + +latest_group_observer = build_latest_group_observer(context, manager, disposer, group) +latest_account_observer = build_latest_account_observer(context, account, manager, disposer, latest_group_observer) +latest_price_observer = build_latest_price_observer(context, disposer, args.oracle_provider, market) + +market_instruction_builder: typing.Optional[mango.MarketInstructionBuilder] = None +if isinstance(market, mango.SerumMarket): + market_instruction_builder = mango.SerumMarketInstructionBuilder.load(context, wallet, market) +elif isinstance(market, mango.SpotMarket): + market_as_spot_market: mango.SpotMarket = typing.cast(mango.SpotMarket, market) + market_instruction_builder = mango.SpotMarketInstructionBuilder.load(context, wallet, group, account, market) + + latest_perp_market_observer = None + latest_spot_open_orders_observer = build_latest_spot_open_orders_observer(manager, disposer, market_as_spot_market) +elif isinstance(market, mango.PerpsMarket): + perp_market_info = mango.PerpMarketInfo.find_by_address(group.perp_markets, market.address) + initial_perp_market = mango.PerpMarket.load(context, perp_market_info.address, group) + + latest_perp_market_observer = build_latest_perp_market_observer( + manager, disposer, perp_market_info, initial_perp_market, latest_group_observer) + market_instruction_builder = mango.PerpMarketInstructionBuilder.load( + context, wallet, group, account, initial_perp_market) +else: + raise Exception(f"Could not determine type of market {market.symbol}") + +websocket_url = context.cluster_url.replace("https", "ws", 1) +ws: mango.ReconnectingWebsocket = mango.ReconnectingWebsocket(websocket_url, manager.open_handler, manager.on_item) +ws.ping_interval = 10 +ws.open() + +order_reconciler = mango.marketmaking.toleranceorderreconciler.ToleranceOrderReconciler( + args.existing_order_tolerance, args.existing_order_tolerance) +desired_orders_builder = mango.marketmaking.fixedratiosdesiredordersbuilder.FixedRatiosDesiredOrdersBuilder( + [args.spread_ratio, args.spread_ratio * 2, args.spread_ratio * 3], + [args.position_size_ratio, args.position_size_ratio, args.position_size_ratio]) +market_maker = mango.marketmaking.marketmaker.MarketMaker( + wallet, market, market_instruction_builder, desired_orders_builder, order_reconciler) +model_state = mango.marketmaking.modelstate.ModelState(market, latest_account_observer, + latest_group_observer, latest_price_observer, + latest_perp_market_observer, + latest_spot_open_orders_observer) +pulse_disposable = rx.interval(10).subscribe(on_next=lambda _: market_maker.pulse(context, model_state)) +disposer.add_disposable(pulse_disposable) + +print("Press to quit.") + +# Wait - don't exit +input() +print("Shutting down...") +ws.close() +disposer.dispose() +cleanup(context, wallet, args.dry_run, market) +print("Done.") diff --git a/mango/__init__.py b/mango/__init__.py index a358e8c..2299c62 100644 --- a/mango/__init__.py +++ b/mango/__init__.py @@ -24,7 +24,7 @@ from .marketlookup import MarketLookup, CompoundMarketLookup from .marketoperations import MarketOperations, NullMarketOperations from .metadata import Metadata from .notification import NotificationTarget, TelegramNotificationTarget, DiscordNotificationTarget, MailjetNotificationTarget, CsvFileNotificationTarget, FilteringNotificationTarget, NotificationHandler, parse_subscription_target -from .observables import DisposePropagator, NullObserverSubscriber, PrintingObserverSubscriber, TimestampedPrintingObserverSubscriber, CollectingObserverSubscriber, LatestItemObserverSubscriber, CaptureFirstItem, FunctionObserver, create_backpressure_skipping_observer, debug_print_item, log_subscription_error, observable_pipeline_error_reporter, EventSource +from .observables import DisposePropagator, NullObserverSubscriber, PrintingObserverSubscriber, TimestampedPrintingObserverSubscriber, CollectingObserverSubscriber, LatestItemObserverSubscriber, CaptureFirstItem, FunctionObserver, create_backpressure_skipping_observer, debug_print_item, log_subscription_error, observable_pipeline_error_reporter, EventSource, FileToucherObserver from .openorders import OpenOrders from .orderbookside import OrderBookSide from .orders import Order, OrderType, Side diff --git a/mango/combinableinstructions.py b/mango/combinableinstructions.py index 4e432ba..daf6414 100644 --- a/mango/combinableinstructions.py +++ b/mango/combinableinstructions.py @@ -91,7 +91,8 @@ class CombinableInstructions(): current_chunk: typing.List[TransactionInstruction] = [] for instruction in self.instructions: in_progress_chunk = current_chunk + [instruction] - if CombinableInstructions.transaction_size(self.signers, in_progress_chunk) < _MAXIMUM_TRANSACTION_LENGTH: + transaction_size = CombinableInstructions.transaction_size(self.signers, in_progress_chunk) + if transaction_size < _MAXIMUM_TRANSACTION_LENGTH: current_chunk = in_progress_chunk else: vetted_chunks += [current_chunk] @@ -99,9 +100,18 @@ class CombinableInstructions(): all_chunks = vetted_chunks + [current_chunk] + if len(all_chunks) == 1 and len(all_chunks[0]) == 0: + self.logger.info("No instructions to run.") + return [] + if len(all_chunks) > 1: self.logger.info(f"Running instructions in {len(all_chunks)} transactions.") + total_in_chunks = sum(map(lambda chunk: len(chunk), all_chunks)) + if total_in_chunks != len(self.instructions): + raise Exception( + f"Failed to chunk instructions. Have {total_in_chunks} instuctions in chunks. Should have {len(self.instructions)}.") + results = [] for chunk in all_chunks: transaction = Transaction() diff --git a/mango/instructions.py b/mango/instructions.py index 2ddaa05..a8ea3c3 100644 --- a/mango/instructions.py +++ b/mango/instructions.py @@ -50,7 +50,7 @@ from .wallet import Wallet # to send to Solana. # # One important distinction between these functions and the more common `create instruction functions` in -# Solana is that these functions *all return a list of instructions and signers*. +# Solana is that these functions *all return a combinable of instructions and signers*. # # It's likely that some operations will require actions split across multiple instructions because of # instruction size limitiations, so all our functions are prepared for this without having to change diff --git a/mango/marketinstructionbuilder.py b/mango/marketinstructionbuilder.py index 6182856..692d46f 100644 --- a/mango/marketinstructionbuilder.py +++ b/mango/marketinstructionbuilder.py @@ -20,7 +20,7 @@ import logging from decimal import Decimal from .combinableinstructions import CombinableInstructions -from .orders import Order, OrderType, Side +from .orders import Order # # πŸ₯­ MarketInstructionBuilder class @@ -50,7 +50,7 @@ class MarketInstructionBuilder(metaclass=abc.ABCMeta): "MarketInstructionBuilder.build_cancel_order_instructions() is not implemented on the base type.") @abc.abstractmethod - def build_place_order_instructions(self, side: Side, order_type: OrderType, price: Decimal, size: Decimal, client_order_id: int) -> CombinableInstructions: + def build_place_order_instructions(self, order: Order) -> CombinableInstructions: raise NotImplementedError( "MarketInstructionBuilder.build_place_order_instructions() is not implemented on the base type.") @@ -81,7 +81,7 @@ class NullMarketInstructionBuilder(MarketInstructionBuilder): def build_cancel_order_instructions(self, order: Order) -> CombinableInstructions: return CombinableInstructions.empty() - def build_place_order_instructions(self, side: Side, order_type: OrderType, price: Decimal, size: Decimal, client_order_id: int) -> CombinableInstructions: + def build_place_order_instructions(self, order: Order) -> CombinableInstructions: return CombinableInstructions.empty() def build_settle_instructions(self) -> CombinableInstructions: diff --git a/mango/marketmaking/desiredordersbuilder.py b/mango/marketmaking/desiredordersbuilder.py index 7a77cb8..c76bae1 100644 --- a/mango/marketmaking/desiredordersbuilder.py +++ b/mango/marketmaking/desiredordersbuilder.py @@ -19,7 +19,6 @@ import logging import mango import typing -from .desiredorder import DesiredOrder from .modelstate import ModelState @@ -35,7 +34,7 @@ class DesiredOrdersBuilder(metaclass=abc.ABCMeta): self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) @abc.abstractmethod - def build(self, context: mango.Context, model_state: ModelState) -> typing.Sequence[DesiredOrder]: + def build(self, context: mango.Context, model_state: ModelState) -> typing.Sequence[mango.Order]: raise NotImplementedError("DesiredOrdersBuilder.build() is not implemented on the base type.") def __repr__(self) -> str: @@ -51,7 +50,7 @@ class NullDesiredOrdersBuilder(DesiredOrdersBuilder): def __init__(self): super().__init__() - def build(self, context: mango.Context, model_state: ModelState) -> typing.Sequence[DesiredOrder]: + def build(self, context: mango.Context, model_state: ModelState) -> typing.Sequence[mango.Order]: return [] def __str__(self) -> str: diff --git a/mango/marketmaking/fixedratiodesiredordersbuilder.py b/mango/marketmaking/fixedratiosdesiredordersbuilder.py similarity index 55% rename from mango/marketmaking/fixedratiodesiredordersbuilder.py rename to mango/marketmaking/fixedratiosdesiredordersbuilder.py index 249a45d..eaa594f 100644 --- a/mango/marketmaking/fixedratiodesiredordersbuilder.py +++ b/mango/marketmaking/fixedratiosdesiredordersbuilder.py @@ -20,46 +20,52 @@ import typing from decimal import Decimal -from .desiredorder import DesiredOrder from .desiredordersbuilder import DesiredOrdersBuilder from .modelstate import ModelState -# # πŸ₯­ FixedRatioDesiredOrdersBuilder class +# # πŸ₯­ FixedRatiosDesiredOrdersBuilder class # # Builds orders using a fixed spread ratio and a fixed position size ratio. # -class FixedRatioDesiredOrdersBuilder(DesiredOrdersBuilder): - def __init__(self, spread_ratio: Decimal, position_size_ratio: Decimal): +class FixedRatiosDesiredOrdersBuilder(DesiredOrdersBuilder): + def __init__(self, spread_ratios: typing.Sequence[Decimal], position_size_ratios: typing.Sequence[Decimal]): self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) - self.spread_ratio: Decimal = spread_ratio - self.position_size_ratio: Decimal = position_size_ratio + if len(spread_ratios) != len(position_size_ratios): + raise Exception("List of spread ratios and position size ratios must be the same length.") - def build(self, context: mango.Context, model_state: ModelState) -> typing.Sequence[DesiredOrder]: + self.spread_ratios: typing.Sequence[Decimal] = spread_ratios + self.position_size_ratios: typing.Sequence[Decimal] = position_size_ratios + + def build(self, context: mango.Context, model_state: ModelState) -> typing.Sequence[mango.Order]: price: mango.Price = model_state.price inventory: typing.Sequence[typing.Optional[mango.TokenValue]] = model_state.account.net_assets base_tokens: typing.Optional[mango.TokenValue] = mango.TokenValue.find_by_token(inventory, price.market.base) - if base_tokens is None: - raise Exception(f"Could not find market-maker base token {price.market.base.symbol} in inventory.") - quote_tokens: typing.Optional[mango.TokenValue] = mango.TokenValue.find_by_token(inventory, price.market.quote) - if quote_tokens is None: - raise Exception(f"Could not find market-maker quote token {price.market.quote.symbol} in inventory.") total = (base_tokens.value * price.mid_price) + quote_tokens.value - position_size = total * self.position_size_ratio - buy_size: Decimal = position_size / price.mid_price - sell_size: Decimal = position_size / price.mid_price + orders: typing.List[mango.Order] = [] + for counter in range(len(self.spread_ratios)): + position_size_ratio = self.position_size_ratios[counter] + spread_ratio = self.spread_ratios[counter] - bid: Decimal = price.mid_price - (price.mid_price * self.spread_ratio) - ask: Decimal = price.mid_price + (price.mid_price * self.spread_ratio) + position_size = total * position_size_ratio + buy_quantity: Decimal = position_size / price.mid_price + sell_quantity: Decimal = position_size / price.mid_price - return [ - DesiredOrder(mango.Side.BUY, mango.OrderType.POST_ONLY, bid, buy_size), - DesiredOrder(mango.Side.SELL, mango.OrderType.POST_ONLY, ask, sell_size) - ] + bid: Decimal = price.mid_price - (price.mid_price * spread_ratio) + ask: Decimal = price.mid_price + (price.mid_price * spread_ratio) + + orders += [ + mango.Order.from_basic_info(mango.Side.BUY, price=bid, quantity=buy_quantity, + order_type=mango.OrderType.POST_ONLY), + mango.Order.from_basic_info(mango.Side.SELL, price=ask, quantity=sell_quantity, + order_type=mango.OrderType.POST_ONLY) + ] + + return orders def __str__(self) -> str: return f"Β« π™΅πš’πš‘πšŽπšπšπšŠπšπš’πš˜π™³πšŽπšœπš’πš›πšŽπšπ™Ύπš›πšπšŽπš›πšœπ™±πšžπš’πš•πšπšŽπš› using ratios - spread: {self.spread_ratio}, position size: {self.position_size_ratio} Β»" diff --git a/mango/marketmaking/marketmaker.py b/mango/marketmaking/marketmaker.py index 0c7459e..03fc1bd 100644 --- a/mango/marketmaking/marketmaker.py +++ b/mango/marketmaking/marketmaker.py @@ -19,10 +19,10 @@ import mango import traceback import typing -from decimal import Decimal - from .desiredordersbuilder import DesiredOrdersBuilder from .modelstate import ModelState +from .orderreconciler import OrderReconciler +from .ordertracker import OrderTracker # # πŸ₯­ MarketMaker class @@ -33,48 +33,44 @@ from .modelstate import ModelState class MarketMaker: def __init__(self, wallet: mango.Wallet, market: mango.Market, market_instruction_builder: mango.MarketInstructionBuilder, - desired_orders_builder: DesiredOrdersBuilder): + desired_orders_builder: DesiredOrdersBuilder, + order_reconciler: OrderReconciler): self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) self.wallet: mango.Wallet = wallet self.market: mango.Market = market self.market_instruction_builder: mango.MarketInstructionBuilder = market_instruction_builder self.desired_orders_builder: DesiredOrdersBuilder = desired_orders_builder + self.order_reconciler: OrderReconciler = order_reconciler + self.order_tracker: OrderTracker = OrderTracker() self.buy_client_ids: typing.List[int] = [] self.sell_client_ids: typing.List[int] = [] def pulse(self, context: mango.Context, model_state: ModelState): try: - desired_orders = self.desired_orders_builder.build(context, model_state) - payer = mango.CombinableInstructions.from_wallet(self.wallet) + desired_orders = self.desired_orders_builder.build(context, model_state) + existing_orders = self.order_tracker.existing_orders(model_state) + reconciled = self.order_reconciler.reconcile(model_state, existing_orders, desired_orders) + cancellations = mango.CombinableInstructions.empty() - for order_id, client_id in model_state.placed_order_ids: - if client_id != 0: - self.logger.info(f"Cancelling order with client ID: {client_id}") - side = mango.Side.BUY if client_id in self.buy_client_ids else mango.Side.SELL - order = mango.Order(id=int(order_id), client_id=int(client_id), owner=self.wallet.address, - side=side, price=Decimal(0), size=Decimal(0)) - cancel = self.market_instruction_builder.build_cancel_order_instructions(order) - cancellations += cancel + for to_cancel in reconciled.to_cancel: + cancel = self.market_instruction_builder.build_cancel_order_instructions(to_cancel) + cancellations += cancel place_orders = mango.CombinableInstructions.empty() - for desired_order in desired_orders: + for to_place in reconciled.to_place: desired_client_id: int = context.random_client_id() - if desired_order.side == mango.Side.BUY: - self.buy_client_ids += [desired_client_id] - else: - self.sell_client_ids += [desired_client_id] + to_place_with_client_id = to_place.with_client_id(desired_client_id) + self.order_tracker.track(to_place_with_client_id) self.logger.info( - f"Placing {desired_order.side} order for {desired_order.quantity} at price {desired_order.price} with client ID: {desired_client_id}") - place_order = self.market_instruction_builder.build_place_order_instructions( - desired_order.side, desired_order.order_type, desired_order.price, desired_order.quantity, desired_client_id) + f"Placing {to_place_with_client_id.side} order for {to_place_with_client_id.quantity} at price {to_place_with_client_id.price} with client ID: {to_place_with_client_id.client_id}") + place_order = self.market_instruction_builder.build_place_order_instructions(to_place_with_client_id) place_orders += place_order settle = self.market_instruction_builder.build_settle_instructions() - crank = self.market_instruction_builder.build_crank_instructions() (payer + cancellations + place_orders + settle + crank).execute(context) except Exception as exception: diff --git a/mango/marketmaking/modelstate.py b/mango/marketmaking/modelstate.py index c856865..313794b 100644 --- a/mango/marketmaking/modelstate.py +++ b/mango/marketmaking/modelstate.py @@ -32,7 +32,6 @@ class ModelState: group_watcher: mango.LatestItemObserverSubscriber[mango.Group], price_watcher: mango.LatestItemObserverSubscriber[mango.Price], perp_market_watcher: typing.Optional[mango.LatestItemObserverSubscriber[mango.PerpMarket]], - spot_market_watcher: mango.LatestItemObserverSubscriber[mango.SpotMarket], spot_open_orders_watcher: mango.LatestItemObserverSubscriber[mango.OpenOrders] ): self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) @@ -42,7 +41,6 @@ class ModelState: self.price_watcher: mango.LatestItemObserverSubscriber[mango.Price] = price_watcher self.perp_market_watcher: typing.Optional[mango.LatestItemObserverSubscriber[mango.PerpMarket] ] = perp_market_watcher - self.spot_market_watcher: mango.LatestItemObserverSubscriber[mango.SpotMarket] = spot_market_watcher self.spot_open_orders_watcher: mango.LatestItemObserverSubscriber[mango.OpenOrders] = spot_open_orders_watcher @property @@ -59,10 +57,6 @@ class ModelState: return None return self.perp_market_watcher.latest - @property - def spot_market(self) -> mango.SpotMarket: - return self.spot_market_watcher.latest - @property def spot_open_orders(self) -> mango.OpenOrders: return self.spot_open_orders_watcher.latest diff --git a/mango/marketmaking/orderreconciler.py b/mango/marketmaking/orderreconciler.py new file mode 100644 index 0000000..cfde244 --- /dev/null +++ b/mango/marketmaking/orderreconciler.py @@ -0,0 +1,60 @@ +# # ⚠ Warning +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +# LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +# NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# [πŸ₯­ Mango Markets](https://mango.markets/) support is available at: +# [Docs](https://docs.mango.markets/) +# [Discord](https://discord.gg/67jySBhxrg) +# [Twitter](https://twitter.com/mangomarkets) +# [Github](https://github.com/blockworks-foundation) +# [Email](mailto:hello@blockworks.foundation) + + +import abc +import logging +import mango +import typing + +from .modelstate import ModelState +from .reconciledorders import ReconciledOrders + + +# # πŸ₯­ OrderReconciler class +# +# Base class for order reconciler that combines existing and desired orders into buckets inside a `ReconciledOrders`. +# +class OrderReconciler(metaclass=abc.ABCMeta): + def __init__(self): + self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) + + @abc.abstractmethod + def reconcile(self, model_state: ModelState, existing_orders: typing.Sequence[mango.Order], desired_orders: typing.Sequence[mango.Order]) -> ReconciledOrders: + raise NotImplementedError("OrderReconciler.reconcile() is not implemented on the base type.") + + def __str__(self) -> str: + return """Β« π™Ύπš›πšπšŽπš›πšπšŽπšŒπš˜πš—πšŒπš’πš•πšŽπš› Β»""" + + def __repr__(self) -> str: + return f"{self}" + + +# # πŸ₯­ NullOrderReconciler class +# +# Null implementation of OrderReconciler. Just maintains all existing orders. +# +class NullOrderReconciler(OrderReconciler): + def __init__(self): + super().__init__() + + def reconcile(self, _: ModelState, existing_orders: typing.Sequence[mango.Order], desired_orders: typing.Sequence[mango.Order]) -> ReconciledOrders: + outcomes: ReconciledOrders = ReconciledOrders() + outcomes.to_keep = list(existing_orders) + outcomes.to_ignore = list(desired_orders) + return outcomes + + def __str__(self) -> str: + return """Β« π™½πšžπš•πš•π™Ύπš›πšπšŽπš›πšπšŽπšŒπš˜πš—πšŒπš’πš•πšŽπš› Β»""" diff --git a/mango/marketmaking/ordertracker.py b/mango/marketmaking/ordertracker.py new file mode 100644 index 0000000..e093433 --- /dev/null +++ b/mango/marketmaking/ordertracker.py @@ -0,0 +1,66 @@ +# # ⚠ Warning +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +# LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +# NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# [πŸ₯­ Mango Markets](https://mango.markets/) support is available at: +# [Docs](https://docs.mango.markets/) +# [Discord](https://discord.gg/67jySBhxrg) +# [Twitter](https://twitter.com/mangomarkets) +# [Github](https://github.com/blockworks-foundation) +# [Email](mailto:hello@blockworks.foundation) + + +import logging +import mango +import typing + +from collections import deque + +from .modelstate import ModelState + + +# # πŸ₯­ OrderTracker class +# +# Maintains a history of orders that were placed (or at least an attempt was made). +# +class OrderTracker: + def __init__(self, max_history: int = 20): + self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) + self.tracked: typing.Deque[mango.Order] = deque(maxlen=max_history) + + def track(self, order: mango.Order): + self.tracked += [order] + + def existing_orders(self, model_state: ModelState) -> typing.Sequence[mango.Order]: + live_orders: typing.List[mango.Order] = [] + for order_id, client_id in model_state.placed_order_ids: + client_id_int = int(client_id) + details = self._find_tracked(client_id_int) + if details is None: + raise Exception(f"Could not find existing order with client ID {client_id_int}") + + order_id_int = int(order_id) + if details.id != order_id_int: + self.tracked.remove(details) + details = details.with_id(order_id_int) + self.tracked += [details] + + live_orders += [details] + + return live_orders + + def _find_tracked(self, client_id_to_find: int) -> typing.Optional[mango.Order]: + for tracked in self.tracked: + if tracked.client_id == client_id_to_find: + return tracked + return None + + def __str__(self) -> str: + return """Β« π™Ύπš›πšπšŽπš›πšπšŽπšŒπš˜πš—πšŒπš’πš•πšŽπš› Β»""" + + def __repr__(self) -> str: + return f"{self}" diff --git a/mango/marketmaking/desiredorder.py b/mango/marketmaking/reconciledorders.py similarity index 54% rename from mango/marketmaking/desiredorder.py rename to mango/marketmaking/reconciledorders.py index 4eba036..e6d0a09 100644 --- a/mango/marketmaking/desiredorder.py +++ b/mango/marketmaking/reconciledorders.py @@ -13,26 +13,29 @@ # [Github](https://github.com/blockworks-foundation) # [Email](mailto:hello@blockworks.foundation) - import mango - -from decimal import Decimal +import typing -# # πŸ₯­ DesiredOrder class +# # πŸ₯­ ReconciledOrders class # -# Encapsulates a single order we want to be present on the orderbook. +# Desired orders and existing orders are reconciled into: +# * existing orders to keep unchanged +# * existing orders to be cancelled +# * new orders to be placed +# * desired orders to ignore # - -class DesiredOrder: - def __init__(self, side: mango.Side, order_type: mango.OrderType, price: Decimal, quantity: Decimal): - self.side: mango.Side = side - self.order_type: mango.OrderType = order_type - self.price: Decimal = price - self.quantity: Decimal = quantity +# This class encapsulates the outcome of such a reconciliation. +# +class ReconciledOrders: + def __init__(self): + self.to_keep: typing.List[mango.Order] = [] + self.to_place: typing.List[mango.Order] = [] + self.to_cancel: typing.List[mango.Order] = [] + self.to_ignore: typing.List[mango.Order] = [] def __str__(self) -> str: - return f"""Β« π™³πšŽπšœπš’πš›πšŽπšπ™Ύπš›πšπšŽπš›: {self.order_type} - {self.side} {self.quantity} at {self.price} Β»""" + return f"Β« πšπšŽπšŒπš˜πš—πšŒπš’πš•πšŽπšπ™Ύπš›πšπšŽπš›πšœ [keep: {len(self.to_keep)}, place: {len(self.to_place)}, cancel: {len(self.to_cancel)}, ignore: {len(self.to_ignore)}] Β»" def __repr__(self) -> str: return f"{self}" diff --git a/mango/marketmaking/simplemarketmaker.py b/mango/marketmaking/simplemarketmaker.py index 9914831..89d3271 100644 --- a/mango/marketmaking/simplemarketmaker.py +++ b/mango/marketmaking/simplemarketmaker.py @@ -86,22 +86,22 @@ class SimpleMarketMaker: # Calculate what we want the orders to be. bid, ask = self.calculate_order_prices(price) - buy_size, sell_size = self.calculate_order_sizes(price, inventory) + buy_quantity, sell_quantity = self.calculate_order_quantities(price, inventory) current_orders = self.market_operations.load_my_orders() buy_orders = [order for order in current_orders if order.side == mango.Side.BUY] - if self.orders_require_action(buy_orders, bid, buy_size): + if self.orders_require_action(buy_orders, bid, buy_quantity): self.logger.info("Cancelling BUY orders.") for order in buy_orders: self.market_operations.cancel_order(order) - self.market_operations.place_order(mango.Side.BUY, mango.OrderType.POST_ONLY, bid, buy_size) + self.market_operations.place_order(mango.Side.BUY, mango.OrderType.POST_ONLY, bid, buy_quantity) sell_orders = [order for order in current_orders if order.side == mango.Side.SELL] - if self.orders_require_action(sell_orders, ask, sell_size): + if self.orders_require_action(sell_orders, ask, sell_quantity): self.logger.info("Cancelling SELL orders.") for order in sell_orders: self.market_operations.cancel_order(order) - self.market_operations.place_order(mango.Side.SELL, mango.OrderType.POST_ONLY, ask, sell_size) + self.market_operations.place_order(mango.Side.SELL, mango.OrderType.POST_ONLY, ask, sell_quantity) self.update_health_on_successful_iteration() except Exception as exception: @@ -155,7 +155,7 @@ class SimpleMarketMaker: return (bid, ask) - def calculate_order_sizes(self, price: mango.Price, inventory: typing.Sequence[typing.Optional[mango.TokenValue]]): + def calculate_order_quantities(self, price: mango.Price, inventory: typing.Sequence[typing.Optional[mango.TokenValue]]): base_tokens: typing.Optional[mango.TokenValue] = mango.TokenValue.find_by_token(inventory, price.market.base) if base_tokens is None: raise Exception(f"Could not find market-maker base token {price.market.base.symbol} in inventory.") @@ -164,15 +164,15 @@ class SimpleMarketMaker: if quote_tokens is None: raise Exception(f"Could not find market-maker quote token {price.market.quote.symbol} in inventory.") - buy_size = base_tokens.value * self.position_size_ratio - sell_size = (quote_tokens.value / price.mid_price) * self.position_size_ratio - return (buy_size, sell_size) + buy_quantity = base_tokens.value * self.position_size_ratio + sell_quantity = (quote_tokens.value / price.mid_price) * self.position_size_ratio + return (buy_quantity, sell_quantity) - def orders_require_action(self, orders: typing.Sequence[mango.Order], price: Decimal, size: Decimal) -> bool: + def orders_require_action(self, orders: typing.Sequence[mango.Order], price: Decimal, quantity: Decimal) -> bool: def within_tolerance(target_value, order_value, tolerance): tolerated = order_value * tolerance return (order_value < (target_value + tolerated)) and (order_value > (target_value - tolerated)) - return len(orders) == 0 or not all([(within_tolerance(price, order.price, self.existing_order_tolerance)) and within_tolerance(size, order.size, self.existing_order_tolerance) for order in orders]) + return len(orders) == 0 or not all([(within_tolerance(price, order.price, self.existing_order_tolerance)) and within_tolerance(quantity, order.quantity, self.existing_order_tolerance) for order in orders]) def update_health_on_successful_iteration(self) -> None: try: diff --git a/mango/marketmaking/toleranceorderreconciler.py b/mango/marketmaking/toleranceorderreconciler.py new file mode 100644 index 0000000..7156366 --- /dev/null +++ b/mango/marketmaking/toleranceorderreconciler.py @@ -0,0 +1,100 @@ +# # ⚠ Warning +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +# LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +# NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +# +# [πŸ₯­ Mango Markets](https://mango.markets/) support is available at: +# [Docs](https://docs.mango.markets/) +# [Discord](https://discord.gg/67jySBhxrg) +# [Twitter](https://twitter.com/mangomarkets) +# [Github](https://github.com/blockworks-foundation) +# [Email](mailto:hello@blockworks.foundation) + + +import mango +import typing + +from decimal import Decimal + +from .modelstate import ModelState +from .orderreconciler import OrderReconciler +from .reconciledorders import ReconciledOrders + + +# # πŸ₯­ ToleranceOrderReconciler class +# +# Has a level of 'tolerance' around whether a desired order matches an existing order. +# +# There are two tolerance levels: +# * A tolerance for price matching +# * A tolderance for quantity matching +# +# Tolerances are expressed as a ratio. To match the existing value must be within +/- the tolderance +# of the desired value. +# +# Note: +# * A BUY only matches with a BUY, a SELL only matches with a SELL. +# * ID and Client ID are ignored when matching. +# * ModelState is ignored when matching. +# +class ToleranceOrderReconciler(OrderReconciler): + def __init__(self, price_tolerance: Decimal, quantity_tolerance: Decimal): + super().__init__() + self.price_tolerance: Decimal = price_tolerance + self.quantity_tolerance: Decimal = quantity_tolerance + + def reconcile(self, _: ModelState, existing_orders: typing.Sequence[mango.Order], desired_orders: typing.Sequence[mango.Order]) -> ReconciledOrders: + remaining_existing_orders: typing.List[mango.Order] = list(existing_orders) + outcomes: ReconciledOrders = ReconciledOrders() + for desired in desired_orders: + acceptable = self.find_acceptable_order(desired, remaining_existing_orders) + if acceptable is None: + outcomes.to_place += [desired] + else: + outcomes.to_keep += [acceptable] + outcomes.to_ignore += [desired] + remaining_existing_orders.remove(acceptable) + + # By this point we have removed all acceptable existing orders, so those that remain + # should be cancelled. + outcomes.to_cancel = remaining_existing_orders + + in_count = len(existing_orders) + len(desired_orders) + out_count = len(outcomes.to_place) + len(outcomes.to_cancel) + len(outcomes.to_keep) + len(outcomes.to_ignore) + if in_count != out_count: + raise Exception( + f"Failure processing all desired orders. Count of orders in: {in_count}. Count of orders out: {out_count}.") + + return outcomes + + def find_acceptable_order(self, desired: mango.Order, existing_orders: typing.Sequence[mango.Order]) -> typing.Optional[mango.Order]: + for existing in existing_orders: + if self.is_within_tolderance(existing, desired): + return existing + return None + + def is_within_tolderance(self, existing: mango.Order, desired: mango.Order) -> bool: + if existing.side != desired.side: + return False + + price_tolerance: Decimal = existing.price * self.price_tolerance + if desired.price > (existing.price + price_tolerance): + return False + + if desired.price < (existing.price - price_tolerance): + return False + + quantity_tolerance: Decimal = existing.quantity * self.quantity_tolerance + if desired.quantity > (existing.quantity + quantity_tolerance): + return False + + if desired.quantity < (existing.quantity - quantity_tolerance): + return False + + return True + + def __str__(self) -> str: + return f"Β« πšƒπš˜πš•πšŽπš›πšŠπš—πšŒπšŽπ™Ύπš›πšπšŽπš›πšπšŽπšŒπš˜πš—πšŒπš’πš•πšŽπš› [price tolerance: {self.price_tolerance}, quantity tolerance: {self.quantity_tolerance}] Β»" diff --git a/mango/marketoperations.py b/mango/marketoperations.py index 7b7e27e..a9d1bfd 100644 --- a/mango/marketoperations.py +++ b/mango/marketoperations.py @@ -29,7 +29,7 @@ from .orders import Order, OrderType, Side # This file deals with placing orders. We want the interface to be simple and basic: # ``` # order_placer.cancel_order(context, market) -# order_placer.place_order(context, market, side, order_type, price, size) +# order_placer.place_order(context, market, side, order_type, price, quantity) # ``` # This requires the `MarketOperations` already know a bit about the market it is placing the # order on, and the code in the `MarketOperations` be specialised for that market platform. @@ -46,7 +46,7 @@ from .orders import Order, OrderType, Side # Whichever choice is made, the calling code shouldn't have to care. It should be able to # use its `MarketOperations` class as simply as: # ``` -# order_placer.place_order(context, side, order_type, price, size) +# order_placer.place_order(context, side, order_type, price, quantity) # ``` # @@ -60,7 +60,7 @@ class MarketOperations(metaclass=abc.ABCMeta): raise NotImplementedError("MarketOperations.cancel_order() is not implemented on the base type.") @abc.abstractmethod - def place_order(self, side: Side, order_type: OrderType, price: Decimal, size: Decimal) -> Order: + def place_order(self, side: Side, order_type: OrderType, price: Decimal, quantity: Decimal) -> Order: raise NotImplementedError("MarketOperations.place_order() is not implemented on the base type.") @abc.abstractmethod @@ -88,13 +88,13 @@ class NullMarketOperations(MarketOperations): def cancel_order(self, order: Order) -> typing.Sequence[str]: self.logger.info( - f"Cancelling order {order.id} for size {order.size} at price {order.price} on market {self.market_name} with client ID {order.client_id}.") + f"Cancelling order {order.id} for quantity {order.quantity} at price {order.price} on market {self.market_name} with client ID {order.client_id}.") return [""] - def place_order(self, side: Side, order_type: OrderType, price: Decimal, size: Decimal) -> Order: + def place_order(self, side: Side, order_type: OrderType, price: Decimal, quantity: Decimal) -> Order: self.logger.info( - f"Placing {order_type} {side} order for size {size} at price {price} on market {self.market_name}.") - return Order(id=0, side=side, price=price, size=size, client_id=0, owner=SYSTEM_PROGRAM_ADDRESS) + f"Placing {order_type} {side} order for quantity {quantity} at price {price} on market {self.market_name}.") + return Order(id=0, side=side, price=price, quantity=quantity, client_id=0, owner=SYSTEM_PROGRAM_ADDRESS, order_type=order_type) def load_orders(self) -> typing.Sequence[Order]: return [] diff --git a/mango/observables.py b/mango/observables.py index 694bdd3..000bc4a 100644 --- a/mango/observables.py +++ b/mango/observables.py @@ -20,6 +20,7 @@ import rx.subject import typing from datetime import datetime +from pathlib import Path from rx.core.abc.disposable import Disposable from rxpy_backpressure import BackPressure @@ -321,3 +322,34 @@ class DisposePropagator(Disposable): def dispose(self): for disposable in self.disposables: disposable.dispose() + + +# # πŸ₯­ FileToucherObserver class +# +# An `Observer` that touches a file every time an item is observed, and deletes that file when the +# `Observable` completes. +# +# The use case for this is for things like health checks. If a file like /var/tmp/helathz is touched +# every time an item is processed, systems running contianer images can watch for these files and +# trigger alerts or restarts if the file hasn't been touched within a certain time limit. +# +class FileToucherObserver(rx.core.typing.Observer): + def __init__(self, filename: str): + self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) + self.filename = filename + + def on_next(self, _: typing.Any) -> None: + try: + Path(self.filename).touch(mode=0o666, exist_ok=True) + except Exception as exception: + self.logger.warning(f"Touching file '{self.filename}' raised exception: {exception}") + + def on_error(self, exception: Exception) -> None: + self.logger.warning(f"FileTouchObserver ignoring error: {exception}") + + def on_completed(self) -> None: + try: + self.logger.info(f"Cleaning up touch file '{self.filename}'.") + Path(self.filename).unlink(missing_ok=True) + except Exception as exception: + self.logger.warning(f"Deleting touch file '{self.filename}' raised exception: {exception}") diff --git a/mango/orders.py b/mango/orders.py index 17bc5fc..77cec2a 100644 --- a/mango/orders.py +++ b/mango/orders.py @@ -23,6 +23,7 @@ from decimal import Decimal from pyserum.market.types import Order as SerumOrder from solana.publickey import PublicKey +from .constants import SYSTEM_PROGRAM_ADDRESS # # πŸ₯­ Orders # @@ -57,6 +58,7 @@ class Side(enum.Enum): class OrderType(enum.Enum): # We use strings here so that argparse can work with these as parameters. + UNKNOWN = "UNKNOWN" LIMIT = "LIMIT" IOC = "IOC" POST_ONLY = "POST_ONLY" @@ -80,13 +82,35 @@ class Order(typing.NamedTuple): owner: PublicKey side: Side price: Decimal - size: Decimal + quantity: Decimal + order_type: OrderType + + # Returns an identical order with the ID changed. + def with_id(self, id: int) -> "Order": + return Order(id=id, side=self.side, price=self.price, quantity=self.quantity, + client_id=self.client_id, owner=self.owner, order_type=self.order_type) + + # Returns an identical order with the Client ID changed. + def with_client_id(self, client_id: int) -> "Order": + return Order(id=self.id, side=self.side, price=self.price, quantity=self.quantity, + client_id=client_id, owner=self.owner, order_type=self.order_type) @staticmethod def from_serum_order(serum_order: SerumOrder) -> "Order": price = Decimal(serum_order.info.price) - size = Decimal(serum_order.info.size) + quantity = Decimal(serum_order.info.size) side = Side.BUY if serum_order.side == pyserum.enums.Side.BUY else Side.SELL - order = Order(id=serum_order.order_id, side=side, price=price, size=size, - client_id=serum_order.client_id, owner=serum_order.open_order_address) + order = Order(id=serum_order.order_id, side=side, price=price, quantity=quantity, + client_id=serum_order.client_id, owner=serum_order.open_order_address, + order_type=OrderType.UNKNOWN) return order + + @staticmethod + def from_basic_info(side: Side, price: Decimal, quantity: Decimal, order_type: OrderType = OrderType.UNKNOWN) -> "Order": + order = Order(id=0, side=side, price=price, quantity=quantity, client_id=0, + owner=SYSTEM_PROGRAM_ADDRESS, order_type=order_type) + return order + + @staticmethod + def from_ids(id: int, client_id: int) -> "Order": + return Order(id=id, client_id=client_id, owner=SYSTEM_PROGRAM_ADDRESS, side=Side.BUY, price=Decimal(0), quantity=Decimal(0), order_type=OrderType.UNKNOWN) diff --git a/mango/perpmarketinstructionbuilder.py b/mango/perpmarketinstructionbuilder.py index 8f7a037..a7ed3a1 100644 --- a/mango/perpmarketinstructionbuilder.py +++ b/mango/perpmarketinstructionbuilder.py @@ -22,7 +22,7 @@ from .context import Context from .group import Group from .marketinstructionbuilder import MarketInstructionBuilder from .instructions import build_cancel_perp_order_instructions, build_mango_consume_events_instructions, build_place_perp_order_instructions -from .orders import Order, OrderType, Side +from .orders import Order from .perpmarket import PerpMarket from .wallet import Wallet @@ -53,9 +53,9 @@ class PerpMarketInstructionBuilder(MarketInstructionBuilder): return build_cancel_perp_order_instructions( self.context, self.wallet, self.account, self.perp_market, order) - def build_place_order_instructions(self, side: Side, order_type: OrderType, price: Decimal, size: Decimal, client_id: int) -> CombinableInstructions: + def build_place_order_instructions(self, order: Order) -> CombinableInstructions: return build_place_perp_order_instructions( - self.context, self.wallet, self.perp_market.group, self.account, self.perp_market, price, size, client_id, side, order_type) + self.context, self.wallet, self.perp_market.group, self.account, self.perp_market, order.price, order.quantity, order.client_id, order.side, order.order_type) def build_settle_instructions(self) -> CombinableInstructions: return CombinableInstructions.empty() diff --git a/mango/perpmarketoperations.py b/mango/perpmarketoperations.py index 56bf663..7d2c1d9 100644 --- a/mango/perpmarketoperations.py +++ b/mango/perpmarketoperations.py @@ -51,21 +51,22 @@ class PerpMarketOperations(MarketOperations): def cancel_order(self, order: Order) -> typing.Sequence[str]: self.logger.info( - f"Cancelling order {order.id} for size {order.size} at price {order.price} on market {self.market_name} with client ID {order.client_id}.") + f"Cancelling order {order.id} for quantity {order.quantity} at price {order.price} on market {self.market_name} with client ID {order.client_id}.") signers: CombinableInstructions = CombinableInstructions.from_wallet(self.wallet) cancel = self.market_instruction_builder.build_cancel_order_instructions(order) return (signers + cancel).execute_and_unwrap_transaction_ids(self.context) - def place_order(self, side: Side, order_type: OrderType, price: Decimal, size: Decimal) -> Order: + def place_order(self, side: Side, order_type: OrderType, price: Decimal, quantity: Decimal) -> Order: client_id: int = self.context.random_client_id() self.logger.info( - f"Placing {order_type} {side} order for size {size} at price {price} on market {self.market_name} with ID {client_id}.") + f"Placing {order_type} {side} order for quantity {quantity} at price {price} on market {self.market_name} with ID {client_id}.") signers: CombinableInstructions = CombinableInstructions.from_wallet(self.wallet) - place = self.market_instruction_builder.build_place_order_instructions( - side, order_type, price, size, client_id) + order = Order(id=0, client_id=client_id, owner=self.account.address, + side=side, price=price, quantity=quantity, order_type=order_type) + place = self.market_instruction_builder.build_place_order_instructions(order) (signers + place).execute(self.context) - return Order(id=0, side=side, price=price, size=size, client_id=client_id, owner=self.account.address) + return order def load_orders(self) -> typing.Sequence[Order]: bids_address: PublicKey = self.perp_market.bids diff --git a/mango/serummarketinstructionbuilder.py b/mango/serummarketinstructionbuilder.py index a28ed23..7fbe2fe 100644 --- a/mango/serummarketinstructionbuilder.py +++ b/mango/serummarketinstructionbuilder.py @@ -92,7 +92,7 @@ class SerumMarketInstructionBuilder(MarketInstructionBuilder): ) return CombinableInstructions.from_instruction(raw_instruction) - def build_place_order_instructions(self, side: Side, order_type: OrderType, price: Decimal, size: Decimal, client_id: int) -> CombinableInstructions: + def build_place_order_instructions(self, order: Order) -> CombinableInstructions: ensure_open_orders = CombinableInstructions.empty() if self.open_orders_address is None: ensure_open_orders = build_create_serum_open_orders_instructions( @@ -100,12 +100,12 @@ class SerumMarketInstructionBuilder(MarketInstructionBuilder): self.open_orders_address = ensure_open_orders.signers[0].public_key() - serum_order_type = pyserum.enums.OrderType.POST_ONLY if order_type == OrderType.POST_ONLY else pyserum.enums.OrderType.IOC if order_type == OrderType.IOC else pyserum.enums.OrderType.LIMIT - serum_side = pyserum.enums.Side.BUY if side == Side.BUY else pyserum.enums.Side.SELL - payer_token_account = self.quote_token_account if side == Side.BUY else self.base_token_account + serum_order_type = pyserum.enums.OrderType.POST_ONLY if order.order_type == OrderType.POST_ONLY else pyserum.enums.OrderType.IOC if order.order_type == OrderType.IOC else pyserum.enums.OrderType.LIMIT + serum_side = pyserum.enums.Side.BUY if order.side == Side.BUY else pyserum.enums.Side.SELL + payer_token_account = self.quote_token_account if order.side == Side.BUY else self.base_token_account raw_instruction = self.raw_market.make_place_order_instruction(payer_token_account.address, self.wallet.account, serum_order_type, serum_side, float( - price), float(size), client_id, self.open_orders_address, self.fee_discount_token_address) + order.price), float(order.quantity), order.client_id, self.open_orders_address, self.fee_discount_token_address) place = CombinableInstructions.from_instruction(raw_instruction) diff --git a/mango/serummarketoperations.py b/mango/serummarketoperations.py index 569b813..f5ebe79 100644 --- a/mango/serummarketoperations.py +++ b/mango/serummarketoperations.py @@ -49,29 +49,30 @@ class SerumMarketOperations(MarketOperations): def cancel_order(self, order: Order) -> typing.Sequence[str]: self.logger.info( - f"Cancelling order {order.id} for size {order.size} at price {order.price} on market {self.serum_market.symbol} with client ID {order.client_id}.") + f"Cancelling order {order.id} for quantity {order.quantity} at price {order.price} on market {self.serum_market.symbol} with client ID {order.client_id}.") signers: CombinableInstructions = CombinableInstructions.from_wallet(self.wallet) cancel = self.market_instruction_builder.build_cancel_order_instructions(order) crank = self.market_instruction_builder.build_crank_instructions() settle = self.market_instruction_builder.build_settle_instructions() return (signers + cancel + crank + settle).execute_and_unwrap_transaction_ids(self.context) - def place_order(self, side: Side, order_type: OrderType, price: Decimal, size: Decimal) -> Order: + def place_order(self, side: Side, order_type: OrderType, price: Decimal, quantity: Decimal) -> Order: client_id: int = self.context.random_client_id() self.logger.info( - f"Placing {order_type} {side} order for size {size} at price {price} on market {self.serum_market.symbol} with client ID {client_id}.") + f"Placing {order_type} {side} order for quantity {quantity} at price {price} on market {self.serum_market.symbol} with client ID {client_id}.") signers: CombinableInstructions = CombinableInstructions.from_wallet(self.wallet) - place = self.market_instruction_builder.build_place_order_instructions( - side, order_type, price, size, client_id) + open_orders_address = self.market_instruction_builder.open_orders_address or SYSTEM_PROGRAM_ADDRESS + order = Order(id=0, client_id=client_id, side=side, price=price, + quantity=quantity, owner=open_orders_address, order_type=order_type) + place = self.market_instruction_builder.build_place_order_instructions(order) crank = self.market_instruction_builder.build_crank_instructions() settle = self.market_instruction_builder.build_settle_instructions() (signers + place + crank + settle).execute(self.context) - open_orders_address = self.market_instruction_builder.open_orders_address or SYSTEM_PROGRAM_ADDRESS - return Order(id=0, side=side, price=price, size=size, client_id=client_id, owner=open_orders_address) + return order def _load_serum_orders(self) -> typing.Sequence[SerumOrder]: raw_market = self.market_instruction_builder.raw_market diff --git a/mango/spotmarketinstructionbuilder.py b/mango/spotmarketinstructionbuilder.py index db7d1cc..c3c17e0 100644 --- a/mango/spotmarketinstructionbuilder.py +++ b/mango/spotmarketinstructionbuilder.py @@ -23,9 +23,9 @@ from .account import Account from .combinableinstructions import CombinableInstructions from .context import Context from .group import Group -from .instructions import build_compound_spot_place_order_instructions, build_cancel_spot_order_instructions +from .instructions import build_compound_spot_place_order_instructions, build_spot_place_order_instructions, build_cancel_spot_order_instructions from .marketinstructionbuilder import MarketInstructionBuilder -from .orders import Order, OrderType, Side +from .orders import Order, Side from .spotmarket import SpotMarket from .tokenaccount import TokenAccount from .wallet import Wallet @@ -84,11 +84,11 @@ class SpotMarketInstructionBuilder(MarketInstructionBuilder): return build_cancel_spot_order_instructions( self.context, self.wallet, self.group, self.account, self.raw_market, order, open_orders) - def build_place_order_instructions(self, side: Side, order_type: OrderType, price: Decimal, size: Decimal, client_id: int) -> CombinableInstructions: - payer_token_account = self.quote_token_account if side == Side.BUY else self.base_token_account - return build_compound_spot_place_order_instructions( - self.context, self.wallet, self.group, self.account, self.raw_market, payer_token_account.address, - order_type, side, price, size, client_id, self.fee_discount_token_address) + def build_place_order_instructions(self, order: Order) -> CombinableInstructions: + return build_spot_place_order_instructions(self.context, self.wallet, self.group, self.account, + self.raw_market, order.order_type, order.side, order.price, + order.quantity, order.client_id, + self.fee_discount_token_address) def build_settle_instructions(self) -> CombinableInstructions: return CombinableInstructions.empty() diff --git a/mango/spotmarketoperations.py b/mango/spotmarketoperations.py index 3099929..baa3eeb 100644 --- a/mango/spotmarketoperations.py +++ b/mango/spotmarketoperations.py @@ -53,19 +53,20 @@ class SpotMarketOperations(MarketOperations): def cancel_order(self, order: Order) -> typing.Sequence[str]: self.logger.info( - f"Cancelling order {order.id} for size {order.size} at price {order.price} on market {self.spot_market.symbol} with client ID {order.client_id}.") + f"Cancelling order {order.id} for quantity {order.quantity} at price {order.price} on market {self.spot_market.symbol} with client ID {order.client_id}.") signers: CombinableInstructions = CombinableInstructions.from_wallet(self.wallet) cancel = self.market_instruction_builder.build_cancel_order_instructions(order) return (signers + cancel).execute_and_unwrap_transaction_ids(self.context) - def place_order(self, side: Side, order_type: OrderType, price: Decimal, size: Decimal) -> Order: + def place_order(self, side: Side, order_type: OrderType, price: Decimal, quantity: Decimal) -> Order: client_id: int = self.context.random_client_id() self.logger.info( - f"Placing {order_type} {side} order for size {size} at price {price} on market {self.spot_market.symbol} with ID {client_id}.") + f"Placing {order_type} {side} order for quantity {quantity} at price {price} on market {self.spot_market.symbol} with ID {client_id}.") signers: CombinableInstructions = CombinableInstructions.from_wallet(self.wallet) - place = self.market_instruction_builder.build_place_order_instructions( - side, order_type, price, size, client_id) + order = Order(id=0, client_id=client_id, side=side, price=price, + quantity=quantity, owner=self.open_orders, order_type=order_type) + place = self.market_instruction_builder.build_place_order_instructions(order) crank = self.market_instruction_builder.build_crank_instructions() @@ -73,7 +74,7 @@ class SpotMarketOperations(MarketOperations): (signers + place + crank + settle).execute(self.context) - return Order(id=0, side=side, price=price, size=size, client_id=client_id, owner=self.open_orders) + return order def _load_serum_orders(self) -> typing.Sequence[SerumOrder]: raw_market = self.market_instruction_builder.raw_market diff --git a/tests/marketmaking/__init__.py b/tests/marketmaking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/marketmaking/test_orderreconciler.py b/tests/marketmaking/test_orderreconciler.py new file mode 100644 index 0000000..53fb7a8 --- /dev/null +++ b/tests/marketmaking/test_orderreconciler.py @@ -0,0 +1,22 @@ +import mango + +from decimal import Decimal + +from mango.marketmaking.orderreconciler import NullOrderReconciler + + +def test_nulloperation(): + existing = [ + mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)), + mango.Order.from_basic_info(mango.Side.SELL, price=Decimal(2), quantity=Decimal(20)) + ] + desired = [ + mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(3), quantity=Decimal(30)), + mango.Order.from_basic_info(mango.Side.SELL, price=Decimal(4), quantity=Decimal(40)) + ] + + actual = NullOrderReconciler() + result = actual.reconcile(None, existing, desired) + + assert result.to_keep == existing + assert result.to_ignore == desired diff --git a/tests/marketmaking/test_toleranceorderreconciler.py b/tests/marketmaking/test_toleranceorderreconciler.py new file mode 100644 index 0000000..07a8076 --- /dev/null +++ b/tests/marketmaking/test_toleranceorderreconciler.py @@ -0,0 +1,222 @@ +import mango + +from decimal import Decimal +from mango.marketmaking.toleranceorderreconciler import ToleranceOrderReconciler + + +def test_buy_does_not_match_sell(): + actual = ToleranceOrderReconciler(Decimal(1), Decimal(1)) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.SELL, price=Decimal(1), quantity=Decimal(10)) + + assert not actual.is_within_tolderance(existing, desired) + + +def test_exact_match_with_small_tolerance_matches(): + actual = ToleranceOrderReconciler(Decimal("0.001"), Decimal("0.001")) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + + assert actual.is_within_tolderance(existing, desired) + + +def test_exact_match_with_zero_tolerance_matches(): + actual = ToleranceOrderReconciler(Decimal(0), Decimal(0)) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + + assert actual.is_within_tolderance(existing, desired) + + +def test_quantity_within_positive_tolerance_matches(): + actual = ToleranceOrderReconciler(Decimal(0), Decimal("0.001")) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal("10.009")) + + assert actual.is_within_tolderance(existing, desired) + + +def test_quantity_positive_tolerance_boundary_matches(): + actual = ToleranceOrderReconciler(Decimal(0), Decimal("0.001")) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal("10.01")) + + assert actual.is_within_tolderance(existing, desired) + + +def test_quantity_outside_positive_tolerance_no_match(): + actual = ToleranceOrderReconciler(Decimal(0), Decimal("0.001")) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal("10.011")) + + assert not actual.is_within_tolderance(existing, desired) + + +def test_quantity_within_negative_tolerance_matches(): + actual = ToleranceOrderReconciler(Decimal(0), Decimal("0.001")) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal("9.991")) + + assert actual.is_within_tolderance(existing, desired) + + +def test_quantity_negative_tolerance_boundary_matches(): + actual = ToleranceOrderReconciler(Decimal(0), Decimal("0.001")) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal("9.99")) + + assert actual.is_within_tolderance(existing, desired) + + +def test_quantity_outside_negative_tolerance_no_match(): + actual = ToleranceOrderReconciler(Decimal(0), Decimal("0.001")) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal("9.989")) + + assert not actual.is_within_tolderance(existing, desired) + + +def test_price_within_positive_tolerance_matches(): + actual = ToleranceOrderReconciler(Decimal("0.001"), Decimal(0)) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal("1.0009"), quantity=Decimal(10)) + + assert actual.is_within_tolderance(existing, desired) + + +def test_price_positive_tolerance_boundary_matches(): + actual = ToleranceOrderReconciler(Decimal("0.001"), Decimal(0)) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal("1.001"), quantity=Decimal(10)) + + assert actual.is_within_tolderance(existing, desired) + + +def test_price_outside_positive_tolerance_no_match(): + actual = ToleranceOrderReconciler(Decimal("0.001"), Decimal(0)) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal("1.0011"), quantity=Decimal(10)) + + assert not actual.is_within_tolderance(existing, desired) + + +def test_price_within_negative_tolerance_matches(): + actual = ToleranceOrderReconciler(Decimal("0.001"), Decimal(0)) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal("0.9991"), quantity=Decimal(10)) + + assert actual.is_within_tolderance(existing, desired) + + +def test_price_negative_tolerance_boundary_matches(): + actual = ToleranceOrderReconciler(Decimal("0.001"), Decimal(0)) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal("0.999"), quantity=Decimal(10)) + + assert actual.is_within_tolderance(existing, desired) + + +def test_price_outside_negative_tolerance_no_match(): + actual = ToleranceOrderReconciler(Decimal("0.001"), Decimal(0)) + existing = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(1), quantity=Decimal(10)) + desired = mango.Order.from_basic_info(mango.Side.BUY, price=Decimal("0.9989"), quantity=Decimal(10)) + + assert not actual.is_within_tolderance(existing, desired) + + +def test_reconcile_no_acceptable_orders(): + existing = [ + mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(99), quantity=Decimal(10)), + mango.Order.from_basic_info(mango.Side.SELL, price=Decimal(101), quantity=Decimal(10)) + ] + desired = [ + mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(100), quantity=Decimal(10)), + mango.Order.from_basic_info(mango.Side.SELL, price=Decimal(102), quantity=Decimal(10)) + ] + actual = ToleranceOrderReconciler(Decimal("0.001"), Decimal("0.001")) + result = actual.reconcile(None, existing, desired) + + assert result.to_place == desired + assert result.to_cancel == existing + assert result.to_keep == [] + assert result.to_ignore == [] + + +def test_reconcile_all_acceptable_orders(): + existing = [ + mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(99), quantity=Decimal(10)), + mango.Order.from_basic_info(mango.Side.SELL, price=Decimal(101), quantity=Decimal(10)) + ] + desired = [ + mango.Order.from_basic_info(mango.Side.BUY, price=Decimal("99.01"), quantity=Decimal(10)), + mango.Order.from_basic_info(mango.Side.SELL, price=Decimal("101.01"), quantity=Decimal(10)) + ] + actual = ToleranceOrderReconciler(Decimal("0.001"), Decimal("0.001")) + result = actual.reconcile(None, existing, desired) + + assert result.to_place == [] + assert result.to_cancel == [] + assert result.to_keep == existing + assert result.to_ignore == desired + + +def test_reconcile_different_list_sizes_orders(): + existing = [ + mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(98), quantity=Decimal(20)), + mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(99), quantity=Decimal(10)), + mango.Order.from_basic_info(mango.Side.SELL, price=Decimal(101), quantity=Decimal(10)), + mango.Order.from_basic_info(mango.Side.SELL, price=Decimal(102), quantity=Decimal(20)) + ] + desired = [ + mango.Order.from_basic_info(mango.Side.BUY, price=Decimal("99.01"), quantity=Decimal(10)), + mango.Order.from_basic_info(mango.Side.SELL, price=Decimal("101.01"), quantity=Decimal(10)) + ] + actual = ToleranceOrderReconciler(Decimal("0.001"), Decimal("0.001")) + result = actual.reconcile(None, existing, desired) + + assert result.to_place == [] + assert len(result.to_cancel) == 2 + assert result.to_cancel[0] == existing[0] + assert result.to_cancel[1] == existing[3] + assert len(result.to_keep) == 2 + assert result.to_keep[0] == existing[1] + assert result.to_keep[1] == existing[2] + assert result.to_ignore == desired + + +def test_reconcile_two_acceptable_two_unacceptable_orders(): + existing = [ + mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(98), quantity=Decimal(20)), + mango.Order.from_basic_info(mango.Side.BUY, price=Decimal(99), quantity=Decimal(10)), + mango.Order.from_basic_info(mango.Side.SELL, price=Decimal(101), quantity=Decimal(10)), + mango.Order.from_basic_info(mango.Side.SELL, price=Decimal(102), quantity=Decimal(20)) + ] + desired = [ + mango.Order.from_basic_info(mango.Side.BUY, price=Decimal("98.1"), quantity=Decimal(20)), + mango.Order.from_basic_info(mango.Side.BUY, price=Decimal("99.01"), quantity=Decimal(10)), + mango.Order.from_basic_info(mango.Side.SELL, price=Decimal("101.01"), quantity=Decimal(10)), + mango.Order.from_basic_info(mango.Side.SELL, price=Decimal("102.11"), quantity=Decimal(20)) + ] + actual = ToleranceOrderReconciler(Decimal("0.001"), Decimal("0.001")) + result = actual.reconcile(None, existing, desired) + + assert len(result.to_place) == 2 + assert len(result.to_cancel) == 2 + assert len(result.to_keep) == 2 + assert len(result.to_ignore) == 2 + + # Desired 1 outcomes + assert result.to_place[0] == desired[0] + assert result.to_cancel[0] == existing[0] + + # Desired 2 outcomes + assert result.to_keep[0] == existing[1] + assert result.to_ignore[0] == desired[1] + + # Desired 3 outcomes + assert result.to_keep[1] == existing[2] + assert result.to_ignore[1] == desired[2] + + # Desired 4 outcomes + assert result.to_place[1] == desired[3] + assert result.to_cancel[1] == existing[3]