Added hedger command. Had to introduce a LotSizeConverter - may expand its use now it's available.

This commit is contained in:
Geoff Taylor 2021-07-28 17:43:58 +01:00
parent 373392cf78
commit ab3657958d
9 changed files with 267 additions and 15 deletions

133
bin/hedger Executable file
View File

@ -0,0 +1,133 @@
#!/usr/bin/env pyston3
import argparse
import logging
import os
import os.path
import rx
import rx.operators
import sys
import threading
import traceback
from decimal import Decimal
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), "..")))
import mango # nopep8
import mango.layouts # nopep8
parser = argparse.ArgumentParser(
description="Hedges perp purchases by trading the underlying in the opposite direction.")
mango.ContextBuilder.add_command_line_parameters(parser)
mango.Wallet.add_command_line_parameters(parser)
parser.add_argument("--market", type=str, required=True, help="perp market symbol to hedge (e.g. ETH-PERP)")
parser.add_argument("--account-index", type=int, default=0,
help="index of the account to use, if more than one available")
parser.add_argument("--max-price-slippage-factor", type=Decimal, default=Decimal("0.05"),
help="the maximum value the IOC hedging order price can slip by (default is 0.05 for 5%)")
parser.add_argument("--notify-errors", type=mango.parse_subscription_target, action="append", default=[],
help="The notification target for error events")
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)
for notify in args.notify_errors:
handler = mango.NotificationHandler(notify)
handler.setLevel(logging.ERROR)
logging.getLogger().addHandler(handler)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
logger: logging.Logger = logging.getLogger("Hedger")
context = mango.ContextBuilder.from_command_line_parameters(args)
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]
disposer = mango.DisposePropagator()
manager = mango.WebSocketSubscriptionManager()
disposer.add_disposable(manager)
buy_price_adjustment_factor: Decimal = Decimal("1") + args.max_price_slippage_factor
sell_price_adjustment_factor: Decimal = Decimal("1") - args.max_price_slippage_factor
watched_market_symbol = args.market.upper()
watched_market_stub = context.market_lookup.find_by_symbol(watched_market_symbol)
if watched_market_stub is None:
raise Exception(f"Could not find market {watched_market_symbol}")
ensured_watched_market = mango.ensure_market_loaded(context, watched_market_stub)
if not isinstance(ensured_watched_market, mango.PerpMarket):
raise Exception(f"Market {watched_market_symbol} is not a perp market.")
watched_market: mango.PerpMarket = ensured_watched_market
hedging_market_symbol = f"{watched_market.base.symbol}/USDC"
hedging_market_stub = context.market_lookup.find_by_symbol(hedging_market_symbol)
if hedging_market_stub is None:
raise Exception(f"Could not find market {hedging_market_symbol}")
hedging_market = mango.ensure_market_loaded(context, hedging_market_stub)
if not isinstance(hedging_market, mango.SpotMarket):
raise Exception(f"Market {hedging_market_symbol} is not a spot market.")
hedging_market_operations: mango.MarketOperations = mango.create_market_operations(
context, wallet, args.dry_run, hedging_market)
initial: mango.PerpEventQueue = mango.PerpEventQueue.load(
context, watched_market.underlying_perp_market.event_queue, watched_market.lot_size_converter)
splitter: mango.UnseenPerpEventChangesTracker = mango.UnseenPerpEventChangesTracker(initial)
event_splitting_subscription = mango.WebSocketAccountSubscription(
context, watched_market.underlying_perp_market.event_queue, lambda account_info: mango.PerpEventQueue.parse(account_info, watched_market.lot_size_converter))
manager.add(event_splitting_subscription)
publisher = event_splitting_subscription.publisher.pipe(
rx.operators.observe_on(context.pool_scheduler),
rx.operators.flat_map(splitter.unseen),
rx.operators.filter(lambda event: isinstance(event, mango.PerpFillEvent)),
rx.operators.filter(lambda event: (event.maker == account.address) or (event.taker == account.address)),
rx.operators.catch(mango.observable_pipeline_error_reporter),
rx.operators.retry()
)
def hedge(event: mango.PerpFillEvent) -> None:
if event.maker == event.taker:
logger.info(f"Ignoring self-trade of {event.quantity:,.8f} at {event.price:,.8f} on {watched_market.symbol}.")
return
opposite_side: mango.Side = mango.Side.BUY if event.side == mango.Side.SELL else mango.Side.SELL
price_adjustment_factor: Decimal = sell_price_adjustment_factor if opposite_side == mango.Side.SELL else buy_price_adjustment_factor
adjusted_price: Decimal = event.price * price_adjustment_factor
quantity: Decimal = event.quantity
order: mango.Order = mango.Order.from_basic_info(opposite_side, adjusted_price, event.quantity, mango.OrderType.IOC)
logger.info(f"Hedging {event.side} of {event.quantity:,.8f} at {event.price:,.8f} on {watched_market.symbol} with {opposite_side} of {quantity:,.8f} at {adjusted_price:,.8f} on {hedging_market.symbol}\n\t{order}")
try:
hedging_market_operations.place_order(order)
except Exception as exception:
logger.error(f"Failed to hedge using order {order} - {exception} - {traceback.format_exc()}")
publisher.subscribe(on_next=hedge)
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_pong_disposable = ws.pong.subscribe(mango.FileToucherObserver("/var/tmp/mango_healthcheck_ws_pong"))
ws.open()
# Wait - don't exit. Exiting will be handled by signals/interrupts.
waiter = threading.Event()
try:
waiter.wait()
except:
pass
logging.info("Shutting down...")
ws.close()
disposer.dispose()
logging.info("Shutdown complete.")

28
bin/show-market Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env pyston3
import argparse
import logging
import os
import os.path
import sys
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), "..")))
import mango # nopep8
parser = argparse.ArgumentParser(description="Shows all properties of a given market.")
mango.ContextBuilder.add_command_line_parameters(parser)
parser.add_argument("--market", type=str, required=True, help="market symbol to buy (e.g. ETH/USDC)")
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
context = mango.ContextBuilder.from_command_line_parameters(args)
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}")
actual_market = mango.ensure_market_loaded(context, market)
print(actual_market)

View File

@ -43,10 +43,12 @@ if args.account_type.upper() == "ACCOUNTINFO":
manager.add(raw_subscription)
publisher: rx.Observable = raw_subscription.publisher
elif args.account_type.upper() == "PERPEVENTS":
initial: mango.PerpEventQueue = mango.PerpEventQueue.load(context, args.address)
# It'd be nice to get the market's lot size converter, but we don't have its address yet.
lot_size_converter: mango.LotSizeConverter = mango.NullLotSizeConverter()
initial: mango.PerpEventQueue = mango.PerpEventQueue.load(context, args.address, lot_size_converter)
splitter: mango.UnseenPerpEventChangesTracker = mango.UnseenPerpEventChangesTracker(initial)
event_splitting_subscription = mango.WebSocketAccountSubscription(
context, args.address, mango.PerpEventQueue.parse)
context, args.address, lambda account_info: mango.PerpEventQueue.parse(account_info, lot_size_converter))
manager.add(event_splitting_subscription)
publisher = event_splitting_subscription.publisher.pipe(rx.operators.flat_map(splitter.unseen))
else:

View File

@ -22,6 +22,7 @@ from .instructiontype import InstructionType
from .liquidatablereport import LiquidatableState, LiquidatableReport
from .liquidationevent import LiquidationEvent
from .liquidationprocessor import LiquidationProcessor, LiquidationProcessorState
from .lotsizeconverter import LotSizeConverter, NullLotSizeConverter
from .market import InventorySource, Market
from .marketinstructionbuilder import MarketInstructionBuilder, NullMarketInstructionBuilder
from .marketlookup import MarketLookup, NullMarketLookup, CompoundMarketLookup

View File

@ -24,6 +24,7 @@ from .addressableaccount import AddressableAccount
from .context import Context
from .group import Group
from .layouts import layouts
from .lotsizeconverter import NullLotSizeConverter
from .openorders import OpenOrders
from .perpeventqueue import PerpEventQueue
from .perpmarketdetails import PerpMarketDetails
@ -50,7 +51,7 @@ def build_account_info_converter(context: Context, account_type: str) -> typing.
elif account_type_upper == "OPENORDERS":
return lambda account_info: OpenOrders.parse(account_info, Decimal(6), Decimal(6))
elif account_type_upper == "PERPEVENTQUEUE":
return lambda account_info: PerpEventQueue.parse(account_info)
return lambda account_info: PerpEventQueue.parse(account_info, NullLotSizeConverter())
elif account_type_upper == "SERUMEVENTQUEUE":
return lambda account_info: SerumEventQueue.parse(account_info)
elif account_type_upper == "PERPMARKETDETAILS":

74
mango/lotsizeconverter.py Normal file
View File

@ -0,0 +1,74 @@
# # ⚠ 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)
from decimal import Decimal
from .token import Token
# # 🥭 LotSizeConverter class
#
class LotSizeConverter():
def __init__(self, base: Token, base_lot_size: Decimal, quote: Token, quote_lot_size: Decimal):
self.base: Token = base
self.base_lot_size: Decimal = base_lot_size
self.quote: Token = quote
self.quote_lot_size: Decimal = quote_lot_size
def price_lots_to_native(self, price_lots: Decimal) -> Decimal:
return (self.quote_lot_size * price_lots) / self.base_lot_size
def quantity_lots_to_native(self, quantity_lots: Decimal) -> Decimal:
return self.base_lot_size * quantity_lots
def price_lots_to_value(self, price_lots: Decimal) -> Decimal:
native_to_ui: Decimal = 10 ** (self.base.decimals - self.quote.decimals)
lots_to_native: Decimal = self.quote_lot_size / self.base_lot_size
return (price_lots * lots_to_native) * native_to_ui
def quantity_lots_to_value(self, quantity_lots: Decimal) -> Decimal:
return self.base.shift_to_native(quantity_lots * self.base_lot_size)
def __str__(self):
return f"« 𝙻𝚘𝚝𝚂𝚒𝚣𝚎𝙲𝚘𝚗𝚟𝚎𝚛𝚝𝚎𝚛 [base lot size: {self.base_lot_size}, quote lot size: {self.quote_lot_size}] »"
def __repr__(self) -> str:
return f"{self}"
# # 🥭 NullLotSizeConverter class
#
class NullLotSizeConverter(LotSizeConverter):
def __init__(self):
super().__init__(Decimal(1), Decimal(1))
def price_lots_to_native(self, price_lots: Decimal) -> Decimal:
return price_lots
def quantity_lots_to_native(self, quantity_lots: Decimal) -> Decimal:
return quantity_lots
def price_lots_to_value(self, price_lots: Decimal) -> Decimal:
return price_lots
def quantity_lots_to_value(self, quantity_lots: Decimal) -> Decimal:
return quantity_lots
def __str__(self):
return "« 𝙽𝚞𝚕𝚕𝙻𝚘𝚝𝚂𝚒𝚣𝚎𝙲𝚘𝚗𝚟𝚎𝚛𝚝𝚎𝚛 »"

View File

@ -25,6 +25,7 @@ from .accountinfo import AccountInfo
from .addressableaccount import AddressableAccount
from .context import Context
from .layouts import layouts
from .lotsizeconverter import LotSizeConverter
from .metadata import Metadata
from .orders import Side
from .version import Version
@ -130,13 +131,15 @@ class PerpUnknownEvent(PerpEvent):
#
# `event_builder()` takes an event layout and returns a typed `PerpEvent`.
#
def event_builder(event_layout) -> typing.Optional[PerpEvent]:
def event_builder(lot_size_converter: LotSizeConverter, event_layout) -> typing.Optional[PerpEvent]:
if event_layout.event_type == b'\x00':
if event_layout.maker is None and event_layout.taker is None:
return None
side: Side = Side.BUY if event_layout.side == pyserum.enums.Side.BUY else Side.SELL
quantity: Decimal = lot_size_converter.quantity_lots_to_value(event_layout.quantity)
price: Decimal = lot_size_converter.price_lots_to_value(event_layout.price)
return PerpFillEvent(event_layout.event_type, event_layout.timestamp, side,
event_layout.price, event_layout.quantity, event_layout.best_initial,
price, quantity, event_layout.best_initial,
event_layout.maker_slot, event_layout.maker_out, event_layout.maker,
event_layout.maker_order_id, event_layout.maker_client_order_id,
event_layout.taker, event_layout.taker_order_id,
@ -166,27 +169,30 @@ class PerpEventQueue(AddressableAccount):
self.events: typing.Sequence[typing.Optional[PerpEvent]] = events
@staticmethod
def from_layout(layout: layouts.PERP_EVENT_QUEUE, account_info: AccountInfo, version: Version) -> "PerpEventQueue":
def from_layout(layout: layouts.PERP_EVENT_QUEUE, account_info: AccountInfo, version: Version, lot_size_converter: LotSizeConverter) -> "PerpEventQueue":
meta_data: Metadata = Metadata.from_layout(layout.meta_data)
head: Decimal = layout.head
count: Decimal = layout.count
seq_num: Decimal = layout.seq_num
events: typing.Sequence[typing.Optional[PerpEvent]] = list(map(event_builder, layout.events))
events: typing.List[typing.Optional[PerpEvent]] = []
for raw_event in layout.events:
built_event = event_builder(lot_size_converter, raw_event)
events += [built_event]
return PerpEventQueue(account_info, version, meta_data, head, count, seq_num, events)
@ staticmethod
def parse(account_info: AccountInfo) -> "PerpEventQueue":
def parse(account_info: AccountInfo, lot_size_converter: LotSizeConverter) -> "PerpEventQueue":
# Data length isn't fixed so can't check we get the right value the way we normally do.
layout = layouts.PERP_EVENT_QUEUE.parse(account_info.data)
return PerpEventQueue.from_layout(layout, account_info, Version.V1)
return PerpEventQueue.from_layout(layout, account_info, Version.V1, lot_size_converter)
@ staticmethod
def load(context: Context, address: PublicKey) -> "PerpEventQueue":
def load(context: Context, address: PublicKey, lot_size_converter: LotSizeConverter) -> "PerpEventQueue":
account_info = AccountInfo.load(context, address)
if account_info is None:
raise Exception(f"PerpEventQueue account not found at address '{address}'")
return PerpEventQueue.parse(account_info)
return PerpEventQueue.parse(account_info, lot_size_converter)
@property
def capacity(self) -> int:

View File

@ -20,6 +20,7 @@ from solana.publickey import PublicKey
from .accountinfo import AccountInfo
from .context import Context
from .group import Group
from .lotsizeconverter import LotSizeConverter
from .market import Market, InventorySource
from .orderbookside import OrderBookSide
from .orders import Order
@ -37,6 +38,8 @@ class PerpMarket(Market):
def __init__(self, address: PublicKey, base: Token, quote: Token, underlying_perp_market: PerpMarketDetails):
super().__init__(address, InventorySource.ACCOUNT, base, quote)
self.underlying_perp_market: PerpMarketDetails = underlying_perp_market
self.lot_size_converter: LotSizeConverter = LotSizeConverter(
base, underlying_perp_market.base_lot_size, quote, underlying_perp_market.quote_lot_size)
@property
def symbol(self) -> str:
@ -47,7 +50,8 @@ class PerpMarket(Market):
return self.underlying_perp_market.group
def unprocessed_events(self, context: Context) -> typing.Sequence[PerpEvent]:
event_queue: PerpEventQueue = PerpEventQueue.load(context, self.underlying_perp_market.event_queue)
event_queue: PerpEventQueue = PerpEventQueue.load(
context, self.underlying_perp_market.event_queue, self.lot_size_converter)
return event_queue.unprocessed_events()
def accounts_to_crank(self, context: Context, additional_account_to_crank: typing.Optional[PublicKey]) -> typing.Sequence[PublicKey]:
@ -77,7 +81,10 @@ class PerpMarket(Market):
return [*bid_side.orders(), *ask_side.orders()]
def __str__(self) -> str:
return f"« 𝙿𝚎𝚛𝚙𝚜𝙼𝚊𝚛𝚔𝚎𝚝 {self.symbol} [{self.address}] »"
underlying: str = f"{self.underlying_perp_market}".replace("\n", "\n ")
return f"""« 𝙿𝚎𝚛𝚙𝙼𝚊𝚛𝚔𝚎𝚝 {self.symbol} [{self.address}]
{underlying}
»"""
# # 🥭 PerpMarketStub class

View File

@ -44,8 +44,8 @@ class Token:
return round(shifted, int(self.decimals))
def shift_to_native(self, value: Decimal) -> Decimal:
divisor = Decimal(10 ** self.decimals)
shifted = value * divisor
multiplier = Decimal(10 ** self.decimals)
shifted = value * multiplier
return round(shifted, 0)
def symbol_matches(self, symbol: str) -> bool: