diff --git a/.dockerignore b/.dockerignore index 50e16ff..6275699 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,35 +1,20 @@ node_modules __pycache__ jsconfig.json -.eslintrc.js +^\.* *.md -.vscode -.devcontainer -.ini -.git -.github -.gitignore -.github/ docs/ ecosystem.config.js -.http mongo_data mongo_data.old mongo_db kafka_data -.DS_Store -.env.local -.env.prod -.env.development.local -.env.test.local /.env .vscode/settings.json -.venv/ -.DS_Store *.pyc api/main/config/*.cfg -.mypy_cache db.dump -.pytest_cache tests/ *.md +!.dockerignore +!.gitignore diff --git a/algorithms/coinrule.py b/algorithms/coinrule.py index abb4b06..976b8be 100644 --- a/algorithms/coinrule.py +++ b/algorithms/coinrule.py @@ -1,8 +1,8 @@ import os -from shared.utils import round_numbers from models.signals import SignalsConsumer from shared.enums import KafkaTopics +from shared.utils import round_numbers def fast_and_slow_macd( @@ -20,13 +20,7 @@ def fast_and_slow_macd( bb_high, bb_mid, bb_low = self.bb_spreads() # If volatility is too low, dynamic trailling will close too early with bb_spreads - if ( - macd > macd_signal - and ma_7 > ma_25 - and bb_high < 1 - and bb_high > 0.001 - and trend - ): + if macd > macd_signal and ma_7 > ma_25 and bb_high < 1 and bb_high > 0.001: msg = f""" - [{os.getenv('ENV')}] {algo} #algorithm #{symbol} - Current price: {close_price} diff --git a/algorithms/ma_candlestick.py b/algorithms/ma_candlestick.py index f58ddb1..5cd4d17 100644 --- a/algorithms/ma_candlestick.py +++ b/algorithms/ma_candlestick.py @@ -1,7 +1,8 @@ import os + +from models.signals import SignalsConsumer from shared.enums import KafkaTopics from shared.utils import round_numbers -from models.signals import SignalsConsumer # Algorithms based on Bollinguer bands diff --git a/algorithms/price_changes.py b/algorithms/price_changes.py index 0eb8cea..9868efa 100644 --- a/algorithms/price_changes.py +++ b/algorithms/price_changes.py @@ -1,54 +1,47 @@ import os -from shared.utils import round_numbers from models.signals import SignalsConsumer from shared.enums import KafkaTopics + def price_rise_15( self, close_price, symbol, - run_autotrade, prev_price, p_value, - r_value, - btc_correlation - volatility + btc_correlation, ): """ Price increase/decrease algorithm https://www.binance.com/en/support/faq/understanding-top-movers-statuses-on-binance-spot-trading-18c97e8ab67a4e1b824edd590cae9f16 """ - + algo = "price_rise_15_rally_pullback" price_diff = (float(close_price) - float(prev_price)) / close_price - volatility = round_numbers(volatility) bb_high, bb_mid, bb_low = self.bb_spreads() trend = self.define_strategy() if 0.07 <= price_diff < 0.11: first_line = "Price increase over 7%" - elif -0.07 <= price_diff < -0.11 : + elif -0.07 <= price_diff < -0.11: first_line = f"{algo} #algorithm over 7%" else: return - - msg = (f""" + msg = f""" - [{os.getenv('ENV')}] {first_line} #{symbol} - Current price: {close_price} -- Log volatility (log SD): {volatility} - P-value: {p_value} - Pearson correlation with BTC: {btc_correlation["close_price"]} - https://www.binance.com/en/trade/{symbol} - Dashboard trade -""") - +""" + value = SignalsConsumer( - spread=volatility, current_price=close_price, msg=msg, symbol=symbol, @@ -58,9 +51,13 @@ def price_rise_15( "bb_high": bb_high, "bb_mid": bb_mid, "bb_low": bb_low, - } + }, ) - self.producer.send(KafkaTopics.signals.value, value=value.model_dump_json()).add_callback(self.base_producer.on_send_success).add_errback(self.base_producer.on_send_error) + self.producer.send( + KafkaTopics.signals.value, value=value.model_dump_json() + ).add_callback(self.base_producer.on_send_success).add_errback( + self.base_producer.on_send_error + ) return diff --git a/algorithms/rally.py b/algorithms/rally.py index 40bc17b..2ae8bae 100644 --- a/algorithms/rally.py +++ b/algorithms/rally.py @@ -1,6 +1,7 @@ import os -from api.streaming.models import SignalsConsumer + from shared.enums import KafkaTopics +from models.signals import SignalsConsumer def rally_or_pullback( @@ -19,6 +20,8 @@ def rally_or_pullback( """ Rally algorithm + Key difference with other algorithms is the trend is determined not by market + but day or minute percentage change https://www.binance.com/en/support/faq/understanding-top-movers-statuses-on-binance-spot-trading-18c97e8ab67a4e1b824edd590cae9f16 """ data = self.get_24_ticker(symbol) @@ -39,62 +42,51 @@ def rally_or_pullback( if day_diff <= 0.08 and minute_diff >= 0.05: algo_type = "Rally" - # trend = "uptrend" if day_diff_pb >= 0.08 and minute_diff_pb <= 0.05: algo_type = "Pullback" - # trend = "downtrend" if not algo_type: return - trend = self.define_strategy() - if not trend: - return - - bb_high, bb_mid, bb_low = self.bb_spreads() - - msg = f""" - - [{os.getenv('ENV')}] {algo_type} #algorithm #{symbol} - - Current price: {close_price} - - Log volatility (log SD): {volatility} - - Bollinguer bands spread: {(bb_high - bb_low) / bb_high } - - Reversal? {"Yes" if self.market_domination_reversal else "No"} - - https://www.binance.com/en/trade/{symbol} - - Dashboard trade - """ - - if algo_type == "Pullback": - algo = f"rally_{algo_type}" - - if ( - float(close_price) > float(open_price) - and volatility > 0.09 - # and close_price < ma_25[len(ma_25) - 1] - # and close_price < ma_25[len(ma_25) - 2] - # and close_price < ma_25[len(ma_25) - 3] - and close_price < ma_100[len(ma_100) - 1] - and close_price < ma_100[len(ma_100) - 2] - and close_price < ma_100[len(ma_100) - 3] - ): - value = SignalsConsumer( - spread=None, - current_price=close_price, - msg=msg, - symbol=symbol, - algo=algo, - trend=trend, - bb_spreads={ - "bb_high": bb_high, - "bb_mid": bb_mid, - "bb_low": bb_low, - }, - ) - - self.producer.send( - KafkaTopics.signals.value, value=value.model_dump_json() - ).add_callback(self.base_producer.on_send_success).add_errback( - self.base_producer.on_send_error - ) + if ( + close_price < ma_25[len(ma_25) - 1] + and close_price < ma_25[len(ma_25) - 2] + and close_price < ma_25[len(ma_25) - 3] + and close_price < ma_100[len(ma_100) - 1] + and close_price < ma_100[len(ma_100) - 2] + and close_price < ma_100[len(ma_100) - 3] + ): + bb_high, bb_mid, bb_low = self.bb_spreads() + + msg = f""" + - [{os.getenv('ENV')}] {algo_type} #algorithm #{symbol} + - Current price: {close_price} + - Log volatility (log SD): {volatility} + - Bollinguer bands spread: {(bb_high - bb_low) / bb_high } + - Reversal? {"Yes" if self.market_domination_reversal else "No"} + - https://www.binance.com/en/trade/{symbol} + - Dashboard trade + """ + + value = SignalsConsumer( + spread=None, + current_price=close_price, + msg=msg, + symbol=symbol, + algo=algo_type, + trend=volatility, + bb_spreads={ + "bb_high": bb_high, + "bb_mid": bb_mid, + "bb_low": bb_low, + }, + ) + + self.producer.send( + KafkaTopics.signals.value, value=value.model_dump_json() + ).add_callback(self.base_producer.on_send_success).add_errback( + self.base_producer.on_send_error + ) return diff --git a/algorithms/timeseries_gpt.py b/algorithms/timeseries_gpt.py index e5fc1b9..57bedab 100644 --- a/algorithms/timeseries_gpt.py +++ b/algorithms/timeseries_gpt.py @@ -1,10 +1,5 @@ -import json import os -import pandas as pd -from shared.enums import KafkaTopics -from shared.utils import round_numbers -from models.signals import SignalsConsumer -from shared.enums import KafkaTopics + from nixtla import NixtlaClient nixtla_client = NixtlaClient(os.environ.get("NIXTLA_API_KEY")) diff --git a/algorithms/top_gainer_drop.py b/algorithms/top_gainer_drop.py index addfac7..063b663 100644 --- a/algorithms/top_gainer_drop.py +++ b/algorithms/top_gainer_drop.py @@ -1,18 +1,18 @@ import os - -from shared.enums import KafkaTopics +from models.signals import SignalsConsumer +from shared.enums import KafkaTopics, TrendEnum def top_gainers_drop( - self, + cls, close_price, open_price, ma_7, - ma_100, ma_25, - symbol, - lowest_price, - slope, + ma_100, + ma_7_prev, + ma_25_prev, + ma_100_prev, volatility, ): """ @@ -21,34 +21,39 @@ def top_gainers_drop( so create margin_short bot """ - if float(close_price) < float(open_price) and symbol in self.top_coins_gainers: + if float(close_price) < float(open_price) and cls.symbol in cls.top_coins_gainers: algo = "top_gainers_drop" - trend = self.define_strategy(self) + trend = cls.define_strategy() if not trend: - return + trend = TrendEnum.down_trend + + bb_high, bb_mid, bb_low = cls.bb_spreads() msg = f""" -- [{os.getenv('ENV')}] Top gainers's drop #{algo} algorithm #{symbol} -- Current price: {close_price} -- Log volatility (log SD): {volatility} -- Slope: {slope} -- https://www.binance.com/en/trade/{symbol} -- Dashboard trade -""" - value = { - "msg": msg, - "symbol": symbol, - "algo": algo, - "spread": volatility, - "current_price": close_price, - "trend": trend, - } - - self.producer.send( + - [{os.getenv('ENV')}] Top gainers's drop #{algo} algorithm #{cls.symbol} + - Current price: {close_price} + - Log volatility (log SD): {volatility} + - Bollinguer bands spread: {(bb_high - bb_low) / bb_high } + - Reversal? {"Yes" if cls.market_domination_reversal else "No"} + - https://www.binance.com/en/trade/{cls.symbol} + - Dashboard trade + """ + + value = SignalsConsumer( + spread=volatility, + current_price=close_price, + msg=msg, + symbol=cls.symbol, + algo=algo, + trend=trend, + bb_spreads=None, + ) + + cls.producer.send( KafkaTopics.signals.value, value=value.model_dump_json() - ).add_callback(self.base_producer.on_send_success).add_errback( - self.base_producer.on_send_error + ).add_callback(cls.base_producer.on_send_success).add_errback( + cls.base_producer.on_send_error ) return diff --git a/algorithms/whale_alert_signals.py b/algorithms/whale_alert_signals.py deleted file mode 100644 index 4b628da..0000000 --- a/algorithms/whale_alert_signals.py +++ /dev/null @@ -1,42 +0,0 @@ -import logging -import os -import time - -# from telegram_bot import TelegramBot # For formatted dictionary printing -# from whalealert.whalealert import WhaleAlert - - -# class WhaleAlertSignals: -# """ -# Receive Whale alerts from telegram channel -# """ -# def __init__(self): -# self.api_key = os.getenv("WHALER_KEY") -# self.whale_alert = WhaleAlert() -# self.telegram_bot = TelegramBot() -# self.transaction_count_limit = 2 -# self.exclude_list = ["GUSD","USDC", "USDT", "BUSD", "BTC", "ETH"] - -# def get_last_transaction(self): -# start_time = int(time.time() - 600) -# success, transactions, status = self.whale_alert.get_transactions(start_time, api_key=self.api_key, limit=self.transaction_count_limit) -# if success: -# if (transactions[0]["amount_usd"] != transactions[1]["amount_usd"]) and transactions[1]["symbol"] not in self.exclude_list: -# return transactions[1] -# else: -# return None -# else: -# return status - -# def run_bot(self) -> None: -# """Run the bot.""" -# logging.info("Running Whale alert signals...") -# transaction = self.get_last_transaction() -# if transaction: -# from_owner = "#" + transaction["from"]["owner"] if transaction["from"]["owner"] == "unknown" else transaction["to"]["owner"] -# to_owner = "#" + transaction["to"]["owner"] if transaction["to"]["owner"] == "unknown" else transaction["to"]["owner"] - -# msg = f'[{os.getenv("ENV")}] #Whale alert: {transaction["transaction_type"]} of #{transaction["symbol"]} ({transaction["amount_usd"]} USD) from {from_owner} wallet to {to_owner}\n- https://www.binance.com/en/trade/{transaction["symbol"]}_USDT \n- Dashboard trade http://terminal.binbot.in/bots/new/{transaction["symbol"]}USDT' -# self.telegram_bot.send_msg(msg) - -# pass diff --git a/consumer.py b/consumer.py index 9f5ac0a..f5a6415 100644 --- a/consumer.py +++ b/consumer.py @@ -1,13 +1,22 @@ -import json -import os import asyncio +import json import logging +import os + from aiokafka import AIOKafkaConsumer +from aiokafka.errors import RequestTimedOutError, UnknownMemberIdError + from consumers.autotrade_consumer import AutotradeConsumer -from shared.enums import KafkaTopics -from consumers.telegram_consumer import TelegramConsumer from consumers.klines_provider import KlinesProvider -from aiokafka.errors import UnknownMemberIdError, RequestTimedOutError +from consumers.telegram_consumer import TelegramConsumer +from shared.enums import KafkaTopics + +logging.basicConfig( + level=logging.INFO, + filename=None, + format="%(asctime)s.%(msecs)03d UTC %(levelname)s %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) async def data_process_pipe(): @@ -62,8 +71,8 @@ async def data_analytics_pipe(): at_consumer.load_data_on_start() if message.topic == KafkaTopics.signals.value: - at_consumer.process_autotrade_restrictions(message.value) await telegram_consumer.send_msg(message.value) + at_consumer.process_autotrade_restrictions(message.value) finally: await consumer.stop() diff --git a/consumers/autotrade_consumer.py b/consumers/autotrade_consumer.py index 30b3bfb..f4b68b0 100644 --- a/consumers/autotrade_consumer.py +++ b/consumers/autotrade_consumer.py @@ -1,8 +1,9 @@ -import logging import json +import logging +from datetime import datetime + from models.signals import SignalsConsumer from shared.apis import BinbotApi -from datetime import datetime from shared.autotrade import Autotrade @@ -24,7 +25,6 @@ def __init__(self, producer) -> None: self.active_test_bots: list = [] self.load_data_on_start() # Because market domination analysis 40 weight from binance endpoints - self.top_coins_gainers: list = [] self.btc_change_perc = 0 self.volatility = 0 pass @@ -74,6 +74,13 @@ def reached_max_active_autobots(self, db_collection_name: str) -> bool: return False + def is_margin_available(self, symbol: str) -> bool: + """ + Check if margin trading is allowed for a symbol + """ + info = self._exchange_info(symbol) + return bool(info["symbols"][0]["isMarginTradingAllowed"]) + def process_autotrade_restrictions(self, result: str): """ Refactored autotrade conditions. @@ -125,9 +132,10 @@ def process_autotrade_restrictions(self, result: str): "Reached maximum number of active bots set in controller settings" ) else: - autotrade = Autotrade( - symbol, self.autotrade_settings, data.algo, "bots" - ) - autotrade.activate_autotrade(data) + if self.is_margin_available(symbol): + autotrade = Autotrade( + symbol, self.autotrade_settings, data.algo, "bots" + ) + autotrade.activate_autotrade(data) return diff --git a/consumers/klines_provider.py b/consumers/klines_provider.py index 58b9c4a..88ebe0e 100644 --- a/consumers/klines_provider.py +++ b/consumers/klines_provider.py @@ -1,11 +1,13 @@ import json import logging + import pandas as pd from kafka import KafkaConsumer -from shared.enums import BinanceKlineIntervals + +from database import KafkaDB from models.klines import KlineProduceModel from producers.technical_indicators import TechnicalIndicators -from database import KafkaDB +from shared.enums import BinanceKlineIntervals # spark = SparkSession.builder.appName("Klines Statistics analyses")\ # .config("compute.ops_on_diff_frames", "true").getOrCreate() diff --git a/consumers/telegram_consumer.py b/consumers/telegram_consumer.py index 7748566..768e89b 100644 --- a/consumers/telegram_consumer.py +++ b/consumers/telegram_consumer.py @@ -1,7 +1,8 @@ import json +import os + from telegram import Bot from telegram.constants import ParseMode -import os class TelegramConsumer: diff --git a/database.py b/database.py index e2b8e68..6aceffd 100644 --- a/database.py +++ b/database.py @@ -1,10 +1,12 @@ import os +from datetime import datetime + from dotenv import load_dotenv from pymongo import DESCENDING, MongoClient from pymongo.collection import Collection + +from models.klines import KlineModel, KlineProduceModel from shared.enums import BinanceKlineIntervals -from models.klines import KlineProduceModel, KlineModel -from datetime import datetime load_dotenv() diff --git a/models/bot.py b/models/bot.py index 36c6ded..af9efbc 100644 --- a/models/bot.py +++ b/models/bot.py @@ -1,8 +1,9 @@ from time import time from typing import Literal + +from shared.enums import Status, Strategy from bson.objectid import ObjectId from pydantic import BaseModel, Field, field_validator -from binquant.shared.enums import Status, Strategy class OrderSchema(BaseModel): @@ -104,8 +105,7 @@ class SafetyOrderSchema(BaseModel): class BotSchema(BaseModel): id: str = "" pair: str - balance_size_to_use: float = 0 - balance_to_use: str = "1" + fiat: str = "USDC" base_order_size: str = "15" # Min Binance 0.0001 BNB candlestick_interval: str = "15m" close_condition: str = "" @@ -175,8 +175,7 @@ class Config: "description": "Most fields are optional. Deal field is generated internally, orders are filled up by Binance", "example": { "pair": "BNBUSDT", - "balance_size_to_use": 0, - "balance_to_use": 0, + "fiat": "USDC", "base_order_size": 15, "candlestick_interval": "15m", "cooldown": 0, diff --git a/models/klines.py b/models/klines.py index 54b7ee4..a8285a6 100644 --- a/models/klines.py +++ b/models/klines.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel, field_validator from datetime import datetime +from pydantic import BaseModel, field_validator + # from pyspark.sql.types import StructType, StructField, StringType, LongType, BooleanType, FloatType diff --git a/models/signals.py b/models/signals.py index f3efce2..db749d2 100644 --- a/models/signals.py +++ b/models/signals.py @@ -1,13 +1,19 @@ -from pydantic import BaseModel, ConfigDict, Field, field_validator -from enum import Enum - -from shared.enums import Status, Strategy +from time import time +from bson import ObjectId +from pydantic import BaseModel, ConfigDict, Field, field_validator -class TrendEnum(str, Enum): - up_trend = "uptrend" - down_trend = "downtrend" - neutral = None +from shared.enums import ( + BinanceKlineIntervals, + BinanceOrderModel, + BinbotEnums, + CloseConditions, + DealModel, + EnumDefinitions, + Status, + Strategy, + TrendEnum, +) class BollinguerSpread(BaseModel): @@ -52,35 +58,103 @@ def name_must_contain_space(cls, v): class BotPayload(BaseModel): + id: str | None = None pair: str - balance_size_to_use: float = 0 - balance_to_use: str = "USDT" - base_order_size: str = "15" # Min Binance 0.0001 BNB - close_condition: str = "" + fiat: str = "USDC" + base_order_size: float | int | str = 15 # Min Binance 0.0001 BNB + candlestick_interval: BinanceKlineIntervals = Field( + default=BinanceKlineIntervals.fifteen_minutes + ) + close_condition: CloseConditions = Field(default=CloseConditions.dynamic_trailling) + # cooldown period in minutes before opening next bot with same pair + cooldown: int = 0 + deal: DealModel = Field(default_factory=DealModel) dynamic_trailling: bool = False errors: list[str] = [] # Event logs - mode: str = "autotrade" # Manual is triggered by the terminal dashboard, autotrade by research app + # to deprecate in new db + locked_so_funds: float | None = 0 # funds locked by Safety orders + mode: str = "manual" name: str = "Default bot" - status: Status = Status.inactive + orders: list[BinanceOrderModel] = [] # Internal + status: Status = Field(default=Status.inactive) stop_loss: float = 0 margin_short_reversal: bool = False # If stop_loss > 0, allow for reversal take_profit: float = 0 trailling: bool = True trailling_deviation: float = 0 trailling_profit: float = 0 # Trailling activation (first take profit hit) - strategy: Strategy - cooldown: int = 0 # Cooldown in seconds - short_buy_price: float = 0 # > 0 base_order does not execute immediately, executes short strategy when this value is hit + strategy: Strategy = Field(default=Strategy.long) + short_buy_price: float = 0 short_sell_price: float = 0 # autoswitch to short_strategy + # Deal and orders are internal, should never be updated by outside data + total_commission: float = 0 + created_at: float = time() * 1000 + updated_at: float = time() * 1000 + + model_config = { + "arbitrary_types_allowed": True, + "use_enum_values": True, + "json_encoders": {ObjectId: str}, + "json_schema_extra": { + "description": "Most fields are optional. Deal field is generated internally, orders are filled up by Exchange", + "examples": [ + { + "pair": "BNBUSDT", + "fiat": "USDC", + "base_order_size": 15, + "candlestick_interval": "15m", + "cooldown": 0, + "errors": [], + # Manual is triggered by the terminal dashboard, autotrade by research app, + "mode": "manual", + "name": "Default bot", + "orders": [], + "status": "inactive", + "stop_loss": 0, + "take_profit": 2.3, + "trailling": "true", + "trailling_deviation": 0.63, + "trailling_profit": 2.3, + "strategy": "long", + "short_buy_price": 0, + "short_sell_price": 0, + "total_commission": 0, + } + ], + }, + } + + @field_validator("pair", "candlestick_interval") + @classmethod + def string_not_empty(cls, v): + assert v != "", "Empty pair field." + return v - @field_validator("pair", "base_order_size") + @field_validator("candlestick_interval") @classmethod def check_names_not_empty(cls, v): - assert v != "", "Empty pair field." + if v not in EnumDefinitions.chart_intervals: + raise ValueError(f"{v} must be a valid candlestick interval") return v + @field_validator("base_order_size", "base_order_size", mode="before") + @classmethod + def countables(cls, v): + if isinstance(v, str): + return float(v) + elif isinstance(v, int): + return float(v) + elif isinstance(v, float): + return v + else: + raise ValueError(f"{v} must be a number (float, int or string)") + @field_validator( - "stop_loss", "take_profit", "trailling_deviation", "trailling_profit" + "stop_loss", + "take_profit", + "trailling_deviation", + "trailling_profit", + mode="before", ) @classmethod def check_percentage(cls, v): @@ -89,12 +163,28 @@ def check_percentage(cls, v): else: raise ValueError(f"{v} must be a percentage") + @field_validator("mode") + @classmethod + def check_mode(cls, v: str): + if v not in BinbotEnums.mode: + raise ValueError(f'Status must be one of {", ".join(BinbotEnums.mode)}') + return v + + @field_validator("strategy") + @classmethod + def check_strategy(cls, v: str): + if v not in BinbotEnums.strategy: + raise ValueError(f'Status must be one of {", ".join(BinbotEnums.strategy)}') + return v + @field_validator("trailling") @classmethod - def check_trailling(cls, v: str | bool): + def string_booleans(cls, v: str | bool): if isinstance(v, str) and v.lower() == "false": return False - return True + if isinstance(v, str) and v.lower() == "true": + return True + return v @field_validator("errors") @classmethod diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..e0f6fc1 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,14 @@ +[mypy] +implicit_optional = True +ignore_missing_imports = True +plugins = pydantic.mypy +show_error_codes = True +follow_imports = silent +warn_redundant_casts = True +warn_unused_ignores = True +check_untyped_defs = True +implicit_reexport = True +explicit_package_bases = True +disallow_untyped_calls = True +disallow_untyped_defs = True +allow-redefinition = True diff --git a/producer.py b/producer.py index 3c89713..5375dc3 100644 --- a/producer.py +++ b/producer.py @@ -1,5 +1,5 @@ -import logging import asyncio +import logging from producers.base import BaseProducer from producers.klines_connector import KlinesConnector diff --git a/producers/base.py b/producers/base.py index 9e0fa18..db61031 100644 --- a/producers/base.py +++ b/producers/base.py @@ -1,8 +1,9 @@ -import os import json import logging +import os from kafka import KafkaProducer + from database import KafkaDB @@ -23,4 +24,4 @@ def on_send_success(self, record_metadata): logging.info(f"Produced: {record_metadata.topic}, {record_metadata.offset}") def on_send_error(self, excp): - print(f"Message production failed to send: {excp}") + logging.error(f"Message production failed to send: {excp}") diff --git a/producers/klines_connector.py b/producers/klines_connector.py index c8e264c..2616a59 100644 --- a/producers/klines_connector.py +++ b/producers/klines_connector.py @@ -2,10 +2,11 @@ import logging from kafka import KafkaProducer -from shared.apis import BinbotApi + from producers.produce_klines import KlinesProducer -from shared.streaming.socket_client import SpotWebsocketStreamClient +from shared.apis import BinbotApi from shared.exceptions import WebSocketError +from shared.streaming.socket_client import SpotWebsocketStreamClient class KlinesConnector(BinbotApi): diff --git a/producers/produce_klines.py b/producers/produce_klines.py index d87878f..8890529 100644 --- a/producers/produce_klines.py +++ b/producers/produce_klines.py @@ -1,6 +1,7 @@ from kafka import KafkaProducer -from models.klines import KlineProduceModel + from database import KafkaDB +from models.klines import KlineProduceModel from shared.enums import KafkaTopics diff --git a/producers/technical_indicators.py b/producers/technical_indicators.py index 6eddddb..ea3c5c6 100644 --- a/producers/technical_indicators.py +++ b/producers/technical_indicators.py @@ -1,15 +1,20 @@ -from datetime import datetime, timedelta import logging -import pandas +from datetime import datetime, timedelta from typing import Literal +import pandas + +from algorithms.coinrule import buy_low_sell_high, fast_and_slow_macd +from algorithms.ma_candlestick import ma_candlestick_drop, ma_candlestick_jump + # from algorithms.timeseries_gpt import detect_anomalies +from algorithms.rally import rally_or_pullback +from algorithms.top_gainer_drop import top_gainers_drop from models.signals import SignalsConsumer, TrendEnum -from shared.enums import KafkaTopics -from shared.apis import BinbotApi from producers.base import BaseProducer -from algorithms.ma_candlestick import ma_candlestick_jump, ma_candlestick_drop -from algorithms.coinrule import buy_low_sell_high, fast_and_slow_macd +from shared.apis import BinbotApi +from shared.enums import KafkaTopics +from shared.utils import round_numbers class TechnicalIndicators(BinbotApi): @@ -24,6 +29,7 @@ def __init__(self, df, symbol) -> None: ) self.market_domination_reversal: bool | None = None self.active_pairs = self.get_active_pairs()["data"] + self.top_coins_gainers: list[str] = [] pass def update_active_bots_bb_spreads(self, close_price, symbol): @@ -80,7 +86,11 @@ def bb_spreads(self) -> tuple[float, float, float]: bb_mid = float(self.df.bb_mid[len(self.df.bb_mid) - 1]) bb_low = float(self.df.bb_lower[len(self.df.bb_lower) - 1]) - return bb_high, bb_mid, bb_low + return ( + round_numbers(bb_high, 6), + round_numbers(bb_mid, 6), + round_numbers(bb_low, 6), + ) def define_strategy(self): """ @@ -102,15 +112,6 @@ def define_strategy(self): ): trend = None - # if self.market_domination_trend == "gainers": - # trend = TrendEnum.up_trend.value - - # elif self.market_domination_trend == "losers": - # trend = TrendEnum.down_trend.value - - # else: - # trend = None - return trend def calculate_slope(candlesticks): @@ -194,8 +195,8 @@ def ma_spreads(self): - bottom_band: diff between ma_7 and ma_25 """ - band_1 = (abs((self.df["ma_100"] - self.df["ma_25"])) / self.df["ma_100"]) * 100 - band_2 = (abs((self.df["ma_25"] - self.df["ma_7"])) / self.df["ma_25"]) * 100 + band_1 = (abs(self.df["ma_100"] - self.df["ma_25"]) / self.df["ma_100"]) * 100 + band_2 = (abs(self.df["ma_25"] - self.df["ma_7"]) / self.df["ma_25"]) * 100 self.df["big_ma_spread"] = band_1 self.df["small_ma_spread"] = band_2 @@ -250,6 +251,8 @@ def market_domination(self) -> Literal["gainers", "losers", "neutral", None]: f"Performing market domination analyses. Current trend: {self.market_domination_trend}" ) data = self.get_market_domination_series() + top_gainers_day = self.get_top_gainers()["data"] + self.top_coins_gainers = [item["symbol"] for item in top_gainers_day] # reverse to make latest series more important data["gainers_count"].reverse() data["losers_count"].reverse() @@ -391,32 +394,31 @@ def publish(self): ) # This function calls a lot ticker24 revise it before uncommenting - # rally_or_pullback( - # self, - # close_price, - # open_price, - # self.symbol, - # ma_7, - # ma_25, - # ma_100, - # ma_7_prev, - # ma_25_prev, - # ma_100_prev, - # volatility - # ) + rally_or_pullback( + self, + close_price, + open_price, + self.symbol, + ma_7, + ma_25, + ma_100, + ma_7_prev, + ma_25_prev, + ma_100_prev, + volatility, + ) - # top_gainers_drop( - # self, - # close_price, - # open_price, - # self.symbol, - # ma_7, - # ma_25, - # ma_100, - # ma_7_prev, - # ma_25_prev, - # ma_100_prev, - # volatility - # ) + top_gainers_drop( + self, + close_price, + open_price, + ma_7, + ma_25, + ma_100, + ma_7_prev, + ma_25_prev, + ma_100_prev, + volatility, + ) return diff --git a/shared/apis.py b/shared/apis.py index 66b5049..fcc7c12 100644 --- a/shared/apis.py +++ b/shared/apis.py @@ -4,8 +4,10 @@ from decimal import Decimal from random import randrange from urllib.parse import urlencode + from dotenv import load_dotenv from requests import Session, get + from shared.utils import handle_binance_errors load_dotenv() @@ -120,7 +122,7 @@ def launchpool_projects(self): data = self.request(url=self.launchpool_url, headers={"User-Agent": "Mozilla"}) return data - def price_precision(self, symbol): + def price_precision(self, symbol) -> int: """ Modified from price_filter_by_symbol from /api/account/account.py @@ -130,16 +132,16 @@ def price_precision(self, symbol): symbols = self._exchange_info(symbol) market = symbols["symbols"][0] price_filter = next( - (m for m in market["filters"] if m["filterType"] == "PRICE_FILTER") + m for m in market["filters"] if m["filterType"] == "PRICE_FILTER" ) - # Once got the filter data of Binance - # Transform into string and remove leading zeros - # This is how the exchange accepts the prices, it will not work with scientific exponential notation e.g. 2.1-10 - price_precision = Decimal(str(price_filter["tickSize"].rstrip(".0"))) + # Convert scientific notation to decimal and remove leading zeros + tick_size = float(price_filter["tickSize"]) + tick_size_str = f"{tick_size:.8f}".rstrip("0").rstrip(".") + price_precision = Decimal(tick_size_str).as_tuple() - # Finally return the correct number of decimals required - return -(price_precision).as_tuple().exponent + # Finally return the correct number of decimals required as positive number + return abs(int(price_precision.exponent)) def min_amount_check(self, symbol, qty): """ @@ -152,7 +154,7 @@ def min_amount_check(self, symbol, qty): symbols = self._exchange_info(symbol) market = symbols["symbols"][0] min_notional_filter = next( - (m for m in market["filters"] if m["filterType"] == "NOTIONAL") + m for m in market["filters"] if m["filterType"] == "NOTIONAL" ) min_qty = float(qty) > float(min_notional_filter["minNotional"]) return min_qty @@ -183,6 +185,7 @@ class BinbotApi(BinanceApi): bb_activate_bot_url = f"{bb_base_url}/bot/activate" bb_gainers_losers = f"{bb_base_url}/account/gainers-losers" bb_market_domination = f"{bb_base_url}/charts/market-domination" + bb_top_gainers = f"{bb_base_url}/charts/top-gainers" # Trade operations bb_buy_order_url = f"{bb_base_url}/order/buy" @@ -353,3 +356,10 @@ def get_active_pairs(self): def margin_trading_check(self, symbol): data = self.request(url=f"{self.bb_margin_trading_check_url}/{symbol}") return data + + def get_top_gainers(self): + """ + Top crypto/token/coin gainers of the day + """ + data = self.request(url=self.bb_top_gainers) + return data diff --git a/shared/autotrade.py b/shared/autotrade.py index a0c9dff..875d283 100644 --- a/shared/autotrade.py +++ b/shared/autotrade.py @@ -1,13 +1,13 @@ import json -import math import logging - +import math from datetime import datetime + +from models.signals import BotPayload, TrendEnum from producers.base import BaseProducer +from shared.apis import BinbotApi from shared.enums import CloseConditions, KafkaTopics, Strategy -from models.signals import BotPayload, TrendEnum from shared.exceptions import AutotradeError -from shared.apis import BinbotApi from shared.utils import round_numbers, supress_notation @@ -35,14 +35,13 @@ def __init__( self.default_bot = BotPayload( pair=pair, name=f"{algorithm_name}_{current_date}", - balance_size_to_use=str(settings["balance_size_to_use"]), - balance_to_use=settings["balance_to_use"], + fiat=settings["balance_to_use"], base_order_size=settings["base_order_size"], + strategy=Strategy.long, stop_loss=settings["stop_loss"], take_profit=settings["take_profit"], trailling=settings["trailling"], trailling_deviation=settings["trailling_deviation"], - strategy=settings["strategy"], close_condition=CloseConditions.dynamic_trailling, ) self.db_collection_name = db_collection_name @@ -150,13 +149,13 @@ def handle_price_drops( ( b["free"] for b in balances["data"] - if b["asset"] == self.default_bot.balance_to_use + if b["asset"] == self.default_bot.fiat ), None, ) if not available_balance: - print(f"Not enough {self.default_bot.balance_to_use} for safety orders") + logging.info(f"Not enough {self.default_bot.fiat} for safety orders") return if trend == "downtrend": @@ -181,20 +180,11 @@ def set_paper_trading_values(self, balances, qty): if self.pair.endswith(b["asset"]): qty = supress_notation(b["free"], self.decimals) if self.min_amount_check(self.pair, qty): - # balance_size_to_use = 0.0 means "Use all balance". float(0) = 0.0 - if float(self.default_bot.balance_size_to_use) != 0.0: - if b["free"] < float(self.default_bot.balance_size_to_use): - # Display warning and continue with full balance - print( - f"Error: balance ({qty}) is less than balance_size_to_use ({float(self.default_bot['balance_size_to_use'])}). Autotrade will use all balance" - ) - else: - qty = float(self.default_bot.balance_size_to_use) - self.default_bot.base_order_size = qty break - rate = rate["price"] + ticker = self.get_24_ticker(symbol=self.pair) + rate = ticker["price"] qty = supress_notation(b["free"], self.decimals) # Round down to 6 numbers to avoid not enough funds base_order_size = ( @@ -298,7 +288,7 @@ def activate_autotrade(self, data, **kwargs): if "error" in bot and bot["error"] > 0: # Failed to activate bot so: - # (1) Add to blacklist/exclude from future autotrades + # (1) Add to blacklist/exclude from future autotrades # (2) Submit error to event logs # (3) Delete inactive bot # this prevents cluttering UI with loads of useless bots diff --git a/shared/enums.py b/shared/enums.py index 1590e39..651d81b 100644 --- a/shared/enums.py +++ b/shared/enums.py @@ -1,4 +1,7 @@ from enum import Enum +from time import time + +from pydantic import BaseModel, field_validator class EnumDefinitions: @@ -73,36 +76,30 @@ class OrderType(str, Enum): take_profit_limit = "TAKE_PROFIT_LIMIT" limit_maker = "LIMIT_MAKER" - def __str__(self): - return str(self.str) - class TimeInForce(str, Enum): gtc = "GTC" ioc = "IOC" fok = "FOK" - def __str__(self): - return str(self.str) - class OrderSide(str, Enum): buy = "BUY" sell = "SELL" - def __str__(self): - return str(self.str) + +class TrendEnum(str, Enum): + up_trend = "uptrend" + down_trend = "downtrend" + neutral = None class CloseConditions(str, Enum): dynamic_trailling = "dynamic_trailling" - timestamp = "timestamp" # No trailling, standard stop loss - market_reversal = ( - "market_reversal" # binbot-research param (self.market_trend_reversal) - ) - - def __str__(self): - return str(self.str) + # No trailling, standard stop loss + timestamp = "timestamp" + # binbot-research param (self.market_trend_reversal) + market_reversal = "market_reversal" class KafkaTopics(str, Enum): @@ -112,9 +109,6 @@ class KafkaTopics(str, Enum): signals = "signals" restart_streaming = "restart_streaming" - def __str__(self): - return str(self.str) - class BinanceKlineIntervals(str, Enum): one_minute = "1m" @@ -133,9 +127,6 @@ class BinanceKlineIntervals(str, Enum): one_week = "1w" one_month = "1M" - def __str__(self): - return str(self.value) - def bin_size(self): return int(self.value[:-1]) @@ -150,3 +141,112 @@ def unit(self): return "week" elif self.value[-1:] == "M": return "month" + + +class DealType(str, Enum): + base_order = "base_order" + take_profit = "take_profit" + stop_loss = "stop_loss" + short_sell = "short_sell" + short_buy = "short_buy" + margin_short = "margin_short" + panic_close = "panic_close" + + +class BinanceOrderModel(BaseModel): + """ + Data model given by Binance, + therefore it should be strings + """ + + order_type: str + time_in_force: str + timestamp: int + order_id: int + order_side: str + pair: str + qty: float + status: str + price: float + deal_type: DealType + + @field_validator("timestamp", "order_id", "price", "qty", "order_id") + @classmethod + def validate_str_numbers(cls, v): + if isinstance(v, float): + return v + elif isinstance(v, int): + return v + elif isinstance(v, str): + return float(v) + else: + raise ValueError(f"{v} must be a number") + + +class DealModel(BaseModel): + """ + Data model that is used for operations, + so it should all be numbers (int or float) + """ + + buy_price: float = 0 + buy_total_qty: float = 0 + buy_timestamp: float = time() * 1000 + current_price: float = 0 + sd: float = 0 + avg_buy_price: float = 0 + take_profit_price: float = 0 + sell_timestamp: float = 0 + sell_price: float = 0 + sell_qty: float = 0 + trailling_stop_loss_price: float = 0 + # take_profit but for trailling, to avoid confusion, trailling_profit_price always be > trailling_stop_loss_price + trailling_profit_price: float = 0 + stop_loss_price: float = 0 + trailling_profit: float = 0 + so_prices: float = 0 + post_closure_current_price: float = 0 + original_buy_price: float = 0 # historical buy_price after so trigger + short_sell_price: float = 0 + short_sell_qty: float = 0 + short_sell_timestamp: float = time() * 1000 + + # fields for margin trading + margin_short_loan_principal: float = 0 + margin_loan_id: float = 0 + hourly_interest_rate: float = 0 + margin_short_sell_price: float = 0 + margin_short_loan_interest: float = 0 + margin_short_buy_back_price: float = 0 + margin_short_sell_qty: float = 0 + margin_short_buy_back_timestamp: int = 0 + margin_short_base_order: float = 0 + margin_short_sell_timestamp: int = 0 + margin_short_loan_timestamp: int = 0 + + @field_validator( + "buy_price", + "current_price", + "avg_buy_price", + "original_buy_price", + "take_profit_price", + "sell_price", + "short_sell_price", + "trailling_stop_loss_price", + "trailling_profit_price", + "stop_loss_price", + "trailling_profit", + "margin_short_loan_principal", + "margin_short_sell_price", + "margin_short_loan_interest", + "margin_short_buy_back_price", + "margin_short_base_order", + "margin_short_sell_qty", + ) + @classmethod + def check_prices(cls, v): + if float(v) < 0: + raise ValueError("Price must be a positive number") + elif isinstance(v, str): + return float(v) + return v diff --git a/shared/streaming/socket_manager.py b/shared/streaming/socket_manager.py index 4a011c8..355933e 100644 --- a/shared/streaming/socket_manager.py +++ b/shared/streaming/socket_manager.py @@ -1,12 +1,13 @@ import json import logging -import time import threading +import time + from websocket import ( ABNF, - create_connection, - WebSocketException, WebSocketConnectionClosedException, + WebSocketException, + create_connection, ) @@ -64,10 +65,10 @@ def read_data(self): if isinstance(e, WebSocketConnectionClosedException): self.logger.error("Lost websocket connection") else: - self.logger.error("Websocket exception: {}".format(e)) + self.logger.error(f"Websocket exception: {e}") raise e except Exception as e: - self.logger.error("Exception in read_data: {}".format(e)) + self.logger.error(f"Exception in read_data: {e}") raise e if op_code == ABNF.OPCODE_CLOSE: @@ -101,7 +102,7 @@ def _callback(self, callback, *args): try: callback(self, *args) except Exception as e: - self.logger.error("Error from callback {}: {}".format(callback, e)) + self.logger.error(f"Error from callback {callback}: {e}") if self.on_error: self.on_error(self, e) diff --git a/shared/utils.py b/shared/utils.py index 9f70dd3..9773d63 100644 --- a/shared/utils.py +++ b/shared/utils.py @@ -1,9 +1,11 @@ import math import re +from datetime import datetime from decimal import Decimal from time import sleep + from requests import Response -from datetime import datetime + from shared.exceptions import InvalidSymbol @@ -28,7 +30,7 @@ def supress_trailling(value: str | float | int) -> float: value = float(value) # supress scientific notation number = float(f"{value:f}") - number = float("{0:g}".format(number)) + number = float(f"{number:g}") return number @@ -75,7 +77,7 @@ def supress_notation(num: float, precision: int = 0) -> str: if precision >= 0: decimal_points = precision else: - decimal_points = Decimal(num).as_tuple().exponent * -1 + decimal_points = int(Decimal(num).as_tuple().exponent * -1) return f"{num:.{str(decimal_points)}f}" @@ -88,11 +90,6 @@ def handle_binance_errors(response: Response): """ response.raise_for_status() - - if 400 <= response.status_code < 500: - if response.status_code == 418: - sleep(120) - # Calculate request weights and pause half of the way (1200/2=600) if ( "x-mbx-used-weight-1m" in response.headers @@ -101,6 +98,9 @@ def handle_binance_errors(response: Response): print("Request weight limit prevention pause, waiting 1 min") sleep(120) + if response.status_code == 418: + sleep(120) + content = response.json() if "code" in content: diff --git a/tests/__init__.py b/tests/__init__.py index d732eff..f2b6227 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,4 @@ -from producers.klines_connector import KlinesConnector from producers.base import BaseProducer +from producers.klines_connector import KlinesConnector __all__ = ["KlinesConnector", "BaseProducer"] diff --git a/tests/test_producer.py b/tests/test_producer.py index 58314e7..29e02de 100644 --- a/tests/test_producer.py +++ b/tests/test_producer.py @@ -1,5 +1,6 @@ -from kafka import KafkaProducer import pytest +from kafka import KafkaProducer + from producers.base import BaseProducer from producers.klines_connector import KlinesConnector @@ -80,5 +81,5 @@ def test_producer_error(klines_connector): connector.start_stream() connector.process_kline_stream(res) assert False - except KeyError as e: + except KeyError: assert True