341 lines
11 KiB
Python
341 lines
11 KiB
Python
"""# Automatic Crypto Seller.
|
|
|
|
Crypto Seller, I am going into finance, and I want only your strongest tokens.
|
|
|
|
This program is a wrapper around
|
|
[`fin_depo`](https://gitfub.space/Jmaa/fin-depo/) for automatically trading
|
|
one-way trades, by using repeated trades over a period. Can be used to slowly
|
|
enter or exit positions, for example low-volume crypto currencies, or as an
|
|
automated investment savings system.
|
|
|
|
Supported sites:
|
|
|
|
- [KuCoin](https://www.kucoin.com/): Online crypto-currency exchange.
|
|
|
|
## Installation
|
|
|
|
Install dependencies:
|
|
|
|
```shell
|
|
pip install -r requirements.txt
|
|
```
|
|
|
|
## Usage
|
|
|
|
1. Create configuration file. See below and in `examples/` folder for inspiration.
|
|
2. [Create KuCoin API key](https://www.kucoin.com/account/api) with **General**
|
|
and **Spot Trading** permissions.
|
|
3. Add KuCoin API key secrets (`KUCOIN_KEY`, `KUCOIN_SECRET`, `KUCOIN_PASS`) to
|
|
an [`secret_loader` accessible
|
|
location](https://gitfub.space/Jmaa/secret_loader#user-content-accessible) .
|
|
4. Run script using `python -m crypto_seller --config CONFIG_FILE`.
|
|
|
|
The script will now automatically sell assets over time. The interval between
|
|
sell-offs are randomized to avoid front-running, and the amounts are also
|
|
randomized to avoid certain attacks (only relevant for smallish intervals of
|
|
less than a week).
|
|
|
|
The log will both be displayed in the shell, and be placed in a log file
|
|
`output/log.txt`. Every sell-off will be logged to the `output/trades.csv`
|
|
file, with as much information as possible. Keep both of these for tax
|
|
purposes, if relevant.
|
|
|
|
## Configuration
|
|
|
|
Example configurations can be found in the `examples/` folder.
|
|
|
|
Configurations are `.json` files, with the following fields:
|
|
|
|
```json
|
|
{
|
|
"input_asset": str,
|
|
"output_asset": str,
|
|
"input_amount_low": float,
|
|
"input_amount_high": float,
|
|
"interval_minutes_low": int,
|
|
"interval_minutes_high": int
|
|
}
|
|
```
|
|
|
|
While `input_amount_low` and `input_amount_high` can be identical (and
|
|
the same for `interval_minutes_XX`) it is discouraged, to avoid frontrunning attacks.
|
|
|
|
## Auditing information
|
|
|
|
As mentioned before, this program is mostly glue code and wrappering around
|
|
library functionality. The glue application code allows for running the main
|
|
loop of checking balance, selling amount and sleeping until the next iteration,
|
|
with some argument and configuration parsing thrown in.
|
|
|
|
The most relevant libraries for auditing are:
|
|
|
|
- [`fin_depo`](https://gitfub.space/Jmaa/fin-depo): Library for programmatic
|
|
fetching of depository assets and in some cases allows for order placement.
|
|
This is the library that reads balances and places market orders.
|
|
- [`fin_defs`](https://gitfub.space/Jmaa/fin-defs): Definitions of financial
|
|
assets and instruments. Used by `fin_depo`.
|
|
- [`secret_loader`](https://gitfub.space/Jmaa/secret_loader): Library for
|
|
loading of secrets (passwords, API keys, secret keys, configuration files,
|
|
etc.) from standardized locations.
|
|
- [`python-kucoin`](https://python-kucoin.readthedocs.io/en/latest/): Used by
|
|
`fin_depo` to provide KuCoin backend.
|
|
|
|
## Taxation
|
|
|
|
Some parts of the world suffers from weird and draconian taxation rules on
|
|
cryptocurrencies, so it helps to keep track of the relevant trading
|
|
information.
|
|
|
|
As mentioned above, keep (and backup) both the log file (`output/log.txt`) and
|
|
the trades file (`output/trades.csv`).
|
|
|
|
To help with tax reporting, it might be useful to sign up to tax oriented
|
|
websites. For example, [CryptoSkat](https://cryptoskat.dk/) seems to be the
|
|
most mature on the danish market, and does support KuCoin.
|
|
|
|
## TODO
|
|
|
|
- [ ] Allow multiple secrets configs
|
|
- [ ] Allow multiple output directories
|
|
- [ ] Fix `TimeoutError` issue occuring from slow Kucoin endpoint. Might
|
|
require implementing own kucoin backend in `fin_depo`.
|
|
- [ ] Ensure that a failure during selling results in a safe winding down of the system.
|
|
* Catch runtime errors when selling
|
|
* Show status
|
|
* Show errors to log.
|
|
* Stop loop and exit with results, and error indicator.
|
|
- [X] Present an overview of what the script will be doing
|
|
* Sell what for what? How much, how often?
|
|
* Give an estimate of how long it will take.
|
|
* Wait 20 seconds before starting, to allow the user to review.
|
|
- [X] Document command-line arguments
|
|
- [X] Document configuration
|
|
- [X] Document code auditing
|
|
- [X] Parse configuration from json.
|
|
- [X] Ensure sell time is included in order details
|
|
- [X] Log all trades to CSV file.
|
|
- [X] Collect information during the run and output after run
|
|
|
|
"""
|
|
|
|
__all__ = ['__version__', 'run_auto_sell']
|
|
|
|
import dataclasses
|
|
import datetime
|
|
import logging
|
|
import random
|
|
from collections.abc import Callable
|
|
from decimal import ROUND_DOWN, Decimal
|
|
from typing import Any
|
|
|
|
import fin_defs
|
|
import fin_depo
|
|
|
|
from ._version import __version__
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
################################################################################
|
|
# Main code #
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class AutoSellConfig:
|
|
"""Configuration for `run_auto_sell`.
|
|
|
|
Fields:
|
|
|
|
- `interval_range`: The range to determine sell-off intervals from. After
|
|
each sell-off it will sleep an amount of time uniformly sampled from this
|
|
range.
|
|
- `input_asset`: Asset to sell.
|
|
- `input_amount_range`: Range to uniformly sample sell-off sizes from.
|
|
- `output_asset`: Asset to "buy".
|
|
- `seller`: Depository seller to use.
|
|
- `sleep`: Sleep method to use.
|
|
- `exit_when_empty`: Whether the system should stop once there is nothing
|
|
more to sell. Recommended.
|
|
"""
|
|
|
|
interval_range: tuple[datetime.timedelta, datetime.timedelta]
|
|
input_asset: fin_defs.Asset
|
|
input_amount_range: tuple[Decimal, Decimal]
|
|
output_asset: fin_defs.Asset
|
|
seller: fin_depo.defi_kucoin.KucoinDepoFetcher
|
|
sleep: Callable[[float], None]
|
|
log_order_to_csv: Callable[[fin_depo.data.TradeOrderDetails], None]
|
|
exit_when_empty: bool = True
|
|
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class AutoSellRunResults:
|
|
"""Result information after `run_auto_sell`.
|
|
|
|
Fields:
|
|
- `order_details`: The list of order details.
|
|
- `total_duration`: Total duration of the run.
|
|
- `total_sleep_duration`: Amount of time spent waiting between sell-offs.
|
|
"""
|
|
|
|
order_details: list[fin_depo.data.TradeOrderDetails]
|
|
total_duration: datetime.timedelta
|
|
total_sleep_duration: datetime.timedelta
|
|
|
|
def total_input_amount(self) -> Decimal:
|
|
"""Total amount of assets sold."""
|
|
return sum((o.input_amount for o in self.order_details), start=Decimal(0))
|
|
|
|
def total_output_amount(self) -> Decimal:
|
|
"""Total amount of assets "bought"."""
|
|
return sum((o.output_amount for o in self.order_details), start=Decimal(0))
|
|
|
|
|
|
def sample_from_range(rng: random.Random, rang: tuple[Any, Any]) -> Any:
|
|
"""Samples uniformly from the given range."""
|
|
multiplier: float | Decimal = rng.random()
|
|
if isinstance(rang[0], Decimal):
|
|
multiplier = Decimal(multiplier)
|
|
return rang[0] + (rang[1] - rang[0]) * multiplier
|
|
|
|
|
|
ROUND_TO_WHOLE = Decimal('1')
|
|
|
|
|
|
def run_auto_sell(
|
|
config: AutoSellConfig,
|
|
initial_rounds_to_skip: int = 0,
|
|
) -> AutoSellRunResults:
|
|
"""Executes the sell-off.
|
|
|
|
Sell-offs are performed in rounds of sizes and with intervals randomly
|
|
based on the given configuration.
|
|
"""
|
|
rng = random.SystemRandom()
|
|
|
|
time_start = datetime.datetime.now(tz=datetime.UTC)
|
|
all_executed_orders: list[fin_depo.data.TradeOrderDetails] = []
|
|
total_sleep_duration = datetime.timedelta(seconds=0)
|
|
|
|
while True:
|
|
# Check that account has tokens.
|
|
input_amount_available = config.seller.get_depo().get_amount_of_asset(
|
|
config.input_asset,
|
|
)
|
|
logger.info(
|
|
'Currently own %s %s',
|
|
input_amount_available,
|
|
config.input_asset.raw_short_name(),
|
|
)
|
|
|
|
if initial_rounds_to_skip > 0:
|
|
initial_rounds_to_skip -= 1
|
|
logger.info('skipping this round')
|
|
elif input_amount_available > 0:
|
|
amount_to_sell = sample_from_range(rng, config.input_amount_range)
|
|
amount_to_sell = min(input_amount_available, amount_to_sell)
|
|
amount_to_sell = amount_to_sell.quantize(
|
|
ROUND_TO_WHOLE,
|
|
rounding=ROUND_DOWN,
|
|
)
|
|
logger.info('Attempting to sell %s %s', amount_to_sell, config.input_asset)
|
|
|
|
order_details = config.seller.place_market_order(
|
|
config.input_asset,
|
|
amount_to_sell,
|
|
config.output_asset,
|
|
)
|
|
|
|
config.log_order_to_csv(order_details)
|
|
all_executed_orders.append(order_details)
|
|
|
|
del amount_to_sell
|
|
elif config.exit_when_empty:
|
|
break
|
|
|
|
# Time out
|
|
time_to_sleep = sample_from_range(rng, config.interval_range)
|
|
time_to_sleep_secs = time_to_sleep.total_seconds()
|
|
logger.info('Sleeping %s (%d seconds)', time_to_sleep, time_to_sleep_secs)
|
|
config.sleep(time_to_sleep_secs)
|
|
total_sleep_duration += time_to_sleep
|
|
|
|
del input_amount_available
|
|
|
|
time_end = datetime.datetime.now(tz=datetime.UTC)
|
|
|
|
return AutoSellRunResults(
|
|
all_executed_orders,
|
|
total_duration=time_end - time_start,
|
|
total_sleep_duration=total_sleep_duration,
|
|
)
|
|
|
|
|
|
def log_estimates(config: AutoSellConfig):
|
|
current_balance = config.seller.get_depo().get_amount_of_asset(
|
|
config.input_asset,
|
|
)
|
|
|
|
average_sleep = (config.interval_range[0] + config.interval_range[1]) / 2
|
|
average_amount = (config.input_amount_range[0] + config.input_amount_range[1]) / 2
|
|
|
|
expected_num_sell_offs = current_balance / average_amount
|
|
expected_duration = average_sleep * float(expected_num_sell_offs)
|
|
|
|
fastest_duration = config.interval_range[0] * float(
|
|
current_balance / config.input_amount_range[1],
|
|
)
|
|
slowest_duration = config.interval_range[1] * float(
|
|
current_balance / config.input_amount_range[0],
|
|
)
|
|
|
|
logger.info('Welcome to crypto seller!')
|
|
config.sleep(1)
|
|
logger.info('')
|
|
config.sleep(1)
|
|
logger.info('I, the great crypto seller, your humble servant, will')
|
|
logger.info('now analyse your configuration and divine some estimates!')
|
|
config.sleep(1)
|
|
logger.info('')
|
|
config.sleep(1)
|
|
logger.info(
|
|
'- Current balance: %s %s',
|
|
current_balance,
|
|
config.input_asset.raw_short_name(),
|
|
)
|
|
config.sleep(1)
|
|
logger.info('- Average sleep: %s seconds', average_sleep)
|
|
config.sleep(1)
|
|
logger.info(
|
|
'- Average amount: %s %s',
|
|
average_amount,
|
|
config.input_asset.raw_short_name(),
|
|
)
|
|
config.sleep(1)
|
|
logger.info('- Expected counts: %s', expected_num_sell_offs)
|
|
config.sleep(1)
|
|
logger.info('- Expected time: %s', expected_duration)
|
|
config.sleep(1)
|
|
logger.info('- Fastest time: %s', fastest_duration)
|
|
config.sleep(1)
|
|
logger.info('- Slowest time: %s', slowest_duration)
|
|
config.sleep(1)
|
|
logger.info('')
|
|
config.sleep(1)
|
|
logger.info('Do you still want to proceed?')
|
|
config.sleep(1)
|
|
logger.info('If not, press CTRL+C within the next 10 seconds...')
|
|
config.sleep(10)
|
|
logger.info('')
|
|
|
|
|
|
def log_results(results: AutoSellRunResults):
|
|
logger.info('Stats:')
|
|
logger.info('- Num orders: %s', len(results.order_details))
|
|
logger.info('- Total sold: %s', results.total_input_amount())
|
|
logger.info('- Total gained: %s', results.total_output_amount())
|
|
logger.info(
|
|
'- Total duration: %s (%s spent sleeping)',
|
|
results.total_duration,
|
|
results.total_sleep_duration,
|
|
)
|