Initial import.

This commit is contained in:
Geoff Taylor 2022-02-02 16:50:23 +00:00
commit f2f5c9c6c9
10 changed files with 560 additions and 0 deletions

7
.envrc Normal file
View File

@ -0,0 +1,7 @@
CURRENT_DIRECTORY="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
if [ -d $CURRENT_DIRECTORY/.venv ]; then
export PATH=$CURRENT_DIRECTORY/.venv/bin:$PATH
VENV_PACKAGES=$(python -c "import site; print(site.getsitepackages()[0])")
export PATH=$PATH:$VENV_PACKAGES/bin
fi

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.venv
.vscode
id.json

46
1.Installation.md Normal file
View File

@ -0,0 +1,46 @@
# 🥭 Mango Explorer
## 📦 Installation
_This section is optional - if you know what you're doing please feel free to ignore it_
To get started, clone this repo to a local directory:
```
git clone https://github.com/blockworks-foundation/example-market-maker/
cd example-market-maker
```
Next, set up a Python 'virtual environment' in the '.venv' sub-directory:
```
python3 -m venv .venv
```
This will keep all dependencies isolated and make sure you're consistently running the proper Python executables and libraries.
Now install all the dependencies. There's really only one dependency - `mango-explorer`, a [Python package on Pypi](https://pypi.org/project/mango-explorer) - but as your project grows you may add more dependencies so it's good to start out the right way.
```
pip install -r requirements.txt
```
`mango-explorer` has a lot of useful commands for interacting with Mango, and it's useful to add them to your `$PATH`. (The rest of this guide will assume the commands are in your `$PATH`, so if you don't do this you'll need to adapt the instructions a bit.)
If you have [direnv](https://direnv.net/) installed (it's very handy!) you need to reload the environment after the .venv directory was created:
```
direnv reload
```
**OR**
if you're not running `direnv` you can load the '.envrc' file using:
```
source .envrc
```
You should now be able to run commands from `mango-explorer` without having to specify the full path to the executable, e.g.:
```
mango-explorer-version
```
# 🦮 Support
[🥭 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)

29
2.Wallet.md Normal file
View File

@ -0,0 +1,29 @@
# 🥭 Mango Explorer
## 👛 Set up a devnet wallet
_This section is optional - if you know what you're doing please feel free to ignore it_
A Solana wallet is just a `Keypair` of private and public keys, and you can create a suitable wallet using:
```
generate-keypair
```
This will create a wallet and write it to the file `id.json` - a common location where other `mango-explorer` commands will look for it. (Other ways of storing and sharing keys are possible.)
We're setting this up on 'devnet', Solana's developer network where tokens are free and no money is at risk. We do still need SOL to 'pay' for transactions, but devnet SOL is also free and we can get some by running:
```
airdrop --symbol SOL --quantity 1 --cluster-name devnet
```
Let's also get some devnet USDC while we're at it - we'll use this to allow us to buy and sell Mango 'perps'.
```
airdrop --symbol USDC --quantity 10000 --faucet B87AhxX6BkBsj3hnyHzcerX2WxPoACC7ZyDr8E7H9geN --cluster-name devnet
```
Your wallet should now contain $10,000 devnet USDC and just under 1 SOL (some SOL was used to pay for the USDC airdrop transaction).
# 🦮 Support
[🥭 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)

58
3.MangoAccount.md Normal file
View File

@ -0,0 +1,58 @@
# 🥭 Mango Explorer
# 🥭 Set up a devnet Mango Account
_This section is optional - if you know what you're doing please feel free to ignore it_
Marketmaking, and buying and selling on Mango more generally, requires a Mango Account. This holds the balances of tokens you've moved to Mango as well as your current perp positions. You can create a Mango Account by running:
```
ensure-account --cluster-name devnet
```
In order to use your devnet USDC as collateral for buying and selling perps you need to move it into your Mango Account. To do this, run:
```
deposit --symbol USDC --quantity 10000 --cluster-name devnet
```
Now if you check your Mango Account balances using:
```
show-account-balances --cluster-name devnet
```
You should see something like:
```
Token Balances [DWJhP3shiGDTPDpn5F2mKN47MRowpCG63MgMpsZcEMPs]:
« InstrumentValue: 0.96714968 Pure SOL »
Total Value: « InstrumentValue: 0.00000000 USDC »
⚠ WARNING! ⚠ This is a work-in-progress and these figures may be wrong!
Account Balances [7tgFpqoUVpc8akBJPRTbKY5w4uGzYtkt51cdGr5fDWQM]:
« AccountInstrumentValues USDC
Deposited : « InstrumentValue: 10,000.00000013 USDC »
Borrowed : « InstrumentValue: 0.00000000 USDC »
Unsettled:
Base : « InstrumentValue: 0.00000000 USDC » (« InstrumentValue: 0.00000000 USDC » free)
Quote : « InstrumentValue: 0.00000000 USDC » (« InstrumentValue: 0.00000000 USDC » free)
Perp:
Base : « InstrumentValue: 0.00000000 USDC »
Quote : 0
If Executed:
All Bids : « InstrumentValue: 0.00000000 USDC »
All Asks : « InstrumentValue: 0.00000000 USDC »
Net Value : « InstrumentValue: 10,000.00000013 USDC »
»
Account Total: « InstrumentValue: 10,000.00000013 USDC »
Grand Total: « InstrumentValue: 10,000.00000013 USDC »
```
A couple of things worth noting from this:
* The amount of devnet SOL you have has fallen to 0.96714968 SOL. Creating the Mango Account ties up some SOL for Solana's 'rent' but you can reclaim this SOL when you close your Mango Account.
* The amount of devnet USDC has grown to something like 10,000.00000013 USDC. Interest on balances in Mango Accounts is applied continuously so even the short time it has been in your account that devnet USDC has been hard at work earning interest.
# 🦮 Support
[🥭 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)

263
4.CodeWalkthrough.md Normal file
View File

@ -0,0 +1,263 @@
# 🥭 Mango Explorer
# 🥭 Code Walkthrough
## 🥭 The Goal
Let's start with the most important bit of the code:
```
while True:
context.client.require_data_from_fresh_slot()
model_state = model_state_builder.build(context)
market_maker.pulse(context, model_state)
time.sleep(PULSE_INTERVAL)
```
This is the core marketmaking loop - the code runs this to fetch data, place orders, and then sleep for a bit before repeating.
The rest of the code exists to set things up so the above few lines can run.
## 🥭 Preliminaries
There are a bunch of imports - all needed but not very interesting.
Then there are some constants to define what market the marketmaker runs against, where it fetches prices, and how frequently it places orders:
```
MARKET = "BTC-PERP"
ORACLE = "ftx"
PULSE_INTERVAL = 10
```
Then there's a basic logging setup. This will output a lot of information, so later on you may choose to reduce the log volume by changing, for example, `logging.DEBUG` to `logging.INFO`):
```
mango.setup_logging(logging.DEBUG, False)
```
## 🥭 The Context, Wallet and Account
This section performs some one-off data loading from different sources.
To connect to Solana with `mango-explorer`, you need to set up a `Context` object. There are many possible parameters to `mango.ContextBuilder.build()`, but here we just specify `devnet` and some retry pauses.
```
context = mango.ContextBuilder.build(
cluster_name="devnet", stale_data_pauses_before_retry=[0.1, 0.2, 0.3, 0.5, 1]
)
```
The retry pauses are worth explaining a bit. Fetching from load-balanced Solana RPC nodes sometimes gets 'stale' data from an earlier block or slot. This usually isn't a big deal - if your wallet balance is out of date by a second or two it usually doesn't matter. It can matter a lot with a marketmaker, so this setting configures the `Context` to check to see if the data it has just received comes from a slot earlier than the latest slot it has received data from. If this happens, the `Context` automatically retries your request after a pause. How many times it retries, and how long the pauses are, is governed by the `stale_data_pauses_before_retry` parameter - each value is the duration of a pause after being sent stale data. If the problem persists for all of the retries, a `StaleSlotException` will be raised.
Next, we load the `Wallet`. Here we do it from an `id.json` file but you can hard-code the key if like, or load it from an environment variable if that's your thing.
```
wallet = mango.Wallet.load("id.json")
```
The Mango `Group` and `Account` objects are important data structures. The `Group` holds the shared market listings and data for all users. The `Account` holds the per-user data.
```
group = mango.Group.load(context, context.group_address)
accounts = mango.Account.load_all_for_owner(context, wallet.address, group)
if len(accounts) == 0:
raise Exception(f"No Mango Accounts for wallet {wallet.address}")
account = accounts[0]
```
OK, pedants, a user wallet can have multiple `Account`s which is why we need to load all of them and choose the first one. So the `Account` data is really per-wallet-account rather than strictly per-user, but the principle is: data in `Group` applies to everyone, data in `Account` is specific to that 'sub-account' for that wallet.
## 🥭 The Market
Next up is loading the `Market` data and building the `MarketInstructionBuilder` for that wallet and market.
```
market_stub = context.market_lookup.find_by_symbol(MARKET)
if market_stub is None:
raise Exception(f"Could not find market {MARKET}")
market = mango.ensure_market_loaded(context, market_stub)
instruction_builder = mango.PerpMarketInstructionBuilder(
context, wallet, market, group, account
)
```
The `Market` object holds per-market data (obviously), but the `MarketInstructionBuilder` is a bit more interesting.
A `MarketInstructionBuilder` allows you to build Solana `Instruction`s that will form part of a Solana `Transaction` to be executed. `Instruction`s are how you perform operations in Solana, and the `MarketInstructionBuilder` is a simplified way of creating `Instruction`s specific to that particular market.
Some examples of instruction types a `MarketInstructionBuilder` allows you to build are:
* place order
* cancel order
* settle
* crank
The marketmaker will use these as its way of performing those actions.
It's worth noting however that the marketmaker uses a `MarketInstructionBuilder` but we're building a more specific `PerpMarketInstructionBuilder`. There are other `MarketInstructionBuilder`s too - a `SerumMarketInstructionBuilder` and a `SpotMarketInstructionBuilder`. They have the same interface so allow the same operations to be performed, just on a different market type.
The code here works against a perp market, but it could easily be adapted to work against a spot market instead.
## 🥭 The Oracle
To run a marketmaker we need a way of deciding what price to use. For our purposes we're going to fetch prices from FTX - the `ORACLE` constant was defined up in the 'Preliminaries' section.
```
oracle_provider: mango.OracleProvider = mango.create_oracle_provider(
context, ORACLE
)
oracle = oracle_provider.oracle_for_market(context, market)
```
The `oracle` is an object with (among others) a `fetch_price()` method to fetch the latest price from the oracle source (FTX in this case).
## 🥭 The Marketmaker Chain
This is where things start getting interesting!
A marketmaker wants to have BUY and SELL orders on the market orderbook, at specific prices and quantities.
One way to do that is to repeatedly cancel and replace orders every 'pulse' of the marketmaker.
Another way to do it is to:
* calculate a set of 'desired' orders, and
* check to see if the marketmaker already has those orders on the orderbook, and
* place any orders that don't exist, and cancel any that shouldn't exist
The `mango-explorer` marketmaker takes this second 'desired orders' approach. Desired orders are built by an 'order chain'.
An [order chain](https://github.com/blockworks-foundation/mango-explorer/blob/main/docs/MarketmakingOrderChain.md) is a sequence of `Element` objects, each of which, in turn, gets a chance to see and modify the desired order list before the marketmaker processes them.
The first `Element` is usually the one responsible for creating the orders for subsequent `Elements` to modify, but that's just a convention. Any `Element` can add desired orders.
Our marketmaker is quite simple - it uses a `RatiosElement` to create orders, followed by a `RoundToLotSizeElement` to properly round prices and quantities.
```
ratios_element = RatiosElement(
mango.OrderType.POST_ONLY_SLIDE, [Decimal("0.001")], [Decimal("0.05")], False
)
round_to_lot_size_element = RoundToLotSizeElement()
desired_orders_chain = Chain([ratios_element, round_to_lot_size_element])
```
The `RatiosElement` uses `POST_ONLY_SLIDE` as the order type. This will ensure a marketmaker order never 'crosses the book' and becomes filled, but will instead be placed one tick inside the current best price. (You can instead choose `POST_ONLY` to have the order cancelled if it would cross the spread, or `LIMIT` if you want it to be filled in that situation.)
The next two parameters are arrays of ratio values for:
* spread
* position size
This configuration of `RatiosElement` will create a desired BUY and SELL order with a spread of 0.001, or 0.1%. The quantity will be 0.05, or 5% of your current account value.
Since both the spread and position size parameters are arrays, you can specify multiple values here. For example:
```
ratios_element = RatiosElement(
mango.OrderType.POST_ONLY_SLIDE, [Decimal("0.001"), Decimal("0.003")], [Decimal("0.05"), Decimal("0.1")], False
)
```
would create *two* sets of desired BUY and SELL orders - one set with a spread of 0.1% and a position size of 5%, and a second set with a spread of 0.3% and a position size of 10%. What's important is that the length of both arrays match - it's an error to specify, say, 3 spread ratios but only 2 position size ratios.
The `RoundToLotSizeElement` is comparatively boring. It just rounds the prices and quantities to align with the market's lot sizes.
This only scratches the surface of what `Element`s can do. There are [a lot more possible `Element`s](https://github.com/blockworks-foundation/mango-explorer/blob/main/docs/MarketmakingOrderChain.md) that can be added to your 'order chain', and of course you can create your own too.
## 🥭 Order Reconciliation
At the end of the 'order chain', what comes out is a set of 'desired orders'. The next step is checking the orderbook to see if these orders already exist, or if any need to be added or cancelled. This process is 'order reconciliation'.
Our marketmaker uses a `ToleranceOrderReconciler` to allow a bit of leeway in deciding if a desired order matchs an existing order:
```
order_reconciler = mango.marketmaking.ToleranceOrderReconciler(
Decimal("0.001"), Decimal("0.001")
)
```
The two parameters - both 0.1% - will match an existing order with a desired order if the desired order's price and quantity are within 0.1% of the existing order's price and quantity. If orders match, they are kept. If orders do not match, existing orders are cancelled and new orders are placed.
This happens at a granular level. It's possible for 1 order to match and 3 orders not to match, in which case 1 existing order is kept, 3 existing orders are cancelled and 3 desired orders are placed.
You can adjust the tolerance by changing the parameters to your satisfaction, or you can turn this off entirely instead by using an `AlwaysReplaceOrderReconciler` which will always place every desired order and will never keep any existing orders.
## 🥭 Create the ModelStateBuilder
Every 'pulse' of the marketmaker gathers a fresh set of data to work with. This data includes account balances and prices as well as the orderbook. This data 'model state' is then passed to each `Element` in turn so it can perform whatever calculations it needs to derive the right desired orders.
The model state includes properties such as:
* group
* account
* price
* inventory
* orderbook
* bids
* asks
* top_bid
* top_ask
* spread
Since the model state may be built for every pulse, what we create is a 'factory' for building it - an object with enough data to be able to build the model state on demand.
```
model_state_builder = mango.marketmaking.PerpPollingModelStateBuilder(
account.address, market, oracle, group.address, group.cache
)
```
## 🥭 Create the Marketmaker
With all that done, creating the marketmaker instance is easy:
```
market_maker = mango.marketmaking.MarketMaker(
wallet,
market,
instruction_builder,
desired_orders_chain,
order_reconciler,
None,
)
```
## 🥭 Pulse the Marketmaker
Finally! Finally we get to the code mentioned at the very beginning:
```
while True:
context.client.require_data_from_fresh_slot()
model_state = model_state_builder.build(context)
market_maker.pulse(context, model_state)
time.sleep(PULSE_INTERVAL)
```
A simple loop that runs forever, or until there's an error. (The code here will exit when there's an error, and that's maybe what you want during development. In production you will probably prefer different error logging and handling.)
The loop does 4 things:
1. Tells our 'stale data' monitor that we want data fresher than any it has seen before. (This is after a pause so the latest slot we know of is already stale.)
2. Builds a fresh model state from the `ModelStateBuilder` created earlier.
3. 'Pulses' the marketmaker with this fresh model state. This, in turn, calls each `Element` in the order chain, performs the order reconciliation, and sends the order place and cancel instructions.
4. Pauses for `PULSE_INTERVAL` seconds.
## 🥭 Cleaning Up
If there's an error or the user presses Control-C, the code stops. `KeyboardInterrupt` is ignored but any error is printed out.
Then it's a matter of cancelling any existing orders before exiting.
The `payer` is a special 'signing' datastructure that is used to sign transactions before sending them to Solana.
```
payer = mango.CombinableInstructions.from_wallet(wallet)
```
The `PerpMarketInstructionBuilder` has a special instruction for cancelling all orders - this isn't available in Spot and Serum markets, so to work there you'd need to fetch the account's current orders and cancel them individually.
```
cancel_all = instruction_builder.build_cancel_all_orders_instructions()
```
Both `payer` and `cancel_all` are `CombinableInstructions`, so to execute them you can add them together and then call `execute()` on the result:
```
cancel_all_signatures = (payer + cancel_all).execute(context)
```
# 🦮 Support
[🥭 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)

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Geoff Taylor
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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.

44
README.md Normal file
View File

@ -0,0 +1,44 @@
# 🥭 Mango Explorer
## 📖 Introduction
This guide will show you how to load and run a customisable marketmaker that runs on [Mango Markets](https://mango.markets) using the [mango-explorer](https://github.com/blockworks-foundation/mango-explorer/) library.
There are plenty of ways to do this. This document shows only one possible approach.
## 🪜 Prerequisites
1. [Installation and Dependencies](1.Installation.md) - shows you how to set up a Python virtual environment and pip install `mango-explorer`. (_Optional - feel free to skip if you're comfortable doing this on your own._)
2. [Devnet Wallet Creation](2.Wallet.md) - shows you how to create a Solana `Keypair` file and prepare it for devnet with some devnet SOL and devnet USDC. (_Optional - feel free to skip if you're comfortable doing this on your own._)
3. [Devnet Mango Account Creation](3.MangoAccount.md) - shows you how to create a Mango Account on devnet and deposit devnet USDC into it. (_Optional - feel free to skip if you're comfortable doing this on your own._)
4. [Code Walkthrough](4.CodeWalkthrough.md) - takes you through the code in `marketmaker.py` line by line. (_Optional - feel free to skip if you're comfortable doing this on your own._)
# 🏃 Running the Marketmaker
That's a lot of setup to get you to this stage but some of it was skippable if you already had a Python venv, Solana wallet and a Mango Account. And if you didn't, you do now!
You can now start the marketmaker by running:
```
python marketmaker.py
```
No parameters are required - all the parameters and options for running the marketmaker are in the code.
When you run it you should see a lot of output, with large 'pulses' of output every 10 seconds or so. (You can tweak the volume of logging and the pulse interval in the code.)
# 🛵 Next Steps
If you've got this far, congratulations! You're now running a marketmaker on devnet.
Things you can do now:
* experiment with different parameters to see how that changes the orders.
* experiment with different `Element`s to filter orders or bias prices or quantities in certain circumstances. (Want to shift the prices in your orders if you've built up too much inventory? Can do!)
* create your own custom `Element`s to change order quantities or prices based on new criteria. (Want to widen the spread when volatility is high? Create a custom `Element`!)
# 🦮 Support
[🥭 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)

88
marketmaker.py Normal file
View File

@ -0,0 +1,88 @@
import logging
import mango
import mango.marketmaking
import time
from datetime import datetime
from decimal import Decimal
from mango.marketmaking.orderchain.chain import Chain
from mango.marketmaking.orderchain.ratioselement import RatiosElement
from mango.marketmaking.orderchain.roundtolotsizeelement import RoundToLotSizeElement
MARKET = "BTC-PERP"
ORACLE = "ftx"
PULSE_INTERVAL = 10
mango.setup_logging(logging.DEBUG, False)
print("Started at", datetime.now())
print("Press Control-C to quit")
try:
context = mango.ContextBuilder.build(
cluster_name="devnet", stale_data_pauses_before_retry=[0.1, 0.2, 0.3, 0.5, 1]
)
wallet = mango.Wallet.load("id.json")
group = mango.Group.load(context, context.group_address)
accounts = mango.Account.load_all_for_owner(context, wallet.address, group)
if len(accounts) == 0:
raise Exception(f"No Mango Accounts for wallet {wallet.address}")
account = accounts[0]
# Set up the market
market_stub = context.market_lookup.find_by_symbol(MARKET)
if market_stub is None:
raise Exception(f"Could not find market {MARKET}")
market = mango.ensure_market_loaded(context, market_stub)
instruction_builder = mango.PerpMarketInstructionBuilder(
context, wallet, market, group, account
)
# Set up the oracle
oracle_provider: mango.OracleProvider = mango.create_oracle_provider(
context, ORACLE
)
oracle = oracle_provider.oracle_for_market(context, market)
# Set up the marketmaker chain
ratios_element = RatiosElement(
mango.OrderType.POST_ONLY_SLIDE, [Decimal("0.001")], [Decimal("0.05")], False
)
round_to_lot_size_element = RoundToLotSizeElement()
desired_orders_chain = Chain([ratios_element, round_to_lot_size_element])
order_reconciler = mango.marketmaking.ToleranceOrderReconciler(
Decimal("0.001"), Decimal("0.001")
)
model_state_builder = mango.marketmaking.PerpPollingModelStateBuilder(
account.address, market, oracle, group.address, group.cache
)
market_maker = mango.marketmaking.MarketMaker(
wallet,
market,
instruction_builder,
desired_orders_chain,
order_reconciler,
None,
)
while True:
context.client.require_data_from_fresh_slot()
model_state = model_state_builder.build(context)
market_maker.pulse(context, model_state)
time.sleep(PULSE_INTERVAL)
except KeyboardInterrupt:
pass
except Exception as ex:
print(ex)
payer = mango.CombinableInstructions.from_wallet(wallet)
cancel_all = instruction_builder.build_cancel_all_orders_instructions()
cancel_all_signatures = (payer + cancel_all).execute(context)
print(f"Cleaning up - cancelling all perp orders: {cancel_all_signatures}")
print("\nStopped at", datetime.now())

1
requirements.txt Normal file
View File

@ -0,0 +1 @@
mango-explorer