├── tests ├── __init__.py ├── context.py ├── _test_template.py ├── test_timer.py ├── test_models_tickers.py ├── test_stats.py ├── test_utils.py ├── test_sqla_reports.py ├── test_order_manager_recovery.py ├── test_datastorage.py ├── test_fok_order.py ├── test_bot.py ├── test_orderbook.py ├── test_core.py └── test_throttle.py ├── MANIFEST.in ├── Makefile ├── ztom ├── models │ ├── _sqla_base.py │ ├── deal.py │ ├── tickers.py │ ├── remainings.py │ └── trade_order.py ├── exchanges │ ├── __init__.py │ ├── bittrex.py │ ├── kucoin.py │ └── binance.py ├── owa_orders.py ├── errors.py ├── owa_manager.py ├── __init__.py ├── stats_influx.py ├── reporter_sqla.py ├── timer.py ├── utils.py ├── reporter.py ├── cli.py ├── datastorage.py ├── throttle.py ├── trade_orders.py ├── orderbook.py ├── core.py └── recovery_orders.py ├── test_data ├── closep.json ├── orders │ ├── _kucoin_1.py │ └── _binance_1.py ├── tickers.csv ├── tickers_binance.csv ├── orders_trades_kucoin.json ├── orders_binance_error.json ├── orders_binance.json ├── orders_binance_cancel.json ├── orders_kucoin_buy.json └── order_books.csv ├── examples ├── balance.py ├── order.py └── tickers_to_csv.py ├── _config_default.yml ├── setup.py ├── requirements.txt ├── _scratches ├── mongo.py ├── remainings.py ├── binance_om.py ├── psql.py ├── binance.py ├── kucoin.py └── bittrex.py ├── _config_default.json ├── LICENSE ├── .gitignore ├── docs └── remainings.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | init: 2 | pip install -r requirements.txt 3 | 4 | test: 5 | nosetests tests 6 | -------------------------------------------------------------------------------- /ztom/models/_sqla_base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.declarative import declarative_base 2 | 3 | Base = declarative_base() 4 | -------------------------------------------------------------------------------- /ztom/exchanges/__init__.py: -------------------------------------------------------------------------------- 1 | from .binance import binance as binance 2 | from .kucoin import kucoin as kucoin 3 | from .bittrex import bittrex as bittrex 4 | -------------------------------------------------------------------------------- /tests/context.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import sys 4 | import os 5 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 6 | 7 | import ztom -------------------------------------------------------------------------------- /test_data/closep.json: -------------------------------------------------------------------------------- 1 | {4641.82, 4642.06, 4641.48, 4641.56, 4640.0, 4640.37, 4642.0, 4640.17, 4631.29, 4626.89, 4625.45, 4626.7, 4619.95, 4615.92, 4617.28, 4621.5, 4613.01, 4619.19, 4624.67, 4622.88, 4621.0, 4623.17, 4618.92, 4619.65, 4620.17, 4625.02, 4626.73, 4628.67, 4626.24, 4621.88} 2 | -------------------------------------------------------------------------------- /tests/_test_template.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .context import ztom 3 | import unittest 4 | 5 | 6 | class TemplateTestSuite(unittest.TestCase): 7 | 8 | def test_template(self): 9 | pass 10 | 11 | 12 | if __name__ == '__main__': 13 | unittest.main() 14 | -------------------------------------------------------------------------------- /ztom/owa_orders.py: -------------------------------------------------------------------------------- 1 | from ztom import ActionOrder 2 | 3 | 4 | # compatibility 5 | class OrderWithAim(ActionOrder): 6 | 7 | def __init__(self, symbol, amount: float, price: float, side: str, 8 | cancel_threshold: float=0.000001, max_order_updates: int=10): 9 | 10 | print("Please change OrderWithAim to ActionOrder") 11 | input("Press Enter to continue...") 12 | super().__init__(symbol, amount, price, side, cancel_threshold, max_order_updates) 13 | 14 | pass 15 | -------------------------------------------------------------------------------- /test_data/orders/_kucoin_1.py: -------------------------------------------------------------------------------- 1 | var = {'symbol': 'ETH/BTC', 2 | 'info': {'data': {'orderOid': '5b2b9c759dda1569e0431ce9'}, 'code': 'OK', 'success': True, 'msg': 'OK', 3 | 'timestamp': 1529584757546}, 'price': 0.07951005, 'amount': 0.05, 'fee': None, 4 | 'timestamp': 1529584757546, 'side': 'sell', 'trades': None, 'datetime': '2018-06-21T12:39:18.546Z', 5 | 'filled': None, 'status': 'open', 'type': 'limit', 'id': '5b2b9c759dda1569e0431ce9', 'lastTradeTimestamp': None, 6 | 'remaining': None, 'cost': 0.0039755025} -------------------------------------------------------------------------------- /ztom/errors.py: -------------------------------------------------------------------------------- 1 | class RecoveryManagerError(Exception): 2 | """Basic exception for errors raised by RecoveryManager""" 3 | pass 4 | 5 | 6 | class OrderError(Exception): 7 | pass 8 | 9 | 10 | class OwaManagerError(Exception): 11 | pass 12 | 13 | 14 | class OwaManagerErrorUnFilled(Exception): 15 | pass 16 | 17 | 18 | class OwaManagerErrorSkip(Exception): 19 | pass 20 | 21 | 22 | class OwaManagerCancelAttemptsExceeded(Exception): 23 | pass 24 | 25 | 26 | class TickerError(Exception): 27 | pass 28 | 29 | -------------------------------------------------------------------------------- /examples/balance.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | import ztom 4 | 5 | load_dotenv() 6 | 7 | api_key = os.getenv("ZTOM_API_KEY") 8 | secret = os.getenv("ZTOM_SECRET") 9 | 10 | ew = ztom.ccxtExchangeWrapper("binance", 11 | api_key=api_key, 12 | secret = secret) 13 | 14 | balance = ew.fetch_balance() 15 | 16 | non_zero_balances = {} 17 | for k,v in balance.items(): 18 | if isinstance(v, dict) and v.get("total", 0) > 0: 19 | non_zero_balances[k] = v 20 | 21 | print(non_zero_balances) 22 | 23 | -------------------------------------------------------------------------------- /ztom/owa_manager.py: -------------------------------------------------------------------------------- 1 | from . import ActionOrderManager 2 | from . import ccxtExchangeWrapper 3 | 4 | # for compatibility 5 | class OwaManager(ActionOrderManager): 6 | 7 | def __init__(self, exchange: ccxtExchangeWrapper, max_order_update_attempts=20, max_cancel_attempts=10, 8 | request_sleep=0.0): 9 | print("Please change OwaManager to ActionOrderManager") 10 | input("Press Enter to continue...") 11 | 12 | super().__init__(exchange, max_order_update_attempts, max_cancel_attempts, 13 | request_sleep) 14 | 15 | pass 16 | 17 | -------------------------------------------------------------------------------- /_config_default.yml: -------------------------------------------------------------------------------- 1 | --- 2 | server_id: CORE2 3 | script_id: core_test 4 | exchange_id: binance 5 | test_balance: 1 6 | commission: 0.0005 7 | api_key: 8 | apiKey: testApiKey 9 | secret: testApiSecret 10 | lap_time: 60 11 | max_requests_per_lap: 850 12 | influxdb: 13 | host: localserver 14 | port: 8086 15 | db: dev 16 | measurement: status 17 | recovery_server: 18 | host: localhost 19 | port: 8080 20 | order_update_total_requests: 25 21 | order_update_requests_for_time_out: 5 22 | order_update_time_out: 1 23 | max_trades_updates: 20 24 | max_oder_books_fetch_attempts: 10 25 | request_sleep: 0.1 26 | om_proceed_sleep: 0.1 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Learn more: https://github.com/kennethreitz/setup.py 3 | 4 | from setuptools import setup, find_packages 5 | 6 | with open('README.md') as f: 7 | readme = f.read() 8 | 9 | with open('LICENSE') as f: 10 | license = f.read() 11 | 12 | setup( 13 | name='ztom', 14 | version='0.1.0', 15 | description='HFT and Algo trading package on top of ccxt', 16 | long_description=readme, 17 | author='Ivan Averin', 18 | author_email='i.averin@gmail.com', 19 | url='https://github.com/ztomsy/ztom', 20 | license=license, 21 | packages=find_packages(exclude=('tests', 'docs')) 22 | ) 23 | 24 | -------------------------------------------------------------------------------- /test_data/orders/_binance_1.py: -------------------------------------------------------------------------------- 1 | var = {'price': 0.07946, 'trades': None, 'side': 'sell', 'type': 'limit', 'cost': 0.003973, 'status': 'closed', 2 | 'info': {'symbol': 'ETHBTC', 'orderId': 169675546, 'side': 'SELL', 'timeInForce': 'GTC', 'price': '0.07946000', 3 | 'status': 'FILLED', 'clientOrderId': 'SwstQ0eZ0ZJKr2y4uPQin2', 'executedQty': '0.05000000', 4 | 'origQty': '0.05000000', 'type': 'LIMIT', 'transactTime': 1529586827997}, 'filled': 0.05, 5 | 'timestamp': 1529586827997, 'fee': None, 'symbol': 'ETH/BTC', 'id': '169675546', 6 | 'datetime': '2018-06-21T13:13:48.997Z', 'lastTradeTimestamp': None, 'remaining': 0.0, 'amount': 0.05} -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiodns==3.0.0 2 | aiohttp==3.8.3 3 | aiosignal==1.3.1 4 | async-timeout==4.0.2 5 | attrs==22.2.0 6 | ccxt==2.7.44 7 | certifi==2022.12.7 8 | cffi==1.15.1 9 | charset-normalizer==2.1.1 10 | cryptography==39.0.0 11 | dnspython==2.3.0 12 | frozenlist==1.3.3 13 | greenlet==2.0.2 14 | idna==3.4 15 | influxdb==5.3.1 16 | jsonpickle==3.0.1 17 | msgpack==1.0.4 18 | multidict==6.0.4 19 | numpy==1.24.1 20 | psycopg2-binary==2.9.5 21 | pycares==4.3.0 22 | pycparser==2.21 23 | pymongo==4.3.3 24 | python-dateutil==2.8.2 25 | pytz==2022.7.1 26 | PyYAML==6.0 27 | requests==2.28.2 28 | six==1.16.0 29 | SQLAlchemy==2.0.1 30 | typing==3.7.4.3 31 | typing_extensions==4.4.0 32 | urllib3==1.26.14 33 | yarl==1.8.2 34 | -------------------------------------------------------------------------------- /ztom/exchanges/bittrex.py: -------------------------------------------------------------------------------- 1 | from .. import exchange_wrapper as ew 2 | 3 | 4 | class bittrex(ew.ccxtExchangeWrapper): 5 | """ 6 | could be outdated! just for reference 7 | """ 8 | 9 | def __init__(self, exchange_id, api_key ="", secret ="" ): 10 | super(bittrex, self).__init__(exchange_id, api_key, secret ) 11 | self.wrapper_id = "bittrex" 12 | 13 | 14 | def _fetch_order_trades(self, order): 15 | 16 | resp = self._ccxt.fetch_order(order.id, order.symbol, {"type": order.side.upper()}) 17 | # if "trades" in resp and len(resp["trades"]) > 0: 18 | # return resp 19 | 20 | return list([resp]) 21 | 22 | def get_exchange_wrapper_id(self): 23 | return self.wrapper_id 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/test_timer.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .context import ztom 3 | import unittest 4 | import ztom.timer 5 | import time 6 | 7 | class TimerTestSuite(unittest.TestCase): 8 | 9 | def test_timer(self): 10 | zt_timer = ztom.timer.Timer() 11 | 12 | timestamp_start = time.time() 13 | 14 | zt_timer.notch("start") 15 | time.sleep(0.1) 16 | zt_timer.notch("notch1") 17 | time.sleep(0.3) 18 | zt_timer.notch("notch2") 19 | 20 | timestamps = zt_timer.timestamps("timestamp_") 21 | 22 | self.assertAlmostEqual(timestamp_start+0.1, timestamps["timestamp_notch1"], 1) 23 | self.assertAlmostEqual(timestamp_start + 0.1 + 0.3, timestamps["timestamp_notch2"], 1) 24 | 25 | 26 | if __name__ == '__main__': 27 | unittest.main() 28 | -------------------------------------------------------------------------------- /_scratches/mongo.py: -------------------------------------------------------------------------------- 1 | import ztom 2 | 3 | mongo_rep = ztom.MongoReporter("test", "offline") 4 | 5 | mongo_rep.init_db("localhost", 27017, "db_test", "test_collection") 6 | 7 | mongo_rep.set_indicator("test_field_int", 1) 8 | mongo_rep.set_indicator("test_field_str", "Hi") 9 | mongo_rep.set_indicator("test_field_dict", {"level1": {"sublevel1": {"key1": "value1", "key1": 7777}}}) 10 | 11 | result = mongo_rep.push_report() 12 | print(result) 13 | 14 | report = list() 15 | 16 | report.append({"symbol": "ABC/XYZ", "amount": 1.11}) 17 | report.append({"symbol": "ABC2/XYZ2", "amount": 2.11}) 18 | report.append({"symbol": "ABC3/XYZ3", "amount": 3.11}) 19 | report.append({"symbol": "ABC4/XYZ4", "amount": 4.11}) 20 | 21 | result = mongo_rep.push_report(report) 22 | print(result) 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /_config_default.json: -------------------------------------------------------------------------------- 1 | { 2 | "server_id": "CORE1", 3 | "script_id": "core_test", 4 | "exchange_id": "binance", 5 | 6 | "test_balance":1, 7 | 8 | "commission" : 0.0005, 9 | 10 | "api_key": {"apiKey": "testApiKey", 11 | "secret": "testApiSecret"}, 12 | 13 | "lap_time" : 60, 14 | "max_requests_per_lap": 850, 15 | 16 | "influxdb": 17 | {"host": "localserver", 18 | "port": 8086, 19 | "db": "dev", 20 | "measurement": "status"}, 21 | 22 | "recovery_server": { 23 | "host": "localhost", 24 | "port": 8080 25 | }, 26 | 27 | "order_update_total_requests": 25, 28 | "order_update_requests_for_time_out": 5, 29 | "order_update_time_out": 1, 30 | "max_trades_updates": 20, 31 | "max_oder_books_fetch_attempts": 10, 32 | "request_sleep": 0.1, 33 | "om_proceed_sleep": 0.1 34 | 35 | } -------------------------------------------------------------------------------- /tests/test_models_tickers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .context import ztom 3 | from ztom import ccxtExchangeWrapper 4 | from ztom.models.tickers import Tickers 5 | import unittest 6 | 7 | 8 | class TickersModelTestSuite(unittest.TestCase): 9 | 10 | def test_model_tickers_list_from_dict(self): 11 | ex = ztom.ccxtExchangeWrapper.load_from_id("binance") # type: ccxtExchangeWrapper 12 | ex.set_offline_mode("test_data/markets.json", "test_data/tickers.csv") 13 | 14 | tickers = ex.fetch_tickers() 15 | 16 | tickers_list = Tickers.bulk_list_from_tickers(ex.exchange_id, tickers) 17 | 18 | for t in tickers_list: 19 | self.assertEqual(t["exchange"], ex.exchange_id) 20 | self.assertEqual(t["ask"], tickers[t["symbol"]]["ask"]) 21 | self.assertEqual(t["ask_quantity"], tickers[t["symbol"]]["askVolume"]) 22 | 23 | 24 | if __name__ == '__main__': 25 | unittest.main() 26 | -------------------------------------------------------------------------------- /_scratches/remainings.py: -------------------------------------------------------------------------------- 1 | import ztom 2 | from ztom import Remainings 3 | from sqlalchemy.exc import IntegrityError 4 | 5 | reporter = ztom.SqlaReporter("test", "testEx") 6 | 7 | connection_string = "postgres://postgres:12345@localhost:5432/ztom" 8 | 9 | reporter.init_db(connection_string, echo=True) 10 | 11 | print("Tables in db: {}".format(list(reporter.metadata.tables.keys()))) 12 | 13 | 14 | tables = reporter.create_tables() 15 | print("Created tables {}".format(tables)) 16 | 17 | remainings = Remainings( 18 | 19 | exchange="binance", 20 | account="test", 21 | currency="BTC", 22 | action='FILL', 23 | symbol="BTC/USDT", 24 | target_currency="USDT", 25 | amount_delta=-0.5, 26 | target_amount_delta=5000 27 | 28 | ) 29 | 30 | reporter.session.add(remainings) 31 | print(remainings) 32 | 33 | try: 34 | reporter.session.commit() 35 | except IntegrityError as e: 36 | print("Integrity error") 37 | 38 | """ 39 | TODO: The flow: 40 | - try to update 41 | - try to insert if prev update failed 42 | - try to update in prev insert failed 43 | """ 44 | 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 ztomsy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ztom/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import * 2 | from .timer import Timer 3 | from .utils import * 4 | from .exchanges import * 5 | from .exchange_wrapper import ccxtExchangeWrapper 6 | from .exchange_wrapper import ExchangeWrapperOfflineFetchError 7 | from .exchange_wrapper import ExchangeWrapperError 8 | from .stats_influx import StatsInflux 9 | from .datastorage import DataStorage 10 | from .reporter import Reporter, MongoReporter 11 | from .models.deal import DealReport 12 | from .models.trade_order import TradeOrderReport 13 | from .models.tickers import Tickers 14 | from .reporter_sqla import SqlaReporter 15 | from .orderbook import OrderBook 16 | from .orderbook import Order 17 | from .orderbook import Depth 18 | from .trade_orders import * 19 | from .trade_order_manager import * 20 | from . import core 21 | from .bot import Bot 22 | from .errors import * 23 | from .action_order import ActionOrder 24 | from .recovery_orders import RecoveryOrder 25 | from .fok_order import FokOrder, FokThresholdTakerPriceOrder 26 | from .order_manager import ActionOrderManager 27 | from .throttle import Throttle 28 | from .models.remainings import Remainings 29 | 30 | # Legacy support 31 | from .owa_manager import OwaManager 32 | from .owa_orders import OrderWithAim 33 | 34 | 35 | -------------------------------------------------------------------------------- /ztom/models/deal.py: -------------------------------------------------------------------------------- 1 | 2 | from sqlalchemy import Column, DateTime, String, Integer, ForeignKey, func, TIMESTAMP, Float, JSON 3 | from ztom.models._sqla_base import Base 4 | 5 | 6 | class DealReport(Base): 7 | """ 8 | Fields: 9 | timestamp should be timezone aware. for example timestamp=datetime.datetime.now(tz=pytz.timezone('UTC')), 10 | timestamp_start should be timezone aware 11 | exchange 12 | instance 13 | server 14 | deal_type 15 | deal_uuid 16 | status 17 | currency 18 | start_amount 19 | result_amount 20 | gross_profit 21 | net_profit 22 | config 23 | deal_data 24 | """ 25 | 26 | __tablename__ = "deal_reports" 27 | 28 | id = Column(Integer, primary_key=True) 29 | timestamp = Column(TIMESTAMP(timezone=True)) 30 | timestamp_start = Column(TIMESTAMP(timezone=True)) 31 | exchange = Column(String) 32 | instance = Column(String) 33 | server = Column(String) 34 | deal_type = Column(String) 35 | deal_uuid = Column(String) 36 | status = Column(String) 37 | currency = Column(String) 38 | start_amount = Column(Float) 39 | result_amount = Column(Float) 40 | gross_profit = Column(Float) 41 | net_profit = Column(Float) 42 | config = Column(JSON) 43 | deal_data = Column(JSON) 44 | 45 | -------------------------------------------------------------------------------- /examples/order.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | from dotenv import load_dotenv 4 | import ztom 5 | 6 | load_dotenv() 7 | 8 | api_key = os.getenv("ZTOM_API_KEY") 9 | secret = os.getenv("ZTOM_SECRET") 10 | 11 | ew = ztom.ccxtExchangeWrapper("binance", api_key=api_key, secret=secret) 12 | ew.enable_requests_throttle() 13 | ew.load_markets() 14 | tickers = ew.fetch_tickers() 15 | 16 | 17 | order = ztom.FokOrder.create_from_start_amount( 18 | "BNB/BUSD", 19 | start_currency="BNB", 20 | amount_start=0.1, 21 | dest_currency="BUSD", 22 | price=tickers["BNB/BUSD"]["ask"], 23 | time_to_cancel=600, 24 | ) 25 | 26 | om = ztom.ActionOrderManager(ew) 27 | om.add_order(order) 28 | 29 | print("Sleeping for {}s".format(om.request_sleep)) 30 | time.sleep(om.request_sleep) 31 | 32 | 33 | while om.have_open_orders(): 34 | # if om.pending_actions_number() == 0: 35 | # sleep_time = ew.requests_throttle.sleep_time() 36 | # print("Sleeping for {}s".format(sleep_time)) 37 | # time.sleep(sleep_time) 38 | # else: 39 | # print("!!! NO SLEEP. ACTIONS ARE PENDING !!!") 40 | om.proceed_orders() 41 | 42 | print(f"Order status {order.status} filled {order.filled}/{order.amount}") 43 | 44 | time.sleep(om.request_sleep) 45 | 46 | print("Result") 47 | print(f"Order status {order.status} filled {order.filled}/{order.amount}") 48 | 49 | -------------------------------------------------------------------------------- /tests/test_stats.py: -------------------------------------------------------------------------------- 1 | from .context import ztom 2 | from ztom import stats_influx 3 | import unittest 4 | import json 5 | 6 | 7 | class StatsTestSuite(unittest.TestCase): 8 | """Basic test cases.""" 9 | 10 | def test_tags(self): 11 | deal_row = {"server-id" : "Arb2", 12 | 13 | 'BNB-after': 41.78100931, 14 | 'BNB-before': 41.78100931, 15 | 'after-start': 26125.586834, 16 | 'bal-after': 89.49255701, 17 | 'bal-before': 89.49255701, 18 | 'deal-uuid': '8091d4cf-e3b5-4b14-b935-ce3eb94de12c', 19 | 'status': 'Ok', 20 | 'tags': '#bal_reduce#incrStart', 21 | 'ticker': 434614, 22 | 'time-start': '2018-05-10 09:49:37.077615', 23 | 'timestamp': '2018-05-10 17:05:02.664449'} 24 | 25 | stats_db = stats_influx.StatsInflux("13.231.173.161", 8086, "dev", "deals_results") 26 | tags = list(["deal-uuid", "server-id"]) 27 | 28 | tags_dict = dict() 29 | for i in tags: 30 | tags_dict[i] = deal_row[i] 31 | 32 | stats_db.set_tags(tags) 33 | self.assertEqual(stats_db.tags, tags) 34 | 35 | stats_data = stats_db.extract_tags_and_fields(deal_row) 36 | 37 | self.assertDictEqual(stats_data["tags"], tags_dict) 38 | 39 | for i in range(1, len(tags)+1): 40 | self.assertNotIn(tags[i-1], stats_data["fields"]) 41 | 42 | 43 | if __name__ == '__main__': 44 | unittest.main() 45 | -------------------------------------------------------------------------------- /test_data/tickers.csv: -------------------------------------------------------------------------------- 1 | fetch_id,timestamp,symbol,ask,bid,askVolume,bidVolume 2 | 0,1527000539972,ETH/USDT,682.82,682.5,0.3505,0.54746 3 | 0,1527000539980,ETH/BTC,0.082975,0.082923,6.33,10.011 4 | 0,1527000534328,AMB/ETH,0.00064991,0.0006471,122,209 5 | 0,1527000539927,XEM/ETH,0.00043706,0.00043406,431,118 6 | 0,1527000539927,BNB/ETH,0.0212,0.021175,0.14,36.06 7 | 0,1527000539966,BTC/USDT,8227,8226.99,0.010867,0.010614 8 | 0,1527000538308,AMB/BNB,0.03182,0.03036,81.06,527.89 9 | 0,1527000539928,TRX/BTC,0.00000949,0.00000948,5088,260368 10 | 0,1527000539826,TRX/ETH,0.00011443,0.00011442,158,725 11 | 0,1527000539778,XEM/BTC,0.00003614,0.00003611,389,9469 12 | 1,1527000541597,ETH/USDT,682.81,682.5,0.3505,0.54746 13 | 1,1527000541515,ETH/BTC,0.082975,0.082923,6.33,10.056 14 | 1,1527000534328,AMB/ETH,0.00064991,0.0006471,122,209 15 | 1,1527000541322,XEM/ETH,0,0,1380,923 16 | 1,1527000541638,BNB/ETH,0.0212,0.021175,0.14,36.06 17 | 1,1527000541079,BTC/USDT,8227,8226.99,0.010867,0.010614 18 | 1,1527000538308,AMB/BNB,0.02982,0.03036,81.06,527.89 19 | 1,1527000541424,TRX/BTC,0.00000949,0.00000948,519,260368 20 | 1,1527000541193,TRX/ETH,0.00011444,0.00012,247121,725 21 | 1,1527000541485,XEM/BTC,0.00003616,0.00003611,61,9469 22 | 2,1527000543167,ETH/USDT,682.82,682.51,0.3505,2.74139 23 | 2,1527000542996,ETH/BTC,0.082975,0.082921,7.891,10 24 | 2,1527000542574,AMB/ETH,0.00064991,0.0006471,122,209 25 | 2,1527000542734,XEM/ETH,0.00043701,0.00043412,431,118 26 | 2,1527000542547,BNB/ETH,0.0212,0.021175,0.14,36.06 27 | 2,1527000542619,AMB/BNB,0.03182,0.03029,81.06,747 28 | 2,1527000543203,TRX/BTC,0.0000095,0.00000949,26616,9 29 | 2,1527000542613,TRX/ETH,0.00011444,,247121,725 30 | 2,1527000543164,XEM/BTC,,0.00003611,399,9469 31 | -------------------------------------------------------------------------------- /test_data/tickers_binance.csv: -------------------------------------------------------------------------------- 1 | fetch_id,timestamp,symbol,ask,bid,askVolume,bidVolume 2 | 0,1527000539972,ETH/USDT,682.82,682.5,0.3505,0.54746 3 | 0,1527000539980,ETH/BTC,0.082975,0.082923,6.33,10.011 4 | 0,1527000534328,AMB/ETH,0.00064991,0.0006471,122,209 5 | 0,1527000539927,XEM/ETH,0.00043706,0.00043406,431,118 6 | 0,1527000539927,BNB/ETH,0.0212,0.021175,0.14,36.06 7 | 0,1527000539966,BTC/USDT,8227,8226.99,0.010867,0.010614 8 | 0,1527000538308,AMB/BNB,0.03182,0.03036,81.06,527.89 9 | 0,1527000539928,TRX/BTC,0.00000949,0.00000948,5088,260368 10 | 0,1527000539826,TRX/ETH,0.00011443,0.00011442,158,725 11 | 0,1527000539778,XEM/BTC,0.00003614,0.00003611,389,9469 12 | 1,1527000541597,ETH/USDT,682.82,682.5,0.3505,0.54746 13 | 1,1527000541515,ETH/BTC,0.082975,0.082923,6.33,10.056 14 | 1,1527000534328,AMB/ETH,0.00064991,0.0006471,122,209 15 | 1,1527000541322,XEM/ETH,0.00043702,0.00043408,1380,923 16 | 1,1527000541638,BNB/ETH,0.0212,0.021175,0.14,36.06 17 | 1,1527000541079,BTC/USDT,8227,8226.99,0.010867,0.010614 18 | 1,1527000538308,AMB/BNB,0.03182,0.03036,81.06,527.89 19 | 1,1527000541424,TRX/BTC,0.00000949,0.00000948,519,260368 20 | 1,1527000541193,TRX/ETH,0.00011444,0.00011442,247121,725 21 | 1,1527000541485,XEM/BTC,0.00003616,0.00003611,61,9469 22 | 2,1527000543167,ETH/USDT,682.82,682.51,0.3505,2.74139 23 | 2,1527000542996,ETH/BTC,0.082975,0.082923,7.891,10 24 | 2,1527000542574,AMB/ETH,0.00064991,0.0006471,122,209 25 | 2,1527000542734,XEM/ETH,0.00043701,0.00043412,431,118 26 | 2,1527000542547,BNB/ETH,0.0212,0.021175,0.14,36.06 27 | 2,1527000543176,BTC/USDT,8228,8225,7.199103,0.006061 28 | 2,1527000542619,AMB/BNB,0.03182,0.03029,81.06,747 29 | 2,1527000543203,TRX/BTC,0.0000095,0.00000949,26616,9 30 | 2,1527000542613,TRX/ETH,0.00011444,0.00011442,247121,725 31 | 2,1527000543164,XEM/BTC,0.00003614,0.00003611,399,9469 32 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .context import ztom 3 | from ztom import utils 4 | import unittest 5 | 6 | 7 | class UtilsTestSuite(unittest.TestCase): 8 | 9 | def test_dict_value_from_path(self): 10 | 11 | data = {"tickers": {"ETH/BTC": {"ask": 1, "bid": 2, "mas": {"5": 5}}}} 12 | 13 | # ok 14 | path_list = "tickers ETH/BTC mas 5".split(" ") 15 | res = utils.dict_value_from_path(data, path_list) 16 | self.assertEqual(5, res) 17 | 18 | # ok case insensitive 19 | path_list = "TICKERS eth/btc MAS".split(" ") 20 | res = utils.dict_value_from_path(data, path_list) 21 | self.assertDictEqual({"5": 5}, res) 22 | 23 | # ok to receive dict 24 | path_list = "tickers ETH/BTC".split(" ") 25 | res = utils.dict_value_from_path(data, path_list) 26 | self.assertDictEqual({"ask": 1, "bid": 2, "mas": {"5": 5}}, res) 27 | 28 | # if path not found 29 | path_list = "tickers EXX/BTC mas 5".split(" ") # getting the 30 | res = utils.dict_value_from_path(data, path_list) 31 | self.assertEqual(None, res) 32 | 33 | # if path not found 34 | path_list = "ticker ETH/BTC mas 5".split(" ") # getting the 35 | res = utils.dict_value_from_path(data, path_list) 36 | self.assertEqual(None, res) 37 | 38 | # ok case sensitive 39 | path_list = "tickers ETH/BTC mas".split(" ") 40 | res = utils.dict_value_from_path(data, path_list) 41 | self.assertDictEqual({"5": 5}, res) 42 | 43 | # not ok with case sensitive 44 | path_list = "TICKERS eth/btc MAS".split(" ") 45 | res = utils.dict_value_from_path(data, path_list, True) 46 | self.assertEqual(None, res) 47 | 48 | 49 | if __name__ == '__main__': 50 | unittest.main() 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Some ignored stuff 2 | .vscode/ 3 | .idea/ 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # celery beat schedule file 88 | celerybeat-schedule 89 | 90 | # SageMath parsed files 91 | *.sage.py 92 | 93 | # Environments 94 | .env 95 | .venv 96 | env/ 97 | venv/ 98 | ENV/ 99 | env.bak/ 100 | venv.bak/ 101 | 102 | # Spyder project settings 103 | .spyderproject 104 | .spyproject 105 | 106 | # Rope project settings 107 | .ropeproject 108 | 109 | # mkdocs documentation 110 | /site 111 | 112 | # mypy 113 | .mypy_cache/ 114 | .dmypy.json 115 | dmypy.json 116 | 117 | -------------------------------------------------------------------------------- /ztom/stats_influx.py: -------------------------------------------------------------------------------- 1 | from influxdb import InfluxDBClient 2 | 3 | 4 | class StatsInflux: 5 | 6 | def __init__(self, host, port, database, measurement): 7 | 8 | self.host = host # "13.231.173.161" 9 | self.port = port # 8086 10 | self.database = database # "dev" 11 | 12 | self.measurement = measurement # "deals_results" 13 | 14 | self.client = InfluxDBClient(host=self.host, port=self.port, database=self.database) 15 | 16 | self.tags = list() # list of tags from the deal info dict 17 | 18 | def set_tags(self, tags: list): 19 | self.tags = tags 20 | 21 | def extract_tags_and_fields(self, deal_row:dict): 22 | tags = dict() 23 | fields = dict() 24 | 25 | for i in deal_row: 26 | if i in self.tags: 27 | tags[i] = deal_row[i] 28 | else: 29 | fields[i] = deal_row[i] 30 | 31 | return {"tags": tags, "fields": fields} 32 | 33 | # time should be a datetime type 34 | def write_deal_info(self, deal_row: dict, time=None): 35 | 36 | updtmsg = dict() 37 | 38 | updtmsg["measurement"] = self.measurement 39 | 40 | stats_data = self.extract_tags_and_fields(deal_row) 41 | 42 | updtmsg["tags"] = stats_data["tags"] 43 | updtmsg["fields"] = stats_data["fields"] 44 | 45 | if time is not None: 46 | updtmsg["time"] = time 47 | 48 | self.client.write_points([updtmsg], protocol="json") 49 | 50 | def push_fields(self, fields: dict, time=None): 51 | 52 | updtmsg = dict() 53 | 54 | updtmsg["measurement"] = self.measurement 55 | 56 | #stats_data = self.extract_tags_and_fields(deal_row) 57 | 58 | updtmsg["tags"] = self.tags 59 | updtmsg["fields"] = fields 60 | 61 | if time is not None: 62 | updtmsg["time"] = time 63 | 64 | return self.client.write_points([updtmsg], protocol="json") 65 | 66 | -------------------------------------------------------------------------------- /_scratches/binance_om.py: -------------------------------------------------------------------------------- 1 | import ztom 2 | import time 3 | import datetime 4 | 5 | 6 | bot = ztom.Bot("_binance_test.json") 7 | 8 | bot.load_config_from_file(bot.config_filename) 9 | 10 | bot.init_exchange() 11 | 12 | if bot.offline: 13 | bot.init_offline_mode() 14 | 15 | # bot.init_remote_reports() 16 | 17 | bot.load_markets() 18 | 19 | # init parameters 20 | symbol = "ETH/BTC" 21 | start_currency = "BTC" 22 | dest_currency = "ETH" 23 | start_amount = 0.002 24 | 25 | order1_max_updates = 40 26 | cancel_threshold = 0.001 27 | 28 | order_manager_sleep = 0.5 29 | 30 | ticker = bot.exchange.fetch_tickers(symbol)[symbol] 31 | 32 | depth = 1 33 | 34 | om = ztom.ActionOrderManager(bot.exchange, order1_max_updates, 50, 0.1) 35 | om.log = bot.log # override order manager logger to the bot logger 36 | om.LOG_INFO = bot.LOG_INFO 37 | om.LOG_ERROR = bot.LOG_ERROR 38 | om.LOG_DEBUG = bot.LOG_DEBUG 39 | om.LOG_CRITICAL = bot.LOG_CRITICAL 40 | 41 | start_price = ticker["ask"] if ztom.core.get_trade_direction_to_currency(symbol, dest_currency) == "buy" \ 42 | else ticker["bid"] 43 | 44 | start_price = start_price * 0.995 45 | 46 | order1 = ztom.FokOrder.create_from_start_amount(symbol, start_currency, start_amount, 47 | dest_currency, start_price, cancel_threshold, order1_max_updates) 48 | 49 | om.add_order(order1) 50 | 51 | while len(om.get_open_orders()) > 0: 52 | om.proceed_orders() 53 | time.sleep(order_manager_sleep) 54 | 55 | closed_trade_orders = om.get_closed_orders()[0].orders_history 56 | date_time = datetime.datetime.utcnow().timestamp() 57 | 58 | reporter = ztom.MongoReporter("test", bot.exchange_id) 59 | reporter.init_db(default_data_base="test", default_collection="trade_orders") 60 | 61 | for order in closed_trade_orders: 62 | report = order.report() 63 | report_result = reporter.push_report(report, "orders_test") 64 | print(report) 65 | print(report_result.inserted_id) 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /ztom/reporter_sqla.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sqlalchemy 3 | from sqlalchemy import Column, DateTime, String, Integer, ForeignKey, func, TIMESTAMP, Float, JSON 4 | 5 | from . import Reporter 6 | from sqlalchemy.orm import sessionmaker 7 | 8 | from .models._sqla_base import Base 9 | from .models.deal import DealReport 10 | from .models.trade_order import TradeOrderReport 11 | from .models.remainings import Remainings 12 | 13 | class SqlaReporter(Reporter): 14 | """ 15 | reporter wrapper for SQLAlchemy 16 | """ 17 | TABLES = [DealReport, TradeOrderReport, Remainings] 18 | 19 | def __init__(self, server_id, exchange_id): 20 | super().__init__(server_id, exchange_id) 21 | 22 | self.engine = None # type: sqlalchemy.engine.Engine 23 | self.metadata = None # type: sqlalchemy.schema.MetaData 24 | self.Base = Base 25 | self.Session = None 26 | self.session = None 27 | self.connection = None # type: sqlalchemy.engine.Connection 28 | 29 | def init_db(self, connection_string: str, **kwargs): 30 | """ 31 | 32 | :param connection_string: 33 | :param echo: boolean set to True to pass the echo to engine 34 | :return: 35 | """ 36 | echo = kwargs["echo"] if "echo" in kwargs else False 37 | 38 | self.engine = sqlalchemy.create_engine(connection_string, echo=echo) 39 | self.connection = self.engine.connect() 40 | self.metadata = sqlalchemy.MetaData(self.engine) 41 | self.metadata.reflect() 42 | 43 | self.Session = sessionmaker(bind=self.engine) 44 | self.session = self.Session() 45 | 46 | def new_session(self): 47 | self.Session = sessionmaker(bind=self.engine) 48 | self.session = self.Session() 49 | 50 | def create_tables(self): 51 | tables = list() 52 | 53 | for t in self.TABLES: 54 | if t.__tablename__ is not None and t.__tablename__ not in self.metadata.tables: 55 | tables.append(t.__table__) 56 | 57 | Base.metadata.create_all(self.engine, tables=tables) 58 | return tables 59 | 60 | 61 | -------------------------------------------------------------------------------- /ztom/exchanges/kucoin.py: -------------------------------------------------------------------------------- 1 | from ..exchange_wrapper import * 2 | from ..trade_orders import TradeOrder 3 | 4 | 5 | class kucoin(ccxtExchangeWrapper): 6 | """ 7 | could be outdated! just for reference 8 | """ 9 | 10 | def __init__(self, exchange_id, api_key ="", secret ="" ): 11 | super(kucoin, self).__init__(exchange_id, api_key, secret ) 12 | self.wrapper_id = "kucoin" 13 | 14 | def _fetch_order(self, order): 15 | # todo add if order was canceled but filled amount is leess/equal from amount - so the order's status should be 16 | # "canceled" 17 | return self._ccxt.fetch_order(order.id, order.symbol, {"type": order.side.upper()}) 18 | 19 | def _cancel_order(self, order: TradeOrder): 20 | return self._ccxt.cancel_order(order.id, order.symbol, {"type": order.side.upper()}) 21 | 22 | def _fetch_order_trades(self, order): 23 | 24 | resp = self._ccxt.fetch_order(order.id, order.symbol, {"type": order.side.upper()}) 25 | if "trades" in resp and len(resp["trades"]) > 0: 26 | return resp["trades"] 27 | 28 | return list() 29 | 30 | @staticmethod 31 | def fees_from_order_trades(order: TradeOrder): 32 | """ 33 | returns the dict of cumulative fee as [""]["amount"] 34 | 35 | :param order: TradeOrder 36 | :return: the dict of cumulative fee as [""]["amount"] 37 | """ 38 | trades = order.trades 39 | total_fee = dict() 40 | 41 | for t in trades: 42 | if "fee" not in t: 43 | break 44 | 45 | # t["fee"]["currency"] = order.dest_currency # fee in dest currency 46 | 47 | if t["fee"]["currency"] not in total_fee: 48 | total_fee[t["fee"]["currency"]] = dict() 49 | total_fee[t["fee"]["currency"]]["amount"] = 0 50 | 51 | total_fee[t["fee"]["currency"]]["amount"] += t["fee"]["cost"] 52 | 53 | for c in order.start_currency, order.dest_currency: 54 | if c not in total_fee: 55 | total_fee[c] = dict({"amount": 0.0}) 56 | 57 | return total_fee 58 | 59 | 60 | 61 | def get_exchange_wrapper_id(self): 62 | return self.wrapper_id 63 | -------------------------------------------------------------------------------- /tests/test_sqla_reports.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .context import ztom 3 | from ztom import ccxtExchangeWrapper 4 | from ztom import TradeOrder 5 | from ztom import ActionOrder, ActionOrderManager 6 | from ztom import TradeOrderReport 7 | import datetime, pytz 8 | 9 | from sqlalchemy import Column, DateTime, String, Integer, ForeignKey, func, TIMESTAMP, Float, JSON 10 | 11 | 12 | import unittest 13 | 14 | 15 | class SqlaReporterTestSuite(unittest.TestCase): 16 | 17 | def test_trade_order_report_from_trade_order_after_fill(self): 18 | 19 | order = ActionOrder.create_from_start_amount("ETH/BTC","BTC", 1, "ETH", 0.01) 20 | 21 | ew = ccxtExchangeWrapper.load_from_id("binace") # type: ccxtExchangeWrapper 22 | ew.set_offline_mode("test_data/markets.json", "test_data/tickers.csv") 23 | om = ActionOrderManager(ew) 24 | 25 | om.add_order(order) 26 | while len(om.get_open_orders()) > 0: 27 | om.proceed_orders() 28 | 29 | trade_order = om.get_closed_orders()[0].orders_history[0] # type: TradeOrder 30 | 31 | order_report = TradeOrderReport.from_trade_order(trade_order, datetime.datetime.now(tz=pytz.timezone("UTC")), 32 | supplementary={"order_no": 1}) 33 | 34 | self.assertEqual(order_report.symbol, trade_order.symbol) 35 | self.assertEqual(order_report.side, trade_order.side) 36 | self.assertEqual(order_report.amount, trade_order.amount) 37 | self.assertEqual(order_report.filled, trade_order.filled) 38 | self.assertEqual(order_report.cost, trade_order.cost) 39 | 40 | self.assertListEqual(order_report.trades, trade_order.trades) 41 | 42 | self.assertDictEqual(order_report.timestamp_closed, trade_order.timestamp_closed) 43 | self.assertDictEqual(order_report.timestamp_open, trade_order.timestamp_open) 44 | 45 | self.assertEqual(order_report.start_currency, trade_order.start_currency) 46 | self.assertEqual(order_report.start_currency, trade_order.start_currency) 47 | 48 | self.assertEqual("fill", order_report.supplementary["parent_action_order"]["state"]) 49 | self.assertEqual(1, order_report.supplementary["order_no"]) 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | if __name__ == '__main__': 64 | unittest.main() 65 | -------------------------------------------------------------------------------- /ztom/timer.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import time 3 | import collections 4 | 5 | class Timer: 6 | 7 | def __init__(self): 8 | self.start_time = datetime.now() 9 | self.notches = [] 10 | 11 | self.bucket_size = 0 12 | self.tokens = 0 13 | self.bucket_seconds = 0 14 | 15 | self.now = datetime.now() 16 | self.request_time = self.now 17 | self.timestamp_before_request = datetime(1, 1, 1, 1, 1, 0) 18 | 19 | self.max_requests_per_lap = int 20 | self.lap_time = int 21 | 22 | 23 | def notch(self, name): 24 | last = self.start_time if len(self.notches) == 0 else self.notches[-1]['time'] 25 | self.notches.append({ 26 | 'name': name, 27 | 'time': datetime.now(), 28 | 'duration': (datetime.now() - last).total_seconds() 29 | }) 30 | 31 | def check_timer(self): 32 | 33 | self.now = datetime.now() 34 | self.request_time = (self.now - self.timestamp_before_request).total_seconds() 35 | self.timestamp_before_request = self.now 36 | 37 | if 1 / self.request_time > self.max_requests_per_lap / self.lap_time: 38 | print("Pause for:", self.lap_time / self.max_requests_per_lap - self.request_time) 39 | time.sleep(self.lap_time / self.max_requests_per_lap - self.request_time) 40 | self.timestamp_before_request = datetime.now() 41 | 42 | 43 | # TODO change to use map 44 | def results(self): 45 | result = [] 46 | for n in self.notches: 47 | result.append('%s: %s s ' % (n['name'], (n['duration']))) 48 | 49 | return '| '.join(result) 50 | 51 | def results_dict(self): 52 | d = collections.OrderedDict() 53 | for i in self.notches: 54 | d[i["name"]] = i["duration"] 55 | return d 56 | 57 | def timestamps(self, notch_prefix: str = ""): 58 | """ 59 | returns the dict of notches with timestamps: {:timestamp} 60 | :param notch_prefix: prefix of dict key , so the resulting key is: prefix + notch name 61 | :return: dict 62 | """ 63 | d = collections.OrderedDict() 64 | for i in self.notches: 65 | d[notch_prefix + i["name"]] = i["time"].timestamp() 66 | return d 67 | 68 | 69 | def reset_notches(self): 70 | self.notches = list() 71 | -------------------------------------------------------------------------------- /ztom/models/tickers.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, DateTime, String, Integer, ForeignKey, func, TIMESTAMP, Float, JSON 2 | from ztom.models._sqla_base import Base 3 | from ztom.trade_orders import TradeOrder 4 | import datetime 5 | import copy 6 | import pytz 7 | 8 | 9 | class Tickers(Base): 10 | """ 11 | abstracts tickers information 12 | """ 13 | 14 | __tablename__ = "tickers" 15 | id = Column(Integer, primary_key=True) 16 | timestamp = Column(TIMESTAMP(timezone=True)) 17 | exchange = Column(String(10)) 18 | symbol = Column(String(10)) 19 | ask = Column(Float) 20 | ask_quantity = Column(Float) 21 | bid = Column(Float) 22 | bid_quantity = Column(Float) 23 | 24 | @classmethod 25 | def from_single_ticker(cls, exchange, symbol: str, ticker: dict): 26 | return Tickers( 27 | timestamp=datetime.datetime.now(tz=pytz.timezone("UTC")), 28 | exchange = exchange, 29 | symbol=symbol, 30 | ask=ticker["ask"], 31 | ask_quantity=ticker["askVolume"], 32 | bid=ticker["bid"], 33 | bid_quantity=ticker["bidVolume"] 34 | ) 35 | 36 | @staticmethod 37 | def bulk_list_from_tickers(exchange:str, tickers: dict): 38 | """ 39 | creates the list of dicts for bulk instert 40 | connection.execute(addresses.insert(), [ 41 | ... {'user_id': 1, 'email_address' : 'jack@yahoo.com'}, 42 | ... {'user_id': 1, 'email_address' : 'jack@msn.com'}, 43 | ... {'user_id': 2, 'email_address' : 'www@www.org'}, 44 | ... {'user_id': 2, 'email_address' : 'wendy@aol.com'}, 45 | ... ]) ) 46 | :param tickers: dict of ccxt tickers where key is the symbol 47 | :return: list of dicts 48 | """ 49 | timestamp = datetime.datetime.now(tz=pytz.timezone("UTC")) 50 | tickers_list = list() 51 | for s, t in tickers.items(): 52 | t_dict = { 53 | "timestamp": timestamp, 54 | "exchange": exchange, 55 | "symbol": s, 56 | "ask": t["ask"], 57 | "ask_quantity": t["askVolume"], 58 | "bid": t["bid"], 59 | "bid_quantity": t["bidVolume"] 60 | } 61 | 62 | tickers_list.append(t_dict) 63 | return tickers_list 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /ztom/models/remainings.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Index, DateTime, String, Integer, ForeignKey, func, TIMESTAMP, Float, JSON 2 | from ztom.models._sqla_base import Base 3 | from ztom.trade_orders import TradeOrder 4 | import datetime 5 | 6 | 7 | class Remainings(Base): 8 | """ 9 | Remainings balance table 10 | """ 11 | 12 | __tablename__ = "remainings" 13 | 14 | id = Column(Integer, primary_key=True, autoincrement=True) 15 | exchange = Column(String) 16 | """ 17 | exchange id 18 | """ 19 | account = Column(String) 20 | """ 21 | account Id 22 | """ 23 | 24 | timestamp = Column(TIMESTAMP(timezone=True), default=datetime.datetime.now) 25 | """ 26 | timestamp of record insertion 27 | """ 28 | 29 | action = Column(String, nullable=False) 30 | """ 31 | action: add, fill or aggregate 32 | """ 33 | 34 | currency = Column(String) 35 | """ 36 | currency of remainings 37 | """ 38 | 39 | amount_delta = Column(Float) 40 | """ 41 | total amount of remainings of currency 42 | """ 43 | target_currency = Column(String) 44 | """ 45 | asset which was intended to be yielded from remaining proceeding 46 | """ 47 | target_amount_delta = Column(Float) 48 | """ 49 | considered best target amount 50 | """ 51 | symbol = Column(String) 52 | """ 53 | trade pair of an order yielded the remaining when remaining was created 54 | """ 55 | 56 | __table_args__ = (Index('idx_exchange_account_currency_symbol_target_currency', 57 | 'exchange', 'account', 'currency', 'symbol', 'target_currency'),) 58 | 59 | # @classmethod 60 | # def from_trade_order(cls, order: TradeOrder, timestamp: datetime, deal_uuid: str = None, 61 | # action_order_id: str = None, supplementary: dict = None) -> 'TradeOrderReport': 62 | 63 | def __str__(self): 64 | return f"Remaining: \n" \ 65 | f"exchange: {self.exchange}\n" \ 66 | f"account: {self.account}\n" \ 67 | f"timestamp: {self.timestamp}\n" \ 68 | f"action: {self.action}\n" \ 69 | f"currency : {self.currency}\n" \ 70 | f"amount_delta: {self.amount_delta}\n" \ 71 | f"target_currency: {self.target_currency}\n" \ 72 | f"target_amount_delta: {self.target_amount_delta}\n" \ 73 | f"symbol: {self.symbol}" 74 | -------------------------------------------------------------------------------- /_scratches/psql.py: -------------------------------------------------------------------------------- 1 | import ztom 2 | from ztom import DealReport 3 | import datetime 4 | import uuid 5 | import pytz 6 | import argparse 7 | import sys 8 | from random import randint 9 | 10 | 11 | deal_uuid = None 12 | parser = argparse.ArgumentParser() 13 | 14 | parser.add_argument("--deal_uuid", help="deal_uuid to update", 15 | dest="deal_uuid", 16 | action="store", default=None) 17 | 18 | deal_uuid = parser.parse_args(sys.argv[1:]).deal_uuid 19 | 20 | print("deal_uuid: {}".format(deal_uuid)) 21 | 22 | reporter = ztom.SqlaReporter("test", "offline") 23 | 24 | connection_string = "postgres://ztom_main:ztom@localserver:5432/ztom_dev" 25 | 26 | reporter.init_db(connection_string, echo=True) 27 | print("Tables in db: {}".format(list(reporter.metadata.tables.keys()))) 28 | 29 | if deal_uuid is not None and ztom.DealReport.__tablename__ in list(reporter.metadata.tables.keys()): 30 | print("Will update the deal_uuid {}".format(deal_uuid)) 31 | 32 | deal_report = reporter.session.query(DealReport).filter_by(deal_uuid=deal_uuid).first() # type: DealReport 33 | if deal_report is not None: 34 | reporter.session.add(deal_report) 35 | print(deal_report) 36 | 37 | deal_data = dict(deal_report.deal_data) 38 | deal_data["recovery1"] = {"leg": 1, "amount": randint(0, 1000000), "new": "new"} 39 | # deal_report.deal_data["recovery1"] = {"leg": 1, "amount": 666} 40 | deal_report.deal_data = deal_data 41 | reporter.session.commit() 42 | 43 | sys.exit(0) 44 | else: 45 | print("Deal not found") 46 | sys.exit() 47 | 48 | 49 | tables = reporter.create_tables() 50 | print("Created tables {}".format(tables)) 51 | 52 | 53 | deal_report = DealReport( 54 | timestamp=datetime.datetime.now(tz=pytz.timezone('UTC')), 55 | timestamp_start=datetime.datetime.now(tz=pytz.timezone('UTC')), 56 | exchange="test_exchange", 57 | instance="test_instance", 58 | server="test_server", 59 | deal_type="test", 60 | deal_uuid=str(uuid.uuid4()), 61 | status="OK", 62 | currency="BTC", 63 | start_amount=1.666, 64 | result_amount=1.8, 65 | gross_profit=1.8 - 1.666, 66 | net_profit=0.1, 67 | config={"server": "test", "limit": {"hi": 111, "low": 0}, "trades": [1, 2, 3, 4, 5]}, 68 | deal_data={"symbol": "ETH/BTC", "limit": {"hi": 111, "low": 0}, "trades": [1, 2, 3, 4, 5]}) 69 | 70 | reporter.session.add(deal_report) 71 | reporter.session.commit() 72 | 73 | print("OK") 74 | 75 | 76 | -------------------------------------------------------------------------------- /ztom/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List 3 | 4 | # 5 | # get next filename under the [exchange directory]. if there is no folder for filename - the folder will be created 6 | # 7 | def get_next_report_filename(dir, filename_mask): 8 | 9 | filename_mask2 = filename_mask % (dir, 0) 10 | 11 | directory = os.path.dirname(filename_mask2) 12 | 13 | try: 14 | os.stat(directory) 15 | 16 | except: 17 | os.mkdir(directory) 18 | print("New directory created:", directory) 19 | 20 | deals_id = 0 21 | while os.path.exists(filename_mask % (directory, deals_id)): 22 | deals_id += 1 23 | 24 | return deals_id 25 | 26 | 27 | # get next filename in indexed way: if file file.txt exists so the file_0.txt will be created.. and so on 28 | def get_next_filename_index(path): 29 | path = os.path.expanduser(path) 30 | 31 | # if not os.path.exists(path): 32 | # return path 33 | 34 | root, ext = os.path.splitext(os.path.expanduser(path)) 35 | directory = os.path.dirname(root) 36 | fname = os.path.basename(root) 37 | candidate = fname+ext 38 | index = 0 39 | ls = set(os.listdir(directory)) 40 | while candidate in ls: 41 | candidate = "{}_{}{}".format(fname,index,ext) 42 | index += 1 43 | return os.path.join(directory, candidate) 44 | 45 | 46 | def dict_value_from_path(src_dict: dict, path: List[str], case_sensitive: bool = False): 47 | """ 48 | returns the value of dict field specified via "path" in form of a list of keys. By default the keys are matching 49 | case insensitive way. 50 | 51 | Example: 52 | src_dict = {"level1:{"level2":{"level3:value}}} 53 | list_of_keys = ["level1", "level2", "level3"] 54 | 55 | :param src_dict: dict from where to extract data b 56 | :param path: list of keys to specify the needed data 57 | :param case_sensitive: case sensototy flag for matching keys of dict against path entries 58 | 59 | :return: value of a dict branch 60 | """ 61 | s = src_dict.copy() 62 | key_upper = dict() 63 | key = "" 64 | 65 | for p in path: 66 | 67 | if not case_sensitive: 68 | key_upper_key = {key.upper(): key for key in s.keys()} 69 | key = key_upper_key[p.upper()] if p.upper() in key_upper_key else None 70 | 71 | else: 72 | key = p 73 | 74 | try: 75 | s = s[key] 76 | 77 | except Exception as e: 78 | s = None 79 | break 80 | 81 | return s 82 | 83 | -------------------------------------------------------------------------------- /ztom/reporter.py: -------------------------------------------------------------------------------- 1 | from .stats_influx import StatsInflux 2 | from pymongo import MongoClient, database, collection 3 | from urllib.parse import quote_plus 4 | 5 | 6 | class Reporter: 7 | 8 | def __init__(self, server_id, exchange_id): 9 | 10 | #self.session_uuid = session_uuid 11 | self.server_id = server_id 12 | self.exchange_id = exchange_id 13 | 14 | self.def_indicators = dict() # definition indicators 15 | self.indicators = dict() 16 | 17 | self.def_indicators["server_id"] = self.server_id 18 | self.def_indicators["exchange_id"] = self.exchange_id 19 | # self.def_indicators["session_uuid"] = self.session_uuid 20 | 21 | def set_indicator(self, key, value): 22 | self.indicators[key] = value 23 | 24 | def init_db(self, host, port, database, measurement, user="", password=""): 25 | self.influx = StatsInflux(host, port, database, measurement) 26 | self.influx.set_tags(self.def_indicators) 27 | 28 | def push_to_influx(self): 29 | return self.influx.push_fields(self.indicators) 30 | 31 | 32 | class MongoReporter(Reporter): 33 | 34 | def __init__(self, server_id: str, exchange_id: str): 35 | super().__init__(server_id, exchange_id) 36 | self.default_db = None # type: database.Database 37 | self.default_collection = None # type:collection.Collection 38 | self.mongo_client = None # type: MongoClient 39 | 40 | def init_db(self, host: str = "localhost", port = None, default_data_base = "", default_collection ="" ): 41 | 42 | uri = host 43 | 44 | self.mongo_client = MongoClient(uri) 45 | self.default_db = self.mongo_client[default_data_base] 46 | self.default_collection = self.default_db[default_collection] 47 | 48 | def push_report(self, report=None, collection: str = None, data_base: str = None): 49 | 50 | _data_base = self.default_db if data_base is None else self.mongo_client[data_base] 51 | _collection = self.default_collection if collection is None else _data_base[collection] 52 | 53 | if report is not None: 54 | if isinstance(report, list): 55 | result = _collection.insert_many(report) 56 | else: 57 | result = _collection.insert_one(report) 58 | 59 | else: 60 | 61 | # for r in report: 62 | # self.reporter.set_indicator(r, report[r]) 63 | 64 | result = self.default_collection.insert_one(self.indicators) 65 | 66 | return result 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /ztom/exchanges/binance.py: -------------------------------------------------------------------------------- 1 | from .. import exchange_wrapper as ew 2 | from ..throttle import Throttle 3 | 4 | class binance(ew.ccxtExchangeWrapper): 5 | 6 | PERIOD_SECONDS = 60 7 | REQUESTS_PER_PERIOD = 1200 8 | REQUEST_TYPE_WIGHTS = { 9 | "single": 1, 10 | "load_markets": 40, 11 | "fetch_tickers": 2, 12 | "fetch_ticker": 1, 13 | "fetch_order_book": 1, # !!! if limit of order book will be more 100 - it will cost more 14 | "create_order": 1, 15 | "fetch_order": 1, 16 | "cancel_order": 1, 17 | "fetch_my_trades": 1, 18 | "fetch_balance": 5} 19 | 20 | def _patch_fetch_bids_asks(self, symbols=None, params={}): 21 | """ 22 | fix for ccxt's method fetch_bids_asks for fetching single ticker from bids and asks 23 | """ 24 | self._ccxt.load_markets() 25 | rawTickers = self._ccxt.publicGetTickerBookTicker(params) 26 | if type(rawTickers) != list: 27 | rawTickers=[rawTickers] 28 | return self._ccxt.parse_tickers(rawTickers, symbols) 29 | 30 | def __init__(self, exchange_id, api_key ="", secret ="" ): 31 | super(binance, self).__init__(exchange_id, api_key, secret ) 32 | self.wrapper_id = "binance" 33 | 34 | # self._ccxt.fetch_bids_asks = self._patch_fetch_bids_asks 35 | 36 | def _fetch_ohlcv(self, symbol, timeframe='1m', since=None, limit=None): 37 | ''' 38 | redefine function to fetch different timeframes 39 | ''' 40 | return self._ccxt.fetch_ohlcv(symbol, timeframe='1m', since=None, limit=None) 41 | 42 | def _fetch_tickers(self, symbol=None): 43 | if symbol is None: 44 | return self._ccxt.fetch_bids_asks(symbol) 45 | else: 46 | return self._patch_fetch_bids_asks(symbols=symbol, params={"symbol": self.markets[symbol]["id"]}) 47 | 48 | def _create_order(self, symbol, order_type, side, amount, price=None): 49 | resp = self._ccxt.create_order(symbol, order_type, side, amount, price, {"newOrderRespType": "FULL"}) 50 | resp["cost"] = float(resp["info"]["cummulativeQuoteQty"]) 51 | return resp 52 | 53 | def _fetch_order(self, order): 54 | resp = self._ccxt.fetch_order(order.id, order.symbol) 55 | resp["cost"] = float(resp["info"]["cummulativeQuoteQty"]) 56 | return resp 57 | 58 | def _cancel_order(self, order): 59 | resp = self._ccxt.cancel_order(order.id, order.symbol) 60 | return resp 61 | 62 | def get_exchange_wrapper_id(self): 63 | return self.wrapper_id 64 | 65 | 66 | -------------------------------------------------------------------------------- /ztom/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | 4 | class SmartFormatter(argparse.HelpFormatter): 5 | 6 | def _split_lines(self, text, width): 7 | if text.startswith('R|'): 8 | return text[2:].splitlines() 9 | # this is the RawTextHelpFormatter._split_lines 10 | return argparse.HelpFormatter._split_lines(self, text, width) 11 | 12 | 13 | def get_cli_parameters(args): 14 | parser = argparse.ArgumentParser() 15 | 16 | parser.add_argument("--config", help="config file", 17 | dest="config_filename", 18 | action="store", default=None) 19 | 20 | parser.add_argument("--debug", help="If debug enabled - exit when error occurs. ", 21 | dest="debug", 22 | default=False, 23 | action="store_true") 24 | 25 | parser.add_argument("--noauth", help="If debug enabled - exit when error occurs. ", 26 | dest="noauth", 27 | default=False, 28 | action="store_true") 29 | 30 | parser.add_argument("--exchange", help="Seth the exchange_id. Ignore config file. ", 31 | dest='exchange_id', 32 | action="store", default=None) 33 | 34 | parser.add_argument("--balance", help="Staring test balance. if no value set - 1 by default", 35 | dest="test_balance", 36 | type=float, 37 | action="store", const=1, nargs="?") 38 | 39 | subparsers = parser.add_subparsers(help="Offline mode") 40 | subparsers.required = False 41 | offline = subparsers.add_parser("offline", help="Set the working mode. offline -h for help") 42 | # online = subparsers.add_parser("online", help="Set the online mode") 43 | offline.set_defaults(offline=True) 44 | 45 | offline.add_argument("--tickers", "-t", 46 | help="path to csv tickers file", 47 | dest="offline_tickers_file", 48 | default=None, 49 | action="store") 50 | 51 | offline.add_argument("--order_books","-ob", 52 | help="path to csv order books file", 53 | dest="offline_order_books_file", 54 | default=None, 55 | action="store") 56 | 57 | offline.add_argument("--markets", "-m", 58 | help="path to markets json file", 59 | dest="offline_markets_file", 60 | default=None, 61 | action="store") 62 | 63 | return parser.parse_args(args) 64 | -------------------------------------------------------------------------------- /examples/tickers_to_csv.py: -------------------------------------------------------------------------------- 1 | import ztom 2 | import csv 3 | import json 4 | import datetime 5 | 6 | """ 7 | This will save markets data and tickers and to tickers.csv and markets.json in 8 | current folder. These files could be used as an offline data sources for ztom. 9 | 10 | """ 11 | 12 | # parameters 13 | number_of_fetches = 10 14 | exchange_id = "binance" 15 | append_tickers = False 16 | symbols_to_save = [] # or ["ETH/BTC", "BNB/ETH", "BNB/BTC"] to save these particular symbols in markets and tickers 17 | 18 | """ 19 | /// start 20 | """ 21 | 22 | print("Started") 23 | storage = ztom.DataStorage(".") 24 | 25 | storage.register("tickers", ["fetch_id", "timestamp", "symbol", "ask", "bid", "askVolume", "bidVolume"], 26 | overwrite=not append_tickers) 27 | 28 | last_fetch_id = 0 29 | # getting last fetch_id in csv file 30 | if append_tickers: 31 | with open(storage.entities["tickers"]["full_path"], "r") as csvfile: 32 | not_header = False 33 | for row in csv.reader(csvfile): 34 | last_row = row 35 | 36 | if last_row is not None and last_row[0] != storage.entities["tickers"]["headers"][0]: 37 | last_fetch_id = int(last_row[0]) + 1 38 | 39 | print("Last fetch_id: {}".format(last_fetch_id)) 40 | 41 | 42 | ex = ztom.ccxtExchangeWrapper.load_from_id(exchange_id) # type: ztom.ccxtExchangeWrapper 43 | ex.enable_requests_throttle() 44 | ex.load_markets() 45 | 46 | markets_to_save = dict() 47 | 48 | if len(symbols_to_save) > 0: 49 | markets_to_save = {k: v for k, v in ex.markets.items() if k in symbols_to_save} 50 | else: 51 | markets_to_save = ex.markets 52 | 53 | 54 | with open('markets.json', 'w') as outfile: 55 | json.dump(markets_to_save, outfile) 56 | 57 | print("Init exchange") 58 | 59 | for i in range(0, number_of_fetches): 60 | 61 | if i > 0: 62 | sleep_time = ex.requests_throttle.sleep_time() 63 | 64 | print("Request in current period {}/{} sleeping for {} ".format( 65 | ex.requests_throttle.total_requests_current_period, 66 | ex.requests_throttle.requests_per_period, 67 | sleep_time)) 68 | 69 | print("Fetching tickers {}/{}...".format(i + 1, number_of_fetches)) 70 | tickers = ex.fetch_tickers() # type: dict 71 | print("... done") 72 | 73 | tickers_to_save = list() 74 | time_stamp = datetime.datetime.now().timestamp() 75 | 76 | for symbol, ticker in tickers.items(): 77 | if (len(symbols_to_save) > 0 and symbol in symbols_to_save) or len(symbols_to_save) == 0: 78 | tickers_to_save.append({"fetch_id": i+last_fetch_id, "timestamp": time_stamp, "symbol": symbol, 79 | "ask": ticker["ask"], 80 | "bid": ticker["bid"], 81 | "askVolume": ticker["askVolume"], 82 | "bidVolume": ticker["bidVolume"]}) 83 | 84 | storage.save_dict_all("tickers", tickers_to_save) 85 | 86 | 87 | 88 | print("OK") 89 | -------------------------------------------------------------------------------- /test_data/orders_trades_kucoin.json: -------------------------------------------------------------------------------- 1 | {"trades": [ 2 | { 3 | "timestamp": 1529946468000, 4 | "datetime": "2018-06-25T17:07:48.000Z", 5 | "symbol": "ETH/BTC", 6 | "amount": 0.329182, 7 | "info": { 8 | "dealValue": 0.02426129, 9 | "createdAt": 1529946468000, 10 | "dealPrice": 0.07370175, 11 | "fee": 2.426e-05, 12 | "amount": 0.329182, 13 | "feeRate": 0.001 14 | }, 15 | "fee": { 16 | "currency": "ETH", 17 | "cost": 2.426e-05 18 | }, 19 | "id": null, 20 | "side": "sell", 21 | "price": 0.07370175, 22 | "order": "5b3121639dda157dea6ad903", 23 | "cost": 0.02426129, 24 | "type": null 25 | }, 26 | { 27 | "timestamp": 1529946468000, 28 | "datetime": "2018-06-25T17:07:48.000Z", 29 | "symbol": "ETH/BTC", 30 | "amount": 0.17, 31 | "info": { 32 | "dealValue": 0.0125293, 33 | "createdAt": 1529946468000, 34 | "dealPrice": 0.07370176, 35 | "fee": 1.253e-05, 36 | "amount": 0.17, 37 | "feeRate": 0.001 38 | }, 39 | "fee": { 40 | "currency": "ETH", 41 | "cost": 1.253e-05 42 | }, 43 | "id": null, 44 | "side": "sell", 45 | "price": 0.07370176, 46 | "order": "5b3121639dda157dea6ad903", 47 | "cost": 0.0125293, 48 | "type": null 49 | }, 50 | { 51 | "timestamp": 1529946468000, 52 | "datetime": "2018-06-25T17:07:48.000Z", 53 | "symbol": "ETH/BTC", 54 | "amount": 0.000818, 55 | "info": { 56 | "dealValue": 6.029e-05, 57 | "createdAt": 1529946468000, 58 | "dealPrice": 0.07370176, 59 | "fee": 6e-08, 60 | "amount": 0.000818, 61 | "feeRate": 0.001 62 | }, 63 | "fee": { 64 | "currency": "ETH", 65 | "cost": 6e-08 66 | }, 67 | "id": null, 68 | "side": "sell", 69 | "price": 0.07370176, 70 | "order": "5b3121639dda157dea6ad903", 71 | "cost": 6.029e-05, 72 | "type": null 73 | } 74 | ] 75 | 76 | } -------------------------------------------------------------------------------- /tests/test_order_manager_recovery.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .context import ztom as zt 3 | import unittest 4 | from unittest.mock import MagicMock 5 | 6 | 7 | class RecoveryOrderManagerTestSuite(unittest.TestCase): 8 | 9 | def test_owa_manager_create(self): 10 | ex = zt.ccxtExchangeWrapper.load_from_id("binance") 11 | order = zt.RecoveryOrder("ETH/BTC", "ETH", 1, "BTC", 0.1) 12 | om = zt.ActionOrderManager(ex) 13 | om.add_order(order) 14 | om.set_order_supplementary_data(order, {"report_kpi": 1}) 15 | self.assertEqual(om.supplementary[order.id]["report_kpi"], 1) 16 | 17 | def test_owa_manager_run_order(self): 18 | 19 | ex = zt.ccxtExchangeWrapper.load_from_id("binance") 20 | ex.set_offline_mode("test_data/markets.json", "test_data/tickers.csv") 21 | ex.load_markets() 22 | order1 = zt.RecoveryOrder("ABC/XYZ", "ABC", 1, "XYZ", 0.1, 23 | ex.markets["ETH/BTC"]["limits"]["amount"]["min"] * 1.01, 24 | max_best_amount_order_updates=2, max_order_updates=5) 25 | 26 | order2 = zt.RecoveryOrder("USD/RUB", "USD", 1, "RUB", 70) 27 | om = zt.ActionOrderManager(ex) 28 | om.add_order(order1) 29 | om.add_order(order2) 30 | i = 0 31 | while len(om.get_open_orders()) > 0: 32 | i += 1 33 | 34 | if i > 5: 35 | om.data_for_orders = {"tickers": { 36 | "ABC/XYZ": {"ask": 0.1, "bid": 0.09}, 37 | "USD/RUB": {"ask": 70, "bid": 69}} 38 | } 39 | 40 | om.proceed_orders() 41 | 42 | self.assertEqual("closed", order1.status) 43 | self.assertAlmostEqual(1, order1.filled, delta=0.0001) 44 | # self.assertEqual(0.1, order1.filled_dest_amount) 45 | self.assertAlmostEqual(1, order1.filled_start_amount, delta=0.00001) 46 | self.assertListEqual(list([order1]), om.get_closed_orders()) 47 | 48 | om.proceed_orders() 49 | self.assertEqual(None, om.get_closed_orders()) 50 | 51 | def test_owa_could_not_create_trade_order(self): 52 | ex = zt.ccxtExchangeWrapper.load_from_id("binance") 53 | ex.set_offline_mode("test_data/markets.json", "test_data/tickers.csv") 54 | ex.load_markets() 55 | 56 | order1 = zt.RecoveryOrder("ABC/XYZ", "ABC", 1, "XYZ", 0.1, 57 | ex.markets["ETH/BTC"]["limits"]["amount"]["min"] * 1.01, 58 | max_best_amount_order_updates=2, max_order_updates=5) 59 | 60 | om = zt.ActionOrderManager(ex) 61 | om.offline_order_updates = 6 62 | om.add_order(order1) 63 | 64 | while len(om.get_open_orders()) > 0: 65 | om.data_for_orders = {"tickers": { 66 | "ABC/XYZ": {"ask": 0.1, "bid": 0.09}, 67 | "USD/RUB": {"ask": 70, "bid": 69}} 68 | } 69 | 70 | om.proceed_orders() 71 | 72 | # override order creation function with None result, so the second order could not be created 73 | ex.add_offline_order_data = MagicMock(return_value=None) 74 | 75 | self.assertLessEqual("closed", order1.status) 76 | self.assertLessEqual(1 / 6, order1.filled) # offline order has 6 updates, so we've run/filled only 1 of them 77 | self.assertEqual(order1.state, "market_price") 78 | 79 | 80 | if __name__ == '__main__': 81 | unittest.main() 82 | -------------------------------------------------------------------------------- /tests/test_datastorage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import random 3 | import shutil 4 | import tempfile 5 | 6 | import os 7 | 8 | from .context import ztom 9 | 10 | import unittest 11 | 12 | 13 | class DataStorageTestSuite(unittest.TestCase): 14 | 15 | def setUp(self): 16 | self.folder = os.path.join(tempfile.gettempdir(), 'storage' + str(random.randint(1, 100000))) 17 | self.storage = ztom.DataStorage(self.folder) 18 | 19 | def tearDown(self): 20 | if not os.path.isdir(self.folder): 21 | raise ValueError('Folder don\'t exist: %s' % self.folder) 22 | self.storage.stop() 23 | shutil.rmtree(self.folder) 24 | 25 | def test_save(self): 26 | self.storage.register('pepe', ['a', 'b', 'c']) 27 | self.storage.save('pepe', [1, '2', 2.0]) 28 | self.assertEqual('a,b,c\n1,2,2.0\n', self.file_contents('pepe')) 29 | 30 | self.storage.register('bobo', ['x', 'y']) 31 | self.storage.save('bobo', ['kkk', 'mmm']) 32 | self.assertEqual('x,y\nkkk,mmm\n', self.file_contents('bobo')) 33 | 34 | self.storage.save('pepe', [2, '3', 4.0]) 35 | self.assertEqual('a,b,c\n1,2,2.0\n2,3,4.0\n', self.file_contents('pepe')) 36 | 37 | def test_save_dict(self): 38 | self.storage.register('pepe', ['a', 'b', 'c']) 39 | self.storage.save_dict('pepe', {'b': '2', 'a': 1, 'c': 2.0}) 40 | self.assertEqual('a,b,c\n1,2,2.0\n', self.file_contents('pepe')) 41 | 42 | def test_save_dict_all(self): 43 | self.storage.register('pepe', ['a']) 44 | self.storage.save_dict_all('pepe', [{'a': '1'}, {'a': '2'}]) 45 | self.assertEqual('a\n1\n2\n', self.file_contents('pepe')) 46 | 47 | def test_save_all(self): 48 | self.storage.register('pepe', ['a', 'b', 'c']) 49 | self.storage.save_all('pepe', [['1', '2', '3'], ['4', '5', '6']]) 50 | self.assertEqual('a,b,c\n1,2,3\n4,5,6\n', self.file_contents('pepe')) 51 | 52 | def test_missing_values(self): 53 | self.storage.register('pepe', ['a', 'b']) 54 | self.storage.save_dict_all('pepe', [{'a': '1'}, {'b': '2'}]) 55 | self.assertEqual('a,b\n1,\n,2\n', self.file_contents('pepe')) 56 | 57 | def file_contents(self, type_name): 58 | with open(self.storage.path(type_name), 'r') as file: 59 | return file.read() 60 | 61 | def test_get_nested_from_dict(self): 62 | column = "column_name__section__subsection__field_name" 63 | dict = { 64 | "column_name": { 65 | "section": { 66 | "subsection": 67 | {"field_name": "value"} 68 | } 69 | } 70 | } 71 | val = self.storage._get_nested_from_dict(dict, column) 72 | self.assertEqual("value", val) 73 | 74 | column = "field_name" 75 | dict = {"field_name": "value2"} 76 | 77 | val = self.storage._get_nested_from_dict(dict, column) 78 | self.assertEqual("value2", val) 79 | 80 | self.storage.register('pepe', ['a__a1__a-2', 'b__b1_b2', 'c']) 81 | self.storage.save_dict('pepe', 82 | {'b': 83 | {"b1_b2": "val_b"} 84 | , 85 | 'a': { 86 | "a1": 87 | {"a-2": "val_a2"} 88 | }, 89 | 'c': 2.0}) 90 | 91 | self.assertEqual('a__a1__a-2,b__b1_b2,c\nval_a2,val_b,2.0\n', self.file_contents('pepe')) 92 | 93 | 94 | -------------------------------------------------------------------------------- /tests/test_fok_order.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from ztom import FokOrder, ccxtExchangeWrapper, ActionOrderManager, core, TradeOrder 3 | import time 4 | 5 | 6 | class FokTestSuite(unittest.TestCase): 7 | def test_fok_create(self): 8 | fok_order = FokOrder("ADA/ETH", 1000, 0.32485131 / 1000, "sell", max_order_updates=10, time_to_cancel=10) 9 | 10 | order = fok_order.active_trade_order 11 | self.assertEqual(order.dest_currency, "ETH") 12 | self.assertEqual(order.amount, 1000) 13 | self.assertEqual(order.side, "sell") 14 | self.assertEqual(fok_order.amount, 1000) 15 | 16 | self.assertEqual(fok_order.time_to_cancel, 10) 17 | 18 | self.assertEqual("fok", order.supplementary["parent_action_order"]["state"]) 19 | 20 | def test_cancel_by_time(self): 21 | fok_order = FokOrder("ADA/ETH", 1000, 0.32485131 / 1000, "sell", max_order_updates=10, time_to_cancel=0.1) 22 | 23 | order_command = fok_order.update_from_exchange({}) 24 | 25 | ex = ccxtExchangeWrapper.load_from_id("binance") # type: ccxtExchangeWrapper 26 | ex.set_offline_mode("test_data/markets.json", "test_data/tickers.csv") 27 | 28 | om = ActionOrderManager(ex) 29 | om.request_trades = False 30 | 31 | om.add_order(fok_order) 32 | om.proceed_orders() 33 | time.sleep(0.11) 34 | 35 | om.proceed_orders() 36 | om.proceed_orders() 37 | 38 | self.assertEqual(fok_order.status, "closed") 39 | self.assertIn("#timeout", fok_order.tags) 40 | 41 | def test_cancel_by_updates(self): 42 | fok_order = FokOrder("ADA/ETH", 1000, 0.32485131 / 1000, "sell", max_order_updates=10) 43 | 44 | order_command = fok_order.update_from_exchange({}) 45 | 46 | ex = ccxtExchangeWrapper.load_from_id("binance") # type: ccxtExchangeWrapper 47 | ex.set_offline_mode("test_data/markets.json", "test_data/tickers.csv") 48 | 49 | om = ActionOrderManager(ex) 50 | om.request_trades = False 51 | 52 | om.add_order(fok_order) 53 | om.proceed_orders() 54 | time.sleep(0.11) 55 | 56 | while len(om.get_open_orders())>0: 57 | om.proceed_orders() 58 | 59 | self.assertEqual(fok_order.status, "closed") 60 | self.assertEqual(11, fok_order.orders_history[0].update_requests_count) 61 | self.assertNotIn("#timeout", fok_order.tags) 62 | 63 | def test_fok_order_order_manager(self): 64 | 65 | ex = ccxtExchangeWrapper.load_from_id("binance") 66 | ex.set_offline_mode("test_data/markets.json", "test_data/tickers.csv") 67 | ex.load_markets() 68 | order1 = FokOrder("ABC/XYZ", 1, 5, "sell", max_order_updates=3, time_to_cancel=0.5) 69 | 70 | om = ActionOrderManager(ex) 71 | 72 | # offline order update data will be created to fill order in 10 updates 73 | om.offline_order_updates = 10 74 | 75 | om.add_order(order1) 76 | 77 | i = 0 78 | while len(om.get_open_orders()) > 0: 79 | i += 1 80 | om.proceed_orders() 81 | time.sleep(0.1) 82 | 83 | self.assertEqual("closed", order1.status) 84 | self.assertAlmostEqual(order1.amount / 2, order1.filled, delta=0.0001) 85 | 86 | # 2 extra updates from order manager updating trade order without requesting the exchange 87 | self.assertEqual(8, order1.orders_history[-1].update_requests_count) 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | if __name__ == '__main__': 107 | unittest.main() 108 | -------------------------------------------------------------------------------- /ztom/datastorage.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import os 3 | import csv 4 | from . import utils 5 | from pathlib import Path 6 | 7 | """ 8 | Class allows creation of csv based data storage. It' possible to save data from the list or nested dicts. 9 | 10 | In case of nested dics: the columns mapped to nested dict fields should contain "__" as attribute delimeter 11 | 12 | for example: 13 | Column name : "column_name__section__sub_section__field_name" will contain the value of 14 | dict["column_name"["section"]["sub_section"]["field_name"] 15 | 16 | Usage: 17 | at the beginning create storage: 18 | >>> storage = tgk.DataStorage('./storage1') 19 | 20 | register your data types: 21 | >>> headers = ['leg1', 'leg2', ...] 22 | >>> storage.register('Exchange', headers) 23 | 24 | then save data: 25 | - from list: 26 | >>> row = [leg1, leg2, ...] 27 | >>> storage.save('Ticker', row) 28 | - or from the nested dict 29 | 30 | at the end stop storage: 31 | >>> storage.stop() 32 | """ 33 | 34 | 35 | class DataStorage: 36 | def __init__(self, folder): 37 | if not os.path.isdir(folder): 38 | os.mkdir(folder) 39 | 40 | self.folder = folder 41 | self.entities = collections.defaultdict(str) 42 | 43 | def register(self, type_name, headers, overwrite=True): 44 | # self.validate_not_exists(type_name) 45 | full_path = os.path.join(self.folder, type_name + ".csv") 46 | exists = False 47 | if os.path.exists(full_path) or not overwrite: 48 | file = open(full_path, "a", newline='') 49 | exists = True 50 | else: 51 | file = open(full_path, "w", newline='') 52 | 53 | writer = csv.writer(file) 54 | 55 | if not exists: 56 | writer.writerow(headers) 57 | 58 | self.entities[type_name] = {'file': file, 'writer': writer, 'headers': headers, "full_path":full_path} 59 | 60 | def save(self, type_name, row): 61 | self.validate_exists(type_name) 62 | # TODO: use named tuples 63 | self.entities[type_name]['writer'].writerow(row) 64 | self.entities[type_name]['file'].flush() # TODO 65 | 66 | def save_all(self, type_name, rows): 67 | self.validate_exists(type_name) 68 | # TODO: use named tuples 69 | self.entities[type_name]['writer'].writerows(rows) 70 | self.entities[type_name]['file'].flush() # TODO 71 | 72 | def validate_not_exists(self, type_name): 73 | if type_name in self.entities.keys(): 74 | raise ValueError('Entity already exist: %s' % type_name) 75 | 76 | def validate_exists(self, type_name): 77 | if type_name not in self.entities.keys(): 78 | raise ValueError('Entity don\'t exist: %s' % type_name) 79 | 80 | def stop(self): 81 | for type_name, entity in self.entities.items(): 82 | self.entities[type_name]['file'].close() 83 | 84 | def path(self, type_name): 85 | return self.entities[type_name]['file'].name 86 | 87 | def _get_nested_from_dict(self, dict, column_name): 88 | 89 | path = column_name.split("__") 90 | return utils.dict_value_from_path(dict, path, True) 91 | 92 | def save_dict(self, type_name, data): 93 | """ 94 | todo = modify lambda - insert _get_nested_from_dict 95 | 96 | """ 97 | headers = self.entities[type_name]['headers'] 98 | values = list(map(lambda k: self._get_nested_from_dict(data, k), headers)) 99 | self.save(type_name, values) 100 | 101 | def save_dict_all(self, type_name, array): 102 | for x in array: self.save_dict(type_name, x) 103 | 104 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /test_data/orders_binance_error.json: -------------------------------------------------------------------------------- 1 | { 2 | "create": { 3 | "price": 0.077212, 4 | "fee": null, 5 | "status": "open", 6 | "datetime": "2018-06-22T14:23:56.403Z", 7 | "filled": 0.0, 8 | "side": "sell", 9 | "remaining": 0.016, 10 | "cost": 0.0, 11 | "info": { 12 | "price": "0.07721200", 13 | "type": "LIMIT", 14 | "fills": [], 15 | "status": "NEW", 16 | "executedQty": "0.00000000", 17 | "side": "SELL", 18 | "orderId": 170254693, 19 | "transactTime": 1529677436403, 20 | "timeInForce": "GTC", 21 | "symbol": "ETHBTC", 22 | "origQty": "0.01600000", 23 | "clientOrderId": "c4TBMKUSxu7ZWBzkDhpWax" 24 | }, 25 | "symbol": "ETH/BTC", 26 | "type": "limit", 27 | "timestamp": 1529677436403, 28 | "id": "170254693", 29 | "amount": 0.016, 30 | "lastTradeTimestamp": null 31 | }, 32 | "updates": [ 33 | { 34 | "price": 0.077212, 35 | "fee": null, 36 | "status": "open", 37 | "datetime": "2018-06-22T14:23:56.403Z", 38 | "filled": 0.0, 39 | "side": "sell", 40 | "remaining": 0.016, 41 | "cost": 0.0, 42 | "info": { 43 | "update":1, 44 | "price": "0.07721200", 45 | "icebergQty": "0.00000000", 46 | "status": "NEW", 47 | "executedQty": "0.00000000", 48 | "side": "SELL", 49 | "orderId": 170254693, 50 | "symbol": "ETHBTC", 51 | "isWorking": true, 52 | "timeInForce": "GTC", 53 | "stopPrice": "0.00000000", 54 | "type": "LIMIT", 55 | "origQty": "0.01600000", 56 | "clientOrderId": "c4TBMKUSxu7ZWBzkDhpWax", 57 | "time": 1529677436403 58 | }, 59 | "symbol": "ETH/BTC", 60 | "type": "limit", 61 | "timestamp": 1529677436403, 62 | "id": "170254693", 63 | "amount": 0.016, 64 | "lastTradeTimestamp": null 65 | }, 66 | { 67 | "price": 0.077212, 68 | "fee": null, 69 | "status": "open", 70 | "datetime": "2018-06-22T14:23:56.403Z", 71 | "filled": 0.0, 72 | "side": "sell", 73 | "remaining": 0.016, 74 | "cost": 0.0, 75 | "info": { 76 | "update":2, 77 | "price": "0.07721200", 78 | "icebergQty": "0.00000000", 79 | "status": "NEW", 80 | "executedQty": "0.00000000", 81 | "side": "SELL", 82 | "orderId": 170254693, 83 | "symbol": "ETHBTC", 84 | "isWorking": true, 85 | "timeInForce": "GTC", 86 | "stopPrice": "0.00000000", 87 | "type": "LIMIT", 88 | "origQty": "0.01600000", 89 | "clientOrderId": "c4TBMKUSxu7ZWBzkDhpWax", 90 | "time": 1529677436403 91 | }, 92 | "symbol": "ETH/BTC", 93 | "type": "limit", 94 | "timestamp": 1529677436403, 95 | "id": "170254693", 96 | "amount": 0.016, 97 | "lastTradeTimestamp": null 98 | }, 99 | { 100 | "price": 0.077212, 101 | "fee": null, 102 | "status": "open", 103 | "datetime": "2018-06-22T14:23:56.403Z", 104 | "filled": 0.0, 105 | "side": "sell", 106 | "remaining": 0.016, 107 | "cost": 0.0, 108 | "info": { 109 | "update":3, 110 | "price": "0.07721200", 111 | "icebergQty": "0.00000000", 112 | "status": "NEW", 113 | "executedQty": "0.00000000", 114 | "side": "SELL", 115 | "orderId": 170254693, 116 | "symbol": "ETHBTC", 117 | "isWorking": true, 118 | "timeInForce": "GTC", 119 | "stopPrice": "0.00000000", 120 | "type": "LIMIT", 121 | "origQty": "0.01600000", 122 | "clientOrderId": "c4TBMKUSxu7ZWBzkDhpWax", 123 | "time": 1529677436403 124 | }, 125 | "symbol": "ETH/BTC", 126 | "type": "limit", 127 | "timestamp": 1529677436403, 128 | "id": "170254693", 129 | "amount": 0.016, 130 | "lastTradeTimestamp": null 131 | } 132 | 133 | ] 134 | } -------------------------------------------------------------------------------- /ztom/models/trade_order.py: -------------------------------------------------------------------------------- 1 | 2 | from sqlalchemy import Column, DateTime, String, Integer, ForeignKey, func, TIMESTAMP, Float, JSON 3 | from ztom.models._sqla_base import Base 4 | from ztom.trade_orders import TradeOrder 5 | import datetime 6 | import copy 7 | 8 | 9 | class TradeOrderReport(Base): 10 | 11 | __tablename__ = "trade_orders" 12 | 13 | id = Column(Integer, primary_key=True) # db id 14 | timestamp = Column(TIMESTAMP(timezone=True)) #timestamp of adding record 15 | 16 | deal_uuid = Column(String) # deal_uuid by which order was generated 17 | action_order_uuid = Column(String) # actionOrder.id 's by which order was generated 18 | 19 | id_from_exchange = Column(String) # TradeOrder.id from exchange 20 | internal_id = Column(String) # internal id for offline orders management 21 | 22 | status = Column(String) # 'open', 'closed', 'canceled' 23 | 24 | # dicts of proceeded UTC timestamps: {"request_sent":value, "request_received":value, "from_exchange":value} 25 | timestamp_open = Column(JSON) # on placing order 26 | timestamp_closed = Column(JSON) # on closing order 27 | 28 | symbol = Column(String) 29 | type = Column(String) # order type (limit) 30 | side = Column(String) # buy or sell 31 | amount = Column(Float) # ordered amount of base currency 32 | init_price = Column(Float) # initial price, when create order 33 | price = Column(Float) # placed price, could be updated from exchange 34 | 35 | start_currency = Column(String) 36 | dest_currency = Column(String) 37 | 38 | fee = Column(JSON) # fee from ccxt 39 | 40 | trades = Column(JSON) 41 | fees = Column(JSON) 42 | 43 | precision_amount = Column(Float) 44 | price_precision = Column(Float) 45 | 46 | filled = Column(Float) # filled amount of base currency 47 | remaining = Column(Float) # remaining amount to fill 48 | cost = Column(Float) # filled amount of quote currency 'filled' * 'price' 49 | 50 | info = Column(JSON) # the original response from exchange 51 | 52 | # order_book = Column(JSON) 53 | 54 | amount_start = Column(Float) # amount of start currency 55 | amount_dest = Column(Float) # amount of dest currency 56 | 57 | update_requests_count = Column(Integer) # number of updates of order. should be in correspondence with API requests 58 | 59 | filled_start_amount = Column(Float) # filled amount of start currency 60 | filled_dest_amount = Column(Float) # filled amount of dest currency 61 | 62 | supplementary = Column(JSON) 63 | 64 | @classmethod 65 | def from_trade_order(cls, order: TradeOrder, timestamp: datetime, deal_uuid: str = None, 66 | action_order_id: str = None, supplementary: dict = None) -> 'TradeOrderReport': 67 | """ 68 | creates TradeOrderReport sqlalchemy table from TradeOrder object. Provide deal_uuid and action_order_uuid to 69 | connect Trade Order to deal and action order. if :param supplementary: will be provided - it will be added with 70 | the supplementary field of original TradeOrder. 71 | 72 | :param order: source TradeOrder object 73 | :param timestamp: datetime with timezone will be used to fill timestamp field in table 74 | :param deal_uuid: deal_uuid - optional 75 | :param action_order_id: optional 76 | :param supplementary: add supplementary data to existing order's supplementary field 77 | :return: sqlalchemy table TradeOrderReport 78 | """ 79 | 80 | supplementary_from_order = copy.copy(order.supplementary) 81 | if supplementary is not None: 82 | supplementary_from_order.update(supplementary) 83 | 84 | # noinspection PyTypeChecker 85 | trade_order_report = TradeOrderReport( 86 | timestamp=timestamp, 87 | deal_uuid=deal_uuid, 88 | action_order_uuid=action_order_id, 89 | id_from_exchange=order.id, 90 | internal_id=order.internal_id, 91 | status=order.status, 92 | timestamp_open=order.timestamp_open, 93 | timestamp_closed=order.timestamp_closed, 94 | symbol=order.symbol, 95 | type=str(order.type), 96 | side=order.side, 97 | amount=order.amount, 98 | init_price=order.init_price, 99 | price=order.price, 100 | start_currency=order.start_currency, 101 | dest_currency=order.dest_currency, 102 | fee=order.fee, 103 | trades=order.trades, 104 | fees=order.fees, 105 | precision_amount=order.precision_amount, 106 | price_precision=order.price_precision, 107 | filled=order.filled, 108 | remaining=order.remaining, 109 | cost=order.cost, 110 | info=order.info, 111 | amount_start=order.amount_start, 112 | amount_dest=order.amount_dest, 113 | update_requests_count=order.update_requests_count, 114 | filled_start_amount=order.filled_start_amount, 115 | filled_dest_amount=order.filled_dest_amount, 116 | supplementary=supplementary_from_order 117 | ) 118 | 119 | return trade_order_report 120 | -------------------------------------------------------------------------------- /tests/test_bot.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .context import ztom 3 | 4 | import unittest 5 | import os 6 | import time 7 | import uuid 8 | 9 | 10 | # todo - tests for reports directories creation 11 | 12 | class BasicTestSuite(unittest.TestCase): 13 | """Basic test cases.""" 14 | 15 | def setUp(self): 16 | self.default_config = "_config_default.json" 17 | self.default_log = "_log_default.log" 18 | 19 | self.bot = ztom.Bot(self.default_config, self.default_log) 20 | 21 | def test_create_bot(self): 22 | self.bot.load_config_from_json(self.default_config) 23 | 24 | self.assertEqual(self.bot.api_key["apiKey"], "testApiKey") 25 | self.assertEqual(self.bot.server_id, "CORE1") 26 | 27 | uuid_obj = uuid.UUID(self.bot.session_uuid) 28 | 29 | self.assertEqual(self.bot.session_uuid, str(uuid_obj)) 30 | 31 | # todo: test for checking if log file created 32 | 33 | def test_load_config_from_yml(self): 34 | bot = ztom.Bot("_config_default.yml", self.default_log) 35 | bot.load_config_from_yml(bot.config_filename) 36 | 37 | self.assertEqual(bot.api_key["apiKey"], "testApiKey") 38 | self.assertEqual(bot.server_id, "CORE2") 39 | 40 | def test_load_config_from_file(self): 41 | bot = ztom.Bot("_config_default.yml", self.default_log) 42 | bot.load_config_from_file(bot.config_filename) 43 | 44 | self.assertEqual(bot.api_key["apiKey"], "testApiKey") 45 | self.assertEqual(bot.server_id, "CORE2") 46 | 47 | bot = ztom.Bot("_config_default.json", self.default_log) 48 | bot.load_config_from_file(bot.config_filename) 49 | 50 | self.assertEqual(bot.api_key["apiKey"], "testApiKey") 51 | self.assertEqual(bot.server_id, "CORE1") 52 | 53 | with self.assertRaises(Exception) as context: 54 | bot = ztom.Bot("_config_default.txt", self.default_log) 55 | bot.load_config_from_file(bot.config_filename) 56 | 57 | self.assertTrue("Wrong config file extension. Should be json or yml." in context.exception.args) 58 | 59 | def test_cli_overrides_config_file(self): 60 | self.bot.debug = True 61 | 62 | self.bot.set_from_cli("--config _config_default.json --balance 2 --noauth --debug --exchange kraken".split(" ")) 63 | 64 | self.bot.load_config_from_file(self.bot.config_filename) 65 | 66 | self.assertEqual(self.bot.debug, True) 67 | self.assertEqual(True, self.bot.noauth) 68 | 69 | self.assertEqual(self.bot.exchange_id, "kraken") 70 | 71 | self.assertEqual(self.bot.config_filename, "_config_default.json") 72 | 73 | self.assertEqual(self.bot.api_key["apiKey"], "testApiKey") 74 | self.assertEqual(self.bot.test_balance, 2) 75 | 76 | def test_load_offline_data(self): 77 | 78 | cli = "--balance 1 offline -ob test_data/order_books.csv -m test_data/markets.json" 79 | self.bot.set_from_cli(cli.split(" ")) 80 | 81 | print("OK") 82 | 83 | 84 | 85 | def test_multi_logging(self): 86 | self.bot.log(self.bot.LOG_ERROR, "ERRORS", list(("error line 1", "error line 2", "error line 3"))) 87 | 88 | def test_logging(self): 89 | default_config = "_config_default.json" 90 | default_log = "_log_default.log" 91 | 92 | bot = ztom.Bot(default_config, default_log) 93 | 94 | bot.log(bot.LOG_INFO, "Test") 95 | 96 | with open(default_log, 'r') as myfile: 97 | log_file = myfile.read() 98 | myfile.close() 99 | 100 | self.assertGreater(log_file.find("Test"), -1) 101 | 102 | handlers = bot.logger.handlers[:] 103 | for handler in handlers: 104 | handler.close() 105 | bot.logger.removeHandler(handler) 106 | 107 | os.remove(default_log) 108 | 109 | def test_timer(self): 110 | timer = ztom.Timer() 111 | timer.notch("start") 112 | time.sleep(0.1) 113 | timer.notch("finish") 114 | 115 | self.assertEqual(timer.notches[0]["name"], "start") 116 | self.assertAlmostEqual(timer.notches[1]["duration"], 0.1, 1) 117 | 118 | def test_reporter_init(self): 119 | self.bot.load_config_from_file(self.default_config) 120 | 121 | self.assertEqual(self.bot.influxdb["measurement"], "status") 122 | self.bot.init_remote_reports() 123 | 124 | def test_exchange_init(self): 125 | pass 126 | 127 | def test_min_amount_ok(self): 128 | self.bot.min_amounts = {"BTC": 0.002, "ETH": 0.02, "BNB": 1, "USDT": 20} 129 | symbol = "ETH/BTC" 130 | price = 0.03185046 131 | sure_coef = 1.003 132 | 133 | self.assertEqual((0.002 / 0.03185046) * 1.001, self.bot.min_order_amount(symbol, price)) 134 | self.assertEqual((0.002 / 0.03185046) * 1.003, 135 | self.bot.min_order_amount(symbol, price, sure_coefficient=sure_coef)) 136 | 137 | self.assertEqual((1 / 0.00000233) * 1.003, 138 | self.bot.min_order_amount("BTC/RUR", 0.00000233, {"RUR": 1}, sure_coefficient=sure_coef)) 139 | 140 | def test_min_amount_not_ok(self): 141 | self.bot.min_amounts = {"BTC": 0.002, "ETH": 0.02, "BNB": 1, "USDT": 20} 142 | 143 | symbol = "ETH/RUB" 144 | price = 0.03185046 145 | 146 | self.assertEqual(0, self.bot.min_order_amount(symbol, price)) 147 | self.assertEqual(0, self.bot.min_order_amount("ETH/BTC", 0.0)) 148 | self.assertEqual(0, self.bot.min_order_amount("ETH/BTC", 0.03185046, {"BTC": 0.0})) 149 | 150 | self.bot.min_amounts = None 151 | self.assertEqual(0, self.bot.min_order_amount(symbol, price)) 152 | 153 | 154 | if __name__ == '__main__': 155 | unittest.main() 156 | -------------------------------------------------------------------------------- /test_data/orders_binance.json: -------------------------------------------------------------------------------- 1 | { 2 | "create": { 3 | "price": 0.077212, 4 | "fee": null, 5 | "status": "open", 6 | "datetime": "2018-06-22T14:23:56.403Z", 7 | "filled": 0.0, 8 | "side": "sell", 9 | "remaining": 0.016, 10 | "cost": 0.0, 11 | "info": { 12 | "price": "0.07721200", 13 | "type": "LIMIT", 14 | "fills": [], 15 | "status": "NEW", 16 | "executedQty": "0.00000000", 17 | "side": "SELL", 18 | "orderId": 170254693, 19 | "transactTime": 1529677436403, 20 | "timeInForce": "GTC", 21 | "symbol": "ETHBTC", 22 | "origQty": "0.01600000", 23 | "clientOrderId": "c4TBMKUSxu7ZWBzkDhpWax" 24 | }, 25 | "symbol": "ETH/BTC", 26 | "type": "limit", 27 | "timestamp": 1529677436403, 28 | "id": "170254693", 29 | "amount": 0.016, 30 | "lastTradeTimestamp": null 31 | }, 32 | "updates": [ 33 | { 34 | "price": 0.077212, 35 | "fee": null, 36 | "status": "open", 37 | "datetime": "2018-06-22T14:23:56.403Z", 38 | "filled": 0.0, 39 | "side": "sell", 40 | "remaining": 0.016, 41 | "cost": 0.0, 42 | "info": { 43 | "price": "0.07721200", 44 | "icebergQty": "0.00000000", 45 | "status": "NEW", 46 | "executedQty": "0.00000000", 47 | "side": "SELL", 48 | "orderId": 170254693, 49 | "symbol": "ETHBTC", 50 | "isWorking": true, 51 | "timeInForce": "GTC", 52 | "stopPrice": "0.00000000", 53 | "type": "LIMIT", 54 | "origQty": "0.01600000", 55 | "clientOrderId": "c4TBMKUSxu7ZWBzkDhpWax", 56 | "time": 1529677436403 57 | }, 58 | "symbol": "ETH/BTC", 59 | "type": "limit", 60 | "timestamp": 1529677436403, 61 | "id": "170254693", 62 | "amount": 0.016, 63 | "lastTradeTimestamp": null 64 | }, 65 | { 66 | "price": 0.077212, 67 | "fee": null, 68 | "status": "open", 69 | "datetime": "2018-06-22T14:23:56.403Z", 70 | "filled": 0.0, 71 | "side": "sell", 72 | "remaining": 0.016, 73 | "cost": 0.0, 74 | "info": { 75 | "price": "0.07721200", 76 | "icebergQty": "0.00000000", 77 | "status": "NEW", 78 | "executedQty": "0.00000000", 79 | "side": "SELL", 80 | "orderId": 170254693, 81 | "symbol": "ETHBTC", 82 | "isWorking": true, 83 | "timeInForce": "GTC", 84 | "stopPrice": "0.00000000", 85 | "type": "LIMIT", 86 | "origQty": "0.01600000", 87 | "clientOrderId": "c4TBMKUSxu7ZWBzkDhpWax", 88 | "time": 1529677436403 89 | }, 90 | "symbol": "ETH/BTC", 91 | "type": "limit", 92 | "timestamp": 1529677436403, 93 | "id": "170254693", 94 | "amount": 0.016, 95 | "lastTradeTimestamp": null 96 | }, 97 | { 98 | "price": 0.077212, 99 | "fee": null, 100 | "status": "open", 101 | "datetime": "2018-06-22T14:23:56.403Z", 102 | "filled": 0.0, 103 | "side": "sell", 104 | "remaining": 0.016, 105 | "cost": 0.0, 106 | "info": { 107 | "price": "0.07721200", 108 | "icebergQty": "0.00000000", 109 | "status": "NEW", 110 | "executedQty": "0.00000000", 111 | "side": "SELL", 112 | "orderId": 170254693, 113 | "symbol": "ETHBTC", 114 | "isWorking": true, 115 | "timeInForce": "GTC", 116 | "stopPrice": "0.00000000", 117 | "type": "LIMIT", 118 | "origQty": "0.01600000", 119 | "clientOrderId": "c4TBMKUSxu7ZWBzkDhpWax", 120 | "time": 1529677436403 121 | }, 122 | "symbol": "ETH/BTC", 123 | "type": "limit", 124 | "timestamp": 1529677436403, 125 | "id": "170254693", 126 | "amount": 0.016, 127 | "lastTradeTimestamp": null 128 | }, 129 | { 130 | "price": 0.077212, 131 | "fee": null, 132 | "status": "closed", 133 | "datetime": "2018-06-22T14:23:56.403Z", 134 | "filled": 0.016, 135 | "side": "sell", 136 | "remaining": 0.0, 137 | "cost": 0.001235392, 138 | "info": { 139 | "price": "0.07721200", 140 | "icebergQty": "0.00000000", 141 | "status": "FILLED", 142 | "executedQty": "0.01600000", 143 | "side": "SELL", 144 | "orderId": 170254693, 145 | "symbol": "ETHBTC", 146 | "isWorking": true, 147 | "timeInForce": "GTC", 148 | "stopPrice": "0.00000000", 149 | "type": "LIMIT", 150 | "origQty": "0.01600000", 151 | "clientOrderId": "c4TBMKUSxu7ZWBzkDhpWax", 152 | "time": 1529677436403 153 | }, 154 | "symbol": "ETH/BTC", 155 | "type": "limit", 156 | "timestamp": 1529677436403, 157 | "id": "170254693", 158 | "amount": 0.016, 159 | "lastTradeTimestamp": null 160 | } 161 | ] 162 | } -------------------------------------------------------------------------------- /test_data/orders_binance_cancel.json: -------------------------------------------------------------------------------- 1 | { 2 | "create": { 3 | "price": 0.077212, 4 | "fee": null, 5 | "status": "open", 6 | "datetime": "2018-06-22T14:23:56.403Z", 7 | "filled": 0.0, 8 | "side": "sell", 9 | "remaining": 0.016, 10 | "cost": 0.0, 11 | "info": { 12 | "price": "0.07721200", 13 | "type": "LIMIT", 14 | "fills": [], 15 | "status": "NEW", 16 | "executedQty": "0.00000000", 17 | "side": "SELL", 18 | "orderId": 170254693, 19 | "transactTime": 1529677436403, 20 | "timeInForce": "GTC", 21 | "symbol": "ETHBTC", 22 | "origQty": "0.01600000", 23 | "clientOrderId": "c4TBMKUSxu7ZWBzkDhpWax" 24 | }, 25 | "symbol": "ETH/BTC", 26 | "type": "limit", 27 | "timestamp": 1529677436403, 28 | "id": "170254693", 29 | "amount": 0.016, 30 | "lastTradeTimestamp": null 31 | }, 32 | "updates": [ 33 | { 34 | "price": 0.077212, 35 | "fee": null, 36 | "status": "open", 37 | "datetime": "2018-06-22T14:23:56.403Z", 38 | "filled": 0.0, 39 | "side": "sell", 40 | "remaining": 0.016, 41 | "cost": 0.0, 42 | "info": { 43 | "update":1, 44 | "price": "0.07721200", 45 | "icebergQty": "0.00000000", 46 | "status": "NEW", 47 | "executedQty": "0.00000000", 48 | "side": "SELL", 49 | "orderId": 170254693, 50 | "symbol": "ETHBTC", 51 | "isWorking": true, 52 | "timeInForce": "GTC", 53 | "stopPrice": "0.00000000", 54 | "type": "LIMIT", 55 | "origQty": "0.01600000", 56 | "clientOrderId": "c4TBMKUSxu7ZWBzkDhpWax", 57 | "time": 1529677436403 58 | }, 59 | "symbol": "ETH/BTC", 60 | "type": "limit", 61 | "timestamp": 1529677436403, 62 | "id": "170254693", 63 | "amount": 0.016, 64 | "lastTradeTimestamp": null 65 | }, 66 | { 67 | "price": 0.077212, 68 | "fee": null, 69 | "status": "open", 70 | "datetime": "2018-06-22T14:23:56.403Z", 71 | "filled": 0.0, 72 | "side": "sell", 73 | "remaining": 0.016, 74 | "cost": 0.0, 75 | "info": { 76 | "update":2, 77 | "price": "0.07721200", 78 | "icebergQty": "0.00000000", 79 | "status": "NEW", 80 | "executedQty": "0.00000000", 81 | "side": "SELL", 82 | "orderId": 170254693, 83 | "symbol": "ETHBTC", 84 | "isWorking": true, 85 | "timeInForce": "GTC", 86 | "stopPrice": "0.00000000", 87 | "type": "LIMIT", 88 | "origQty": "0.01600000", 89 | "clientOrderId": "c4TBMKUSxu7ZWBzkDhpWax", 90 | "time": 1529677436403 91 | }, 92 | "symbol": "ETH/BTC", 93 | "type": "limit", 94 | "timestamp": 1529677436403, 95 | "id": "170254693", 96 | "amount": 0.016, 97 | "lastTradeTimestamp": null 98 | }, 99 | { 100 | "price": 0.077212, 101 | "fee": null, 102 | "status": "open", 103 | "datetime": "2018-06-22T14:23:56.403Z", 104 | "filled": 0.0, 105 | "side": "sell", 106 | "remaining": 0.016, 107 | "cost": 0.0, 108 | "info": { 109 | "update":3, 110 | "price": "0.07721200", 111 | "icebergQty": "0.00000000", 112 | "status": "NEW", 113 | "executedQty": "0.00000000", 114 | "side": "SELL", 115 | "orderId": 170254693, 116 | "symbol": "ETHBTC", 117 | "isWorking": true, 118 | "timeInForce": "GTC", 119 | "stopPrice": "0.00000000", 120 | "type": "LIMIT", 121 | "origQty": "0.01600000", 122 | "clientOrderId": "c4TBMKUSxu7ZWBzkDhpWax", 123 | "time": 1529677436403 124 | }, 125 | "symbol": "ETH/BTC", 126 | "type": "limit", 127 | "timestamp": 1529677436403, 128 | "id": "170254693", 129 | "amount": 0.016, 130 | "lastTradeTimestamp": null 131 | }, 132 | { 133 | "price": 0.077212, 134 | "fee": null, 135 | "status": "canceled", 136 | "datetime": "2018-06-22T14:23:56.403Z", 137 | "filled": 0.016, 138 | "side": "sell", 139 | "remaining": 0.0, 140 | "cost": 0.001235392, 141 | "info": { 142 | "update":4, 143 | "price": "0.07721200", 144 | "icebergQty": "0.00000000", 145 | "status": "CANCELED", 146 | "executedQty": "0.01600000", 147 | "side": "SELL", 148 | "orderId": 170254693, 149 | "symbol": "ETHBTC", 150 | "isWorking": true, 151 | "timeInForce": "GTC", 152 | "stopPrice": "0.00000000", 153 | "type": "LIMIT", 154 | "origQty": "0.01600000", 155 | "clientOrderId": "c4TBMKUSxu7ZWBzkDhpWax", 156 | "time": 1529677436403 157 | }, 158 | "symbol": "ETH/BTC", 159 | "type": "limit", 160 | "timestamp": 1529677436403, 161 | "id": "170254693", 162 | "amount": 0.016, 163 | "lastTradeTimestamp": null 164 | } 165 | ] 166 | } -------------------------------------------------------------------------------- /docs/remainings.md: -------------------------------------------------------------------------------- 1 | Remainings Management 2 | ===================== 3 | 4 | For **Remainings** we will consider some amount of assets which appear because of order execution and could not be 5 | processed with the next orders. 6 | 7 | Sources of remainings: 8 | - order was not filled by 100% and cancelled within FOK or other ActionOrder 9 | - target order amount was less than minimum amount so exchange rejected to create order 10 | 11 | 12 | #### Typical case for remainings management 13 | 14 | Consider you have placed FOK order with time constraint to sell 1 BTC for ETH and it was canceled/closed by order manager: 15 | - FOK was closed because of time and filled for 0.9999 BTC so there is 0.0001 remained unfilled 16 | - Exchange not allows to place any order for 0.0001 17 | - You could have several situations of this kind within your trading application 18 | 19 | So, in order to convert all the BTCs remainings you have to collect all the remained BTC until its amount will be enough 20 | to place an order and actually run the orders. 21 | 22 | 23 | #### Remaining definition 24 | 25 | We will define remaining as a set of following data: 26 | - remained currency/asset 27 | - target currency and pair's symbol of order which produced remaining 28 | - amount of remaining 29 | 30 | Using this data it will be possible to create orders in accordance to remaining's target currency. 31 | The efficiency of converting remainings could be tracked by accumulation target asset's amount. 32 | 33 | To deal with remainings ZTOM will provide following capabilities: 34 | 35 | - Remainings Management Workflow 36 | - Data structures tailored for remainings management 37 | - Supplemental Tools and dashboards 38 | 39 | Workflow 40 | -------- 41 | 0. ActionOrder have been closed with some remaining amount 42 | 1. Add remaining to remainings balance tracking db 43 | 2. Wait until amount of some remaining asset will be enough for creating order to convert remaining into target currency 44 | 3. Place an ActionOrder to trade particular remained currency into and target currency 45 | 4. Add remainings deduction to db on filled amount of ActionOrder 46 | 5. Create Deal record in accordance of trading result 47 | 48 | Data structures 49 | --------------- 50 | Following data structures are involved during remainings management. 51 | 52 | 1. Remainings balance change for tracking all remainings changes 53 | 2. Deal record when some of the remainings were filled. 54 | 3. (optional) Remainings events to track separate adding and filling remainings (within the common events reporting capabilities) 55 | 56 | ## Remainings balance change table (remainings_balance) 57 | 58 | - remainings_balance_id 59 | - exchange_id 60 | - account_id 61 | - timestamp 62 | - action: add, fill (deduct) remainings or aggregate records to optimize table 63 | - currency: currency of remainings 64 | - symbol: trade pair of an order yielded the remaining when remaining was created 65 | - amount_delta: change of amount of currency. could be positive or negative 66 | - target_currency: asset which was intended to be yielded from remaining proceeding 67 | - target_amount_delta: considered target amount delta 68 | 69 | ## Deal report of remaining conversion result 70 | 71 | Essential Content of deal report: 72 | - deal_type: "REMAININGS_CONVERT" 73 | - deal_uuid: new deal_uuid 74 | - status: "FILLED", "ERROR" 75 | - currency: target currency of remaining conversoin 76 | - start_amount: 0 77 | - result_amount: filled target currency amount 78 | - gross_profit: filled target currency amount 79 | 80 | TODO: add sample generating Deal Report from ActionOrder 81 | ```python 82 | import datetime, pytz 83 | from ztom import ActionOrder, Bot, DealReport 84 | 85 | 86 | bot = Bot(...) 87 | order = ActionOrder(...) 88 | 89 | deal_report = DealReport( 90 | timestamp=datetime.now(tz=pytz.timezone("UTC")), 91 | timestamp_start=datetime.fromtimestamp(order.timestamp, tz=pytz.timezone("UTC")), 92 | exchange=bot.exchange.exchange_id, 93 | instance=bot.server_id, 94 | server=bot.server_id, 95 | deal_type="REMAININGS_CONVERT", 96 | deal_uuid="134134-1341234-1341341-2341", 97 | status="FILLED", 98 | currency=order.dest_currency, 99 | start_amount=0.0, 100 | result_amount=order.filled_dest_amount, 101 | gross_profit=order.filled_dest_amount, 102 | net_profit=0.0, 103 | config=bot.config, 104 | deal_data={}) 105 | ``` 106 | 107 | ## Remainings events (optional) 108 | 109 | For tracking adding and filling remainings following date should be tracked or reported. 110 | 111 | #### Adding remaining 112 | 113 | When adding remaining we should record the particular data regarding added remaining so later it's possible to distinguish 114 | the dynamics of adding remainings. 115 | 116 | - type of event: REMAININGS_ADD 117 | - event_source_id: deal_uuid 118 | - payload: 119 | ```JSON 120 | { 121 | "deal_uuid": "1233-ABCDH-DDD-sAAAA", 122 | "currency": "BTC", 123 | "amount": 0.0001, 124 | "target_currency": "ETH", 125 | "target_amount": "0.000001", 126 | "symbol": "BTC/ETH", 127 | "exchange_id": "superDex", 128 | "account_id": "trader_bot_1", 129 | "timestamps": { 130 | "trade_order_created": 123123123123, 131 | "trade_order_closed": 123123123163 132 | }, 133 | "ActionOrder": 134 | { 135 | "id": 1414 136 | } 137 | } 138 | ``` 139 | #### Filling remaining 140 | - type of event: REMAININGS_FILL 141 | - event_source_id: deal_uuid 142 | - payload: 143 | ```JSON 144 | { 145 | "currency": "BTC", 146 | "amount": 0.01, 147 | "filled_amount": 0.005, 148 | "average_price": 231, 149 | "target_currency": "ETH", 150 | "target_amount": "0.01", 151 | "filled_target_amount": "0.005", 152 | "symbol": "BTC/ETH", 153 | "exchange_id": "superDex", 154 | "account_id": "trader_bot_1", 155 | "timestamps": { 156 | "trade_order_created": 123123123123, 157 | "trade_order_closed": 123123123163 158 | }, 159 | "ActionOrder": 160 | { 161 | "id": 1414 162 | } 163 | } 164 | ``` 165 | 166 | 167 | -------------------------------------------------------------------------------- /tests/test_orderbook.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .context import ztom 3 | 4 | import unittest 5 | 6 | 7 | class OrderBookTestSuite(unittest.TestCase): 8 | 9 | def ignore_test_orderbook(self): 10 | asks = [(1., 1.), (2., 3.)] 11 | bids = [(3., 1.)] 12 | orderbook = ztom.OrderBook('S', asks, bids) 13 | 14 | self.assertEqual(len(orderbook.csv_header()), 4) 15 | self.assertEqual(len(orderbook.csv_rows(6)), 4) 16 | 17 | def test_depth(self): 18 | ob = dict() 19 | ob["asks"] = [[0.0963510000, 2], 20 | [0.0963880000, 2], 21 | [0.0964390000, 3]] 22 | 23 | ob["bids"] = [[0.0963360000, 1], 24 | [0.0963300000, 2], 25 | [0.0963280000, 3]] 26 | 27 | orderbook = ztom.OrderBook("ETH/BTC", ob["asks"], ob["bids"]) 28 | 29 | self.assertEqual( 30 | orderbook.get_depth(0.096351, "buy", "quote"), 31 | ztom.Depth( 32 | total_quantity=1.0, 33 | total_price=0.096351, 34 | depth=1, 35 | currency="base" 36 | ) 37 | ) 38 | 39 | # self.assertEqual( 40 | # orderbook.get_depth(0.1, "buy", "quote"), 41 | # tgk.Depth( 42 | # total_quantity=1.0378574096360542, 1.037871947359135 43 | # total_price=0.09635234963063667, 44 | # depth=2, 45 | # currency="base" 46 | # ) 47 | # ) 48 | 49 | self.assertEqual( 50 | orderbook.get_depth(0.1, "buy", "quote"), 51 | ztom.Depth( 52 | total_quantity=1.037871947359135, 53 | total_price = 0.09635099999999999, 54 | depth = 1, 55 | currency = "base" 56 | ) 57 | ) 58 | 59 | 60 | self.assertEqual( 61 | orderbook.get_depth(1, "sell"), 62 | ztom.Depth( 63 | total_quantity=0.096336, 64 | total_price=0.096336, 65 | depth=1, 66 | currency="quote" 67 | ) 68 | ) 69 | self.assertEqual( 70 | orderbook.get_depth(5, "sell"), 71 | ztom.Depth( 72 | total_quantity=0.481652, 73 | total_price=0.09633040000000001, 74 | depth=3, 75 | currency="quote" 76 | ) 77 | ) 78 | # amount more than available in OrderBook 79 | 80 | self.assertEqual(ztom.Depth( 81 | total_quantity=0.0963360000*1 + 0.0963300000*2+0.0963280000*3, 82 | total_price=0.09633000000000001, 83 | depth=3, 84 | currency="quote", filled_share=6/10), orderbook.get_depth(10, "sell")) 85 | 86 | def test_sorted(self): 87 | ob = dict() 88 | ob["asks"] = [[3, 10], 89 | [1, 2], 90 | [2, 3]] 91 | 92 | ob["bids"] = [[5, 1], 93 | [4, 2], 94 | [6, 3]] 95 | 96 | orderbook = ztom.OrderBook("ETH/BTC", ob["asks"], ob["bids"]) 97 | 98 | self.assertEqual(1, orderbook.asks[0].price) 99 | self.assertEqual(2, orderbook.asks[1].price) 100 | self.assertEqual(3, orderbook.asks[2].price) 101 | 102 | self.assertEqual(6, orderbook.bids[0].price) 103 | self.assertEqual(5, orderbook.bids[1].price) 104 | self.assertEqual(4, orderbook.bids[2].price) 105 | 106 | def test_trade_direction(self): 107 | ob = dict() 108 | ob["asks"] = [[3, 10], 109 | [1, 2], 110 | [2, 3]] 111 | 112 | ob["bids"] = [[5, 1], 113 | [4, 2], 114 | [6, 3]] 115 | 116 | orderbook = ztom.OrderBook("ETH/BTC", ob["asks"], ob["bids"]) 117 | 118 | self.assertEqual("sell", orderbook.get_trade_direction_to_currency("BTC")) 119 | self.assertEqual("buy", orderbook.get_trade_direction_to_currency("ETH")) 120 | self.assertEqual(False, orderbook.get_trade_direction_to_currency("XYZ")) 121 | 122 | def test_order_book_depth_for_destination_currency(self): 123 | 124 | ob = dict() 125 | ob["asks"] = [[0.0963510000, 2], 126 | [0.0963880000, 2], 127 | [0.0964390000, 3]] 128 | 129 | ob["bids"] = [[0.0963360000, 1], 130 | [0.0963300000, 2], 131 | [0.0963280000, 3]] 132 | 133 | orderbook = ztom.OrderBook("ETH/BTC", ob["asks"], ob["bids"]) 134 | 135 | self.assertEqual(orderbook.get_depth_for_destination_currency(0.1, "ETH"), 136 | ztom.Depth(total_quantity=1.037871947359135, total_price=0.096351, depth=1, currency="base")) 137 | 138 | self.assertEqual(orderbook.get_depth_for_destination_currency(0.3, "ETH"), 139 | ztom.Depth(total_quantity=3.1131883637, total_price=0.0963642302, depth=2, currency="base")) 140 | 141 | self.assertEqual(orderbook.get_depth_for_destination_currency(1, "BTC"), 142 | ztom.Depth(total_quantity=0.0963360000, total_price=0.0963360000, depth=1, 143 | currency="quote")) 144 | 145 | self.assertEqual(orderbook.get_depth_for_destination_currency(5, "BTC"), 146 | ztom.Depth( 147 | total_quantity=0.481652, 148 | total_price=0.09633040000000001, 149 | depth=3, 150 | currency="quote")) 151 | 152 | def test_order_book_depth_for_side(self): 153 | 154 | ob = dict() 155 | ob["asks"] = [[0.0963510000, 2], 156 | [0.0963880000, 2], 157 | [0.0964390000, 3]] 158 | 159 | ob["bids"] = [[0.0963360000, 1], 160 | [0.0963300000, 2], 161 | [0.0963280000, 3]] 162 | 163 | orderbook = ztom.OrderBook("ETH/BTC", ob["asks"], ob["bids"]) 164 | 165 | self.assertEqual(orderbook.get_depth_for_trade_side(0.1, "buy"), 166 | ztom.Depth(total_quantity=1.037871947359135, total_price=0.096351, depth=1, currency="base")) 167 | 168 | self.assertEqual(orderbook.get_depth_for_trade_side(1, "sell"), 169 | ztom.Depth(total_quantity=0.0963360000, total_price=0.0963360000, depth=1, 170 | currency="quote")) 171 | 172 | 173 | if __name__ == '__main__': 174 | unittest.main() -------------------------------------------------------------------------------- /ztom/throttle.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import time 3 | import collections 4 | 5 | 6 | class Throttle(object): 7 | 8 | REQUEST_TYPE_WEIGHT = { 9 | "single": 1, 10 | "load_markets": 1, 11 | "fetch_tickers": 1, 12 | "fetch_ticker": 1, 13 | "fetch_order_book": 1, 14 | "create_order": 1, 15 | "fetch_order": 1, 16 | "cancel_order": 1, 17 | "fetch_my_trades": 1, 18 | "fetch_balance": 1} 19 | 20 | def __init__(self, period: float = 60.0, requests_per_period: int = 60, requests_weights: dict = None): 21 | """ 22 | 23 | Requests with the timestamps >=0 and < period_start + current_period_time are belong to respective period. 24 | 25 | :param period: period in seconds 26 | :param requests_per_period: maximumum amount of requests during the period 27 | """ 28 | 29 | self.period = period 30 | self.requests_per_period = requests_per_period 31 | 32 | self.total_requests_current_period = int() 33 | 34 | self.requests_current_period = list() # - list of requests in current period 35 | # dicts of: 36 | # {"timestamp": timestamp, "request_type": request_type, "added": requests, 37 | # "total_requests_to_time": net_requests} 38 | # request_type: type of request 39 | # added: number of requests of the type added on timestamp time 40 | # total_requests_to_time - cumulative number of "single" requests up to timestamp 41 | 42 | self._current_period_time = float() 43 | self._period_start_timestamp = 0.0 44 | self._requests_in_current_period = int() 45 | 46 | self.request_weights = Throttle.REQUEST_TYPE_WEIGHT 47 | 48 | if requests_weights is not None: 49 | self.request_weights.update(requests_weights) 50 | 51 | # min allowed time between single requests 52 | self.allowed_time_for_single_request = self.period / self.requests_per_period \ 53 | if self.requests_per_period != 0 else 0 54 | 55 | self.periods_since_start = 0 56 | 57 | def update(self, current_time_stamp: float = None): 58 | """ 59 | updates the internal time. If the time passed since the last update greater than period duration - the new 60 | period is initiated: 61 | - period start time is set as prev_period_time + number_of_periods*self.period 62 | - current period time is set in accordance to period start time 63 | - requests_current_ 64 | 65 | 66 | :param current_time_stamp: 67 | :return: 68 | """ 69 | 70 | if len(self.requests_current_period) > 0: 71 | self._period_start_timestamp = self.requests_current_period[0]["timestamp"] 72 | else: 73 | # nothing to update 74 | return False 75 | 76 | if current_time_stamp is None: 77 | current_time_stamp = datetime.now().timestamp() 78 | 79 | last_period_start_timestamp = self._period_start_timestamp 80 | 81 | number_of_periods_since_last_update = (current_time_stamp - last_period_start_timestamp) // self.period 82 | 83 | self._period_start_timestamp = last_period_start_timestamp + number_of_periods_since_last_update*self.period 84 | 85 | self._current_period_time = current_time_stamp - self._period_start_timestamp 86 | 87 | if number_of_periods_since_last_update > 0: 88 | self.periods_since_start += int(number_of_periods_since_last_update) 89 | 90 | self.requests_current_period = \ 91 | [k for k in self.requests_current_period 92 | if self._period_start_timestamp <= k["timestamp"] <= current_time_stamp] 93 | 94 | self.total_requests_current_period = \ 95 | sum([k["added"] * self.request_weights[k["request_type"]] for k in self.requests_current_period]) 96 | 97 | # else: 98 | # self.requests_current_period = list() 99 | # self.total_requests_current_period = 0 100 | 101 | def _add_request_for_current_period_time(self, timestamp, request_type: str = "single", requests: int = 1): 102 | 103 | net_requests = self.request_weights[request_type] * requests 104 | self.total_requests_current_period += net_requests 105 | 106 | self.requests_current_period.append({"timestamp": timestamp, "request_type": request_type, "added": requests 107 | }) 108 | 109 | # return self.requests_in_current_period 110 | 111 | def add_request(self, timestamp=None, request_type: str ="single", requests: int = 1): 112 | """ 113 | adds the requests and updates the state of throttle object 114 | :param timestamp: current or request's timestamp. if not set current system's timestamp will be used 115 | :param request_type: 116 | :param requests 117 | :return: 118 | """ 119 | 120 | if timestamp is None: 121 | timestamp = datetime.timestamp(datetime.now()) 122 | 123 | self._add_request_for_current_period_time(timestamp, request_type, requests ) 124 | self.update(timestamp) 125 | 126 | def _calc_time_sleep_to_recover_requests_rate(self, current_period_time, requests_in_current_period) -> float: 127 | """ 128 | Returns the sleep time to recover requests rate till the end of the current period. 129 | :return: sleep time in seconds 130 | """ 131 | 132 | sleep_time = 0.0 133 | 134 | if requests_in_current_period * self.allowed_time_for_single_request > current_period_time: 135 | sleep_time = requests_in_current_period * self.allowed_time_for_single_request - current_period_time 136 | 137 | return sleep_time 138 | 139 | def sleep_time(self, timestamp=None): 140 | """ 141 | get the current sleep time in seconds in order to maintain the maximun requests per period. 142 | 143 | :param timestamp: if not set - the current system's timestamp will be taken 144 | :return: sleep time in seconds 145 | """ 146 | if timestamp is None: 147 | timestamp = datetime.timestamp(datetime.now()) 148 | 149 | # self.update(timestamp) 150 | 151 | requests_in_current_period = 0 152 | if len(self.requests_current_period) > 0: 153 | requests_in_current_period = self.total_requests_current_period 154 | 155 | return self._calc_time_sleep_to_recover_requests_rate(timestamp - self._period_start_timestamp, 156 | requests_in_current_period) 157 | 158 | -------------------------------------------------------------------------------- /_scratches/binance.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | import collections 4 | import jsonpickle 5 | import ztom 6 | from ztom import bot 7 | from ztom.orderbook import OrderBook 8 | from ztom.trade_order_manager import * 9 | import ccxt 10 | 11 | start_cur = "ETH" 12 | dest_cur = "BTC" 13 | start_amount = 0.05 14 | 15 | bot = ztom.Bot("../_binance.json", "binance_test.log") 16 | 17 | bot.start_currency = [start_cur] # legacy from Bot class 18 | 19 | bot.load_config_from_file(bot.config_filename) 20 | 21 | bot.init_logging(bot.log_filename) 22 | bot.init_exchange() 23 | bot.load_markets() 24 | bot.load_balance() 25 | bot.fetch_tickers() 26 | 27 | 28 | order_history_file_name = ztom.utils.get_next_filename_index("./{}_all_objects.json".format(bot.exchange_id)) 29 | 30 | 31 | def test_trade(start_cur, dest_cur, start_amount): 32 | 33 | balance = dict() 34 | while not balance: 35 | try: 36 | balance = bot.exchange.fetch_free_balance() 37 | except: 38 | print("Retry fetching balance...") 39 | 40 | init_balance = dict() 41 | init_balance[start_cur] = balance[start_cur] 42 | init_balance[dest_cur] = balance[dest_cur] 43 | 44 | symbol = ztom.core.get_symbol(start_cur, dest_cur, bot.markets) 45 | ob_array = dict() 46 | while not ob_array: 47 | try: 48 | ob_array = bot.exchange._ccxt.fetch_order_book(symbol, 100) 49 | except: 50 | print("retying to fetch order book") 51 | 52 | order_book = OrderBook(symbol, ob_array["asks"], ob_array["bids"]) 53 | 54 | price = order_book.get_depth_for_destination_currency(start_amount, dest_cur).total_price * 1.0002 55 | 56 | order1 = ztom.TradeOrder.create_limit_order_from_start_amount(symbol, start_cur, start_amount, dest_cur, price) 57 | 58 | order_resps = collections.OrderedDict() 59 | 60 | om = ztom.OrderManagerFok(order1, None, 100, 10) 61 | 62 | try: 63 | om.fill_order(bot.exchange) 64 | except OrderManagerErrorUnFilled as e: 65 | print("Unfilled order. Should cancel and recover/continue") 66 | try: 67 | print("Cancelling....") 68 | om.cancel_order(bot.exchange) 69 | print(".. Ok") 70 | except OrderManagerCancelAttemptsExceeded: 71 | print("Could not cancel. Check the exchange.") 72 | 73 | except OrderManagerError as e: 74 | print("Unknown Order Manager error") 75 | print(type(e).__name__, "!!!", e.args, ' ') 76 | 77 | except ccxt.errors.InsufficientFunds: 78 | print("Low balance!") 79 | sys.exit(0) 80 | 81 | except Exception as e: 82 | print("error") 83 | print(type(e).__name__, "!!!", e.args, ' ') 84 | sys.exit(0) 85 | 86 | results = list() 87 | 88 | if order1.filled > 0: 89 | i = 0 90 | while bool(results) is not True and i < 100: 91 | print("getting trades #{}".format(i)) 92 | try: 93 | results = bot.exchange.get_trades_results(order1) 94 | except Exception as e: 95 | print(type(e).__name__, "!!!", e.args, ' ') 96 | print("retrying to get trades...") 97 | i += 1 98 | 99 | order1.update_order_from_exchange_resp(results) 100 | order1.fees = bot.exchange.fees_from_order_trades(order1) 101 | else: 102 | print("Order filled with Zero result... Try again") 103 | sys.exit() 104 | 105 | 106 | balance_after_order1 = dict(init_balance) 107 | 108 | i = 0 109 | while (balance_after_order1[start_cur] == init_balance[start_cur] or 110 | balance_after_order1[dest_cur] == init_balance[dest_cur]) and i < 50: 111 | try: 112 | balance = dict(bot.exchange.fetch_free_balance()) 113 | balance_after_order1[start_cur], balance_after_order1[dest_cur] = balance[start_cur], balance[dest_cur] 114 | except: 115 | print("Error receiving balance") 116 | 117 | print("Balance receive attempt {}".format(i)) 118 | i += 1 119 | 120 | all_data = dict() 121 | 122 | all_data["exchange_id"] = bot.exchange_id 123 | all_data["start_balance"] = init_balance 124 | all_data["balance_after_order1"] = balance_after_order1 125 | all_data["start_currency"] = start_cur 126 | all_data["dest_currency"] = dest_cur 127 | all_data["symbol"] = symbol 128 | all_data["price"] = price 129 | all_data["order1"] = order1 130 | all_data["order_book_1"] = order_book 131 | all_data["market"] = bot.markets[symbol] 132 | all_data["ticker"] = bot.tickers[symbol] 133 | all_data["balance_after_order1"] = balance_after_order1 134 | all_data["balance_diff_start_cur"] = init_balance[start_cur] - balance_after_order1[start_cur] 135 | all_data["balance_diff_dest_cur"] = init_balance[dest_cur] - balance_after_order1[dest_cur] 136 | all_data["check_balance_dest_curr_diff_eq_filled_dest_minus_fee"] = round( 137 | balance_after_order1[dest_cur] - (init_balance[dest_cur] + order1.filled_dest_amount - 138 | order1.fees[dest_cur]["amount"]), 139 | bot.exchange._ccxt.currencies[dest_cur]["precision"]) 140 | 141 | all_data["check_balance_src_curr_diff_eq_filled_src"] =round( 142 | balance_after_order1[start_cur] - (init_balance[start_cur] - order1.filled_start_amount 143 | - order1.fees[start_cur]["amount"]), 144 | 145 | bot.exchange._ccxt.currencies[dest_cur]["precision"]) 146 | 147 | # check if deal results are consistent with amount and fees 148 | if all_data["check_balance_dest_curr_diff_eq_filled_dest_minus_fee"] == 0 \ 149 | and all_data["check_balance_src_curr_diff_eq_filled_src"] == 0: 150 | all_data["_check_trades_amount_fees"] = True 151 | else: 152 | all_data["_check_trades_amount_fees"] = False 153 | 154 | return all_data 155 | 156 | 157 | report = dict() 158 | 159 | print("Trade 1") 160 | print("======================") 161 | report["trade1"] = test_trade(start_cur, dest_cur, start_amount) 162 | print("======================") 163 | print("Trade 2") 164 | report["trade2"] = test_trade(dest_cur, start_cur, 165 | report["trade1"]["order1"].filled_dest_amount - 166 | report["trade1"]["order1"].fees[dest_cur]["amount"]) 167 | 168 | 169 | print("Check Trade 1:{}".format(report["trade1"]["_check_trades_amount_fees"])) 170 | print("Check Trade 2:{}".format(report["trade2"]["_check_trades_amount_fees"])) 171 | 172 | j = jsonpickle.encode(report) 173 | 174 | s = json.dumps(json.loads(j), indent=4, sort_keys=True) 175 | 176 | 177 | with open(order_history_file_name, "w") as file: 178 | file.writelines(s) 179 | 180 | sys.exit(0) 181 | # d = ob.(bal_to_bid, dest_cur) 182 | # price = d.total_price 183 | # amount = d.total_quantity 184 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .context import ztom 3 | from ztom import core 4 | from ztom import errors 5 | import unittest 6 | 7 | 8 | class CoreFuncTestSuite(unittest.TestCase): 9 | 10 | def test_get_trade_direction_to_currency(self): 11 | symbol = "ETH/BTC" 12 | self.assertEqual("buy", core.get_trade_direction_to_currency(symbol, "ETH")) 13 | self.assertEqual("sell", core.get_trade_direction_to_currency(symbol, "BTC")) 14 | self.assertEqual(False, core.get_trade_direction_to_currency(symbol, "USD")) 15 | 16 | def test_get_symbol(self): 17 | markets = dict({"ETH/BTC": True}) 18 | self.assertEqual("ETH/BTC", core.get_symbol("ETH", "BTC", markets)) 19 | self.assertEqual("ETH/BTC", core.get_symbol("BTC", "ETH", markets)) 20 | self.assertEqual(False, core.get_symbol("USD", "ETH", markets)) 21 | 22 | def test_order_type(self): 23 | symbol = "ETH/BTC" 24 | self.assertEqual("buy", core.get_order_type("BTC", "ETH", symbol)) 25 | self.assertEqual("sell", core.get_order_type("ETH", "BTC", symbol)) 26 | self.assertEqual(False, core.get_order_type("BTC", "USD", symbol)) 27 | 28 | def test_get_symbol_order_price_from_tickers(self): 29 | 30 | ticker = {"CUR1/CUR2": { 31 | "ask": 2, 32 | "bid": 1 33 | }} 34 | 35 | # sell 36 | maker_taker_price = core.get_symbol_order_price_from_tickers("CUR1", "CUR2", ticker) 37 | 38 | self.assertDictEqual( 39 | {"symbol": "CUR1/CUR2", 40 | "order_type": "sell", 41 | "price_type": "bid", 42 | "price": 1, 43 | "maker_price_type": "ask", 44 | "maker_price": 2 45 | }, maker_taker_price) 46 | 47 | # buy 48 | maker_taker_price = core.get_symbol_order_price_from_tickers("CUR2", "CUR1", ticker) 49 | 50 | self.assertDictEqual( 51 | {"symbol": "CUR1/CUR2", 52 | "order_type": "buy", 53 | "price_type": "ask", 54 | "price": 2, 55 | "maker_price_type": "bid", 56 | "maker_price": 1 57 | }, maker_taker_price) 58 | 59 | 60 | 61 | 62 | def test_amount_to_precision(self): 63 | self.assertEqual(1.399, core.amount_to_precision(1.399, 3)) 64 | self.assertEqual(1, core.amount_to_precision(1.3999)) 65 | self.assertEqual(1, core.amount_to_precision(1.9999)) 66 | self.assertEqual(1.99, core.amount_to_precision(1.9999, 2)) 67 | 68 | def test_price_to_precision(self): 69 | self.assertEqual(1.399, core.price_to_precision(1.399, 3)) 70 | self.assertEqual(1.3999, core.price_to_precision(1.3999)) 71 | self.assertEqual(1.9999, core.price_to_precision(1.9999)) 72 | self.assertEqual(2, core.price_to_precision(1.9999, 2)) 73 | 74 | def test_relative_target_price_difference(self): 75 | 76 | self.assertAlmostEqual(0.1, core.relative_target_price_difference("sell", 1, 1.1), 6) 77 | self.assertAlmostEqual(-0.1, core.relative_target_price_difference("sell", 1, 0.9), 6) 78 | 79 | self.assertAlmostEqual(-0.1, core.relative_target_price_difference("buy", 1, 1.1), 6) 80 | self.assertAlmostEqual(0.1, core.relative_target_price_difference("buy", 1, 0.9), 6) 81 | 82 | self.assertAlmostEqual(0.3, core.relative_target_price_difference("buy", 1, 0.7), 6) 83 | 84 | self.assertAlmostEqual(0.3, core.relative_target_price_difference("buy", 2, 1.4), 6) 85 | self.assertAlmostEqual(0.3, core.relative_target_price_difference("buy", 2, 1.4), 6) 86 | 87 | with self.assertRaises(ValueError) as cntx: 88 | core.relative_target_price_difference("selll", 1, 1.1) 89 | 90 | self.assertEqual(ValueError, type(cntx.exception)) 91 | 92 | def test_convert_currency(self): 93 | # buy side 94 | dest_amount = core.convert_currency("RUB", 1, "USD", "USD/RUB", price=70) 95 | self.assertEqual(dest_amount, 1/70) 96 | 97 | # sell side 98 | dest_amount = core.convert_currency("USD", 1, "RUB", "USD/RUB", price=70) 99 | self.assertEqual(dest_amount, 70) 100 | 101 | # taker price from ticker sell side 102 | dest_amount = core.convert_currency("USD", 1, "RUB", symbol="USD/RUB", ticker={"bid": 70}) 103 | self.assertEqual(dest_amount, 70) 104 | 105 | # maker price from ticker sell side 106 | dest_amount = core.convert_currency("USD", 1, "RUB", symbol="USD/RUB", ticker={"bid": 70, "ask": 71}, 107 | taker=False) 108 | self.assertEqual(dest_amount, 71) 109 | 110 | # taker price from ticker buy side 111 | dest_amount = core.convert_currency("RUB", 1, "USD", symbol="USD/RUB", ticker={"ask": 71}) 112 | self.assertEqual(dest_amount, 1/71) 113 | 114 | # maker price from ticker buy side 115 | dest_amount = core.convert_currency("RUB", 1, "USD", symbol="USD/RUB", ticker={"ask": 71, "bid": 70}, 116 | taker=False) 117 | self.assertEqual(dest_amount, 1/70) 118 | 119 | # no ticker or symbol provided 120 | res = core.convert_currency("RUB", 1, "USD") 121 | self.assertIsNone(res) 122 | 123 | # no symbol in ticker 124 | res= core.convert_currency("RUB", 1, "USD", ticker={}) 125 | self.assertIsNone(res) 126 | 127 | # symbol not contains both currencies 128 | res = core.convert_currency("RUB", 1, "USD", symbol="RUB/GBP") 129 | self.assertIsNone(res) 130 | 131 | # zero price 132 | res = core.convert_currency("RUB", 1, "USD", symbol="RUB/USD", price=0) 133 | self.assertIsNone(res) 134 | 135 | def test_price_convert_dest_amount(self): 136 | 137 | price = core.ticker_price_for_dest_amount("sell", 1000, 0.32485131) 138 | self.assertAlmostEqual(price, 0.00032485, 8) 139 | 140 | price = core.ticker_price_for_dest_amount("buy", 1, 70) 141 | self.assertAlmostEqual(price, 1/70, 8) 142 | 143 | def test_order_amount_for_target_currency(self): 144 | 145 | symbol = "USD/RUB" 146 | price = 50 147 | 148 | ticker = {"USD/RUB": { 149 | "ask": 1000, 150 | "bid": 100 151 | }} 152 | 153 | base_amount = core.base_amount_for_target_currency("RUB", 100, symbol, price) 154 | self.assertEqual(2, base_amount) 155 | 156 | base_amount = core.base_amount_for_target_currency("USD", 100, symbol, price) 157 | self.assertEqual(100, base_amount) 158 | 159 | base_amount = core.base_amount_for_target_currency("USD", 100, symbol, ticker=ticker[symbol]) 160 | self.assertEqual(100, base_amount) 161 | 162 | base_amount = core.base_amount_for_target_currency("RUB", 100, symbol, ticker=ticker[symbol]) 163 | self.assertEqual(1, base_amount) 164 | 165 | # error - bid should be set 166 | ticker_ask = {"EUR/USD": 167 | {"ask": 2}} 168 | 169 | base_amount = core.base_amount_for_target_currency("USD", 100, "EUR/USD", ticker=ticker_ask["EUR/USD"]) 170 | self.assertEqual(0, base_amount) 171 | 172 | 173 | if __name__ == '__main__': 174 | unittest.main() -------------------------------------------------------------------------------- /_scratches/kucoin.py: -------------------------------------------------------------------------------- 1 | import ztom 2 | import sys 3 | import json 4 | import collections 5 | import jsonpickle 6 | from jsonpickle import handlers 7 | import ztom 8 | from ztom import bot 9 | from ztom.orderbook import OrderBook 10 | from ztom.trade_order_manager import * 11 | import ccxt 12 | 13 | exchange_id = "kucoin" 14 | start_cur = "ETH" 15 | dest_cur = "BTC" 16 | 17 | bot = ztom.Bot("../_kucoin.json", "kucoin_test.log") 18 | bot.load_config_from_file(bot.config_filename) 19 | 20 | if start_cur != bot.start_currency[0]: 21 | print("Start currency in config != start currency in test {} != {}".format(bot.start_currency[0], start_cur)) 22 | sys.exit(0) 23 | 24 | bot.init_logging(bot.log_filename) 25 | bot.init_exchange() 26 | bot.load_markets() 27 | bot.load_balance() 28 | bot.fetch_tickers() 29 | 30 | 31 | order_history_file_name = ztom.utils.get_next_filename_index("./{}_all_objects.json".format(bot.exchange_id)) 32 | 33 | 34 | def test_trade(start_cur, dest_cur, start_amount): 35 | 36 | balance = dict() 37 | while not balance: 38 | try: 39 | balance = bot.exchange.fetch_free_balance() 40 | except: 41 | print("Retry fetching balance...") 42 | 43 | init_balance = dict() 44 | init_balance[start_cur] = balance[start_cur] 45 | init_balance[dest_cur] = balance[dest_cur] 46 | 47 | symbol = ztom.core.get_symbol(start_cur, dest_cur, bot.markets) 48 | ob_array = dict() 49 | while not ob_array: 50 | try: 51 | ob_array = bot.exchange._ccxt.fetch_order_book(symbol, 100) 52 | except: 53 | print("retying to fetch order book") 54 | 55 | order_book = OrderBook(symbol, ob_array["asks"], ob_array["bids"]) 56 | 57 | price = order_book.get_depth_for_destination_currency(start_amount, dest_cur).total_price 58 | 59 | order1 = ztom.TradeOrder.create_limit_order_from_start_amount(symbol, start_cur, start_amount, dest_cur, price) 60 | 61 | order_resps = collections.OrderedDict() 62 | 63 | om = ztom.OrderManagerFok(order1, None, 100, 10) 64 | 65 | try: 66 | om.fill_order(bot.exchange) 67 | except OrderManagerErrorUnFilled as e: 68 | print("Unfilled order. Should cancel and recover/continue") 69 | try: 70 | print("Cancelling....") 71 | om.cancel_order(bot.exchange) 72 | except OrderManagerCancelAttemptsExceeded: 73 | print("Could not cancel") 74 | 75 | except OrderManagerError as e: 76 | print("Unknown Order Manager error") 77 | print(type(e).__name__, "!!!", e.args, ' ') 78 | 79 | except ccxt.errors.InsufficientFunds: 80 | print("Low balance!") 81 | sys.exit(0) 82 | 83 | except Exception as e: 84 | print("error") 85 | print(type(e).__name__, "!!!", e.args, ' ') 86 | sys.exit(0) 87 | 88 | results = list() 89 | i = 0 90 | while bool(results) is not True and i < 100: 91 | print("getting trades #{}".format(i)) 92 | try: 93 | results = bot.exchange.get_trades_results(order1) 94 | except Exception as e: 95 | print(type(e).__name__, "!!!", e.args, ' ') 96 | print("retrying to get trades...") 97 | i += 1 98 | 99 | order1.update_order_from_exchange_resp(results) 100 | order1.fees = bot.exchange.fees_from_order_trades(order1) 101 | 102 | balance_after_order1 = dict(init_balance) 103 | 104 | i = 0 105 | while (balance_after_order1[start_cur] == init_balance[start_cur] or 106 | balance_after_order1[dest_cur] == init_balance[dest_cur]) and i < 50: 107 | try: 108 | balance = dict(bot.exchange.fetch_free_balance()) 109 | balance_after_order1[start_cur], balance_after_order1[dest_cur] = balance[start_cur], balance[dest_cur] 110 | except: 111 | print("Error receiving balance") 112 | 113 | print("Balance receive attempt {}".format(i)) 114 | i += 1 115 | 116 | all_data = dict() 117 | 118 | all_data["exchange_id"] = bot.exchange_id 119 | all_data["start_balance"] = init_balance 120 | all_data["balance_after_order1"] = balance_after_order1 121 | all_data["start_currency"] = start_cur 122 | all_data["dest_currency"] = dest_cur 123 | all_data["symbol"] = symbol 124 | all_data["price"] = price 125 | all_data["order1"] = order1 126 | all_data["order_book_1"] = order_book 127 | all_data["market"] = bot.markets[symbol] 128 | all_data["ticker"] = bot.tickers[symbol] 129 | all_data["balance_after_order1"] = balance_after_order1 130 | all_data["balance_diff_start_cur"] = init_balance[start_cur] - balance_after_order1[start_cur] 131 | all_data["balance_diff_dest_cur"] = init_balance[dest_cur] - balance_after_order1[dest_cur] 132 | all_data["check_balance_dest_curr_diff_eq_filled_dest_minus_fee"] = round( 133 | balance_after_order1[dest_cur] - (init_balance[dest_cur] + order1.filled_dest_amount - 134 | order1.fees[dest_cur]["amount"]), 135 | bot.exchange._ccxt.currencies[dest_cur]["precision"]) 136 | 137 | all_data["check_balance_src_curr_diff_eq_filled_src"] =round( 138 | balance_after_order1[start_cur] - (init_balance[start_cur] - order1.filled_src_amount 139 | ), 140 | 141 | bot.exchange._ccxt.currencies[dest_cur]["precision"]) 142 | 143 | # check if deal results are consistent with amount and fees 144 | if all_data["check_balance_dest_curr_diff_eq_filled_dest_minus_fee"] == 0 \ 145 | and all_data["check_balance_src_curr_diff_eq_filled_src"] == 0: 146 | all_data["_check_trades_amount_fees"] = True 147 | else: 148 | all_data["_check_trades_amount_fees"] = False 149 | 150 | return all_data 151 | 152 | 153 | report = dict() 154 | 155 | print("Trade 1") 156 | print("======================") 157 | report["trade1"] = test_trade(start_cur, dest_cur, bot.min_amounts[start_cur]) 158 | print("======================") 159 | print("Trade 2") 160 | report["trade2"] = test_trade(dest_cur, start_cur, 161 | report["trade1"]["order1"].filled_dest_amount - 162 | report["trade1"]["order1"].fees[dest_cur]["amount"]) 163 | 164 | 165 | print("Check Trade 1:{}".format(report["trade1"]["_check_trades_amount_fees"])) 166 | print("Check Trade 2:{}".format(report["trade2"]["_check_trades_amount_fees"])) 167 | 168 | j = jsonpickle.encode(report) 169 | 170 | s = json.dumps(json.loads(j), indent=4, sort_keys=True) 171 | 172 | 173 | with open(order_history_file_name, "w") as file: 174 | file.writelines(s) 175 | 176 | # print("Order resp:") 177 | # print(s) 178 | # print("Trades resp:{}".format(results)) 179 | # 180 | # print("=====================================") 181 | # print("Symbol:{}".format(order1.symbol)) 182 | # print("Side:{}".format(order1.side)) 183 | # print("Amount:{}".format(order1.amount)) 184 | # print("Price:{}".format(order1.init_price)) 185 | # print("Status:{}".format(order1.status)) 186 | # print(" - - - -") 187 | # print("Result:") 188 | # print("Filled order amount: {} of {}".format(order1.filled, order1.amount)) 189 | # print("Filled dest amount: {} of {}".format(results["dest_amount"], order1.filled_dest_amount)) 190 | # print("Filled src amount: {} of {}".format(results["src_amount"], order1.amount_start)) 191 | # print("Price Fact vs Order price : {} of {}".format(results["price"], order1.init_price)) 192 | # print("Trades count: {}".format(len(results["trades"]))) 193 | 194 | sys.exit(0) 195 | # d = ob.(bal_to_bid, dest_cur) 196 | # price = d.total_price 197 | # amount = d.total_quantity 198 | -------------------------------------------------------------------------------- /ztom/trade_orders.py: -------------------------------------------------------------------------------- 1 | from .orderbook import OrderBook 2 | from ztom import core 3 | from datetime import datetime 4 | import pytz 5 | import uuid 6 | 7 | 8 | class OrderError(Exception): 9 | """Basic exception for errors raised by Orders""" 10 | pass 11 | 12 | 13 | class OrderErrorSymbolNotFound(OrderError): 14 | """Basic exception for errors raised by cars""" 15 | pass 16 | 17 | 18 | class OrderErrorBadPrice(OrderError): 19 | pass 20 | 21 | 22 | class OrderErrorSideNotFound(OrderError): 23 | pass 24 | 25 | 26 | class TradeOrder(object): 27 | # todo create wrapper constructor for fake/real orders with any starting asset 28 | # different wrapper constructors for amount of available asset 29 | # so developer have not to implement the bid calculation 30 | # 31 | # TradeOrder.fake_order_from_asset(symbol, start_asset, amount, ticker_price, order_book = None, exchange = None, 32 | # commission = 0 ) 33 | # 34 | # TradeOrder.order_from_asset(symbol, start_asset, amount, ticker_price, exchange ) 35 | # 36 | 37 | # fields to update from ccxt order placement response 38 | _UPDATE_FROM_EXCHANGE_FIELDS = ["id", "datetime", "timestamp", "lastTradeTimestamp", "status", "amount", "filled", 39 | "remaining", "cost", "price", "info", "trades", "fee", "fees", "timestamp_open", 40 | "timestamp_closed"] 41 | 42 | def __init__(self, type: str, symbol, amount, side, price=None, precision_amount=None, precision_price=None): 43 | 44 | self.id = str() # order id from exchange 45 | self.internal_id = str(uuid.uuid4()) # internal id for offline orders management 46 | 47 | self.datetime = str() # datetime 48 | self.timestamp = int() # order placing/opening Unix timestamp in milliseconds 49 | self.lastTradeTimestamp = int() # Unix timestamp of the most recent trade on this order 50 | self.status = str() # 'open', 'closed', 'canceled' 51 | 52 | # dicts of proceeded UTC timestamps: {"request_sent":value, "request_received":value, "from_exchange":value} 53 | self.timestamp_open = dict() # on placing order 54 | self.timestamp_closed = dict() # on closing order 55 | 56 | self.symbol = symbol.upper() 57 | self.type = type # limit 58 | self.side = side.lower() # buy or sell 59 | self.amount = amount # ordered amount of base currency 60 | self.init_price = price if price is not None else 0.0 # initial price, when create order 61 | self.price = self.init_price # placed price, could be updated from exchange 62 | 63 | self.fee = dict() # fee from ccxt 64 | 65 | self.trades = list() 66 | self.fees = dict() 67 | 68 | self.precision_amount = precision_amount 69 | self.price_precision = precision_price 70 | 71 | self.filled = 0.0 # filled amount of base currency 72 | self.remaining = 0.0 # remaining amount to fill 73 | self.cost = 0.0 # filled amount of quote currency 'filled' * 'price' 74 | 75 | self.info = None # the original response from exchange 76 | 77 | self.order_book = None 78 | 79 | self.amount_start = float() # amount of start currency 80 | self.amount_dest = float() # amount of dest currency 81 | 82 | self.update_requests_count = 0 # number of updates of order. should be in correspondence with API requests 83 | 84 | self.filled_start_amount = 0.0 # filled amount of start currency 85 | self.filled_dest_amount = 0.0 # filled amount of dest currency 86 | 87 | self.start_currency = self.symbol.split("/")[1] if side == "buy" else self.symbol.split("/")[0] 88 | self.dest_currency = self.symbol.split("/")[0] if side == "buy" else self.symbol.split("/")[1] 89 | 90 | self.supplementary = dict() # additional data regarding the order 91 | 92 | # if not side: 93 | # raise OrderErrorSymbolNotFound("Wrong symbol {} for trade {} - {}".format(symbol, start_currency, 94 | # dest_currency)) 95 | 96 | if price is not None: 97 | if side == "sell": 98 | 99 | self.amount_start = amount 100 | self.amount_dest = self.amount_start * price 101 | 102 | elif side == "buy": 103 | self.amount_start = price * self.amount 104 | self.amount_dest = amount 105 | 106 | def __str__(self): 107 | s = "TradeOrder {id}. {start_currency} -{side}-> {dest_currency} filled {filled}/{amount}" \ 108 | .format( 109 | id=self.id, 110 | start_currency=self.start_currency, 111 | side=self.side, 112 | dest_currency=self.dest_currency, 113 | filled=self.filled, 114 | amount=self.amount) 115 | return s 116 | 117 | @classmethod 118 | def create_limit_order_from_start_amount(cls, symbol, start_currency, amount_start, dest_currency, price) -> 'TradeOrder': 119 | 120 | side = core.get_order_type(start_currency, dest_currency, symbol) 121 | 122 | if not side: 123 | raise OrderErrorSymbolNotFound( 124 | "Wrong symbol {} for trade {} - {}".format(symbol, start_currency, dest_currency)) 125 | 126 | if price <= 0: 127 | raise (OrderErrorBadPrice("Wrong price. Symbol: {}, Side:{}, Price:{} ".format(symbol, side, price))) 128 | 129 | if side == "sell": 130 | amount = amount_start 131 | # amount_dest = amount_start * price 132 | 133 | elif side == "buy": 134 | amount = amount_start / price 135 | # amount_dest = amount 136 | 137 | order = cls("limit", symbol, amount, side, price) # type: TradeOrder 138 | 139 | # order.amount_start = amount_start 140 | # order.amount_dest = amount_dest 141 | 142 | return order 143 | 144 | def cancel_order(self): 145 | pass 146 | 147 | def update_order_from_exchange_resp(self, exchange_data: dict): 148 | 149 | if isinstance(exchange_data, dict): 150 | for field in self._UPDATE_FROM_EXCHANGE_FIELDS: 151 | if field in exchange_data and exchange_data[field] is not None: 152 | setattr(self, field, exchange_data[field]) 153 | 154 | if self.side == "buy": 155 | self.filled_start_amount = self.cost 156 | self.filled_dest_amount = self.filled 157 | 158 | elif self.side == "sell": 159 | self.filled_start_amount = self.filled 160 | self.filled_dest_amount = self.cost 161 | 162 | self.update_requests_count += 1 163 | 164 | # will return the dict: 165 | # "amount" - total amount of base currency filled 166 | # "cost" - total amount of quote currency filled 167 | # "price" - total (average) price of fills = cost / amount 168 | def total_amounts_from_trades(self, trades): 169 | 170 | total = dict() 171 | total["amount"] = 0.0 172 | total["cost"] = 0.0 173 | total["price"] = 0.0 174 | # total["_cost_from_ccxt"] = 0.0 175 | 176 | for trade in trades: 177 | # if trade["order"] == self.id: 178 | total["amount"] += trade["amount"] 179 | total["cost"] += trade["amount"] * trade["price"] 180 | # total["_cost_from_ccxt"] += trade["cost"] 181 | 182 | total["price"] = total["cost"] / total["amount"] 183 | 184 | return total 185 | 186 | def report(self): 187 | 188 | report = dict((key, value) for key, value in self.__dict__.items() 189 | if not callable(value) and not key.startswith('__')) 190 | 191 | return report 192 | 193 | 194 | 195 | -------------------------------------------------------------------------------- /ztom/orderbook.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import math 3 | 4 | 5 | class Depth: 6 | """ 7 | Represents depth calculation in order book 8 | """ 9 | 10 | def __init__(self, total_quantity, total_price, depth, currency="quote", filled_share=1): 11 | """ 12 | 13 | :param total_quantity: total quantity of :param currency: filled within order book 14 | :param total_price: average price 15 | :param depth: final level of orderbook where amount is being filled 16 | :param currency: result currency 17 | :param filled_share: filled quantity / amount 18 | """ 19 | self.total_quantity = total_quantity 20 | self.total_price = total_price 21 | self.depth = depth 22 | self.currency = currency 23 | self.filled_share = filled_share 24 | 25 | def __eq__(self, other): 26 | 27 | return math.isclose(self.total_quantity, other.total_quantity, rel_tol=1e-8) and \ 28 | math.isclose(self.total_price, other.total_price, rel_tol=1e-8) and \ 29 | self.depth == other.depth and \ 30 | self.currency == other.currency and \ 31 | self.filled_share == other.filled_share 32 | 33 | def __str__(self): 34 | 35 | return "Depth: total_qty %s, total_price: %s, depth: %s, currency: %s, filled: %s" % (self.total_quantity, 36 | self.total_price, 37 | self.depth, 38 | self.currency, 39 | self.filled_share) 40 | __repr__ = __str__ 41 | 42 | class Order: 43 | def __init__(self, price, quantity): 44 | self.quantity = float(quantity) if quantity else 0.0 45 | self.price = float(price) if price else 0.0 46 | 47 | def __str__(self): 48 | return 'Order[p:%s q:%s]' % (self.price, self.quantity) 49 | 50 | 51 | class OrderBook: 52 | def __init__(self, symbol, asks, bids): 53 | self.symbol = symbol 54 | 55 | self.asks = sorted(list(map(lambda x: Order(x[0], x[1]), asks)), key=lambda x: x.price) 56 | self.bids = sorted(list(map(lambda x: Order(x[0], x[1]), bids)), key=lambda x: x.price, reverse=True) 57 | 58 | def __str__(self): 59 | asks_str = '\n'.join(list(map(lambda o: str(o), self.asks))) 60 | bids_str = '\n'.join(list(map(lambda o: str(o), self.bids))) 61 | return ('OrderBook[' 62 | '\tAsks:\n' 63 | '\t\t%s' 64 | '\tBids:\n' 65 | '\t\t%s') % (asks_str, bids_str) 66 | 67 | __repr__ = __str__ 68 | 69 | @staticmethod 70 | def csv_header(): 71 | return ['ask', 'ask-qty', 'bid', 'bid-qty'] 72 | 73 | def to_csv(self, order1, order2, precision, as_list=False): 74 | template = '{0:.%sf}' % precision 75 | quantity1 = template.format(order1.quantity) or '' 76 | price1 = template.format(order1.price) or '' 77 | quantity2 = template.format(order2.quantity) if order2 is not None else '' 78 | price2 = template.format(order2.price) if order2 is not None else '' 79 | if not as_list: 80 | return '%s, %s, %s, %s' % (quantity1, price1, quantity2, price2) 81 | else: 82 | return list([price1, quantity1, price2, quantity2]) 83 | 84 | # use as_list=True if return as list 85 | 86 | def csv_rows(self, precision, as_list=False): 87 | table = itertools.zip_longest(self.asks, self.bids) 88 | rows = list(map(lambda pair: self.to_csv(pair[0], pair[1], precision, as_list), table)) 89 | return rows 90 | 91 | # 92 | # 93 | 94 | def get_depth(self, amount, direction, currency="base"): 95 | """ 96 | get order book depth for taker positions, qty and average price for amount of base or quote currency amount 97 | for buy or sell side in case of return None if not enough amount or Depth instance 98 | 99 | 100 | :param amount: 101 | :param direction: 102 | :param currency: 103 | :return: Depth object 104 | """ 105 | order_fills = self.asks if direction == 'buy' else self.bids 106 | 107 | if currency == "base": # we collect base currency from setted quote 108 | amount = amount 109 | add_amount = lambda x: x.quantity # plain base quantity in orderbook 110 | add_total = lambda x: x.quantity * x.price # base amount 111 | ob_qty = lambda x: x.quantity # base amount 112 | currency="quote" 113 | 114 | elif currency == "quote": 115 | add_amount = lambda x: x.quantity * x.price # we collect quote currency - so multiplying 116 | add_total = lambda x: x.quantity 117 | ob_qty = lambda x: x.quantity / x.price # base from quote 118 | currency = "base" 119 | 120 | else: 121 | return None 122 | 123 | amount_filled = 0 124 | total_quantity = 0 125 | depth = 0 126 | 127 | while depth < len(order_fills) and amount_filled < amount: 128 | 129 | quantity = add_amount(order_fills[depth]) 130 | 131 | if amount_filled + quantity >= amount: 132 | # quantity = amount - amount_filled 133 | # order_fills[depth].quantity = ob_qty(Order(order_fills[depth].price, quantity)) 134 | 135 | total_quantity += add_total( Order(order_fills[depth].price, ob_qty(Order(order_fills[depth].price, amount - amount_filled)))) 136 | amount_filled = amount 137 | 138 | else: 139 | amount_filled += quantity 140 | total_quantity += add_total(order_fills[depth]) 141 | 142 | depth += 1 143 | 144 | # if amount_filled < amount: 145 | # 146 | # else: 147 | 148 | if direction == "buy": 149 | price = amount_filled / total_quantity # were collecting total_qty in base from amount quote 150 | 151 | if direction == "sell": 152 | price = total_quantity / amount_filled # were collecting total_qty in base from amount quote 153 | 154 | return Depth(total_quantity, price, depth, currency, amount_filled / amount) 155 | 156 | # 157 | # get trade direction to receive dest_currency 158 | # 159 | def get_trade_direction_to_currency(self, dest_currency): 160 | cs = self.symbol.split("/") 161 | 162 | if cs[0] == dest_currency: 163 | return "buy" 164 | 165 | elif cs[1] == dest_currency: 166 | return "sell" 167 | 168 | else: 169 | return False 170 | 171 | # 172 | # get depth for destination currency for initial amount of available currency in symbol and trade direction side 173 | # 174 | def get_depth_for_destination_currency(self, init_amount, dest_currency): 175 | 176 | direction = self.get_trade_direction_to_currency(dest_currency) 177 | 178 | if direction == "buy": 179 | return self.get_depth(init_amount, "buy", "quote") 180 | 181 | if direction == "sell": 182 | return self.get_depth(init_amount, "sell", "base") 183 | 184 | return False 185 | 186 | # 187 | # get orderbook price and result for trade side (buy or sell) of initial amount of start currency 188 | # if buy - on start we have quote currency and result will be for base 189 | # if sell - we have base currency and result in quote 190 | # 191 | def get_depth_for_trade_side(self, start_amount:float, side:str): 192 | 193 | if side == "buy": 194 | return self.get_depth(start_amount, "buy", "quote") 195 | 196 | if side == "sell": 197 | return self.get_depth(start_amount, "sell", "base") 198 | 199 | return False 200 | -------------------------------------------------------------------------------- /_scratches/bittrex.py: -------------------------------------------------------------------------------- 1 | import ztom 2 | import sys 3 | import json 4 | import collections 5 | import jsonpickle 6 | from jsonpickle import handlers 7 | import ztom 8 | from ztom import bot 9 | from ztom.orderbook import OrderBook 10 | from ztom.trade_order_manager import * 11 | import ccxt 12 | 13 | exchange_id = "bittrex" 14 | start_cur = "XRP" 15 | dest_cur = "ETH" 16 | 17 | start_amount = 4.5 18 | 19 | bot = ztom.Bot("../_config_bittrex.json", "bittrex_test.log") 20 | bot.load_config_from_file(bot.config_filename) 21 | 22 | # if start_cur != bot.start_currency[0]: 23 | # print("Start currency in config != start currency in test {} != {}".format(bot.start_currency[0], start_cur)) 24 | # sys.exit(0) 25 | 26 | bot.init_logging(bot.log_filename) 27 | bot.init_exchange() 28 | bot.load_markets() 29 | bot.load_balance() 30 | bot.fetch_tickers() 31 | 32 | 33 | order_history_file_name = ztom.utils.get_next_filename_index("./{}_all_objects.json".format(bot.exchange_id)) 34 | 35 | 36 | def test_trade(start_cur, dest_cur, start_amount): 37 | 38 | balance = dict() 39 | while not balance: 40 | try: 41 | balance = bot.exchange.fetch_free_balance() 42 | except: 43 | print("Retry fetching balance...") 44 | 45 | init_balance = dict() 46 | init_balance[start_cur] = balance[start_cur] 47 | 48 | # fix! Bittex return NULL if free balance = 0 and raising KeyError 49 | try: 50 | init_balance[dest_cur] = balance[dest_cur] 51 | except KeyError as e: 52 | init_balance[dest_cur] = 0 53 | pass 54 | 55 | 56 | # init_balance[dest_cur] = balance[dest_cur] 57 | 58 | symbol = ztom.core.get_symbol(start_cur, dest_cur, bot.markets) 59 | ob_array = dict() 60 | while not ob_array: 61 | try: 62 | ob_array = bot.exchange._ccxt.fetch_order_book(symbol, 100) 63 | except: 64 | print("retying to fetch order book") 65 | 66 | order_book = OrderBook(symbol, ob_array["asks"], ob_array["bids"]) 67 | 68 | price = order_book.get_depth_for_destination_currency(start_amount, dest_cur).total_price # * 1.01 69 | 70 | order1 = ztom.TradeOrder.create_limit_order_from_start_amount(symbol, start_cur, start_amount, dest_cur, price) 71 | 72 | order_resps = collections.OrderedDict() 73 | 74 | om = ztom.OrderManagerFok(order1, None, 100, 10) 75 | 76 | try: 77 | om.fill_order(bot.exchange) 78 | except OrderManagerErrorUnFilled as e: 79 | print("Unfilled order. Should cancel and recover/continue") 80 | try: 81 | print("Cancelling....") 82 | om.cancel_order(bot.exchange) 83 | except OrderManagerCancelAttemptsExceeded: 84 | print("Could not cancel") 85 | 86 | except OrderManagerError as e: 87 | print("Unknown Order Manager error") 88 | print(type(e).__name__, "!!!", e.args, ' ') 89 | 90 | except ccxt.errors.InsufficientFunds: 91 | print("Low balance!") 92 | sys.exit(0) 93 | 94 | except Exception as e: 95 | print("error") 96 | print(type(e).__name__, "!!!", e.args, ' ') 97 | sys.exit(0) 98 | 99 | results = list() 100 | i = 0 101 | while bool(results) is not True and i < 100: 102 | print("getting trades #{}".format(i)) 103 | try: 104 | results = bot.exchange.get_trades_results(order1) 105 | except Exception as e: 106 | print(type(e).__name__, "!!!", e.args, ' ') 107 | print("retrying to get trades...") 108 | i += 1 109 | 110 | order1.update_order_from_exchange_resp(results) 111 | order1.fees = bot.exchange.fees_from_order_trades(order1) 112 | 113 | balance_after_order1 = dict(init_balance) 114 | 115 | i = 0 116 | while (balance_after_order1[start_cur] == init_balance[start_cur] or 117 | balance_after_order1[dest_cur] == init_balance[dest_cur]) and i < 50: 118 | try: 119 | balance = dict(bot.exchange.fetch_free_balance()) 120 | balance_after_order1[start_cur], balance_after_order1[dest_cur] = balance[start_cur], balance[dest_cur] 121 | except: 122 | print("Error receiving balance") 123 | 124 | print("Balance receive attempt {}".format(i)) 125 | i += 1 126 | 127 | all_data = dict() 128 | 129 | all_data["exchange_id"] = bot.exchange_id 130 | all_data["start_balance"] = init_balance 131 | all_data["balance_after_order1"] = balance_after_order1 132 | all_data["start_currency"] = start_cur 133 | all_data["dest_currency"] = dest_cur 134 | all_data["symbol"] = symbol 135 | all_data["price"] = price 136 | all_data["order1"] = order1 137 | all_data["order_book_1"] = order_book 138 | all_data["market"] = bot.markets[symbol] 139 | all_data["ticker"] = bot.tickers[symbol] 140 | all_data["balance_after_order1"] = balance_after_order1 141 | all_data["balance_diff_start_cur"] = init_balance[start_cur] - balance_after_order1[start_cur] 142 | all_data["balance_diff_dest_cur"] = init_balance[dest_cur] - balance_after_order1[dest_cur] 143 | all_data["check_balance_dest_curr_diff_eq_filled_dest_minus_fee"] = round( 144 | balance_after_order1[dest_cur] - (init_balance[dest_cur] + order1.filled_dest_amount - 145 | order1.fees[dest_cur]["amount"]), 146 | bot.exchange._ccxt.currencies[dest_cur]["precision"]) 147 | 148 | all_data["check_balance_src_curr_diff_eq_filled_src"] =round( 149 | balance_after_order1[start_cur] - (init_balance[start_cur] - order1.filled_start_amount 150 | ), 151 | 152 | bot.exchange._ccxt.currencies[dest_cur]["precision"]) 153 | 154 | # check if deal results are consistent with amount and fees 155 | if all_data["check_balance_dest_curr_diff_eq_filled_dest_minus_fee"] == 0 \ 156 | and all_data["check_balance_src_curr_diff_eq_filled_src"] == 0: 157 | all_data["_check_trades_amount_fees"] = True 158 | else: 159 | all_data["_check_trades_amount_fees"] = False 160 | 161 | return all_data 162 | 163 | 164 | report = dict() 165 | 166 | print("Trade 1") 167 | print("======================") 168 | # report["trade1"] = test_trade(start_cur, dest_cur, bot.min_amounts[start_cur]) 169 | report["trade1"] = test_trade(start_cur, dest_cur, start_amount) 170 | print("======================") 171 | print("Trade 2") 172 | report["trade2"] = test_trade(dest_cur, start_cur, 173 | report["trade1"]["order1"].filled_dest_amount - 174 | report["trade1"]["order1"].fees[dest_cur]["amount"]) 175 | 176 | 177 | print("Check Trade 1:{}".format(report["trade1"]["_check_trades_amount_fees"])) 178 | print("Check Trade 2:{}".format(report["trade2"]["_check_trades_amount_fees"])) 179 | 180 | j = jsonpickle.encode(report) 181 | 182 | s = json.dumps(json.loads(j), indent=4, sort_keys=True) 183 | 184 | 185 | with open(order_history_file_name, "w") as file: 186 | file.writelines(s) 187 | 188 | # print("Order resp:") 189 | # print(s) 190 | # print("Trades resp:{}".format(results)) 191 | # 192 | # print("=====================================") 193 | # print("Symbol:{}".format(order1.symbol)) 194 | # print("Side:{}".format(order1.side)) 195 | # print("Amount:{}".format(order1.amount)) 196 | # print("Price:{}".format(order1.init_price)) 197 | # print("Status:{}".format(order1.status)) 198 | # print(" - - - -") 199 | # print("Result:") 200 | # print("Filled order amount: {} of {}".format(order1.filled, order1.amount)) 201 | # print("Filled dest amount: {} of {}".format(results["dest_amount"], order1.filled_dest_amount)) 202 | # print("Filled src amount: {} of {}".format(results["src_amount"], order1.amount_start)) 203 | # print("Price Fact vs Order price : {} of {}".format(results["price"], order1.init_price)) 204 | # print("Trades count: {}".format(len(results["trades"]))) 205 | 206 | sys.exit(0) 207 | # d = ob.(bal_to_bid, dest_cur) 208 | # price = d.total_price 209 | # amount = d.total_quantity 210 | -------------------------------------------------------------------------------- /ztom/core.py: -------------------------------------------------------------------------------- 1 | # for basic exchange operations 2 | import math 3 | from ztom import errors 4 | 5 | def get_trade_direction_to_currency(symbol: str, dest_currency: str): 6 | cs = symbol.split("/") 7 | 8 | if cs[0] == dest_currency: 9 | return "buy" 10 | 11 | elif cs[1] == dest_currency: 12 | return "sell" 13 | 14 | else: 15 | return False 16 | 17 | 18 | def get_symbol(c1: str, c2: str, markets: dict): 19 | if c1 + "/" + c2 in markets: 20 | a = c1 + "/" + c2 21 | elif c2 + "/" + c1 in markets: 22 | a = c2 + "/" + c1 23 | else: 24 | return False 25 | return a 26 | 27 | 28 | def get_order_type(source_cur: str, dest_cur: str, symbol: str): 29 | 30 | if source_cur + "/" + dest_cur == symbol: 31 | a = "sell" 32 | elif dest_cur + "/" + source_cur == symbol: 33 | a = "buy" 34 | else: 35 | a = False 36 | 37 | return a 38 | 39 | 40 | def get_symbol_order_price_from_tickers(source_cur: str, dest_cur: str, tickers: dict): 41 | """ 42 | returns dict with taker side and price for converting currency source_cur to dest_cur, using the ticker(-s) dict 43 | 44 | :param source_cur: str 45 | :param dest_cur: str 46 | :param tickers: ticker (-s) dict {"sym/bol":{"ask":value, "bid":value}} 47 | :return: dict of {"symbol": symbol, 48 | "order_type": "buy" or "sell", 49 | "price_type": "ask" or "bid", 50 | "price": price, 51 | "maker_price_type": "bid" or "ask", 52 | "maker_price":val} 53 | 54 | """ 55 | if source_cur + "/" + dest_cur in tickers: 56 | symbol = source_cur + "/" + dest_cur 57 | order_type = "sell" 58 | price_type = "bid" 59 | 60 | maker_price_type = "ask" 61 | 62 | elif dest_cur + "/" + source_cur in tickers: 63 | symbol = dest_cur + "/" + source_cur 64 | order_type = "buy" 65 | price_type = "ask" 66 | 67 | maker_price_type = "bid" 68 | 69 | else: 70 | return None 71 | 72 | if symbol in tickers: 73 | price = tickers[symbol][price_type] if price_type in tickers[symbol] and \ 74 | tickers[symbol][price_type] > 0 else None 75 | 76 | maker_price = tickers[symbol][maker_price_type] if maker_price_type in tickers[symbol] and \ 77 | tickers[symbol][maker_price_type] > 0 else None 78 | 79 | else: 80 | price = None 81 | 82 | a = dict({"symbol": symbol, "order_type": order_type, "price_type": price_type, "price": price, 83 | "maker_price_type": maker_price_type, "maker_price": maker_price}) 84 | return a 85 | 86 | 87 | def price_to_precision(fee, precision=8): 88 | return float(('{:.' + str(precision) + 'f}').format(float(fee))) 89 | 90 | 91 | def amount_to_precision(amount, precision=0): 92 | if precision > 0: 93 | decimal_precision = math.pow(10, precision) 94 | return math.trunc(amount * decimal_precision) / decimal_precision 95 | else: 96 | return float(('%d' % amount)) 97 | 98 | 99 | def relative_target_price_difference(side: str, target_price: float, current_price: float) -> float: 100 | """ 101 | Returns the relative difference of current_price from target price. Negative vallue could be considered as "bad" 102 | difference, positive as "good". 103 | 104 | For "sell" order: relative_target_price_difference = (target_price / current_price) - 1 105 | For "buy" orders: relative_target_price_difference = (current_price / target_price) - 1 106 | 107 | Means that for "buy" order if the price is greater that the target price - the relative difference will be negative. 108 | For "sell" orders: if the price will be less than target price - the rel. difference will be negative. 109 | 110 | :param side: side of the order to compare 111 | :param target_price: the price to compare with 112 | :param current_price: the price which is being compared to target price 113 | 114 | :return: relative difference between the current_price and target_price regarding the order's side or None 115 | """ 116 | 117 | result = None 118 | 119 | if side.lower() == "sell": 120 | result = (current_price / target_price) - 1 121 | return result 122 | 123 | if side.lower() == "buy": 124 | result = 1 - (current_price / target_price) 125 | return result 126 | 127 | raise (ValueError("Wrong side of the order {}".format(side))) 128 | 129 | 130 | def convert_currency(start_currency:str, start_amount:float, dest_currency: str = None, symbol: str = None, price: float = None, 131 | ticker: dict = None, side: str = None, taker: bool = True): 132 | """ 133 | :returns the amount of :param dest_currency: which could be gained if converted from :param start_amount: 134 | of :param start_currency: 135 | 136 | :param start_currency: currency to convert from 137 | :param start_amount: amount of start_currency 138 | :param dest_currency: currency to convert to 139 | :param symbol: symbol of pair within the conversion 140 | :param price: if price is not set, it would be taken from ticker (by default for TAKER price) 141 | :param side: if symbol is not set, side "buy" or "sell" should be provided 142 | :param ticker: content of dict returned by fetch_tickers for symbol. ex. fetch_tickers()["ETH/BTC"] 143 | :param taker: set to False is maker price should be taken from ticker 144 | 145 | """ 146 | 147 | if symbol is None: 148 | if ticker is None: 149 | return None 150 | if "symbol" in ticker: 151 | symbol = ticker["symbol"] 152 | else: 153 | return None 154 | 155 | if side is None: 156 | side = get_trade_direction_to_currency(symbol, dest_currency) 157 | 158 | if not side: 159 | return None 160 | 161 | if price is None: 162 | 163 | if (taker and side == "buy") or \ 164 | (not taker and side == "sell"): 165 | price = float(ticker["ask"]) 166 | 167 | elif (taker and side == "sell") or \ 168 | (not taker and side == "buy"): 169 | price = float(ticker["bid"]) 170 | else: 171 | return None 172 | 173 | if price == 0: 174 | return None 175 | 176 | dest_amount = 0.0 177 | 178 | if side.lower() == "sell": 179 | dest_amount = start_amount * price 180 | 181 | if side.lower() == "buy": 182 | dest_amount = start_amount / price 183 | 184 | return dest_amount 185 | 186 | 187 | def ticker_price_for_dest_amount(side: str, start_amount: float, dest_amount: float): 188 | """ 189 | :return: price for order to convert start_amount to dest_amount considering order's side 190 | """ 191 | 192 | if dest_amount == 0 or start_amount == 0: 193 | raise ValueError("Zero start ot dest amount") 194 | 195 | if side is None: 196 | raise ValueError("RecoveryManagerError: Side not set") 197 | else: 198 | side = side.lower() 199 | 200 | if side == "buy": 201 | return start_amount / dest_amount 202 | 203 | if side == "sell": 204 | return dest_amount / start_amount 205 | 206 | return False 207 | 208 | 209 | def base_amount_for_target_currency(currency, amount, symbol, price: float=None, ticker:dict=None): 210 | """ 211 | Returns amount in base currency in symbol for provided currency, amount and price. 212 | Price could be set directly or taken as taker from ticker symbol dict. 213 | 214 | Returns: amount for base currency or 0 if prices are not provided 215 | 216 | """ 217 | 218 | side = get_trade_direction_to_currency(symbol, dest_currency=currency) 219 | 220 | if currency == symbol.split("/")[0]: 221 | return amount 222 | 223 | else: 224 | 225 | ticker_price = 0.0 226 | 227 | if price is None and ticker_price is not None: 228 | ticker_price = ticker.get("bid", 0.0) 229 | elif price is not None: 230 | ticker_price = price 231 | 232 | if ticker_price == 0.0: 233 | return 0 234 | 235 | return amount / ticker_price 236 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ZTOM 2 | 3 | ZTOM is the Python SDK for implementing the Trade Order Management System for crypto exchanges. 4 | 5 | It's build upon the [CCXT ](https://github.com/ccxt/ccxt)library and simplifies the development of fail-safe applications and trading algorithms by providing tools for managing the trade orders execution and some supplementary tools for configuration, flow control, reporting and etc. 6 | 7 | With ZTOM it's possible to create, maintain and cancel trade orders using different triggers and conditions apart from the implementation of exchange's communication API. 8 | 9 | Could be used for Algorithmic (algo) and High Frequency Trading (hft) for prototyping and production. 10 | 11 | 12 | 13 | **Main Features:** 14 | 15 | * Customizable exchanges REST API wrapper 16 | * Request throttling control 17 | * Order Book depth calculation 18 | * Order's Management 19 | * Configuration (config files, cli) 20 | * Logging, Reporting 21 | * Errors management 22 | * Offline testing: 23 | * Back testing with prepared marked data 24 | * Order execution emulation 25 | 26 | 27 | 28 | # Why it was created 29 | 30 | ZTOM was created because of 2 main reasons: 31 | 32 | First one is to fail-safe proceeding with the common exchange API use cases like working with orders, exceptions handling so it could be possible to focus on trading algorithms rather than overcoming all the tech things. 33 | 34 | This allows to create simple and clean "syntax" framework for order management and flow control so it could be possible easily read the code and understand the implemented algorithm as good as increase the speed of development and leverage on rich Python's analytical abilities. 35 | 36 | The second big thing in mind was the implementation of the offline mode for both market data gathering and order execution using the same code as for online trading. This allows more quickly develop the algos, emulate different cases and as a result launch actual live tests more quickly. 37 | 38 | 39 | 40 | # Code Example 41 | 42 | The order management could be something like this: 43 | 44 | ```python 45 | import time 46 | import ztom as zt 47 | 48 | ex = zt.ccxtExchangeWrapper.load_from_id("binance") 49 | ex.enable_requests_throttle(60, 1200) # 1200 requests per minute for binance 50 | 51 | # just checking in offline mode 52 | ex.set_offline_mode("test_data/markets.json", "test_data/tickers.csv") 53 | 54 | tickers = ex.fetch_tickers() 55 | 56 | # sleep to maintain requests rate 57 | time.sleep(ex.requests_throttle.sleep_time()) 58 | 59 | # below orders object are being created - not actual orders 60 | order1 = zt.ActionOrder.create_from_start_amount(symbol="BTC/USDT", 61 | start_currency="BTC", 62 | amount_start=1, 63 | dest_currency="USDT", 64 | price=tickers["BTC/USDT"]["ask"]) 65 | 66 | order2 = zt.ActionOrder.create_from_start_amount("USD/RUB", "USD", 1, 67 | "RUB", 70) 68 | # new OrderManager object 69 | om = zt.ActionOrderManager(ex) 70 | 71 | # adding orders to OM 72 | # they will not be committed to exchange at this point 73 | om.add_order(order1) 74 | om.add_order(order2) 75 | 76 | i = 0 77 | 78 | while len(om.get_open_orders()) > 0: 79 | 80 | # check if order manager has something to do like create or cancel order 81 | # and if yes we will not wait for it 82 | if om.pending_actions_number() == 0: 83 | sleep_time = ex.requests_throttle.sleep_time() 84 | print("Sleeping for {}s".format(sleep_time)) 85 | time.sleep(sleep_time) 86 | else: 87 | print("!!! NO SLEEP. ACTIONS ARE PENDING !!!") 88 | 89 | # here all transaction are committed with the error handling and etc 90 | om.proceed_orders() 91 | 92 | # just to demonstrate how to cancel the order 93 | # - operation will be committed on om.proceed_orders() 94 | if i == 5 and not order.filled > 0 : 95 | order1.force_close() 96 | 97 | i += 1 98 | 99 | print(order1.filled) 100 | print(order2.filled) 101 | ``` 102 | 103 | 104 | 105 | # Tested Exchanges 106 | 107 | Binance 108 | 109 | 110 | 111 | # Components and Features 112 | 113 | | **Components** | **Features** | 114 | | ------------------------------------------ | ------------------------------------------------------------ | 115 | | **Core** | **most used for creation apps and algos** | 116 | | `ccxtExchangeWrapper` | communication to exchange via the ccxt offline exchange connection emulation | 117 | | `action_order` | action order base class. allows to implement "smart" orders which could perform some actions (creation and cancellation of basic orders) in dependence from the data provided by order manager | 118 | | `order_manager` | action orders lifecycle managementsafe communications with echange wrapperdata provision for ActionOrders | 119 | | `bot` | configuration, logging, reporting and workflow management | 120 | | `throttle` | requests throttle contoll | 121 | | `trade_orders` | container of basic trade order data | 122 | | | | 123 | | **Action Orders** | various types of action orders | 124 | | `fok_orders` | fill-or-kill order implementation (with some amount of time or on price changing) | 125 | | `recovery_orders` | stop loss taker order implementation | 126 | | `maker_stop_loss` (will be added soon) | maker stop loss order | 127 | | | | 128 | | **Calculation helpers** | Prices, amounts and other helpers for calculations | 129 | | `core` | essential assets operations (without connections):trade symbol detectionorder side detectionrelative price differenceprecision convertion | 130 | | `orderbook` | orderbooks in-depth amounts and prices calculations | 131 | | | | 132 | | **Tech/Supplementary** | | 133 | | `utils` | various general purpose supplementary functions | 134 | | `datastorage` | csv file management | 135 | | `cli` | command line tools | 136 | | `timer` | operations time counter and reporter | 137 | | `errors` | some custom exceptions | 138 | | | | 139 | | **Reporing** | | 140 | | `reporter` | influxdb client wrapper for db connection management | 141 | | `reporter_sqla` | sqlalchemy wrapper for db connection management | 142 | | `data models` | sqlalchemy tables to represent trade orders and deals | 143 | | `grafana dashboards` (will be added soon) | samples of grafana dashboards | 144 | 145 | 146 | 147 | 148 | 149 | # Installation 150 | 151 | (the installation from pypi will be implemented soon) 152 | 153 | **Requirements:** python3.6+ and some libs 154 | 155 | 156 | 157 | 1. Clone the repo: 158 | 159 | `git clone https://github.com/ztomsy/ztom.git ` 160 | 161 | 162 | 163 | 2. install the dependencies: 164 | 165 | `pip3 install -r requirements.txt` 166 | 167 | 168 | 169 | 3. install the ztom lib 170 | 171 | `pip3 install -e . ` 172 | 173 | 174 | 175 | 4. run some tests: 176 | 177 | `python3 -m unittest -v -b` 178 | 179 | 180 | 181 | # License 182 | 183 | This project is licensed under the MIT License - see the [LICENSE.md](https://gist.github.com/PurpleBooth/LICENSE.md) file for details 184 | 185 | -------------------------------------------------------------------------------- /test_data/orders_kucoin_buy.json: -------------------------------------------------------------------------------- 1 | { 2 | "create": { 3 | "side": "buy", 4 | "lastTradeTimestamp": null, 5 | "remaining": null, 6 | "cost": 0.049999999999999996, 7 | "timestamp": 1529947000139, 8 | "type": "limit", 9 | "symbol": "ETH/BTC", 10 | "id": "5b3123789dda157b36df4a1d", 11 | "amount": 0.6773607955034138, 12 | "trades": null, 13 | "datetime": "2018-06-25T17:16:40.139Z", 14 | "status": "open", 15 | "price": 0.07381590480571001, 16 | "info": { 17 | "data": { 18 | "orderOid": "5b3123789dda157b36df4a1d" 19 | }, 20 | "msg": "OK", 21 | "timestamp": 1529947000139, 22 | "success": true, 23 | "code": "OK" 24 | }, 25 | "fee": null, 26 | "filled": null 27 | }, 28 | "updates": [ 29 | { 30 | "side": "buy", 31 | "lastTradeTimestamp": null, 32 | "remaining": 0.6773607, 33 | "cost": 0.049999989695130004, 34 | "timestamp": 1529947001000, 35 | "type": "limit", 36 | "symbol": "ETH/BTC", 37 | "id": "5b3123789dda157b36df4a1d", 38 | "amount": 0.6773607, 39 | "trades": [], 40 | "datetime": "2018-06-25T17:16:41.000Z", 41 | "status": "open", 42 | "price": 0.0738159, 43 | "info": { 44 | "type": "BUY", 45 | "userOid": "5b2137bfcbdbf716873b3b85", 46 | "coinType": "ETH", 47 | "pendingAmount": 0.6773607, 48 | "dealPriceAverage": 0.0, 49 | "dealValueTotal": 0.0, 50 | "orderOid": "5b3123789dda157b36df4a1d", 51 | "orderPrice": 0.0738159, 52 | "coinTypePair": "BTC", 53 | "dealAmount": 0.0, 54 | "feeTotal": 0.0, 55 | "dealOrders": { 56 | "limit": 20, 57 | "total": 0, 58 | "currPageNo": 1, 59 | "datas": [], 60 | "lastPage": false, 61 | "firstPage": true, 62 | "pageNos": 1 63 | }, 64 | "createdAt": 1529947001000 65 | }, 66 | "fee": { 67 | "currency": "ETH", 68 | "cost": 0.0, 69 | "rate": null 70 | }, 71 | "filled": 0.0 72 | }, 73 | { 74 | "side": "buy", 75 | "lastTradeTimestamp": null, 76 | "remaining": 0.6773607, 77 | "cost": 0.049999989695130004, 78 | "timestamp": 1529947001000, 79 | "type": "limit", 80 | "symbol": "ETH/BTC", 81 | "id": "5b3123789dda157b36df4a1d", 82 | "amount": 0.6773607, 83 | "trades": [], 84 | "datetime": "2018-06-25T17:16:41.000Z", 85 | "status": "open", 86 | "price": 0.0738159, 87 | "info": { 88 | "type": "BUY", 89 | "userOid": "5b2137bfcbdbf716873b3b85", 90 | "coinType": "ETH", 91 | "pendingAmount": 0.6773607, 92 | "dealPriceAverage": 0.0, 93 | "dealValueTotal": 0.0, 94 | "orderOid": "5b3123789dda157b36df4a1d", 95 | "orderPrice": 0.0738159, 96 | "coinTypePair": "BTC", 97 | "dealAmount": 0.0, 98 | "feeTotal": 0.0, 99 | "dealOrders": { 100 | "limit": 20, 101 | "total": 0, 102 | "currPageNo": 1, 103 | "datas": [], 104 | "lastPage": false, 105 | "firstPage": true, 106 | "pageNos": 1 107 | }, 108 | "createdAt": 1529947001000 109 | }, 110 | "fee": { 111 | "currency": "ETH", 112 | "cost": 0.0, 113 | "rate": null 114 | }, 115 | "filled": 0.0 116 | }, 117 | { 118 | "side": "buy", 119 | "lastTradeTimestamp": null, 120 | "remaining": 0.6773607, 121 | "cost": 0.049999989695130004, 122 | "timestamp": 1529947001000, 123 | "type": "limit", 124 | "symbol": "ETH/BTC", 125 | "id": "5b3123789dda157b36df4a1d", 126 | "amount": 0.6773607, 127 | "trades": [], 128 | "datetime": "2018-06-25T17:16:41.000Z", 129 | "status": "open", 130 | "price": 0.0738159, 131 | "info": { 132 | "type": "BUY", 133 | "userOid": "5b2137bfcbdbf716873b3b85", 134 | "coinType": "ETH", 135 | "pendingAmount": 0.6773607, 136 | "dealPriceAverage": 0.0, 137 | "dealValueTotal": 0.0, 138 | "orderOid": "5b3123789dda157b36df4a1d", 139 | "orderPrice": 0.0738159, 140 | "coinTypePair": "BTC", 141 | "dealAmount": 0.0, 142 | "feeTotal": 0.0, 143 | "dealOrders": { 144 | "limit": 20, 145 | "total": 0, 146 | "currPageNo": 1, 147 | "datas": [], 148 | "lastPage": false, 149 | "firstPage": true, 150 | "pageNos": 1 151 | }, 152 | "createdAt": 1529947001000 153 | }, 154 | "fee": { 155 | "currency": "ETH", 156 | "cost": 0.0, 157 | "rate": null 158 | }, 159 | "filled": 0.0 160 | }, 161 | { 162 | "side": "buy", 163 | "lastTradeTimestamp": null, 164 | "remaining": 0.0, 165 | "cost": 0.04995144, 166 | "timestamp": 1529947001000, 167 | "type": "limit", 168 | "symbol": "ETH/BTC", 169 | "id": "5b3123789dda157b36df4a1d", 170 | "amount": 0.6773607, 171 | "trades": [ 172 | { 173 | "side": "buy", 174 | "type": null, 175 | "cost": 0.04995144, 176 | "timestamp": 1529947001000, 177 | "symbol": "ETH/BTC", 178 | "id": null, 179 | "amount": 0.6773607, 180 | "info": { 181 | "dealValue": 0.04995144, 182 | "amount": 0.6773607, 183 | "dealPrice": 0.07374422, 184 | "feeRate": 0.001, 185 | "fee": 0.00067736, 186 | "createdAt": 1529947001000 187 | }, 188 | "order": "5b3123789dda157b36df4a1d", 189 | "datetime": "2018-06-25T17:16:41.000Z", 190 | "price": 0.07374422, 191 | "fee": { 192 | "currency": "ETH", 193 | "cost": 0.00067736 194 | } 195 | } 196 | ], 197 | "datetime": "2018-06-25T17:16:41.000Z", 198 | "status": "closed", 199 | "price": 0.07374423, 200 | "info": { 201 | "type": "BUY", 202 | "userOid": "5b2137bfcbdbf716873b3b85", 203 | "coinType": "ETH", 204 | "pendingAmount": 0.0, 205 | "dealPriceAverage": 0.07374423, 206 | "dealValueTotal": 0.04995144, 207 | "orderOid": "5b3123789dda157b36df4a1d", 208 | "orderPrice": 0.0738159, 209 | "coinTypePair": "BTC", 210 | "dealAmount": 0.6773607, 211 | "feeTotal": 0.00067736, 212 | "dealOrders": { 213 | "limit": 20, 214 | "total": 1, 215 | "currPageNo": 1, 216 | "datas": [ 217 | { 218 | "dealValue": 0.04995144, 219 | "amount": 0.6773607, 220 | "dealPrice": 0.07374422, 221 | "feeRate": 0.001, 222 | "fee": 0.00067736, 223 | "createdAt": 1529947001000 224 | } 225 | ], 226 | "lastPage": false, 227 | "firstPage": true, 228 | "pageNos": 1 229 | }, 230 | "createdAt": 1529947001000 231 | }, 232 | "fee": { 233 | "currency": "ETH", 234 | "cost": 0.00067736, 235 | "rate": null 236 | }, 237 | "filled": 0.6773607 238 | } 239 | ] 240 | } -------------------------------------------------------------------------------- /tests/test_throttle.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .context import ztom 3 | from ztom import Throttle 4 | import datetime as datetime 5 | import unittest 6 | 7 | 8 | class ThrottleTestSuite(unittest.TestCase): 9 | 10 | def test_throttle_create(self): 11 | throttle = Throttle(100, 1000) 12 | 13 | self.assertEqual(100, throttle.period) 14 | self.assertEqual(1000, throttle.requests_per_period) 15 | 16 | self.assertEqual(0.0, throttle._current_period_time) 17 | self.assertEqual(0.0, throttle._requests_in_current_period) 18 | self.assertEqual(0.0, throttle._period_start_timestamp) 19 | 20 | self.assertEqual(0.1, throttle.allowed_time_for_single_request) 21 | 22 | def test_create_throttle_with_request_weights(self): 23 | # default 24 | 25 | request_type_weight = { 26 | "single": 1, 27 | "load_markets": 1, 28 | "fetch_tickers": 1, 29 | "fetch_ticker": 1, 30 | "fetch_order_book": 1, 31 | "create_order": 1, 32 | "fetch_order": 1, 33 | "cancel_order": 1, 34 | "fetch_my_trades": 1, 35 | "fetch_balance": 1} 36 | 37 | throttle = Throttle(60, 1000) 38 | self.assertDictEqual(request_type_weight, throttle.request_weights) 39 | 40 | # some fields changed 41 | 42 | request_type_weight = { 43 | "fetch_tickers": 10, 44 | "fetch_my_trades": 66 45 | } 46 | 47 | throttle = Throttle(60, 100, request_type_weight) 48 | 49 | self.assertDictEqual( 50 | { 51 | "single": 1, 52 | "load_markets": 1, 53 | "fetch_tickers": 10, 54 | "fetch_ticker": 1, 55 | "fetch_order_book": 1, 56 | "create_order": 1, 57 | "fetch_order": 1, 58 | "cancel_order": 1, 59 | "fetch_my_trades": 66, 60 | "fetch_balance": 1}, 61 | throttle.request_weights) 62 | 63 | # changed and new added 64 | request_type_weight = { 65 | "fetch_tickers": 13, 66 | "fetch_my_trades": 67, 67 | "modify_order": 3} 68 | throttle = Throttle(60, 100, request_type_weight) 69 | 70 | self.assertDictEqual( 71 | { 72 | "single": 1, 73 | "load_markets": 1, 74 | "fetch_tickers": 13, 75 | "fetch_ticker": 1, 76 | "fetch_order_book": 1, 77 | "create_order": 1, 78 | "fetch_order": 1, 79 | "cancel_order": 1, 80 | "fetch_my_trades": 67, 81 | "modify_order": 3, 82 | "fetch_balance": 1}, 83 | 84 | throttle.request_weights) 85 | 86 | def test_calc_time_sleep_to_recover_requests_rate(self): 87 | throttle = Throttle(60, 1000) 88 | 89 | self.assertEqual(0.06, throttle.allowed_time_for_single_request) 90 | 91 | # we should not sleep because have 1 request in allowed_time_for_single_request 92 | self.assertEqual(0, throttle._calc_time_sleep_to_recover_requests_rate(0.06, 1)) 93 | 94 | # more time for single request 95 | self.assertEqual(0, throttle._calc_time_sleep_to_recover_requests_rate(0.07, 1)) 96 | 97 | # less time for single request 98 | self.assertAlmostEqual(0.01, throttle._calc_time_sleep_to_recover_requests_rate(0.05, 1), 8) 99 | 100 | # more requests than allowed in current_time - so we need to sleep more 101 | self.assertAlmostEqual(0.07, throttle._calc_time_sleep_to_recover_requests_rate(0.05, 2), 8) 102 | self.assertAlmostEqual(0.13, throttle._calc_time_sleep_to_recover_requests_rate(0.05, 3), 8) 103 | 104 | def test_add_single_request(self): 105 | throttle = Throttle(60, 1000) 106 | 107 | throttle._add_request_for_current_period_time(1) 108 | throttle._add_request_for_current_period_time(2) 109 | throttle._add_request_for_current_period_time(3) 110 | 111 | self.assertDictEqual(throttle.requests_current_period[0], { 112 | "timestamp": 1, "request_type": "single", "added": 1}) 113 | 114 | self.assertDictEqual(throttle.requests_current_period[1], { 115 | "timestamp": 2, "request_type": "single", "added": 1}) 116 | 117 | self.assertDictEqual(throttle.requests_current_period[2], { 118 | "timestamp": 3, "request_type": "single", "added": 1}) 119 | 120 | def test_update(self): 121 | 122 | throttle = Throttle(60, 1000) 123 | 124 | # we will add first request at timestamp = 1 125 | throttle._add_request_for_current_period_time(1) 126 | throttle._add_request_for_current_period_time(2) 127 | throttle._add_request_for_current_period_time(3) 128 | 129 | throttle.update(60.1) 130 | 131 | self.assertEqual(1, throttle._period_start_timestamp) 132 | self.assertAlmostEqual(59.1, throttle._current_period_time, 6) 133 | 134 | self.assertDictEqual({'added': 1, 'timestamp': 1, 'request_type': 'single'}, throttle.requests_current_period[0]) 135 | self.assertDictEqual({'added': 1, 'timestamp': 2, 'request_type': 'single'}, 136 | throttle.requests_current_period[1]) 137 | self.assertDictEqual({'added': 1, 'timestamp': 3, 'request_type': 'single'}, 138 | throttle.requests_current_period[2]) 139 | 140 | 141 | # 2 periods ahead 142 | throttle._add_request_for_current_period_time(100) 143 | throttle._add_request_for_current_period_time(181) 144 | throttle._add_request_for_current_period_time(182) 145 | throttle._add_request_for_current_period_time(183) 146 | 147 | throttle.update(183) 148 | self.assertAlmostEqual(2, throttle._current_period_time, 6) 149 | self.assertEqual(throttle._period_start_timestamp, 181) 150 | 151 | self.assertEqual(3, len(throttle.requests_current_period)) 152 | 153 | self.assertDictEqual({'added': 1, 'timestamp': 181, 'request_type': 'single'}, 154 | throttle.requests_current_period[0]) 155 | 156 | self.assertDictEqual({'added': 1, 'timestamp': 182, 'request_type': 'single'}, 157 | throttle.requests_current_period[1]) 158 | 159 | self.assertDictEqual({'added': 1, 'timestamp': 183, 'request_type': 'single'}, 160 | throttle.requests_current_period[2]) 161 | 162 | # add request to the of the new period 163 | throttle._add_request_for_current_period_time(240) 164 | throttle.update(240) 165 | self.assertEqual(throttle._period_start_timestamp, 181) 166 | self.assertEqual(4, len(throttle.requests_current_period)) 167 | 168 | throttle._add_request_for_current_period_time(299) 169 | throttle._add_request_for_current_period_time(300) 170 | 171 | throttle.update(300) 172 | self.assertEqual(throttle._period_start_timestamp, 241) 173 | self.assertEqual(2, len(throttle.requests_current_period)) 174 | self.assertEqual(299, throttle.requests_current_period[0]["timestamp"]) 175 | self.assertEqual(300, throttle.requests_current_period[1]["timestamp"]) 176 | 177 | def test_requests_sleep_time(self): 178 | 179 | throttle = Throttle(60, 1000) 180 | 181 | throttle.add_request(timestamp=0, request_type="single", requests=1) 182 | sleep = throttle.sleep_time(timestamp=0) 183 | 184 | self.assertEqual(0, throttle._current_period_time) 185 | self.assertEqual(1, len(throttle.requests_current_period)) 186 | self.assertEqual(0.06, sleep) 187 | 188 | throttle.add_request(0.061) 189 | throttle.add_request(0.062) 190 | throttle.add_request(0.12) 191 | 192 | self.assertEqual(4, len(throttle.requests_current_period)) 193 | 194 | sleep = throttle.sleep_time(timestamp=0.12) 195 | 196 | self.assertEqual(4, len(throttle.requests_current_period)) 197 | 198 | # we have 4 requests in 0.12 seconds. if we were requesting with the constant rate 1 request in 0.06s we 199 | # should be done to 0.24s. Since the current timestamp is 0.12 we should wait for 0.12s until the next 200 | # request 201 | self.assertEqual(0.12, sleep) 202 | 203 | throttle.add_request(59, requests=999) 204 | 205 | self.assertEqual(1003, throttle.total_requests_current_period) 206 | sleep = throttle.sleep_time(59) 207 | self.assertEqual(1003*0.06 - 59, sleep) 208 | 209 | throttle.add_request(60) 210 | self.assertEqual(1, throttle.total_requests_current_period) 211 | self.assertEqual(0.06, throttle.sleep_time(60)) 212 | 213 | throttle.add_request(60.061) 214 | 215 | self.assertEqual(2, throttle.total_requests_current_period) 216 | self.assertAlmostEqual(0.059, throttle.sleep_time(60.061), 8) 217 | 218 | 219 | if __name__ == '__main__': 220 | unittest.main() 221 | -------------------------------------------------------------------------------- /ztom/recovery_orders.py: -------------------------------------------------------------------------------- 1 | from ztom import core 2 | from ztom import errors 3 | from ztom import TradeOrder 4 | from ztom import ActionOrder 5 | import time 6 | import uuid 7 | from ztom import ccxtExchangeWrapper 8 | 9 | 10 | 11 | class RecoveryOrder(ActionOrder): 12 | 13 | def __init__(self, symbol, start_currency: str, start_amount: float, dest_currency: str, 14 | dest_amount: float=0.0, 15 | fee: float=0.0, cancel_threshold: float=0.000001, max_best_amount_order_updates: int=50, 16 | max_order_updates: int=10): 17 | """ 18 | Creates the Recovery Order. Recovery Order is aimed to be filled for the setted dest amount and if fails fills 19 | on best market price. 20 | 21 | Workflow of Recovery Order: 22 | - create limit order with the price in accordance to receive the dest amount 23 | - if this order is not filled, than cancel it and run a series of consecutive limit orders on ticker 24 | price (taker) 25 | 26 | :param symbol: pair symbol for order 27 | :param start_currency: start currency to trade from (available currency) 28 | :param start_amount: amount of start currency 29 | :param dest_currency: destination currency to trade to 30 | :param dest_amount: amount of dest currency 31 | :param fee: exchange fee for order (not used) 32 | :param cancel_threshold: cancel current trade order and set new only if the remained amount to fill is greater than 33 | this threshold. This is for avoiding the situation of creating new order for less than minimun amount. Usually 34 | should be minimum order amount/value for the order's pair + commission. 35 | In ccxt: markets[symbol]["limits"]["amount"]["min"] 36 | :param max_best_amount_order_updates: number of best amount trade order updates before cancelling 37 | :param max_order_updates: max order updates for market price trade orders 38 | 39 | """ 40 | 41 | # just to instantiate class, will set all the necessary parameters properly below 42 | super().__init__(symbol, 0.0, 1, "") 43 | 44 | self.id = str(uuid.uuid4()) 45 | self.timestamp = time.time() # timestamp of object creation 46 | self.timestamp_close = float() 47 | 48 | self.symbol = symbol 49 | self.start_currency = start_currency 50 | self.start_amount = start_amount 51 | self.dest_currency = dest_currency 52 | self.fee = fee 53 | self.cancel_threshold = cancel_threshold # 54 | self.best_dest_amount = dest_amount 55 | self.best_price = 0.0 56 | self.price = 0.0 57 | 58 | self.status = "new" # new, open, closed 59 | self.state = "best_amount" # "market_price" for reporting purposes 60 | 61 | self.filled_dest_amount = 0.0 62 | self.filled_start_amount = 0.0 63 | self.filled_price = 0.0 64 | 65 | self.filled = 0.0 # filled amount of base currency 66 | self.amount = 0.0 # total expected amount of to be filled base currency 67 | 68 | self.max_best_amount_orders_updates = max_best_amount_order_updates # max order updates for best amount 69 | self.max_order_updates = max_order_updates # max amount of order updates for market price orders 70 | 71 | self.order_command = None # None, new, cancel 72 | 73 | if symbol is not None: 74 | self.side = core.get_trade_direction_to_currency(symbol, self.dest_currency) 75 | if self.side == "buy": 76 | self.amount = self.best_dest_amount 77 | else: 78 | self.amount = self.start_amount 79 | 80 | self.active_trade_order = None # type: TradeOrder 81 | self.orders_history = list() 82 | 83 | self.market_data = dict() # market data dict: {symbol : {price :{"buy": , "sell": }} 84 | 85 | self._prev_filled_dest_amount = 0.0 # filled amounts on previous orders 86 | self._prev_filled_start_amount = 0.0 # filled amountsbot, on previous orders 87 | self._prev_filled = 0.0 # filled amounts on previous orders 88 | 89 | self._force_close = False 90 | self._init_best_amount() 91 | 92 | # @property 93 | # def symbol(self): 94 | # return self.__symbol 95 | # 96 | # # set the symbol and side of recovery order 97 | # @symbol.setter 98 | # def symbol(self, value): 99 | # self.__symbol = value 100 | # if value is not None: 101 | # self.side = core.get_trade_direction_to_currency(value, self.dest_currency) 102 | 103 | def _init_best_amount(self): 104 | 105 | self.status = "open" 106 | self.state = "best_amount" 107 | self.order_command = "new" 108 | 109 | price = self._get_recovery_price_for_best_dest_amount() 110 | self.active_trade_order = self._create_recovery_order(price, self.state) 111 | 112 | def _init_market_price(self): 113 | try: 114 | price = self.market_data[self.symbol]["price"][self.side] 115 | except Exception: 116 | raise errors.OrderError("Could not set price from market data") 117 | 118 | self.status = "open" 119 | self.state = "market_price" 120 | self.order_command = "new" 121 | 122 | self.active_trade_order = self._create_recovery_order(price, self.state) 123 | 124 | def _get_recovery_price_for_best_dest_amount(self): 125 | """ 126 | :return: price for recovery order from target_amount and target_currency without fee 127 | """ 128 | if self.best_dest_amount == 0 or self.start_amount == 0: 129 | raise errors.OrderError("RecoveryManagerError: Zero start ot dest amount") 130 | 131 | if self.symbol is None: 132 | raise errors.OrderError("RecoveryManagerError: Symbol is not set") 133 | 134 | if self.side is None: 135 | raise errors.OrderError("RecoveryManagerError: Side not set") 136 | 137 | if self.side == "buy": 138 | return self.start_amount / self.best_dest_amount 139 | if self.side == "sell": 140 | return self.best_dest_amount / self.start_amount 141 | return False 142 | 143 | def _create_recovery_order(self, price, state:str): 144 | 145 | amount = self.start_amount - self.filled_start_amount 146 | 147 | if amount <= 0: 148 | raise errors.OrderError("Bad new order amount {}".format(amount)) 149 | 150 | order_params = (self.symbol, self.start_currency, amount, 151 | self.dest_currency, price) 152 | 153 | if False not in order_params: 154 | self.price = price 155 | order = TradeOrder.create_limit_order_from_start_amount(*order_params) # type: TradeOrder 156 | order.supplementary.update({"parent_action_order": {"state": state}}) 157 | return order 158 | 159 | else: 160 | raise errors.OrderError("Not all parameters for Order are set") 161 | 162 | def _on_open_order(self, active_trade_order: TradeOrder, market_data): 163 | self.order_command = "hold" 164 | 165 | current_state_max_order_updates = self.max_best_amount_orders_updates if self.state == "best_amount" \ 166 | else self.max_order_updates 167 | 168 | if self.active_trade_order.update_requests_count >= current_state_max_order_updates \ 169 | and self.active_trade_order.amount - self.active_trade_order.filled > self.cancel_threshold: 170 | # add ticker request command to order manager 171 | self.order_command = "cancel tickers {symbol}".format(symbol=self.active_trade_order.symbol) 172 | 173 | return self.order_command 174 | 175 | def _on_closed_order(self, active_trade_order: TradeOrder, market_data=None): 176 | if self.filled_start_amount >= self.start_amount * 0.99999: # close order if filled amount is OK 177 | self.order_command = "" 178 | self._close_active_order() 179 | self.close_order() 180 | return self.order_command 181 | 182 | self.state = "market_price" # else set new order status 183 | if market_data is not None and market_data[0] is not None: 184 | self._close_active_order() 185 | 186 | ticker = market_data[0] 187 | 188 | new_price = core.get_symbol_order_price_from_tickers(self.start_currency, self.dest_currency, 189 | {self.symbol: ticker})["price"] 190 | 191 | self.active_trade_order = self._create_recovery_order(new_price, self.state) 192 | self.order_command = "new" 193 | else: 194 | # if we did not received ticker - so just re request the ticker 195 | self.order_command = "hold tickers {symbol}".format(symbol=self.active_trade_order.symbol) 196 | 197 | # print("New price not set... Hodling..") 198 | # raise errors.OrderError("New price not set") 199 | 200 | return self.order_command 201 | 202 | 203 | -------------------------------------------------------------------------------- /test_data/order_books.csv: -------------------------------------------------------------------------------- 1 | fetch,symbol,ask,ask-qty,bid,bid-qty 2 | 0,ETH/BTC,0.092727,1.451,0.092698,0.3685058 3 | 0,ETH/BTC,0.092728,0.026,0.092697,23.058 4 | 0,ETH/BTC,0.09273,3.508,0.092696,9.381 5 | 0,ETH/BTC,0.092747,0.801,0.092695,267.077 6 | 0,ETH/BTC,0.09275,0.027,0.09269,0.363 7 | 0,ETH/BTC,0.092765,0.001,0.092689,0.294 8 | 0,ETH/BTC,0.092766,0.354,0.092685,0.283 9 | 0,ETH/BTC,0.092769,0.03,0.092682,0.026 10 | 0,ETH/BTC,0.092772,1.308,0.09265,1 11 | 0,ETH/BTC,0.092774,1.815,0.092603,8.662 12 | 0,FUEL/BTC,0.0000131,2607.6335877863,0.00001287,2615 13 | 0,FUEL/BTC,0.00001311,2695,0.00001281,9828 14 | 0,FUEL/BTC,0.00001313,17903,0.00001279,695 15 | 0,FUEL/BTC,0.00001314,419,0.00001275,1195 16 | 0,FUEL/BTC,0.0000132,227,0.00001274,36946 17 | 0,FUEL/BTC,0.00001323,1117,0.00001273,15699 18 | 0,FUEL/BTC,0.00001324,99,0.00001271,78678 19 | 0,FUEL/BTC,0.00001338,49,0.0000127,1920 20 | 0,FUEL/BTC,0.00001343,216,0.00001263,8628 21 | 0,FUEL/BTC,0.00001346,2162,0.00001261,150 22 | 0,FUEL/ETH,0.00014092,2331,0.000138,2607 23 | 0,FUEL/ETH,0.00014205,2460,0.00013722,21133 24 | 0,FUEL/ETH,0.00014207,1404,0.00013705,30235 25 | 0,FUEL/ETH,0.0001421,14373,0.00013704,5000 26 | 0,FUEL/ETH,0.00014229,3516,0.00013703,6082 27 | 0,FUEL/ETH,0.00014327,1198,0.00013701,31115 28 | 0,FUEL/ETH,0.00014328,1531,0.000137,12070 29 | 0,FUEL/ETH,0.00014421,6954,0.00013668,5820 30 | 0,FUEL/ETH,0.00014499,3391,0.00013666,70283 31 | 0,FUEL/ETH,0.000146,1509,0.00013665,5642 32 | 1,WAVES/ETH,0.00831,41.9785234657,0.008241,120 33 | 1,WAVES/ETH,0.00832,5.51,0.00824,556.97 34 | 1,WAVES/ETH,0.008333,9.63,0.008239,2.42 35 | 1,WAVES/ETH,0.008359,24.04,0.008235,121 36 | 1,WAVES/ETH,0.00836,12.33,0.008212,48.7 37 | 1,WAVES/ETH,0.008384,3,0.008208,65.86 38 | 1,WAVES/ETH,0.008397,61.07,0.0082,10.67 39 | 1,WAVES/ETH,0.008404,41,0.008186,20.29 40 | 1,WAVES/ETH,0.008406,4.15,0.00815,660.69 41 | 1,WAVES/ETH,0.008416,23.79,0.00814,610 42 | 1,WAVES/BTC,0.000777,36.1,0.000767,41.97 43 | 1,WAVES/BTC,0.0007771,14.95,0.0007665,39.13 44 | 1,WAVES/BTC,0.0007779,129.99,0.0007663,238.36 45 | 1,WAVES/BTC,0.0007782,27.85,0.0007661,3 46 | 1,WAVES/BTC,0.000779,66.11,0.0007628,136 47 | 1,WAVES/BTC,0.0007792,14.95,0.0007626,1284.93 48 | 1,WAVES/BTC,0.0007793,35.75,0.00076,2.89 49 | 1,WAVES/BTC,0.00078,611.85,0.0007597,15 50 | 1,WAVES/BTC,0.0007801,1.73,0.0007585,16.3 51 | 1,WAVES/BTC,0.0007811,6.6,0.000758,4 52 | 1,ETH/BTC,0.092436,0.311,0.092407,0.05 53 | 1,ETH/BTC,0.092468,0.0372388718,0.092406,9.625 54 | 1,ETH/BTC,0.092476,0.148,0.092405,0.965 55 | 1,ETH/BTC,0.092478,0.03,0.092404,0.735 56 | 1,ETH/BTC,0.092481,2.11,0.092403,0.51 57 | 1,ETH/BTC,0.092487,0.05,0.092402,12.81 58 | 1,ETH/BTC,0.0925,0.503,0.092376,0.14 59 | 1,ETH/BTC,0.092502,0.327,0.092374,0.027 60 | 1,ETH/BTC,0.092511,0.18,0.092372,0.05 61 | 1,ETH/BTC,0.092526,1.037,0.092369,0.54 62 | 2,ZRX/ETH,0.00124211,479.8286786194,0.00122,132419 63 | 2,ZRX/ETH,0.00124219,13013,0.00118122,93 64 | 2,ZRX/ETH,0.00124357,47,0.00117994,3015 65 | 2,ZRX/ETH,0.00124436,137,0.0011778,818 66 | 2,ZRX/ETH,0.001245,9000,0.00117779,826 67 | 2,ZRX/ETH,0.00124644,16,0.00117592,136 68 | 2,ZRX/ETH,0.00124759,10,0.0011759,68 69 | 2,ZRX/ETH,0.00124773,415,0.00117412,361 70 | 2,ZRX/ETH,0.00124784,284,0.00116747,116 71 | 2,ZRX/ETH,0.00124785,165,0.00116437,230 72 | 2,ZRX/BTC,0.00011997,515,0.0001152,479 73 | 2,ZRX/BTC,0.00011998,163,0.00011405,13 74 | 2,ZRX/BTC,0.00012,8239,0.00011239,682 75 | 2,ZRX/BTC,0.00012001,151,0.00011238,141 76 | 2,ZRX/BTC,0.00012006,177,0.0001111,2096 77 | 2,ZRX/BTC,0.00012007,50,0.00011108,757 78 | 2,ZRX/BTC,0.00012008,81,0.00010931,178 79 | 2,ZRX/BTC,0.00012019,40,0.0001093,4297 80 | 2,ZRX/BTC,0.00012033,25,0.00010923,915 81 | 2,ZRX/BTC,0.00012036,11,0.00010922,7312 82 | 2,ETH/BTC,0.092163,0.5987305101,0.092127,1.383 83 | 2,ETH/BTC,0.092181,0.033,0.092126,1.441 84 | 2,ETH/BTC,0.092188,0.02,0.09212,0.041 85 | 2,ETH/BTC,0.09219,1.428,0.092104,0.03 86 | 2,ETH/BTC,0.092198,0.03,0.0921,11.657 87 | 2,ETH/BTC,0.0922,1.508,0.092097,0.216 88 | 2,ETH/BTC,0.092205,0.246,0.092096,0.971 89 | 2,ETH/BTC,0.092213,0.184,0.092092,0.173 90 | 2,ETH/BTC,0.092214,0.15,0.092088,0.147 91 | 2,ETH/BTC,0.092215,0.492,0.092085,1.214 92 | 33,ZRX/ETH,0.00133055,97.6776520988,0.001329,235 93 | 33,ZRX/ETH,0.00133099,60,0.00129965,11 94 | 33,ZRX/ETH,0.00133205,257,0.00128472,1844 95 | 33,ZRX/ETH,0.0013321,136,0.0012847,1687 96 | 33,ZRX/ETH,0.00133214,277,0.00128469,233 97 | 33,ZRX/ETH,0.00133239,100,0.00128468,126 98 | 33,ZRX/ETH,0.00133333,22,0.00128306,128 99 | 33,ZRX/ETH,0.001334,56,0.00128304,636 100 | 33,ZRX/ETH,0.00133498,1156,0.00127501,154 101 | 33,ZRX/ETH,0.00133555,14,0.001275,36 102 | 33,ZRX/BTC,0.00012742,148,0.00012739,97 103 | 33,ZRX/BTC,0.00012749,850,0.000119,584 104 | 33,ZRX/BTC,0.0001275,835,0.00011899,459 105 | 33,ZRX/BTC,0.00012753,300,0.00011898,704 106 | 33,ZRX/BTC,0.00012756,255,0.00011897,437 107 | 33,ZRX/BTC,0.00012762,40,0.00011896,41 108 | 33,ZRX/BTC,0.0001278,20,0.00011871,33 109 | 33,ZRX/BTC,0.00012782,482,0.0001181,14407 110 | 33,ZRX/BTC,0.00012793,19,0.00011806,1988 111 | 33,ZRX/BTC,0.00012798,99,0.00011805,348 112 | 33,ETH/BTC,0.0923,0.1338768147,0.0922,0.284 113 | 33,ETH/BTC,0.092306,1.219,0.092124,0.5 114 | 33,ETH/BTC,0.092317,1.802,0.092123,3.296 115 | 33,ETH/BTC,0.092326,4.207,0.092122,4.167 116 | 33,ETH/BTC,0.092332,0.222,0.092115,0.529 117 | 33,ETH/BTC,0.092335,0.06,0.092114,0.879 118 | 33,ETH/BTC,0.092336,0.042,0.092113,0.46 119 | 33,ETH/BTC,0.092338,0.051,0.092103,0.011 120 | 33,ETH/BTC,0.092339,0.081,0.092101,1.755 121 | 33,ETH/BTC,0.09234,0.047,0.0921,16.282 122 | 4,ZRX/ETH,0.001334,18,0.00129965,9 123 | 4,ZRX/ETH,0.00133498,114.4139987116,0.00128472,1844 124 | 4,ZRX/ETH,0.00133555,14,0.00128469,233 125 | 4,ZRX/ETH,0.00133876,190,0.00128468,126 126 | 4,ZRX/ETH,0.00133886,100,0.00128306,128 127 | 4,ZRX/ETH,0.00133992,30,0.00128304,636 128 | 4,ZRX/ETH,0.00134,61,0.00127501,154 129 | 4,ZRX/ETH,0.00134216,17,0.001275,36 130 | 4,ZRX/ETH,0.0013422,93,0.00127399,333 131 | 4,ZRX/ETH,0.00134245,50,0.00125273,214 132 | 4,ZRX/BTC,0.00012742,148,0.000119,132 133 | 4,ZRX/BTC,0.00012748,615,0.00011899,459 134 | 4,ZRX/BTC,0.00012749,850,0.00011898,28 135 | 4,ZRX/BTC,0.0001275,835,0.00011897,437 136 | 4,ZRX/BTC,0.00012753,300,0.00011896,41 137 | 4,ZRX/BTC,0.00012756,255,0.00011871,33 138 | 4,ZRX/BTC,0.00012762,40,0.0001181,14407 139 | 4,ZRX/BTC,0.0001278,20,0.00011806,1988 140 | 4,ZRX/BTC,0.00012782,482,0.00011805,348 141 | 4,ZRX/BTC,0.00012793,19,0.00011801,20849 142 | 4,ETH/BTC,0.092306,0.1701731198,0.092201,0.29 143 | 4,ETH/BTC,0.092317,1.802,0.0922,0.185 144 | 4,ETH/BTC,0.092326,4.207,0.092124,0.5 145 | 4,ETH/BTC,0.092332,0.222,0.092123,3.296 146 | 4,ETH/BTC,0.092335,0.06,0.092122,4.167 147 | 4,ETH/BTC,0.092336,0.042,0.092115,0.529 148 | 4,ETH/BTC,0.092338,0.051,0.092114,0.879 149 | 4,ETH/BTC,0.092339,0.081,0.092113,0.46 150 | 4,ETH/BTC,0.09234,0.047,0.092103,0.011 151 | 4,ETH/BTC,0.092341,0.018,0.092101,1.755 152 | 03043288-995b-45ae-ac39-c397e6b7fc51,ETH/BTC,0.09206,32.685,0.092045,0.35547606 153 | 03043288-995b-45ae-ac39-c397e6b7fc51,ETH/BTC,0.09208,0.971,0.092043,0.064 154 | 03043288-995b-45ae-ac39-c397e6b7fc51,ETH/BTC,0.092109,2.031,0.092019,0.111 155 | 03043288-995b-45ae-ac39-c397e6b7fc51,ETH/BTC,0.09211,0.631,0.092015,0.271 156 | 03043288-995b-45ae-ac39-c397e6b7fc51,ETH/BTC,0.092117,0.056,0.092014,0.271 157 | 03043288-995b-45ae-ac39-c397e6b7fc51,ETH/BTC,0.092127,0.253,0.092013,0.032 158 | 03043288-995b-45ae-ac39-c397e6b7fc51,ETH/BTC,0.092129,4.105,0.092012,0.032 159 | 03043288-995b-45ae-ac39-c397e6b7fc51,ETH/BTC,0.09214,0.099,0.092003,0.07 160 | 03043288-995b-45ae-ac39-c397e6b7fc51,ETH/BTC,0.092191,1.283,0.092,1 161 | 03043288-995b-45ae-ac39-c397e6b7fc51,ETH/BTC,0.092192,0.329,0.091997,0.07 162 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/BTC,0.00001302,125,0.00001292,5195 163 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/BTC,0.00001303,2386.2240982348,0.0000129,4296 164 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/BTC,0.00001304,14160,0.00001288,205 165 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/BTC,0.00001305,1567,0.00001287,336 166 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/BTC,0.00001313,306,0.00001281,7800 167 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/BTC,0.00001315,671,0.00001279,1176 168 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/BTC,0.00001318,113,0.00001275,20401 169 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/BTC,0.00001321,300,0.00001273,1176 170 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/BTC,0.00001323,1217,0.00001265,37249 171 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/BTC,0.00001324,3493,0.00001264,22834 172 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/ETH,0.00014241,226,0.00014042,78 173 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/ETH,0.00014251,1531,0.00014026,1430 174 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/ETH,0.0001435,221,0.00014025,1003 175 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/ETH,0.00014448,371,0.00013893,554 176 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/ETH,0.00014498,12874,0.00013843,6979 177 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/ETH,0.00014499,6131,0.00013818,6860 178 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/ETH,0.000145,16934,0.000138,8999 179 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/ETH,0.000146,609,0.00013727,235 180 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/ETH,0.0001462,3054,0.00013711,70195 181 | 03043288-995b-45ae-ac39-c397e6b7fc51,FUEL/ETH,0.0001463,3000,0.0001371,20000 182 | --------------------------------------------------------------------------------